diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 348413fc1f..d92e2507c9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -15,6 +15,7 @@ on: - "apps/simplex-chat/**" - "apps/simplex-bot/**" - "apps/simplex-bot-advanced/**" + - "apps/simplex-badge-service/**" - "apps/simplex-broadcast-bot/**" - "apps/simplex-directory-service/**" - "tests/**" diff --git a/.gitignore b/.gitignore index 035d24c6cd..4f73442c36 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,7 @@ website/src/images/ website/src/js/lottie.min.js website/src/js/ethers.* website/src/js/directory.js +website/src/js/page.js website/src/js/channel-preview.js website/src/js/simplex-lib.js website/src/file-assets/ diff --git a/README.md b/README.md index f7c2ccd514..fa4247caf8 100644 --- a/README.md +++ b/README.md @@ -220,6 +220,8 @@ You can use SimpleX with your own servers and still communicate with people usin Recent and important updates: +[Sep 19, 2026. SimpleX Supporter Badges — Send Larger Files That Stay Available Longer, Without Being Identified](./blog/20260919-simplex-supporter-badges.md) + [Aug 20, 2026. Equity Crowdfunding Launched - You Can Get a Stake in SimpleX Chat](./blog/20260819-simplex-chat-crowdfunding.md) [Jul 22, 2026. SimpleX Public Names — a Name Nobody Can Take From You](./blog/20260722-simplex-public-names.md) diff --git a/apps/ios/BadgeTestProducts.storekit b/apps/ios/BadgeTestProducts.storekit new file mode 100644 index 0000000000..f32c2c8dc3 --- /dev/null +++ b/apps/ios/BadgeTestProducts.storekit @@ -0,0 +1,230 @@ +{ + "appPolicies" : { + "eula" : "", + "policies" : [ + { + "locale" : "en_US", + "policyText" : "", + "policyURL" : "" + } + ] + }, + "identifier" : "B4D6E1A0", + "nonRenewingSubscriptions" : [ + + ], + "products" : [ + { + "displayPrice" : "7.00", + "familyShareable" : false, + "internalID" : "3010000001", + "localizations" : [ + { + "description" : "Supporter badge for one month", + "displayName" : "SimpleX Supporter Badge", + "locale" : "en_US" + } + ], + "productID" : "BADGE_SUPPORTER_01", + "referenceName" : "SimpleX Supporter Badge", + "type" : "Consumable" + }, + { + "displayPrice" : "70.00", + "familyShareable" : false, + "internalID" : "3010000002", + "localizations" : [ + { + "description" : "Legend badge for one month", + "displayName" : "SimpleX Legend Badge", + "locale" : "en_US" + } + ], + "productID" : "BADGE_LEGEND_01", + "referenceName" : "SimpleX Legend Badge", + "type" : "Consumable" + } + ], + "settings" : { + "_applicationInternalID" : "", + "_developerTeamID" : "", + "_failTransactionsEnabled" : false, + "_lastSynchronizedDate" : 0, + "_locale" : "en_US", + "_storefront" : "USA", + "_storeKitErrors" : [ + { + "current" : null, + "enabled" : false, + "name" : "Load Products" + }, + { + "current" : null, + "enabled" : false, + "name" : "Purchase" + }, + { + "current" : null, + "enabled" : false, + "name" : "Verification" + }, + { + "current" : null, + "enabled" : false, + "name" : "App Store Sync" + }, + { + "current" : null, + "enabled" : false, + "name" : "Subscription Status" + }, + { + "current" : null, + "enabled" : false, + "name" : "App Transaction" + }, + { + "current" : null, + "enabled" : false, + "name" : "Manage Subscriptions Sheet" + }, + { + "current" : null, + "enabled" : false, + "name" : "Refund Request Sheet" + }, + { + "current" : null, + "enabled" : false, + "name" : "Offer Code Redeem Sheet" + } + ] + }, + "subscriptionGroups" : [ + { + "id" : "3020000001", + "localizations" : [ + + ], + "name" : "SimpleX Badge Subscription", + "subscriptions" : [ + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "7.00", + "familyShareable" : false, + "groupNumber" : 2, + "internalID" : "3020000011", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "Supporter badge, renews monthly", + "displayName" : "SimpleX Supporter Badge (Month)", + "locale" : "en_US" + } + ], + "productID" : "SUBSCR_BADGE_SUPPORTER_MONTH_01", + "recurringSubscriptionPeriod" : "P1M", + "referenceName" : "SimpleX Supporter Badge (Month)", + "subscriptionGroupID" : "3020000001", + "type" : "RecurringSubscription", + "winbackOffers" : [ + + ] + }, + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "42.00", + "familyShareable" : false, + "groupNumber" : 2, + "internalID" : "3020000012", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "Supporter badge, renews yearly", + "displayName" : "SimpleX Supporter Badge (Year)", + "locale" : "en_US" + } + ], + "productID" : "SUBSCR_BADGE_SUPPORTER_YEAR_01", + "recurringSubscriptionPeriod" : "P1Y", + "referenceName" : "SimpleX Supporter Badge (Year)", + "subscriptionGroupID" : "3020000001", + "type" : "RecurringSubscription", + "winbackOffers" : [ + + ] + }, + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "70.00", + "familyShareable" : false, + "groupNumber" : 1, + "internalID" : "3020000013", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "Legend badge, renews monthly", + "displayName" : "SimpleX Legend Badge (Month)", + "locale" : "en_US" + } + ], + "productID" : "SUBSCR_BADGE_LEGEND_MONTH_01", + "recurringSubscriptionPeriod" : "P1M", + "referenceName" : "SimpleX Legend Badge (Month)", + "subscriptionGroupID" : "3020000001", + "type" : "RecurringSubscription", + "winbackOffers" : [ + + ] + }, + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "420.00", + "familyShareable" : false, + "groupNumber" : 1, + "internalID" : "3020000014", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "Legend badge, renews yearly", + "displayName" : "SimpleX Legend Badge (Year)", + "locale" : "en_US" + } + ], + "productID" : "SUBSCR_BADGE_LEGEND_YEAR_01", + "recurringSubscriptionPeriod" : "P1Y", + "referenceName" : "SimpleX Legend Badge (Year)", + "subscriptionGroupID" : "3020000001", + "type" : "RecurringSubscription", + "winbackOffers" : [ + + ] + } + ] + } + ], + "version" : { + "major" : 4, + "minor" : 0 + } +} diff --git a/apps/ios/CODE.md b/apps/ios/CODE.md index 5a8356f656..e6372a9e33 100644 --- a/apps/ios/CODE.md +++ b/apps/ios/CODE.md @@ -166,6 +166,7 @@ After completing all changes (code + documentation), you MUST run an adversarial | Shared/SimpleXApp.swift | spec/architecture.md | product/flows/onboarding.md | | Shared/AppDelegate.swift | spec/services/notifications.md | product/flows/onboarding.md | | Shared/Views/ChatList/ChatListView.swift | spec/client/chat-list.md | product/views/chat-list.md | +| Shared/Views/ChatList/GetStakeBanner.swift | spec/client/chat-list.md | product/views/chat-list.md | | Shared/Views/Chat/ChatView.swift | spec/client/chat-view.md | product/views/chat.md | | Shared/Views/Chat/ComposeMessage/ComposeView.swift | spec/client/compose.md | product/views/chat.md | | Shared/Views/Chat/ChatItem/ | spec/client/chat-view.md | product/views/chat.md | diff --git a/apps/ios/Shared/Assets.xcassets/wefunder_logo.imageset/Contents.json b/apps/ios/Shared/Assets.xcassets/wefunder_logo.imageset/Contents.json new file mode 100644 index 0000000000..182abac4a6 --- /dev/null +++ b/apps/ios/Shared/Assets.xcassets/wefunder_logo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "wefunder_logo.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/Shared/Assets.xcassets/wefunder_logo.imageset/wefunder_logo.png b/apps/ios/Shared/Assets.xcassets/wefunder_logo.imageset/wefunder_logo.png new file mode 100644 index 0000000000..ac90142f8d Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/wefunder_logo.imageset/wefunder_logo.png differ diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index db70511a5a..4ae103d746 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -191,6 +191,11 @@ enum ChatCommand: ChatCmdProtocol { case apiUploadStandaloneFile(userId: Int64, file: CryptoFile) case apiDownloadStandaloneFile(userId: Int64, url: String, file: CryptoFile) case apiStandaloneFileInfo(url: String) + // badges + case apiRedeemBadgeCode(userId: Int64, code: String) + case apiGetBadgeState(userId: Int64) + case apiGetBadgeLedger(userId: Int64, badgePurchaseId: Int64) + case apiAckBadgeAlert(userId: Int64, badgePurchaseId: Int64, alertKind: BadgeAlertKind, snooze: Bool, episode: String) // misc case showVersion case getAgentSubsTotal(userId: Int64) @@ -413,6 +418,11 @@ enum ChatCommand: ChatCmdProtocol { case let .apiUploadStandaloneFile(userId, file): return "/_upload \(userId) \(file.filePath)" case let .apiDownloadStandaloneFile(userId, link, file): return "/_download \(userId) \(link) \(file.filePath)" case let .apiStandaloneFileInfo(link): return "/_download info \(link)" + case let .apiRedeemBadgeCode(userId, code): return "/_redeem_badge_code \(userId) \(code)" + case let .apiGetBadgeState(userId): return "/_badge state \(userId)" + case let .apiGetBadgeLedger(userId, badgePurchaseId): return "/_badge ledger \(userId) \(badgePurchaseId)" + case let .apiAckBadgeAlert(userId, badgePurchaseId, alertKind, snooze, episode): + return "/_badge ack \(userId) \(badgePurchaseId) \(badgeAlertKindParam(alertKind)) \(onOff(snooze)) \(episode)" case .showVersion: return "/version" case let .getAgentSubsTotal(userId): return "/get subs total \(userId)" case let .getAgentServersSummary(userId): return "/get servers summary \(userId)" @@ -600,6 +610,10 @@ enum ChatCommand: ChatCmdProtocol { case .apiUploadStandaloneFile: return "apiUploadStandaloneFile" case .apiDownloadStandaloneFile: return "apiDownloadStandaloneFile" case .apiStandaloneFileInfo: return "apiStandaloneFileInfo" + case .apiRedeemBadgeCode: return "apiRedeemBadgeCode" + case .apiGetBadgeState: return "apiGetBadgeState" + case .apiGetBadgeLedger: return "apiGetBadgeLedger" + case .apiAckBadgeAlert: return "apiAckBadgeAlert" case .showVersion: return "showVersion" case .getAgentSubsTotal: return "getAgentSubsTotal" case .getAgentServersSummary: return "getAgentServersSummary" @@ -653,6 +667,9 @@ enum ChatCommand: ChatCmdProtocol { return .apiDeleteUser(userId: userId, delSMPQueues: delSMPQueues, viewPwd: obfuscate(viewPwd)) case let .testStorageEncryption(key): return .testStorageEncryption(key: obfuscate(key)) + // a code is a bearer secret until it is redeemed, and the terminal shows and copies cmdString + case let .apiRedeemBadgeCode(userId, code): + return .apiRedeemBadgeCode(userId: userId, code: obfuscate(code)) default: return self } } @@ -679,6 +696,17 @@ enum ChatCommand: ChatCmdProtocol { pwd == "" || pwd == nil ? "" : " " + encodeJSON(pwd) } + // /_badge ack takes the kind in core's text encoding, not the JSON tag + private func badgeAlertKindParam(_ kind: BadgeAlertKind) -> String { + switch kind { + case .renewalApproaching: "renewal_approaching" + case .paymentIssue: "payment_issue" + case .subscriptionEnded: "subscription_ended" + case .prepaidEnding: "prepaid_ending" + case .supportEnded: "support_ended" + } + } + private func maybeContent(_ mc: MsgContent?) -> String { if case let .text(s) = mc, s.isEmpty { "" @@ -1019,6 +1047,11 @@ enum ChatResponse2: Decodable, ChatAPIResult { case archiveExported(archiveErrors: [ArchiveError]) case archiveImported(archiveErrors: [ArchiveError]) case appSettings(appSettings: AppSettings) + // badges + // the full user, not UserRef: its profile carries the badge that setUserBadge just stored + case badgeRedeemed(user: User, redeemedBadge: LocalBadge, newBadge: Bool, badgeState: BadgeState?) + case badgeState(user: UserRef, badgeState: BadgeState?) + case badgeLedger(user: UserRef, badgeLedger: [StatementEntry]) var responseType: String { switch self { @@ -1070,6 +1103,9 @@ enum ChatResponse2: Decodable, ChatAPIResult { case .archiveExported: "archiveExported" case .archiveImported: "archiveImported" case .appSettings: "appSettings" + case .badgeRedeemed: "badgeRedeemed" + case .badgeState: "badgeState" + case .badgeLedger: "badgeLedger" } } @@ -1123,6 +1159,9 @@ enum ChatResponse2: Decodable, ChatAPIResult { case let .archiveExported(archiveErrors): return String(describing: archiveErrors) case let .archiveImported(archiveErrors): return String(describing: archiveErrors) case let .appSettings(appSettings): return String(describing: appSettings) + case let .badgeRedeemed(u, redeemedBadge, newBadge, badgeState): return withUser(u, "redeemedBadge: \(String(describing: redeemedBadge))\nnewBadge: \(newBadge)\nbadgeState: \(String(describing: badgeState))") + case let .badgeState(u, badgeState): return withUser(u, String(describing: badgeState)) + case let .badgeLedger(u, badgeLedger): return withUser(u, String(describing: badgeLedger)) } } } @@ -1205,6 +1244,9 @@ enum ChatEvent: Decodable, ChatAPIResult { case remoteCtrlStopped(rcsState: RemoteCtrlSessionState, rcStopReason: RemoteCtrlStopReason) // pq case contactPQEnabled(user: UserRef, contact: Contact, pqEnabled: Bool) + // badges + case badgeChanged(user: User, badgeState: BadgeState?) + case badgeAlert(user: UserRef, badgeAlert: BadgeAlert) var responseType: String { switch self { @@ -1277,6 +1319,8 @@ enum ChatEvent: Decodable, ChatAPIResult { case .remoteCtrlConnected: "remoteCtrlConnected" case .remoteCtrlStopped: "remoteCtrlStopped" case .contactPQEnabled: "contactPQEnabled" + case .badgeChanged: "badgeChanged" + case .badgeAlert: "badgeAlert" } } @@ -1359,6 +1403,8 @@ enum ChatEvent: Decodable, ChatAPIResult { case let .remoteCtrlConnected(remoteCtrl): return String(describing: remoteCtrl) case let .remoteCtrlStopped(rcsState, rcStopReason): return "rcsState: \(String(describing: rcsState))\nrcStopReason: \(String(describing: rcStopReason))" case let .contactPQEnabled(u, contact, pqEnabled): return withUser(u, "contact: \(String(describing: contact))\npqEnabled: \(pqEnabled)") + case let .badgeChanged(u, badgeState): return withUser(u, String(describing: badgeState)) + case let .badgeAlert(u, badgeAlert): return withUser(u, String(describing: badgeAlert)) } } } diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index e3a6ae30b9..1bf711ca5f 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -359,6 +359,30 @@ class ChannelRelaysModel: ObservableObject { } } +// The badge of whichever profile it was last loaded for, kept current by the badgeChanged event so +// that a screen already open shows what the renewal worker did with no command behind it. +class BadgeModel: ObservableObject { + static let shared = BadgeModel() + @Published private(set) var userId: Int64? + @Published private(set) var badgeState: BadgeState? + @Published private(set) var alert: BadgeAlert? + + // alert follows the state: getUserBadgeState derives it on every read, so a badgeChanged is + // never staler than the alert it carries - the invariant a new alert kind must keep + func set(userId: Int64, badgeState: BadgeState?) { + self.userId = userId + self.badgeState = badgeState + alert = badgeState?.alert + } + + func setAlert(userId: Int64, alert: BadgeAlert) { + if self.userId == userId { + self.alert = alert + badgeState?.alert = alert + } + } +} + // Spec: spec/state.md#ChatModel final class ChatModel: ObservableObject { @Published var onboardingStage: OnboardingStage? diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index d7f29087d8..d4de1205c7 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -124,8 +124,8 @@ func chatSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDe } // Spec: spec/api.md#chatApiSendCmdWithRetry -func chatApiSendCmdWithRetry(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, inProgress: BoxedValue? = nil, retryNum: Int32 = 0) async -> APIResult? { - let r: APIResult = await chatApiSendCmd(cmd, bgTask: bgTask, bgDelay: bgDelay, retryNum: retryNum) +func chatApiSendCmdWithRetry(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, inProgress: BoxedValue? = nil, retryNum: Int32 = 0, log: Bool = true) async -> APIResult? { + let r: APIResult = await chatApiSendCmd(cmd, bgTask: bgTask, bgDelay: bgDelay, retryNum: retryNum, log: log) if inProgress == nil || inProgress?.boxedValue == true, case let .error(e) = r, let alert = retryableNetworkErrorAlert(e) { return await withCheckedContinuation { cont in @@ -135,7 +135,7 @@ func chatApiSendCmdWithRetry(_ cmd: ChatCommand, bgTask: Bool cont.resume(returning: nil) }, onRetry: { - let r1: APIResult? = await chatApiSendCmdWithRetry(cmd, bgTask: bgTask, bgDelay: bgDelay, inProgress: inProgress, retryNum: retryNum + 1) + let r1: APIResult? = await chatApiSendCmdWithRetry(cmd, bgTask: bgTask, bgDelay: bgDelay, inProgress: inProgress, retryNum: retryNum + 1, log: log) cont.resume(returning: r1) } ) @@ -200,6 +200,10 @@ func retryableNetworkErrorAlert(_ e: ChatError) -> (title: String, message: Stri title: NSLocalizedString("No private routing session", comment: "alert title"), message: proxyDestinationErrorAlertMessage(proxyServer: proxyServer, destServer: destServer) ) + case .errorAgent(.AGENT(.A_SERVICE(.timeout))): ( + title: NSLocalizedString("Connection timeout", comment: "alert title"), + message: NSLocalizedString("The service did not respond. Please try again.", comment: "alert message") + ) default: nil } } @@ -2182,6 +2186,74 @@ func resetAgentServersStats() async throws { try await sendCommandOkResp(.resetAgentServersStats) } +// log: false because the code is a bearer secret until it is redeemed - it is in the command. +// nil when the user cancels the retry alert. +func apiRedeemBadgeCode(_ userId: Int64, _ code: String) async throws -> (user: User, newBadge: Bool, badgeState: BadgeState?)? { + let r: APIResult? = await chatApiSendCmdWithRetry(.apiRedeemBadgeCode(userId: userId, code: code), log: false) + guard let r else { return nil } + // redeemedBadge is dropped: the user's profile carries what is shown + if case let .result(.badgeRedeemed(user, _, newBadge, badgeState)) = r { return (user, newBadge, badgeState) } + throw r.unexpected +} + +// localized where the user can act on it; otherwise the error itself, so a screenshot says what happened +func redeemErrorText(_ error: Error) -> String { + if case let .error(.badgeRedeemError(e)) = error as? ChatError { + switch e { + case .invalidCode: return NSLocalizedString("This code is not valid.", comment: "alert message") + case .serviceNotConfigured: return NSLocalizedString("This app version cannot redeem badge codes.", comment: "alert message") + case .badgeActive: return NSLocalizedString("This profile already has a badge. Redeem the code on another profile, or once this badge ends.", comment: "alert message") + case .serviceError(.codeInvalid): return NSLocalizedString("This code was not recognised.", comment: "alert message") + case .serviceError(.codeUsed): return NSLocalizedString("This code has already been used.", comment: "alert message") + case .serviceError(.codeExpired): return NSLocalizedString("This code has expired.", comment: "alert message") + case .serviceError(.rateLimited): return NSLocalizedString("Too many attempts. Please try again later.", comment: "alert message") + case .serviceError(.unsupportedVersion): return NSLocalizedString("This app version is too old for the badge service. Please update the app.", comment: "alert message") + case .serviceError: break + case let .invalidResponse(message): + return String.localizedStringWithFormat(NSLocalizedString("The badge service sent an unexpected response: %@", comment: "alert message"), message) + case .unknownKeyIndex, .credentialNotVerified: return NSLocalizedString("This app version cannot verify this badge. Please update the app.", comment: "alert message") + } + } + return String.localizedStringWithFormat(NSLocalizedString("Error: %@", comment: "alert message"), responseError(error)) +} + +func apiGetBadgeState(_ userId: Int64) async throws -> BadgeState? { + let r: ChatResponse2 = try await chatSendCmd(.apiGetBadgeState(userId: userId)) + if case let .badgeState(_, badgeState) = r { return badgeState } + throw r.unexpected +} + +func apiGetBadgeStateSync(_ userId: Int64) throws -> BadgeState? { + let r: ChatResponse2 = try chatSendCmdSync(.apiGetBadgeState(userId: userId)) + if case let .badgeState(_, badgeState) = r { return badgeState } + throw r.unexpected +} + +func apiGetBadgeLedger(_ userId: Int64, _ badgePurchaseId: Int64) async throws -> [StatementEntry] { + let r: ChatResponse2 = try await chatSendCmd(.apiGetBadgeLedger(userId: userId, badgePurchaseId: badgePurchaseId)) + if case let .badgeLedger(_, badgeLedger) = r { return badgeLedger } + throw r.unexpected +} + +func apiAckBadgeAlert(_ userId: Int64, _ badgePurchaseId: Int64, _ alertKind: BadgeAlertKind, snooze: Bool, episode: String) async throws -> BadgeState? { + let r: ChatResponse2 = try await chatSendCmd(.apiAckBadgeAlert(userId: userId, badgePurchaseId: badgePurchaseId, alertKind: alertKind, snooze: snooze, episode: episode)) + if case let .badgeState(_, badgeState) = r { return badgeState } + throw r.unexpected +} + +// An API call and not a stored flag: the ack is kept on the purchase in core, which then stops +// raising this occurrence on every pass and across restarts, or until a snooze lapses. +func ackBadgeAlert(snooze: Bool) async { + let badgeModel = BadgeModel.shared + guard let userId = badgeModel.userId, let purchaseId = badgeModel.badgeState?.badgePurchaseId, let alert = badgeModel.alert else { return } + do { + let badgeState = try await apiAckBadgeAlert(userId, purchaseId, alert.kind, snooze: snooze, episode: alert.episode) + await MainActor.run { badgeModel.set(userId: userId, badgeState: badgeState) } + } catch let error { + logger.error("ackBadgeAlert: \(responseError(error))") + } +} + private func currentUserId(_ funcName: String) throws -> Int64 { if let userId = ChatModel.shared.currentUser?.userId { return userId @@ -2352,12 +2424,34 @@ func getUserChatData() throws { tm.activeFilter = nil tm.userTags = tags tm.updateChatTags(m.chats) + loadBadgeState() +} + +// Not thrown: a failed badge read must not stop the app starting, and the model is left alone +// rather than set to nil, which would read as "no badge". +private func loadBadgeState() { + do { + let userId = try currentUserId("loadBadgeState") + let badgeState = try apiGetBadgeStateSync(userId) + BadgeModel.shared.set(userId: userId, badgeState: badgeState) + } catch let error { + logger.error("loadBadgeState: \(responseError(error))") + } +} + +private func loadBadgeStateAsync(_ userId: Int64) async { + do { + let badgeState = try await apiGetBadgeState(userId) + await MainActor.run { BadgeModel.shared.set(userId: userId, badgeState: badgeState) } + } catch let error { + logger.error("loadBadgeState: \(responseError(error))") + } } private func getUserChatDataAsync(keepingChatId: String?) async throws { let m = ChatModel.shared let tm = ChatTagsModel.shared - if m.currentUser != nil { + if let userId = m.currentUser?.userId { let userAddress = try await apiGetUserAddressAsync() let chatItemTTL = try await getChatItemTTLAsync() let chats = try await apiGetChatsAsync() @@ -2370,6 +2464,7 @@ private func getUserChatDataAsync(keepingChatId: String?) async throws { tm.userTags = tags tm.updateChatTags(m.chats) } + await loadBadgeStateAsync(userId) } else { await MainActor.run { m.userAddress = nil @@ -2948,6 +3043,20 @@ func processReceivedMsg(_ res: ChatEvent) async { m.updateContact(contact) } } + case let .badgeChanged(user, badgeState): + await MainActor.run { + // read by core after retiring or presenting, so it carries the profile badge as changed + m.updateUser(user) + if active(user) { + BadgeModel.shared.set(userId: user.userId, badgeState: badgeState) + } + } + case let .badgeAlert(user, badgeAlert): + if active(user) { + await MainActor.run { + BadgeModel.shared.setAlert(userId: user.userId, alert: badgeAlert) + } + } default: logger.debug("unsupported event: \(res.responseType)") } diff --git a/apps/ios/Shared/Views/Badges/BadgeStore.swift b/apps/ios/Shared/Views/Badges/BadgeStore.swift new file mode 100644 index 0000000000..8d092bbc19 --- /dev/null +++ b/apps/ios/Shared/Views/Badges/BadgeStore.swift @@ -0,0 +1,183 @@ +// +// BadgeStore.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 14.08.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import Foundation +import Combine +import StoreKit + +// TODO [badges] product ids will come from app config and prices from the badge service catalog; +// hardcoded here so the App Store integration can be tested before the purchase API lands. +func badgeProductId(_ level: BadgeLevel, _ period: BadgePeriod) -> String { + switch (level, period) { + case (.supporter, .oneMonth): "BADGE_SUPPORTER_01" + case (.supporter, .monthly): "SUBSCR_BADGE_SUPPORTER_MONTH_01" + case (.supporter, .annual): "SUBSCR_BADGE_SUPPORTER_YEAR_01" + case (.legend, .oneMonth): "BADGE_LEGEND_01" + case (.legend, .monthly): "SUBSCR_BADGE_LEGEND_MONTH_01" + case (.legend, .annual): "SUBSCR_BADGE_LEGEND_YEAR_01" + } +} + +let badgeProductIds: [String] = BadgeLevel.allCases.flatMap { level in + BadgePeriod.allCases.map { badgeProductId(level, $0) } +} + +// TODO [badges] replaced by APIGetBadgeInvoice, which creates the invoice row and returns its id. +// Apple requires a UUID - it is sent as appAccountToken and echoed back in the signed transaction, +// which is how the service learns which invoice a store transaction settles. +func newBadgeInvoiceId() -> UUID { UUID() } + +enum BadgePrice { + case loading + case price(String) + case unavailable + + var canPurchase: Bool { + switch self { + case .price: true + case .loading, .unavailable: false + } + } +} + +struct BadgeStoreReceipt { + // the signed token the badge service verifies - never transaction.jsonRepresentation + let jws: String + let productId: String + let transactionId: UInt64 + let invoiceId: UUID? + let environment: String? + let signatureVerified: Bool +} + +enum BadgePurchaseOutcome { + case purchased(BadgeStoreReceipt) + case pending + case cancelled +} + +enum BadgeStoreError: Error { + case productUnavailable(productId: String) + case unknownPurchaseResult +} + +final class BadgeStore: ObservableObject { + static let shared = BadgeStore() + + private enum LoadState { case notLoaded, loading, loaded, failed } + + @Published private var state: LoadState = .notLoaded + private var products: [String: Product] = [:] + + private init() {} + + func price(_ level: BadgeLevel, _ period: BadgePeriod) -> BadgePrice { + switch state { + case .notLoaded, .loading: return .loading + case .loaded, .failed: + if let p = products[badgeProductId(level, period)] { return .price(compactPrice(p)) } + return .unavailable + } + } + + // percentage the annual subscription saves against 12 monthly payments + func annualSavings(_ level: BadgeLevel) -> Int? { + guard let monthly = products[badgeProductId(level, .monthly)], + let annual = products[badgeProductId(level, .annual)] + else { return nil } + let year = monthly.price * 12 + guard year > 0, annual.price < year else { return nil } + let saved = (year - annual.price) / year * 100 + let percent = Int(NSDecimalNumber(decimal: saved).doubleValue.rounded()) + return percent > 0 ? percent : nil + } + + func load() async { + guard await startLoading() else { return } + do { + let loaded = try await Product.products(for: badgeProductIds) + let byId = Dictionary(loaded.map { ($0.id, $0) }, uniquingKeysWith: { p, _ in p }) + let missing = badgeProductIds.filter { byId[$0] == nil } + if !missing.isEmpty { + // the store drops ids it cannot resolve without saying why, so the storefront and + // the bundle the request was made for are logged with them + let storefront = await Storefront.current + logger.warning("BadgeStore.load: no product returned for \(missing.joined(separator: ", ")) - bundle \(Bundle.main.bundleIdentifier ?? "?"), storefront \(storefront?.countryCode ?? "none"), canMakePayments \(AppStore.canMakePayments)") + } + await MainActor.run { + products = byId + state = .loaded + } + } catch let error { + logger.error("BadgeStore.load: \(String(describing: error))") + await MainActor.run { state = .failed } + } + } + + func purchase(_ level: BadgeLevel, _ period: BadgePeriod, invoiceId: UUID) async throws -> BadgePurchaseOutcome { + let productId = badgeProductId(level, period) + guard let product = await MainActor.run(body: { products[productId] }) else { + throw BadgeStoreError.productUnavailable(productId: productId) + } + switch try await product.purchase(options: [.appAccountToken(invoiceId)]) { + case let .success(verification): + let transaction: Transaction + let signatureVerified: Bool + switch verification { + case let .verified(t): + transaction = t + signatureVerified = true + case let .unverified(t, _): + transaction = t + signatureVerified = false + } + // nothing is delivered in this build, so the transaction is finished right away; once the + // service issues credentials it must only be finished after the credential is stored + await transaction.finish() + return .purchased(storeReceipt(verification.jwsRepresentation, transaction, signatureVerified)) + case .pending: return .pending + case .userCancelled: return .cancelled + @unknown default: throw BadgeStoreError.unknownPurchaseResult + } + } + + @MainActor + private func startLoading() -> Bool { + switch state { + case .notLoaded, .failed: + state = .loading + return true + case .loading, .loaded: + return false + } + } +} + +// drops the fraction from whole amounts ("$7", not "$7.00") in the product's own currency style; +// Product.displayPrice remains the exact form for views that need the cents +private func compactPrice(_ product: Product) -> String { + var whole = Decimal() + var price = product.price + NSDecimalRound(&whole, &price, 0, .plain) + return whole == product.price + ? product.price.formatted(product.priceFormatStyle.precision(.fractionLength(0))) + : product.displayPrice +} + +private func storeReceipt(_ jws: String, _ t: Transaction, _ signatureVerified: Bool) -> BadgeStoreReceipt { + var environment: String? = nil + if #available(iOS 16.0, *) { environment = t.environment.rawValue } + return BadgeStoreReceipt( + jws: jws, + productId: t.productID, + transactionId: t.id, + invoiceId: t.appAccountToken, + environment: environment, + signatureVerified: signatureVerified + ) +} diff --git a/apps/ios/Shared/Views/Badges/BadgeUserPreview.swift b/apps/ios/Shared/Views/Badges/BadgeUserPreview.swift new file mode 100644 index 0000000000..45d94c3ed6 --- /dev/null +++ b/apps/ios/Shared/Views/Badges/BadgeUserPreview.swift @@ -0,0 +1,40 @@ +// +// BadgeUserPreview.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 30.07.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct BadgeUserPreview: View { + @EnvironmentObject var chatModel: ChatModel + let level: BadgeLevel + let trailing: () -> Trailing + + init(level: BadgeLevel, @ViewBuilder trailing: @escaping () -> Trailing = { EmptyView() }) { + self.level = level + self.trailing = trailing + } + + var body: some View { + let user = chatModel.currentUser + let displayName = user?.displayName ?? NSLocalizedString("My nickname", comment: "badges preview placeholder") + let previewBadge = LocalBadge( + // fabricated for the preview: the status is given here, and NameBadge renders from it alone + badge: BadgeInfo(badgeType: level.badgeType, badgeExpiry: .distantFuture), + status: .active + ) + return VStack(spacing: 12) { + ProfileImage(imageStr: user?.image, size: 128) + HStack(alignment: .center, spacing: 6) { + NameWithBadge(Text(displayName).font(.largeTitle), previewBadge, .largeTitle) + .lineLimit(1) + .minimumScaleFactor(0.75) + trailing() + } + } + } +} diff --git a/apps/ios/Shared/Views/Badges/BadgesHowItWorksView.swift b/apps/ios/Shared/Views/Badges/BadgesHowItWorksView.swift new file mode 100644 index 0000000000..5758ceb12c --- /dev/null +++ b/apps/ios/Shared/Views/Badges/BadgesHowItWorksView.swift @@ -0,0 +1,47 @@ +// +// BadgesHowItWorksView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 28.07.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct BadgesHowItWorksView: View { + @EnvironmentObject var theme: AppTheme + + var body: some View { + VStack(alignment: .leading) { + Text("How badges protect your privacy") + .font(.largeTitle) + .bold() + .foregroundColor(theme.colors.primary) + .padding(.top, 8) + .padding(.bottom, 16) + ScrollView { + VStack(alignment: .leading, spacing: 12) { + Text("A badge is an anonymous credential stored in your profile on your device. This credential is never sent to anyone.") + Text("To prove to a contact or a server that you have a badge, the app generates a new proof that reveals only the badge type and the expiry date, rounded to a week.") + Text("Nobody can link two different proofs to each other or to the purchase.") + ExternalLink("Read more in our blog.", destination: URL(string: "https://simplex.chat/blog/20260919-simplex-supporter-badges.html")!) + } + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + } + Spacer() + } + .padding(.horizontal, 25) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .navigationBarTitleDisplayMode(.inline) + } +} + +struct BadgesHowItWorksView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + BadgesHowItWorksView() + } + } +} diff --git a/apps/ios/Shared/Views/Badges/BadgesLedgerView.swift b/apps/ios/Shared/Views/Badges/BadgesLedgerView.swift new file mode 100644 index 0000000000..a89bcb1299 --- /dev/null +++ b/apps/ios/Shared/Views/Badges/BadgesLedgerView.swift @@ -0,0 +1,156 @@ +// +// BadgesLedgerView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 17.09.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct BadgesLedgerView: View { + @EnvironmentObject var theme: AppTheme + @EnvironmentObject var chatModel: ChatModel + let badgeState: BadgeState + @State private var entries: [StatementEntry]? = nil + @State private var expanded: Set = [] + + var body: some View { + List { + if let entries { + Section { + if entries.isEmpty { + Text("No entries") + .foregroundColor(theme.colors.secondary) + } else { + ForEach(entries, id: \.entryId) { entry in + ledgerRow(entry) + } + } + } + } + } + .navigationTitle("Badge ledger") + .navigationBarTitleDisplayMode(.inline) + .modifier(ThemedBackground(grouped: true)) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { showShareSheet(items: [ledgerShareText()]) } label: { + Image(systemName: "square.and.arrow.up") + } + .disabled(entries?.isEmpty ?? true) + } + } + .onAppear(perform: loadLedger) + } + + @ViewBuilder private func ledgerRow(_ entry: StatementEntry) -> some View { + let isExpanded = expanded.contains(entry.entryId) + Button { + withAnimation { + if isExpanded { expanded.remove(entry.entryId) } else { expanded.insert(entry.entryId) } + } + } label: { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(entry.entryType.text) + Text(dateText(entry.createdAt)) + .font(.caption) + .foregroundColor(theme.colors.secondary) + } + Spacer() + Text(changeText(entry)) + .foregroundStyle(.secondary) + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .foregroundColor(theme.colors.secondary) + } + } + .foregroundColor(theme.colors.onBackground) + if isExpanded { + ForEach(entryFields(entry), id: \.0) { field in + infoRow(Text(field.0), field.1).padding(.leading, 24) + } + } + } + + private func changeText(_ entry: StatementEntry) -> String { + let n = entry.changeMonths + let months = abs(n) == 1 + ? String.localizedStringWithFormat(NSLocalizedString("%d month", comment: "time interval"), n) + : String.localizedStringWithFormat(NSLocalizedString("%d months", comment: "time interval"), n) + return n > 0 ? "+" + months : months + } + + private func entryFields(_ entry: StatementEntry) -> [(String, String)] { + var fields = [ + (NSLocalizedString("Date", comment: "ledger entry field"), dateTimeText(entry.createdAt)), + (NSLocalizedString("Balance", comment: "ledger entry field"), "\(entry.balanceMonths)"), + (NSLocalizedString("Balance start", comment: "ledger entry field"), dateTimeText(entry.balanceStartTs)), + (NSLocalizedString("Anchor", comment: "ledger entry field"), dateTimeText(entry.balanceAnchorTs)), + (NSLocalizedString("Badge type", comment: "ledger entry field"), entry.balanceBadgeType.text) + ] + if let pausedSince = entry.wasPausedSince { + fields.append((NSLocalizedString("Paused since", comment: "ledger entry field"), dateTimeText(pausedSince))) + } + fields.append((NSLocalizedString("Entry ID", comment: "ledger entry field"), entry.entryId)) + if let payload = payloadField(entry.entryType) { + fields.append(payload) + } + return fields + } + + private func payloadField(_ entryType: StatementEntryType) -> (String, String)? { + switch entryType { + case let .credit(credit): + switch credit { + case let .payment(invoiceId): + return invoiceId.map { (NSLocalizedString("Invoice ID", comment: "ledger entry field"), $0) } + case let .charge(chargeId): + return (NSLocalizedString("Charge ID", comment: "ledger entry field"), chargeId) + case let .transferIn(fromPurchaseKey): + return (NSLocalizedString("From purchase key", comment: "ledger entry field"), fromPurchaseKey) + case .code, .support, .opening, .unknown: + return nil + } + case let .debit(debit): + switch debit { + case let .upgrade(toPurchaseKey), let .transferOut(toPurchaseKey): + return (NSLocalizedString("To purchase key", comment: "ledger entry field"), toPurchaseKey) + case .refund, .support, .badge, .lapse, .unknown: + return nil + } + } + } + + // the JSON as core sent it: English field names and ISO dates, for support + private func ledgerShareText() -> String { + let encoder = getJSONEncoder() + encoder.outputFormatting = .prettyPrinted + let data = (try? encoder.encode(entries ?? [])) ?? Data() + return String(decoding: data, as: UTF8.self) + } + + private func dateText(_ date: Date) -> String { + DateFormatter.localizedString(from: date, dateStyle: .medium, timeStyle: .none) + } + + private func dateTimeText(_ date: Date) -> String { + DateFormatter.localizedString(from: date, dateStyle: .medium, timeStyle: .short) + } + + private func loadLedger() { + guard let user = chatModel.currentUser else { return } + Task { + do { + let ledger = try await apiGetBadgeLedger(user.userId, badgeState.badgePurchaseId) + await MainActor.run { entries = ledger } + } catch let e { + logger.error("apiGetBadgeLedger error: \(responseError(e))") + await MainActor.run { + showErrorAlert(e, NSLocalizedString("Error", comment: "")) + } + } + } + } +} diff --git a/apps/ios/Shared/Views/Badges/BadgesPayView.swift b/apps/ios/Shared/Views/Badges/BadgesPayView.swift new file mode 100644 index 0000000000..707bdb3059 --- /dev/null +++ b/apps/ios/Shared/Views/Badges/BadgesPayView.swift @@ -0,0 +1,269 @@ +// +// BadgesPayView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 28.07.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +// TODO [badges]: replace with types produced by the badge purchase API when it lands. +enum BadgePeriod: String, CaseIterable, Identifiable { + case oneMonth + case monthly + case annual + + var id: String { rawValue } + + var icon: String { + switch self { + case .oneMonth: "calendar" + case .monthly: "arrow.clockwise" + case .annual: "arrow.clockwise" + } + } + + var label: LocalizedStringKey { + switch self { + case .oneMonth: "1 month" + case .monthly: "Monthly" + case .annual: "Annual" + } + } + + func priceText(_ price: BadgePrice) -> Text { + switch price { + case .loading: return Text(verbatim: "…") + case .unavailable: return Text(verbatim: "—") + case let .price(p): + switch self { + case .oneMonth: return Text(verbatim: p) + case .monthly: return Text("\(p)/month") + case .annual: return Text("\(p)/year") + } + } + } + + func payText(_ price: BadgePrice) -> Text { + switch price { + case .loading: return Text("Loading…") + case .unavailable: return Text("Not available") + case let .price(p): + switch self { + case .oneMonth: return Text("Pay \(p)") + case .monthly: return Text("Pay \(p)/month") + case .annual: return Text("Pay \(p)/year") + } + } + } +} + +struct BadgesPayView: View { + @EnvironmentObject var theme: AppTheme + @ObservedObject private var store = BadgeStore.shared + let level: BadgeLevel + @State private var selectedPeriod: BadgePeriod = .monthly + @State private var purchasing = false + // presented from this view, not AlertManager: its host is behind the sheet these views open in + @State private var alert: SomeAlert? + + var body: some View { + GeometryReader { g in + ScrollView { + VStack(alignment: .center, spacing: 16) { + Text(level.title) + .font(.largeTitle) + .bold() + .foregroundColor(theme.colors.primary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + BadgeUserPreview(level: level) + .padding(.top, 4) + + Text(level.tagline) + .font(.body) + .foregroundColor(theme.colors.onBackground) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 4) + + Spacer(minLength: 20) + + // fixedSize + maxHeight on the cards so all three match the tallest one - + // only Annual carries a savings line, and prices wrap at large fonts + HStack(alignment: .top, spacing: 12) { + periodCard(.oneMonth) + periodCard(.monthly) + periodCard(.annual) + } + .fixedSize(horizontal: false, vertical: true) + + Spacer(minLength: 20) + + VStack(spacing: 10) { + payButton() + .padding(.vertical, 10) + Text(billingFooter) + .font(.footnote) + .foregroundColor(theme.colors.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .frame(height: 22) + } + .padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0) + } + .padding(.horizontal, 25) + .padding(.top, 0) + .padding(.bottom, 20) + .frame(minHeight: g.size.height) + } + } + .frame(maxHeight: .infinity) + .navigationBarTitleDisplayMode(.inline) + .task { await store.load() } + .alert(item: $alert) { $0.alert } + } + + private func periodCard(_ period: BadgePeriod) -> some View { + let isSelected = period == selectedPeriod + return Button { + selectedPeriod = period + } label: { + VStack(spacing: 12) { + Image(systemName: period.icon) + .resizable() + .scaledToFit() + .frame(width: 32, height: 32) + .foregroundColor(isSelected ? theme.colors.primary : theme.colors.secondary) + Text(period.label) + .font(.title3) + .fontWeight(.bold) + period.priceText(store.price(level, period)) + .font(.body) + if let percent = savingsPercent(period) { + Text("Save \(percent)%") + .font(.footnote) + .foregroundColor(isSelected ? theme.colors.primary : theme.colors.secondary) + } + } + .multilineTextAlignment(.center) + .padding(.vertical, 25) + .padding(.horizontal, 12) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(Color(uiColor: .secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(isSelected ? theme.colors.primary : Color(uiColor: .secondarySystemFill), lineWidth: 2) + ) + } + .buttonStyle(.plain) + } + + private func savingsPercent(_ period: BadgePeriod) -> Int? { + period == .annual ? store.annualSavings(level) : nil + } + + private func payButton() -> some View { + let price = store.price(level, selectedPeriod) + let disabled = !price.canPurchase || purchasing + return Button { + purchase() + } label: { + selectedPeriod.payText(price) + } + .buttonStyle(OnboardingButtonStyle(isDisabled: disabled)) + .disabled(disabled) + } + + private func purchase() { + let period = selectedPeriod + let invoiceId = newBadgeInvoiceId() + purchasing = true + Task { + do { + let outcome = try await store.purchase(level, period, invoiceId: invoiceId) + await MainActor.run { + purchasing = false + switch outcome { + case let .purchased(receipt): showPurchasedAlert(receipt, invoiceId) + case .pending: + alert = SomeAlert( + alert: mkAlert( + title: "Purchase pending", + message: "The purchase is awaiting approval. This build does not deliver purchases approved later." + ), + id: "badgePurchasePending" + ) + case .cancelled: break + } + } + } catch let error { + logger.error("BadgesPayView.purchase: \(String(describing: error))") + await MainActor.run { + purchasing = false + alert = SomeAlert( + alert: Alert( + title: Text("Purchase error"), + message: Text(verbatim: String(describing: error)) + ), + id: "badgePurchaseError" + ) + } + } + } + } + + // TODO [badges] store integration diagnostics - replaced by the issued badge once the service lands. + private func showPurchasedAlert(_ receipt: BadgeStoreReceipt, _ invoiceId: UUID) { + let returnedInvoice: String + if let returned = receipt.invoiceId { + returnedInvoice = returned == invoiceId ? "yes" : "mismatch: \(returned.uuidString)" + } else { + returnedInvoice = "none" + } + var lines = [ + "Product: \(receipt.productId)", + "Invoice: \(invoiceId.uuidString)", + "Invoice returned by Apple: \(returnedInvoice)", + "Transaction: \(receipt.transactionId)" + ] + if let environment = receipt.environment { lines.append("Environment: \(environment)") } + lines.append("Signature: \(receipt.signatureVerified ? "verified" : "unverified")") + lines.append("Token: \(receipt.jws.count) bytes") + let summary = lines.joined(separator: "\n") + // logged as well as shown: the alert races StoreKit's own sheets, the log always lands + logger.debug("badge purchase succeeded\n\(summary)") + alert = SomeAlert( + alert: Alert( + title: Text("Purchase successful"), + message: Text(verbatim: summary), + primaryButton: .default(Text(verbatim: "Copy token")) { UIPasteboard.general.string = receipt.jws }, + secondaryButton: .cancel(Text("Ok")) + ), + id: "badgePurchased" + ) + } + + private var billingFooter: LocalizedStringKey { + // TODO [badges] source the actual date from the purchase state machine when wired. + var comps = DateComponents(); comps.year = 2026; comps.month = 7; comps.day = 22 + let stubDate = Calendar.current.date(from: comps) ?? Date() + let date = DateFormatter.localizedString(from: stubDate, dateStyle: .long, timeStyle: .none) + switch selectedPeriod { + case .monthly, .annual: return "Renews on \(date). Cancel anytime." + case .oneMonth: return "Ends on \(date)." + } + } +} + +struct BadgesPayView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + BadgesPayView(level: .supporter) + } + } +} diff --git a/apps/ios/Shared/Views/Badges/BadgesRedeemCodeView.swift b/apps/ios/Shared/Views/Badges/BadgesRedeemCodeView.swift new file mode 100644 index 0000000000..6ea0c58edb --- /dev/null +++ b/apps/ios/Shared/Views/Badges/BadgesRedeemCodeView.swift @@ -0,0 +1,226 @@ +// +// BadgesRedeemCodeView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 28.07.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat +import CodeScanner + +private let badgeCodePrefix = "SB" +private let badgeCodeBodyLength = 20 +private let badgeCodeGroupLength = 5 + +// Regroups what was typed; validity and the folding of ambiguous characters are core's alone. +private func formatBadgeCodeInput(_ s: String) -> String { + var normalized = "" + for c in s.prefix(256).uppercased() { + guard c.isLetter || c.isNumber else { continue } + normalized.append(c) + if normalized.count == badgeCodePrefix.count + badgeCodeBodyLength { break } + } + guard normalized.hasPrefix(badgeCodePrefix) else { return normalized } + var groups = [badgeCodePrefix] + var i = normalized.index(normalized.startIndex, offsetBy: badgeCodePrefix.count) + while i < normalized.endIndex { + let j = normalized.index(i, offsetBy: badgeCodeGroupLength, limitedBy: normalized.endIndex) ?? normalized.endIndex + groups.append(String(normalized[i.. some View { + Text("Redeem code") + .font(.largeTitle) + .bold() + .foregroundColor(theme.colors.primary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + Text("Paste the code you received.") + .font(.body) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + codeField() + + pasteButton() + + ScannerInView( + showQRCodeScanner: $showQRCodeScanner, + scannerPaused: $submitting, + processQRCode: processQRCode, + scanMode: .oncePerCode, + placeholderBackground: Color(.tertiarySystemFill) + ) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 12) + + Spacer(minLength: 0) + + VStack(spacing: 10) { + submitButton() + .padding(.vertical, 10) + Color.clear + .frame(height: 22) + } + .padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0) + } + + private func codeField() -> some View { + TextField(text: $code) { Text(verbatim: "SB-XXXXX-XXXXX-XXXXX-XXXXX") } + .font(.body.monospaced()) + .multilineTextAlignment(.center) + .autocorrectionDisabled(true) + .textInputAutocapitalization(.characters) + .disabled(submitting) + .padding(EdgeInsets(top: 14, leading: 12, bottom: 14, trailing: 12)) + .background(Color(.tertiarySystemFill)) + .cornerRadius(10.0) + .onChange(of: code) { applyCodeInput($0) } + } + + private func pasteButton() -> some View { + Button { + if let pasted = UIPasteboard.general.string { applyCodeInput(pasted) } + } label: { + Text("Paste") + .font(.body) + .fontWeight(.medium) + .foregroundColor(theme.colors.primary) + } + .disabled(submitting) + } + + // a changed field re-enters through onChange with the formatted text, which is when it is parsed + private func applyCodeInput(_ s: String) { + let formatted = formatBadgeCodeInput(s) + if formatted != code { + code = formatted + } else { + canonicalCode = parseBadgeCode(formatted) + } + } + + private func processQRCode(_ resp: Result) { + switch resp { + case let .success(r): + let formatted = formatBadgeCodeInput(r.string) + if let canonical = parseBadgeCode(formatted) { + // set here rather than through onChange, which runs after redeem() reads it + code = formatted + canonicalCode = canonical + redeem() + } else { + showAlert( + NSLocalizedString("Invalid QR code", comment: "alert title"), + message: NSLocalizedString("The code you scanned is not a badge code.", comment: "alert message") + ) + } + case let .failure(e): + logger.error("processQRCode QR code error: \(e.localizedDescription)") + showAlert( + NSLocalizedString("Invalid QR code", comment: "alert title"), + message: String.localizedStringWithFormat(NSLocalizedString("Error scanning code: %@", comment: "alert message"), e.localizedDescription) + ) + } + } + + private func submitButton() -> some View { + let disabled = canonicalCode == nil || submitting + return Button { + redeem() + } label: { + Text("Redeem") + } + .buttonStyle(OnboardingButtonStyle(isDisabled: disabled)) + .disabled(disabled) + } + + private func redeem() { + guard let sending = canonicalCode, let user = chatModel.currentUser else { return } + submitting = true + Task { + do { + guard let redeemed = try await apiRedeemBadgeCode(user.userId, sending) else { + await MainActor.run { submitting = false } + return + } + await MainActor.run { + submitting = false + // set before dismissing: BadgesView then switches Support to Your Badge while this screen + // still covers it, so the pop lands on Your Badge instead of showing the switch + BadgeModel.shared.set(userId: user.userId, badgeState: redeemed.badgeState) + chatModel.updateUser(redeemed.user) + if let badgeState = redeemed.badgeState, !badgeState.shown { + // a replay adds no purchase; a fresh code's badge can be retired on arrival + let message = redeemed.newBadge + ? NSLocalizedString("The code was accepted, but the badge it grants has already ended.", comment: "alert message") + : NSLocalizedString("This code has already been used.", comment: "alert message") + showAlert(NSLocalizedString("Cannot redeem code", comment: "alert title"), message: message) + } else { + supporterBannerShown = true + dismiss() + } + } + } catch let error { + logger.error("apiRedeemBadgeCode: \(responseError(error))") + await MainActor.run { + submitting = false + showAlert(NSLocalizedString("Cannot redeem code", comment: "alert title"), message: redeemErrorText(error)) + } + } + } + } +} + +struct BadgesRedeemCodeView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + BadgesRedeemCodeView() + } + } +} diff --git a/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift b/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift new file mode 100644 index 0000000000..55d05f2615 --- /dev/null +++ b/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift @@ -0,0 +1,196 @@ +// +// BadgesSupportSimplexView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 28.07.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct BadgesSupportSimplexView: View { + @EnvironmentObject var theme: AppTheme + // set true when presented as a sheet root (from the chat-list banner) — that path doesn't + // reserve nav-bar space like a NavigationLink push does, so the title lands too close to the top + var showsAsSheet: Bool = false + @State private var whyBuiltActive = false + @State private var howItWorksActive = false + @State private var chooseLevelActive = false + @State private var redeemCodeActive = false + + var body: some View { + GeometryReader { g in + VStack(alignment: .center, spacing: 16) { + Text("Support SimpleX") + .font(.largeTitle) + .bold() + .foregroundColor(theme.colors.primary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + Text("Get a badge to send larger files (2-5GB) that stay available longer (7-21 days), and to show it on your profile.") + .font(.body) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + // TODO [badges] restore whyBuiltButton() when in-app purchase lands: the level screen + // returns to the flow and howItWorksButton() moves there, leaving this one alone here. + howItWorksButton() + + Spacer(minLength: 0) + + PhoneSupporterHero() + .frame(maxWidth: g.size.width * 0.55) + .layoutPriority(-1) + + Spacer(minLength: 0) + + VStack(spacing: 10) { + redeemCodeButton() + .padding(.vertical, 10) + getCodeButton() + .frame(height: 22) + } + .padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0) + } + .padding(.horizontal, 25) + .padding(.top, showsAsSheet ? 48 : 0) + .padding(.bottom, 20) + // .frame(height:) not minHeight — inside the banner sheet's NavigationView minHeight + // would let the VStack expand past the visible area and inflate the hero. + .frame(height: g.size.height) + } + .frame(maxHeight: .infinity) + .navigationBarTitleDisplayMode(.inline) + } + + // the in-app purchase path, kept compiling and uncalled until payments return after the MVP + private func chooseLevelButton() -> some View { + ZStack { + Button { + chooseLevelActive = true + } label: { + Text("Choose your level") + } + .buttonStyle(OnboardingButtonStyle(isDisabled: false)) + + NavigationLink(isActive: $chooseLevelActive) { + BadgesYourLevelView() + .modifier(ThemedBackground()) + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() + } + } + + private func whyBuiltButton() -> some View { + ZStack { + Button { whyBuiltActive = true } label: { + HStack(spacing: 4) { + Image(systemName: "info.circle") + Text("Why SimpleX is built.").fontWeight(.medium) + } + .font(.body) + } + NavigationLink(isActive: $whyBuiltActive) { + WhySimpleX(onboarding: false, titleColor: theme.colors.primary, createProfileNavLinkActive: .constant(false)) + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() + } + } + + private func howItWorksButton() -> some View { + ZStack { + Button { howItWorksActive = true } label: { + HStack(spacing: 4) { + Image(systemName: "info.circle") + Text("How badges protect your privacy").fontWeight(.medium) + } + .font(.body) + } + NavigationLink(isActive: $howItWorksActive) { + BadgesHowItWorksView() + .modifier(ThemedBackground()) + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() + } + } + + private func redeemCodeButton() -> some View { + ZStack { + Button { + redeemCodeActive = true + } label: { + Text("Redeem badge code") + } + .buttonStyle(OnboardingButtonStyle(isDisabled: false)) + + NavigationLink(isActive: $redeemCodeActive) { + BadgesRedeemCodeView() + .modifier(ThemedBackground()) + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() + } + } + + private func getCodeButton() -> some View { + Button { + openExternalLink(URL(string: "https://simplex.chat/badges/")!) + } label: { + Text("Get your code") + .font(.body) + .fontWeight(.medium) + .foregroundColor(theme.colors.primary) + } + } +} + +struct PhoneSupporterHero: View { + @EnvironmentObject var theme: AppTheme + @Environment(\.colorScheme) var colorScheme: ColorScheme + + var body: some View { + #if SIMPLEX_ASSETS + Image(colorScheme == .light ? "phone-supporter" : "phone-supporter-light") + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + #else + ZStack { + let gp = OnboardingCardView.gradientPoints(aspectRatio: 1.0, scale: colorScheme == .light ? 1.2 : 1.5) + LinearGradient( + stops: colorScheme == .light ? OnboardingCardView.lightStops : OnboardingCardView.darkStops, + startPoint: gp.start, + endPoint: gp.end + ) + Image("badge-supporter") + .resizable() + .scaledToFit() + .frame(width: 96) + } + .aspectRatio(1.0, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 24)) + .frame(maxWidth: .infinity) + #endif + } +} + +struct BadgesSupportSimplexView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + BadgesSupportSimplexView() + } + } +} diff --git a/apps/ios/Shared/Views/Badges/BadgesView.swift b/apps/ios/Shared/Views/Badges/BadgesView.swift new file mode 100644 index 0000000000..1960d25bf0 --- /dev/null +++ b/apps/ios/Shared/Views/Badges/BadgesView.swift @@ -0,0 +1,35 @@ +// +// BadgesView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 11.09.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct BadgesView: View { + @EnvironmentObject var chatModel: ChatModel + @ObservedObject private var badgeModel = BadgeModel.shared + var showsAsSheet: Bool = false + + private var shownBadge: BadgeState? { + guard badgeModel.userId == chatModel.currentUser?.userId else { return nil } + if let badgeState = badgeModel.badgeState, badgeState.shown { return badgeState } + return nil + } + + var body: some View { + Group { + if let badgeState = shownBadge { + BadgesYourBadgeView(badgeState: badgeState, showsAsSheet: showsAsSheet) + .transition(.opacity) + } else { + BadgesSupportSimplexView(showsAsSheet: showsAsSheet) + .transition(.opacity) + } + } + .animation(.default, value: shownBadge != nil) + } +} diff --git a/apps/ios/Shared/Views/Badges/BadgesYourBadgeView.swift b/apps/ios/Shared/Views/Badges/BadgesYourBadgeView.swift new file mode 100644 index 0000000000..5654d6e5aa --- /dev/null +++ b/apps/ios/Shared/Views/Badges/BadgesYourBadgeView.swift @@ -0,0 +1,105 @@ +// +// BadgesYourBadgeView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 11.09.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct BadgesYourBadgeView: View { + @EnvironmentObject var theme: AppTheme + @EnvironmentObject var chatModel: ChatModel + @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false + let badgeState: BadgeState + var showsAsSheet: Bool = false + + var body: some View { + VStack(spacing: 0) { + if showsAsSheet { + Text("Your badge") + .font(.largeTitle) + .bold() + .foregroundColor(theme.colors.primary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 25) + .padding(.top, 48) + } + + List { + Section { + BadgeSummary(badgeState: badgeState) + } + Section { + Text(badgeState.paidThroughText) + } header: { + Text("Ends") + .foregroundColor(theme.colors.secondary) + } footer: { + Text("Prepaid months have no billing date. The badge is reissued each month from the balance you already paid for, and ends when it runs out.") + .foregroundColor(theme.colors.secondary) + } + Section { + NavigationLink { + BadgesHowItWorksView() + .modifier(ThemedBackground()) + } label: { + settingsRow("info.circle", color: theme.colors.secondary) { + Text("How badges protect your privacy") + } + } + } + if developerTools { + Section(header: Text("Credential").foregroundColor(theme.colors.secondary)) { + if let badge = chatModel.currentUser?.profile.localBadge { + infoRow("Status", badge.status.rawValue) + infoRow("Expires", DateFormatter.localizedString(from: badge.badge.badgeExpiry, dateStyle: .medium, timeStyle: .short)) + } + infoRow("Months left", "\(badgeState.monthsLeft)") + infoRow("Purchase ID", "\(badgeState.badgePurchaseId)") + Button("Copy purchase key") { + UIPasteboard.general.string = badgeState.purchaseKey + } + NavigationLink { + BadgesLedgerView(badgeState: badgeState) + } label: { + Text("Badge ledger") + } + } + } + } + } + .frame(maxHeight: .infinity) + .navigationTitle(showsAsSheet ? "" : "Your badge") + .navigationBarTitleDisplayMode(showsAsSheet ? .inline : .large) + .modifier(ThemedBackground(grouped: true)) + } +} + +struct BadgeSummary: View { + @EnvironmentObject var theme: AppTheme + let badgeState: BadgeState + + var body: some View { + VStack(spacing: 4) { + Image(badgeImageName(badgeState.badgeType)) + .resizable() + .scaledToFit() + .frame(width: 68, height: 68) + .padding(.bottom, 8) + + Text(badgeTypeName(badgeState.badgeType)) + .font(.title3) + .fontWeight(.semibold) + + Text("shown on your profile") + .font(.footnote) + .foregroundColor(theme.colors.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + } +} diff --git a/apps/ios/Shared/Views/Badges/BadgesYourLevelView.swift b/apps/ios/Shared/Views/Badges/BadgesYourLevelView.swift new file mode 100644 index 0000000000..d47bcaa0a7 --- /dev/null +++ b/apps/ios/Shared/Views/Badges/BadgesYourLevelView.swift @@ -0,0 +1,188 @@ +// +// BadgesYourLevelView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 28.07.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +// TODO [badges]: replace with types produced by the badge purchase API when it lands. +enum BadgeLevel: String, CaseIterable, Identifiable { + case supporter + case legend + + var id: String { rawValue } + + var title: LocalizedStringKey { + switch self { + case .supporter: "Supporter" + case .legend: "Legend" + } + } + + var filesDescription: LocalizedStringKey { + switch self { + case .supporter: "Send 2GB files" + case .legend: "Send 5GB files" + } + } + + var tagline: LocalizedStringKey { + switch self { + case .supporter: "Optional profile badge\nand 2GB files" + case .legend: "Optional profile badge\nand 5GB files" + } + } + + var badgeType: BadgeType { + switch self { + case .supporter: .supporter + case .legend: .legend + } + } +} + +struct BadgesYourLevelView: View { + @EnvironmentObject var theme: AppTheme + @ObservedObject private var store = BadgeStore.shared + @State private var selectedLevel: BadgeLevel = .supporter + @State private var continueActive = false + @State private var howItWorksActive = false + + var body: some View { + GeometryReader { g in + ScrollView { + VStack(alignment: .center, spacing: 16) { + Text("Your level") + .font(.largeTitle) + .bold() + .foregroundColor(theme.colors.primary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + BadgeUserPreview(level: selectedLevel) { + Image(systemName: "chevron.down") + .font(.body) + .foregroundColor(theme.colors.primary) + } + .padding(.top, 4) + + Spacer(minLength: 20) + + // fixedSize + maxHeight on the cards so both match the taller one when a + // store price wraps in one of them + HStack(alignment: .top, spacing: 12) { + levelCard(.supporter) + levelCard(.legend) + } + .fixedSize(horizontal: false, vertical: true) + + Spacer(minLength: 20) + + VStack(spacing: 10) { + continueButton() + .padding(.vertical, 10) + howItWorksButton() + .frame(height: 22) + } + .padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0) + } + .padding(.horizontal, 25) + .padding(.top, 0) + .padding(.bottom, 20) + .frame(minHeight: g.size.height) + } + } + .frame(maxHeight: .infinity) + .navigationBarTitleDisplayMode(.inline) + .task { await store.load() } + } + + private func levelCard(_ level: BadgeLevel) -> some View { + let isSelected = level == selectedLevel + return Button { + selectedLevel = level + } label: { + VStack(spacing: 10) { + Image(badgeImageName(level.badgeType)) + .resizable() + .scaledToFit() + .frame(width: 60, height: 60) + .padding(.top, 20) + Text(level.title) + .font(.title3) + .fontWeight(.bold) + Text(level.filesDescription) + .font(.subheadline) + .foregroundColor(theme.colors.secondary) + BadgePeriod.monthly.priceText(store.price(level, .monthly)) + .font(.body) + .padding(.bottom, 20) + } + .multilineTextAlignment(.center) + .padding(.horizontal, 12) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(Color(uiColor: .secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(isSelected ? theme.colors.primary : Color(uiColor: .secondarySystemFill), lineWidth: 2) + ) + } + .buttonStyle(.plain) + } + + private func continueButton() -> some View { + ZStack { + Button { + continueActive = true + } label: { + Text("Continue") + } + .buttonStyle(OnboardingButtonStyle(isDisabled: false)) + + NavigationLink(isActive: $continueActive) { + BadgesPayView(level: selectedLevel) + .modifier(ThemedBackground()) + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() + } + } + + private func howItWorksButton() -> some View { + ZStack { + Button { + howItWorksActive = true + } label: { + HStack(spacing: 4) { + Image(systemName: "info.circle") + Text("How badges protect your privacy").fontWeight(.medium) + } + .font(.body) + } + + NavigationLink(isActive: $howItWorksActive) { + BadgesHowItWorksView() + .modifier(ThemedBackground()) + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() + } + } +} + +struct BadgesYourLevelView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + BadgesYourLevelView() + } + } +} diff --git a/apps/ios/Shared/Views/Badges/SupportSimpleXBanner.swift b/apps/ios/Shared/Views/Badges/SupportSimpleXBanner.swift new file mode 100644 index 0000000000..c2f6136d31 --- /dev/null +++ b/apps/ios/Shared/Views/Badges/SupportSimpleXBanner.swift @@ -0,0 +1,123 @@ +// +// SupportSimpleXBanner.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 28.07.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct SupportSimpleXBanner: View { + @EnvironmentObject var theme: AppTheme + @Environment(\.colorScheme) var colorScheme: ColorScheme + var title: LocalizedStringKey = "Support SimpleX" + var subtitle: LocalizedStringKey = "Get badge + better files" + let onTap: () -> Void + let onDismiss: () -> Void + + private let cardCornerRadius: CGFloat = 16 + // grows with Dynamic Type but never shrinks below the default so small-font users see the same + // banner as today; hero stays fixed so its above-card overhang shrinks at very large fonts + @ScaledMetric(relativeTo: .body) private var scaledCardHeight: CGFloat = 72 + private var cardHeight: CGFloat { max(72, scaledCardHeight) } + // matches OneHandUICard's segment icon leading so the text aligns with it in the list + private let cardLeadingPadding: CGFloat = 16 + private let cardTrailingPadding: CGFloat = 8 + private let heroWidth: CGFloat = 110 + // shorter than the natural drawn height so .clipped() slices the phone body at card bottom + private let heroVisibleHeight: CGFloat = 108 + // hero right edge sits exactly at the dismiss X's left edge (X trailing 16 + width 12) + private let heroTrailingPadding: CGFloat = 28 + private let textToHeroGap: CGFloat = 6 + + var body: some View { + // Card is the Button; hero is an overlay so it can extend above the card top without + // affecting layout size. Dismiss X is a ZStack sibling anchored to the card's top-right. + ZStack(alignment: .topTrailing) { + Button(action: onTap) { + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.headline) + .foregroundColor(theme.colors.primary) + .lineLimit(2) + Text(subtitle) + .font(.subheadline) + .foregroundColor(theme.colors.onBackground) + .lineLimit(2) + } + Spacer(minLength: heroWidth + heroTrailingPadding + textToHeroGap) + } + .padding(.leading, cardLeadingPadding) + .padding(.trailing, cardTrailingPadding) + .padding(.vertical, 12) + .frame(minHeight: cardHeight) + .background(gradientBackground()) + .clipShape(RoundedRectangle(cornerRadius: cardCornerRadius)) + } + .buttonStyle(.plain) + .overlay(alignment: .bottomTrailing) { + heroThumbnail() + .padding(.trailing, heroTrailingPadding) + .allowsHitTesting(false) + } + + Image(systemName: "multiply") + .foregroundColor(colorScheme == .dark ? theme.colors.onBackground : theme.colors.secondary) + .frame(width: 12, height: 12) + .padding(.top, 12) + .padding(.bottom, 4) + .padding(.trailing, 16) + .padding(.leading, 4) + .contentShape(Rectangle()) + .onTapGesture(perform: onDismiss) + } + } + + @ViewBuilder + private func heroThumbnail() -> some View { + #if SIMPLEX_ASSETS + // draws at natural aspect, top-aligned in a shorter slot; .clipped() cuts the overflow at card bottom + Image(colorScheme == .light ? "phone-supporter" : "phone-supporter-light") + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: heroWidth, height: heroVisibleHeight, alignment: .top) + .clipped() + #else + Image("badge-supporter") + .resizable() + .scaledToFit() + .frame(width: 48, height: 48) + .padding(.vertical, (cardHeight - 48) / 2) + .padding(.trailing, 12) + #endif + } + + private func gradientBackground() -> some View { + // Asymmetric scale: start (dark end) pushed further below the card than the end (warm) is + // above, so the card's middle lands at the bright/mid-transition stop instead of the dark + // navy region. Keeps the small warm accent at top-right. + GeometryReader { geo in + let aspect = max(geo.size.height, 1) / max(geo.size.width, 1) + let startScale: CGFloat = colorScheme == .light ? 2.5 : 3.0 + let endScale: CGFloat = colorScheme == .light ? 1.7 : 2.1 + let gp = OnboardingCardView.gradientPoints(aspectRatio: aspect, scale: 1.0) + let start = UnitPoint(x: 0.5 + (gp.start.x - 0.5) * startScale, y: 0.5 + (gp.start.y - 0.5) * startScale) + let end = UnitPoint(x: 0.5 + (gp.end.x - 0.5) * endScale, y: 0.5 + (gp.end.y - 0.5) * endScale) + return LinearGradient( + stops: colorScheme == .light ? OnboardingCardView.lightStops : OnboardingCardView.darkStops, + startPoint: start, + endPoint: end + ) + } + } +} + +struct SupportSimpleXBanner_Previews: PreviewProvider { + static var previews: some View { + SupportSimpleXBanner(onTap: {}, onDismiss: {}) + .padding() + } +} diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift index f08f4fba6a..8bb76ca155 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift @@ -21,7 +21,6 @@ struct CIFileView: View { @ObservedObject var chat: Chat let file: CIFile? let meta: CIMeta - let senderProfile: LocalProfile? var smallViewSize: CGFloat? var body: some View { @@ -91,19 +90,15 @@ struct CIFileView: View { if let file = file { switch (file.fileStatus) { case .rcvInvitation, .rcvAborted: - if fileSizeValid(file, senderProfile) { + if let prohibited = file.fileProhibited { + showProhibitedFileAlert(file, prohibited) + } else { Task { logger.debug("CIFileView fileAction - in .rcvInvitation, .rcvAborted, in Task") if let user = m.currentUser { await receiveFile(user: user, fileId: file.fileId) } } - } else { - let prettyMaxFileSize = ByteCountFormatter.string(fromByteCount: getMaxFileSize(file.fileProtocol, senderProfile), countStyle: .binary) - AlertManager.shared.showAlertMsg( - title: "Large file!", - message: "Your contact sent a file that is larger than currently supported maximum size (\(prettyMaxFileSize))." - ) } case .rcvAccepted: switch file.fileProtocol { @@ -171,7 +166,7 @@ struct CIFileView: View { case .sndError: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10) case .sndWarning: fileIcon("doc.fill", innerIcon: "exclamationmark.triangle.fill", innerIconSize: 10) case .rcvInvitation: - if !fileSizeValid(file, senderProfile) { + if !fileSizeValid(file) { fileIcon("doc.fill", color: .orange, innerIcon: "exclamationmark", innerIconSize: 12) } else if file.expired { fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10) @@ -235,13 +230,27 @@ struct CIFileView: View { } } -func fileSizeValid(_ file: CIFile?, _ senderProfile: LocalProfile?) -> Bool { +// the core decides whether a received file is above the size the sender's badge allows +func fileSizeValid(_ file: CIFile?) -> Bool { if let file = file { - return file.fileSize <= getMaxFileSize(file.fileProtocol, senderProfile) + return file.fileProhibited == nil } return false } +func showProhibitedFileAlert(_ file: CIFile, _ prohibited: FileProhibited) { + let badgeIssue = switch prohibited.badgeStatus { + case .none, .some(.active): "" + case .some(.expired), .some(.expiredOld): NSLocalizedString("Contact's badge expired.", comment: "file alert") + case .some(.failed): NSLocalizedString("Contact's badge verification failed.", comment: "file alert") + case .some(.unknownKey): NSLocalizedString("No key to verify contact's badge.", comment: "file alert") + } + showAlert( + NSLocalizedString("Large file!", comment: "file alert title"), + message: largeFileMessage(file.fileSize, badgeIssue: badgeIssue) + ) +} + func saveCryptoFile(_ fileSource: CryptoFile) { if let cfArgs = fileSource.cryptoArgs { let url = getAppFilePath(fileSource.filePath) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift index 93966543c6..fb6ccb1ea3 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift @@ -14,7 +14,6 @@ import SimpleXChat struct CIImageView: View { @EnvironmentObject var m: ChatModel let chatItem: ChatItem - let senderProfile: LocalProfile? var scrollToItem: ((ChatItem.ID) -> Void)? = nil var preview: UIImage? let maxWidth: CGFloat @@ -52,18 +51,14 @@ struct CIImageView: View { if let file = file { switch file.fileStatus { case .rcvInvitation, .rcvAborted: - if fileSizeValid(file, senderProfile) { + if let prohibited = file.fileProhibited { + showProhibitedFileAlert(file, prohibited) + } else { Task { if let user = m.currentUser { await receiveFile(user: user, fileId: file.fileId) } } - } else { - let prettyMaxFileSize = ByteCountFormatter.string(fromByteCount: getMaxFileSize(file.fileProtocol, senderProfile), countStyle: .binary) - AlertManager.shared.showAlertMsg( - title: "Large file!", - message: "Your contact sent a file that is larger than currently supported maximum size (\(prettyMaxFileSize))." - ) } case .rcvAccepted: switch file.fileProtocol { @@ -152,7 +147,7 @@ struct CIImageView: View { case .sndCancelled: fileIcon("xmark", 10, 13) case .sndError: fileIcon("xmark", 10, 13) case .sndWarning: fileIcon("exclamationmark.triangle.fill", 10, 13) - case .rcvInvitation: fileIcon(file.expired && fileSizeValid(file, senderProfile) ? "xmark" : "arrow.down", 10, 13) + case .rcvInvitation: fileIcon(file.expired && fileSizeValid(file) ? "xmark" : "arrow.down", 10, 13) case .rcvAccepted: fileIcon("ellipsis", 14, 11) case .rcvTransfer: progressView() case .rcvAborted: fileIcon("exclamationmark.arrow.circlepath", 14, 11) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift index d5340010ab..0df17dfe3d 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift @@ -16,7 +16,6 @@ import Combine struct CIVideoView: View { @EnvironmentObject var m: ChatModel private let chatItem: ChatItem - private let senderProfile: LocalProfile? private let preview: UIImage? @State private var duration: Int @State private var progress: Int = 0 @@ -36,9 +35,8 @@ struct CIVideoView: View { private var sizeMultiplier: CGFloat { smallView ? 0.38 : 1 } @State private var blurred: Bool = UserDefaults.standard.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) > 0 - init(chatItem: ChatItem, senderProfile: LocalProfile?, preview: UIImage?, duration: Int, maxWidth: CGFloat, videoWidth: CGFloat?, smallView: Bool = false, showFullscreenPlayer: Binding) { + init(chatItem: ChatItem, preview: UIImage?, duration: Int, maxWidth: CGFloat, videoWidth: CGFloat?, smallView: Bool = false, showFullscreenPlayer: Binding) { self.chatItem = chatItem - self.senderProfile = senderProfile self.preview = preview self._duration = State(initialValue: duration) self.maxWidth = maxWidth @@ -368,7 +366,7 @@ struct CIVideoView: View { .simultaneousGesture(TapGesture().onEnded { showFileErrorAlert(sndFileError, temporary: true) }) - case .rcvInvitation: fileIcon(file.expired && fileSizeValid(file, senderProfile) ? "xmark" : "arrow.down", 10, 13) + case .rcvInvitation: fileIcon(file.expired && fileSizeValid(file) ? "xmark" : "arrow.down", 10, 13) case .rcvAccepted: fileIcon("ellipsis", 14, 11) case let .rcvTransfer(rcvProgress, rcvTotal): if file.fileProtocol == .xftp && rcvProgress < rcvTotal { @@ -423,18 +421,14 @@ struct CIVideoView: View { // TODO encrypt: where file size is checked? private func receiveFileIfValidSize(file: CIFile, receiveFile: @escaping (User, Int64, Bool, Bool) async -> Void) { - if fileSizeValid(file, senderProfile) { + if let prohibited = file.fileProhibited { + showProhibitedFileAlert(file, prohibited) + } else { Task { if let user = m.currentUser { await receiveFile(user, file.fileId, false, false) } } - } else { - let prettyMaxFileSize = ByteCountFormatter.string(fromByteCount: getMaxFileSize(file.fileProtocol, senderProfile), countStyle: .binary) - AlertManager.shared.showAlertMsg( - title: "Large file!", - message: "Your contact sent a file that is larger than currently supported maximum size (\(prettyMaxFileSize))." - ) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index ac27cb27c2..f822970ea4 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -155,7 +155,7 @@ struct FramedItemView: View { } else { switch (chatItem.content.msgContent) { case let .image(text, _): - CIImageView(chatItem: chatItem, senderProfile: ciSenderProfile(chatItem, chat.chatInfo), scrollToItem: scrollToItem, preview: preview, maxWidth: maxWidth, imgWidth: imgWidth, showFullScreenImage: $showFullscreenGallery) + CIImageView(chatItem: chatItem, scrollToItem: scrollToItem, preview: preview, maxWidth: maxWidth, imgWidth: imgWidth, showFullScreenImage: $showFullscreenGallery) .overlay(DetermineWidth()) if text == "" && !chatItem.meta.isLive { Color.clear @@ -170,7 +170,7 @@ struct FramedItemView: View { ciMsgContentView(chatItem) } case let .video(text, _, duration): - CIVideoView(chatItem: chatItem, senderProfile: ciSenderProfile(chatItem, chat.chatInfo), preview: preview, duration: duration, maxWidth: maxWidth, videoWidth: videoWidth, showFullscreenPlayer: $showFullscreenGallery) + CIVideoView(chatItem: chatItem, preview: preview, duration: duration, maxWidth: maxWidth, videoWidth: videoWidth, showFullscreenPlayer: $showFullscreenGallery) .overlay(DetermineWidth()) if text == "" && !chatItem.meta.isLive { Color.clear @@ -387,7 +387,7 @@ struct FramedItemView: View { } @ViewBuilder private func ciFileView(_ ci: ChatItem, _ text: String) -> some View { - CIFileView(chat: chat, file: chatItem.file, meta: chatItem.meta, senderProfile: ciSenderProfile(chatItem, chat.chatInfo)) + CIFileView(chat: chat, file: chatItem.file, meta: chatItem.meta) .overlay(DetermineWidth()) if text != "" || ci.meta.isLive { ciMsgContentView (chatItem) diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 08941e9ccc..d18d8ae145 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -2322,7 +2322,7 @@ struct ChatView: View { } else { saveButton(file: fileSource) } - } else if let file = ci.file, case .rcvInvitation = file.fileStatus, fileSizeValid(file, ciSenderProfile(ci, chat.chatInfo)) { + } else if let file = ci.file, case .rcvInvitation = file.fileStatus, fileSizeValid(file) { downloadButton(file: file) } if ci.meta.editable && !mc.isVoice && !live { diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 54f8597ffc..caa4e2a501 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -668,10 +668,9 @@ struct ComposeView: View { fileSize <= maxFileSize { composeState = composeState.copy(preview: .filePreview(fileName: fileURL.lastPathComponent, file: fileURL)) } else { - let prettyMaxFileSize = ByteCountFormatter.string(fromByteCount: maxFileSize, countStyle: .binary) - AlertManager.shared.showAlertMsg( - title: "Large file!", - message: "Currently maximum supported file size is \(prettyMaxFileSize)." + showAlert( + NSLocalizedString("Large file!", comment: "file alert title"), + message: largeFileMessage(Int64(fileSize ?? 0), incognito: sendIncognito, badgeIssue: expiredBadgeReason(Int64(fileSize ?? 0), sendProfile)) ) } } catch { @@ -1266,12 +1265,15 @@ struct ComposeView: View { } } - private var maxFileSize: Int64 { - // the user's active badge raises the limit, but not in incognito chats where no badge is presented - let incognito = chat.chatInfo.profileChangeProhibited ? chat.chatInfo.incognito : incognitoDefault - return getMaxFileSize(.xftp, incognito ? nil : chatModel.currentUser?.profile) + // no badge is presented in incognito chats, so it does not raise the limit there + private var sendIncognito: Bool { + chat.chatInfo.profileChangeProhibited ? chat.chatInfo.incognito : incognitoDefault } + private var sendProfile: LocalProfile? { sendIncognito ? nil : chatModel.currentUser?.profile } + + private var maxFileSize: Int64 { getMaxFileSize(.xftp, sendProfile) } + // Spec: spec/client/compose.md#sendLiveMessage private func sendLiveMessage() async { let typedMsg = composeState.message diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index b05e0696e3..3797ec0f63 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -154,9 +154,11 @@ struct UserPickerSheetView: View { struct ChatListView: View { @EnvironmentObject var chatModel: ChatModel @StateObject private var connectProgressManager = ConnectProgressManager.shared + @ObservedObject private var badgeModel = BadgeModel.shared @EnvironmentObject var theme: AppTheme @Binding var activeUserPickerSheet: UserPickerSheet? @State private var showNewChatSheet = false + @State private var showGetStakeSheet = false @State private var searchMode = false @FocusState private var searchFocussed @State private var searchText = "" @@ -174,7 +176,11 @@ struct ChatListView: View { @AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true @AppStorage(DEFAULT_ONE_HAND_UI_CARD_SHOWN) private var oneHandUICardShown = false @AppStorage(DEFAULT_ADDRESS_CREATION_CARD_SHOWN) private var addressCreationCardShown = false + @AppStorage(DEFAULT_SUPPORTER_BANNER_SHOWN) private var supporterBannerShown = false + @AppStorage(DEFAULT_GET_STAKE_BANNER_TAPPED) private var getStakeBannerTapped = false + @AppStorage(DEFAULT_GET_STAKE_BANNER_DISMISSED) private var getStakeBannerDismissed = false @AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial + @State private var showBadgesSheet = false // Spec: spec/client/chat-list.md#body var body: some View { @@ -211,6 +217,15 @@ struct ChatListView: View { NewChatSheet() .environment(\EnvironmentValues.refresh as! WritableKeyPath, nil) } + .appSheet(isPresented: $showBadgesSheet) { + NavigationView { + BadgesView(showsAsSheet: true) + .modifier(ThemedBackground()) + } + } + .appSheet(isPresented: $showGetStakeSheet) { + GetStakeView(fromSettings: false, showFirstImage: true) + } .onChange(of: activeUserPickerSheet) { if $0 != nil { DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { @@ -363,8 +378,42 @@ struct ChatListView: View { } } + // the onboarding cards replace the whole chat list, and the support-ended banner lives in the + // list - a lapsed supporter is not a newcomer, and must be told even with no conversations yet private var shouldShowOnboarding: Bool { - !addressCreationCardShown && !chatModel.chats.isEmpty && !hasConversations + !addressCreationCardShown && !chatModel.chats.isEmpty && !hasConversations && !supportEnded + } + + private var supportEnded: Bool { + badgeModel.alert?.kind == .supportEnded && badgeModel.userId == chatModel.currentUser?.userId + } + + private var hasShownBadge: Bool { + badgeModel.badgeState?.shown == true && badgeModel.userId == chatModel.currentUser?.userId + } + + private func showSupportEndedDismissAlert() { + showAlert(NSLocalizedString("Your badge expired", comment: "alert title")) { + [ + UIAlertAction(title: NSLocalizedString("Remind me later", comment: "alert button"), style: .default) { _ in + Task { await ackBadgeAlert(snooze: true) } + }, + UIAlertAction(title: NSLocalizedString("Dismiss", comment: "alert button"), style: .default) { _ in + Task { await ackBadgeAlert(snooze: false) } + }, + cancelAlertAction + ] + } + } + + private func showSupportSimpleXDismissAlert() { + showAlert( + title: NSLocalizedString("Support SimpleX", comment: "alert title"), + message: NSLocalizedString("You can support SimpleX later in Settings.", comment: "alert message"), + buttonTitle: NSLocalizedString("Ok", comment: "alert button"), + buttonAction: { withAnimation { supporterBannerShown = true } }, + cancelButton: false + ) } private var hasConversations: Bool { @@ -382,14 +431,26 @@ struct ChatListView: View { @ViewBuilder private var chatList: some View { if shouldShowOnboarding { - ConnectOnboardingView() - .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) - .modifier(ThemedBackground()) + VStack(spacing: 0) { + ConnectOnboardingView() + if isInUS && !getStakeBannerDismissed { + GetStakeBanner(showDismiss: false, onTap: openGetStake, onDismiss: {}) + .padding(.horizontal, 20) + .padding(.bottom, 8) + } + } + .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) + .modifier(ThemedBackground()) } else { chatListContent } } + private func openGetStake() { + getStakeBannerTapped = true + showGetStakeSheet = true + } + private var chatListContent: some View { let cs = filteredChats() return ZStack { @@ -418,6 +479,41 @@ struct ChatListView: View { .listRowSeparator(.hidden) .listRowBackground(Color.clear) } + // one slot: a badge the user paid for ending outranks the pitch to get one + if supportEnded, let alert = badgeModel.alert { + SupportSimpleXBanner( + title: "Your badge expired", + subtitle: "Your badge expired on \(alert.dateText).", + onTap: { showBadgesSheet = true }, + onDismiss: showSupportEndedDismissAlert + ) + .padding(.vertical, 3) + .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + .zIndex(1) + } else if !supporterBannerShown && !hasShownBadge && chatModel.chats.count > 3 { + SupportSimpleXBanner( + onTap: { showBadgesSheet = true }, + onDismiss: showSupportSimpleXDismissAlert + ) + .padding(.vertical, 3) + .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + .zIndex(1) + } else if isInUS && !getStakeBannerDismissed { + GetStakeBanner( + showDismiss: getStakeBannerTapped && !chatModel.chats.isEmpty, + onTap: openGetStake, + onDismiss: { withAnimation { getStakeBannerDismissed = true } } + ) + .padding(.vertical, 3) + .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + .zIndex(1) + } if #available(iOS 16.0, *) { ForEach(cs, id: \.viewId) { chat in ChatListNavLink(chat: chat, parentSheet: $sheet) @@ -518,7 +614,7 @@ struct ChatListView: View { VoiceItemState.smallView.values.forEach { $0.audioPlayer?.stop() } VoiceItemState.smallView = [:] } - + // Spec: spec/client/chat-list.md#filteredChats private func filteredChats() -> [Chat] { if !searchChatFilteredBySimplexLink.isEmpty { diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 59b92a265b..5ee991a466 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -426,11 +426,11 @@ struct ChatPreviewView: View { } case let .image(_, image): smallContentPreview(size: dynamicMediaSize) { - CIImageView(chatItem: ci, senderProfile: ciSenderProfile(ci, chat.chatInfo), preview: imageFromBase64(image), maxWidth: dynamicMediaSize, smallView: true, showFullScreenImage: $showFullscreenGallery) + CIImageView(chatItem: ci, preview: imageFromBase64(image), maxWidth: dynamicMediaSize, smallView: true, showFullScreenImage: $showFullscreenGallery) } case let .video(_,image, duration): smallContentPreview(size: dynamicMediaSize) { - CIVideoView(chatItem: ci, senderProfile: ciSenderProfile(ci, chat.chatInfo), preview: imageFromBase64(image), duration: duration, maxWidth: dynamicMediaSize, videoWidth: nil, smallView: true, showFullscreenPlayer: $showFullscreenGallery) + CIVideoView(chatItem: ci, preview: imageFromBase64(image), duration: duration, maxWidth: dynamicMediaSize, videoWidth: nil, smallView: true, showFullscreenPlayer: $showFullscreenGallery) } case let .voice(_, duration): smallContentPreviewVoice(size: dynamicMediaSize) { @@ -438,7 +438,7 @@ struct ChatPreviewView: View { } case .file: smallContentPreviewFile(size: dynamicMediaSize) { - CIFileView(chat: chat, file: ci.file, meta: ci.meta, senderProfile: ciSenderProfile(ci, chat.chatInfo), smallViewSize: dynamicMediaSize) + CIFileView(chat: chat, file: ci.file, meta: ci.meta, smallViewSize: dynamicMediaSize) } case let .chat(_, chatLink, ownerSig): smallContentPreview(size: dynamicMediaSize, borderColor: chatLink.image != nil ? .secondary : .clear) { diff --git a/apps/ios/Shared/Views/ChatList/GetStakeBanner.swift b/apps/ios/Shared/Views/ChatList/GetStakeBanner.swift new file mode 100644 index 0000000000..1ba9b4ab09 --- /dev/null +++ b/apps/ios/Shared/Views/ChatList/GetStakeBanner.swift @@ -0,0 +1,111 @@ +// +// GetStakeBanner.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 21.09.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +// Spec: spec/client/chat-list.md#GetStakeBanner +struct GetStakeBanner: View { + @EnvironmentObject var theme: AppTheme + var showDismiss: Bool + let onTap: () -> Void + let onDismiss: () -> Void + + var body: some View { + ZStack(alignment: .topTrailing) { + Button(action: onTap) { + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 4) { + Text("Get a stake in SimpleX Chat!") + .font(.headline) + .foregroundColor(theme.colors.primary) + .lineLimit(2) + Text("Invest on Wefunder from $100") + .font(.subheadline) + .foregroundColor(theme.colors.onBackground) + .lineLimit(2) + } + Spacer(minLength: 6) + Image("wefunder_logo") + .resizable() + .scaledToFit() + .frame(width: 48, height: 48) + // roughly where the phone of the badge banner's hero is: 40pt from the card edge, less its 8pt inset + .padding(.trailing, 32) + } + .modifier(BannerCard()) + } + .buttonStyle(.plain) + + if showDismiss { + BannerDismissButton(onDismiss: onDismiss) + } + } + } +} + +struct BannerCard: ViewModifier { + @Environment(\.colorScheme) var colorScheme: ColorScheme + + // grows with Dynamic Type but never shrinks below the default, so small-font users see the same card + @ScaledMetric(relativeTo: .body) private var scaledCardHeight: CGFloat = 72 + private var cardHeight: CGFloat { max(72, scaledCardHeight) } + + func body(content: Content) -> some View { + content + // the leading padding matches OneHandUICard's segment icon, so the text aligns with it in the list + .padding(.leading, 16) + .padding(.trailing, 8) + .padding(.vertical, 12) + .frame(minHeight: cardHeight) + .background(gradientBackground()) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } + + private func gradientBackground() -> some View { + // Asymmetric scale: start (dark end) pushed further below the card than the end (warm) is + // above, so the card's middle lands at the bright/mid-transition stop instead of the dark + // navy region. Keeps the small warm accent at top-right. + GeometryReader { geo in + let aspect = max(geo.size.height, 1) / max(geo.size.width, 1) + let startScale: CGFloat = colorScheme == .light ? 2.5 : 3.0 + let endScale: CGFloat = colorScheme == .light ? 1.7 : 2.1 + let gp = OnboardingCardView.gradientPoints(aspectRatio: aspect, scale: 1.0) + let start = UnitPoint(x: 0.5 + (gp.start.x - 0.5) * startScale, y: 0.5 + (gp.start.y - 0.5) * startScale) + let end = UnitPoint(x: 0.5 + (gp.end.x - 0.5) * endScale, y: 0.5 + (gp.end.y - 0.5) * endScale) + return LinearGradient( + stops: colorScheme == .light ? OnboardingCardView.lightStops : OnboardingCardView.darkStops, + startPoint: start, + endPoint: end + ) + } + } +} + +struct BannerDismissButton: View { + @EnvironmentObject var theme: AppTheme + @Environment(\.colorScheme) var colorScheme: ColorScheme + let onDismiss: () -> Void + + var body: some View { + Image(systemName: "multiply") + .foregroundColor(colorScheme == .dark ? theme.colors.onBackground : theme.colors.secondary) + .frame(width: 12, height: 12) + .padding(.top, 12) + .padding(.bottom, 4) + .padding(.trailing, 16) + .padding(.leading, 4) + .contentShape(Rectangle()) + .onTapGesture(perform: onDismiss) + } +} + +#Preview { + GetStakeBanner(showDismiss: true, onTap: {}, onDismiss: {}) + .padding() +} diff --git a/apps/ios/Shared/Views/ChatList/UserPicker.swift b/apps/ios/Shared/Views/ChatList/UserPicker.swift index 8c230dc56a..a25ebf3419 100644 --- a/apps/ios/Shared/Views/ChatList/UserPicker.swift +++ b/apps/ios/Shared/Views/ChatList/UserPicker.swift @@ -42,7 +42,8 @@ struct UserPicker: View { HStack { ProfileImage(imageStr: user.image, size: imageSize, color: Color(uiColor: .tertiarySystemGroupedBackground)) .padding(.trailing, 6) - profileName(user).lineLimit(1) + NameWithBadge(profileName(user), user.profile.localBadge, .title2) + .lineLimit(1) } .padding(rowPadding) .frame(width: otherUsers.isEmpty ? sectionWidth : currentUserWidth, alignment: .leading) diff --git a/apps/ios/Shared/Views/Helpers/NameBadge.swift b/apps/ios/Shared/Views/Helpers/NameBadge.swift index 5a48e495a2..26d9821158 100644 --- a/apps/ios/Shared/Views/Helpers/NameBadge.swift +++ b/apps/ios/Shared/Views/Helpers/NameBadge.swift @@ -95,7 +95,7 @@ struct NameBadge: View { } } -private func badgeImageName(_ t: BadgeType) -> String { +func badgeImageName(_ t: BadgeType) -> String { switch t { case .legend: "badge-legend" case .investor: "badge-investor" @@ -147,8 +147,7 @@ func showBadgeInfoAlert(_ name: String, _ badge: LocalBadge) { ) default: // a verified badge's type is signed and can't be faked, so the real (possibly unknown) type name is the title - let t = badge.badge.badgeType.text - let title = t.prefix(1).uppercased() + t.dropFirst() + let title = badgeTypeName(badge.badge.badgeType) if case .investor = badge.badge.badgeType { let message = String.localizedStringWithFormat(NSLocalizedString("%@ invested in SimpleX Chat crowdfunding.", comment: "badge alert"), name) showAlert(title, message: message) { @@ -172,3 +171,13 @@ func showBadgeInfoAlert(_ name: String, _ badge: LocalBadge) { } } } + +// verbatim for an unknown type: it is the service's string, and must not be looked up as a localised key +func badgeTypeName(_ t: BadgeType) -> String { + switch t { + case .supporter: NSLocalizedString("Supporter", comment: "badge type") + case .legend: NSLocalizedString("Legend", comment: "badge type") + case .investor: NSLocalizedString("Investor", comment: "badge type") + case let .unknown(s): s + } +} diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index f938fb0063..ef691b10f9 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -739,6 +739,7 @@ struct ScannerInView: View { let processQRCode: (_ resp: Result) -> Void @State private var cameraAuthorizationStatus: AVAuthorizationStatus? var scanMode: ScanMode = .continuous + var placeholderBackground = Color(uiColor: .secondarySystemGroupedBackground) var body: some View { Group { @@ -777,7 +778,7 @@ struct ScannerInView: View { .padding() .background( RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(Color(uiColor: .secondarySystemGroupedBackground)) + .fill(placeholderBackground) ) .padding(.horizontal) .listRowBackground(Color.clear) diff --git a/apps/ios/Shared/Views/Onboarding/HowItWorks.swift b/apps/ios/Shared/Views/Onboarding/HowItWorks.swift index e9b9c6b970..dd01534dfb 100644 --- a/apps/ios/Shared/Views/Onboarding/HowItWorks.swift +++ b/apps/ios/Shared/Views/Onboarding/HowItWorks.swift @@ -65,6 +65,7 @@ struct WhySimpleX: View { @Environment(\.dismiss) var dismiss: DismissAction @EnvironmentObject var m: ChatModel var onboarding: Bool + var titleColor: Color? = nil @Binding var createProfileNavLinkActive: Bool var body: some View { @@ -74,6 +75,7 @@ struct WhySimpleX: View { Text("You were born without an account") .font(.title) .bold() + .foregroundColor(titleColor) .padding(.top) Text("Nobody tracked your conversations. No one drew a map of where you'd been. Privacy was never a feature - it was the way of life.") Text("Then we moved online, and every platform asked for a piece of you - your name, your number, your friends. We accepted that the price of talking to others is letting someone know who we talk to. Every generation, people and tech, had it this way - telephone, email, messengers, social media. It seemed the only way possible.") diff --git a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift index ba87729012..6cafb78043 100644 --- a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift +++ b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift @@ -698,6 +698,20 @@ private let versionDescriptions: [VersionDescription] = [ )) ] ), + // TODO [badges] restore when badges ship, with final copy and the Read more link. + /* + VersionDescription( + version: "v7.1", + post: nil, + features: [ + .view(FeatureView( + icon: nil, + title: "Supporter badge", + view: { SupporterBadgeWhatsNew() } + )) + ] + ), + */ ] private let lastVersion = versionDescriptions.last!.version @@ -712,6 +726,42 @@ func shouldShowWhatsNew() -> Bool { return v != lastVersion } +fileprivate struct SupporterBadgeWhatsNew: View { + @Environment(\.colorScheme) var colorScheme: ColorScheme + @EnvironmentObject var theme: AppTheme + + var body: some View { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 6) { + Text("Supporter badge ❤️") + .font(.title3) + .bold() + Text("Help keep the network running — send files up to 2 GB.") + .multilineTextAlignment(.leading) + .lineLimit(10) + } + .frame(maxWidth: .infinity, alignment: .leading) + + heroThumbnail() + } + } + + @ViewBuilder + private func heroThumbnail() -> some View { + #if SIMPLEX_ASSETS + Image(colorScheme == .light ? "phone-supporter" : "phone-supporter-light") + .resizable() + .scaledToFit() + .frame(width: 110, height: 110) + #else + Image("badge-supporter") + .resizable() + .scaledToFit() + .frame(width: 72, height: 72) + #endif + } +} + fileprivate struct NewOperatorsView: View { var body: some View { VStack(alignment: .leading) { @@ -795,7 +845,7 @@ fileprivate struct InvestInSimpleXChat: View { } .frame(maxWidth: .infinity, alignment: .leading) .sheet(isPresented: $showGetStakeSheet) { - GetStakeView(fromSettings: false) + GetStakeView(fromSettings: false, showFirstImage: false) } } } @@ -835,6 +885,7 @@ struct GetStakeView: View { @Environment(\.dismiss) var dismiss: DismissAction @EnvironmentObject var chatModel: ChatModel var fromSettings: Bool + var showFirstImage: Bool var body: some View { ZoomablePageView { @@ -844,7 +895,7 @@ struct GetStakeView: View { .bold() .fixedSize(horizontal: false, vertical: true) .if(!fromSettings) { $0.padding(.top) } - if fromSettings { + if showFirstImage { slideImage(getStakeSlides[0]) } (Text(verbatim: getStakeSlides[0].text) + Text(verbatim: " Learn more and invest on Wefunder.").bold().foregroundColor(.accentColor)) diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index ae317cd864..efc16a6309 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -56,6 +56,9 @@ let DEFAULT_CHAT_ITEM_ROUNDNESS = "chatItemRoundness" let DEFAULT_CHAT_ITEM_TAIL = "chatItemTail" let DEFAULT_ONE_HAND_UI_CARD_SHOWN = "oneHandUICardShown" let DEFAULT_ADDRESS_CREATION_CARD_SHOWN = "addressCreationCardShown" +let DEFAULT_SUPPORTER_BANNER_SHOWN = "supporterBannerShown" +let DEFAULT_GET_STAKE_BANNER_TAPPED = "getStakeBannerTapped" +let DEFAULT_GET_STAKE_BANNER_DISMISSED = "getStakeBannerDismissed" let DEFAULT_TOOLBAR_MATERIAL = "toolbarMaterial" let DEFAULT_CONNECT_VIA_LINK_TAB = "connectViaLinkTab" let DEFAULT_LIVE_MESSAGE_ALERT_SHOWN = "liveMessageAlertShown" @@ -117,6 +120,9 @@ let appDefaults: [String: Any] = [ DEFAULT_CHAT_ITEM_TAIL: true, DEFAULT_ONE_HAND_UI_CARD_SHOWN: false, DEFAULT_ADDRESS_CREATION_CARD_SHOWN: false, + DEFAULT_SUPPORTER_BANNER_SHOWN: false, + DEFAULT_GET_STAKE_BANNER_TAPPED: false, + DEFAULT_GET_STAKE_BANNER_DISMISSED: false, DEFAULT_TOOLBAR_MATERIAL: ToolbarMaterial.defaultMaterial, DEFAULT_CONNECT_VIA_LINK_TAB: ConnectViaLinkTab.scan.rawValue, DEFAULT_LIVE_MESSAGE_ALERT_SHOWN: false, @@ -148,6 +154,9 @@ let hintDefaults = [ DEFAULT_LA_NOTICE_SHOWN, DEFAULT_ONE_HAND_UI_CARD_SHOWN, DEFAULT_ADDRESS_CREATION_CARD_SHOWN, + DEFAULT_SUPPORTER_BANNER_SHOWN, + DEFAULT_GET_STAKE_BANNER_TAPPED, + DEFAULT_GET_STAKE_BANNER_DISMISSED, DEFAULT_LIVE_MESSAGE_ALERT_SHOWN, DEFAULT_SIGN_MESSAGE_ALERT_SHOWN, DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE, @@ -341,6 +350,22 @@ struct SettingsView: View { } } + Section { + NavigationLink { + BadgesView() + .modifier(ThemedBackground()) + } label: { + let badgeType = chatModel.currentUser?.profile.localBadge?.badge.badgeType ?? .supporter + ZStack(alignment: .leading) { + Image(badgeImageName(badgeType)) + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + Text("Supporter perks").padding(.leading, indent) + } + } + } + Section(header: Text("Advanced settings").foregroundColor(theme.colors.secondary)) { NavigationLink { NetworkAndServers() @@ -375,7 +400,7 @@ struct SettingsView: View { NavigationLink { VersionView() .navigationBarTitle("App version") - .modifier(ThemedBackground()) + .modifier(ThemedBackground(grouped: true)) } label: { Text(verbatim: "v\(appVersion ?? "?")") } @@ -384,7 +409,7 @@ struct SettingsView: View { if isInUS { Section(header: Text("You can now invest in SimpleX Chat").foregroundColor(theme.colors.secondary)) { NavigationLink { - GetStakeView(fromSettings: true) + GetStakeView(fromSettings: true, showFirstImage: true) .navigationBarTitle("", displayMode: .inline) } label: { settingsRow("dollarsign.circle", color: theme.colors.secondary) { Text("Crowdfunding on Wefunder") } diff --git a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift index 3f3adbcb2d..890c672bcd 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift @@ -346,7 +346,7 @@ struct UserProfilesView: View { HStack { ProfileImage(imageStr: user.image, size: 38) .padding(.trailing, 12) - Text(user.chatViewName) + NameWithBadge(Text(user.chatViewName), user.profile.localBadge) Spacer() if user.activeUser { Image(systemName: "checkmark").foregroundColor(theme.colors.onBackground) diff --git a/apps/ios/SimpleX SE/ShareModel.swift b/apps/ios/SimpleX SE/ShareModel.swift index 9790e5944f..ea1bbbd46f 100644 --- a/apps/ios/SimpleX SE/ShareModel.swift +++ b/apps/ios/SimpleX SE/ShareModel.swift @@ -97,37 +97,42 @@ class ShareModel: ObservableObject { if let e = initChat(with: dbKey) { await MainActor.run { errorAlert = e } } else { - // Load Chats - Task { - switch fetchChats() { - case let .success(chats): - // Decode base64 images on background thread - let profileImages = chats.reduce(into: Dictionary()) { dict, chatData in - if let profileImage = chatData.chatInfo.image, - let uiImage = imageFromBase64(profileImage) { - dict[chatData.id] = uiImage + switch activeUser() { + case let .failure(error): + await MainActor.run { errorAlert = error } + case let .success(user): + // Load Chats + Task { + switch fetchChats(user) { + case let .success(chats): + // Decode base64 images on background thread + let profileImages = chats.reduce(into: Dictionary()) { dict, chatData in + if let profileImage = chatData.chatInfo.image, + let uiImage = imageFromBase64(profileImage) { + dict[chatData.id] = uiImage + } } + await MainActor.run { + self.chats = chats + self.profileImages = profileImages + withAnimation { isLoaded = true } + } + case let .failure(error): + await MainActor.run { errorAlert = error } } - await MainActor.run { - self.chats = chats - self.profileImages = profileImages - withAnimation { isLoaded = true } - } - case let .failure(error): - await MainActor.run { errorAlert = error } } - } - // Process Attachment - Task { - switch await getSharedContent(self.itemProvider!) { - case let .success(chatItemContent): - await MainActor.run { - self.sharedContent = chatItemContent - self.bottomBar = .sendButton - if case let .text(string) = chatItemContent { comment = string } + // Process Attachment + Task { + switch await getSharedContent(self.itemProvider!, user.profile) { + case let .success(chatItemContent): + await MainActor.run { + self.sharedContent = chatItemContent + self.bottomBar = .sendButton + if case let .text(string) = chatItemContent { comment = string } + } + case let .failure(errorAlert): + await MainActor.run { self.errorAlert = errorAlert } } - case let .failure(errorAlert): - await MainActor.run { self.errorAlert = errorAlert } } } } @@ -253,7 +258,7 @@ class ShareModel: ObservableObject { } } - private func fetchChats() -> Result, ErrorAlert> { + private func activeUser() -> Result { do { guard let user = try apiGetActiveUser() else { return .failure( @@ -263,6 +268,14 @@ class ShareModel: ObservableObject { ) ) } + return .success(user) + } catch { + return .failure(ErrorAlert(error)) + } + } + + private func fetchChats(_ user: User) -> Result, ErrorAlert> { + do { return .success(try apiGetChats(userId: user.id)) } catch { return .failure(ErrorAlert(error)) @@ -405,7 +418,7 @@ enum SharedContent { } } -fileprivate func getSharedContent(_ ip: NSItemProvider) async -> Result { +fileprivate func getSharedContent(_ ip: NSItemProvider, _ senderProfile: LocalProfile) async -> Result { if let type = firstMatching(of: [.image, .movie, .fileURL, .url, .text]) { switch type { // Prepare Image message @@ -443,15 +456,13 @@ fileprivate func getSharedContent(_ ip: NSItemProvider) async -> Result maxSize { return .failure( ErrorAlert( title: "Large file!", - message: "Currently maximum supported file size is \(sizeString)." + message: LocalizedStringKey(largeFileMessage(size, badgeIssue: expiredBadgeReason(size, senderProfile))) ) ) } @@ -534,9 +545,3 @@ fileprivate func transcodeVideo(from input: URL) async -> URL? { } } -fileprivate func isFileTooLarge(for url: URL) -> Bool { - fileSize(url) - .map { $0 > getMaxFileSize(.xftp) } - ?? false -} - diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 418e1c99b5..e0a634a713 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -155,6 +155,7 @@ 6442E0BE2880182D00CEC0F9 /* GroupChatInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6442E0BD2880182D00CEC0F9 /* GroupChatInfoView.swift */; }; 64466DCC29FFE3E800E3D48D /* MailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64466DCB29FFE3E800E3D48D /* MailView.swift */; }; 6448BBB628FA9D56000D2AB9 /* GroupLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6448BBB528FA9D56000D2AB9 /* GroupLinkView.swift */; }; + 644CC229305BFAE400D2A571 /* BadgesLedgerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 644CC228305BFAE400D2A571 /* BadgesLedgerView.swift */; }; 644EFFDE292BCD9D00525D5B /* ComposeVoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 644EFFDD292BCD9D00525D5B /* ComposeVoiceView.swift */; }; 644EFFE0292CFD7F00525D5B /* CIVoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 644EFFDF292CFD7F00525D5B /* CIVoiceView.swift */; }; 644EFFE2292D089800525D5B /* FramedCIVoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 644EFFE1292D089800525D5B /* FramedCIVoiceView.swift */; }; @@ -164,6 +165,7 @@ 646BB38E283FDB6D001CE359 /* LocalAuthenticationUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */; }; 647B15E82F4C8D2500EB431E /* AddChannelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647B15E72F4C8D2500EB431E /* AddChannelView.swift */; }; 647B15EA2F4C8D5100EB431E /* ChatRelayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647B15E92F4C8D5100EB431E /* ChatRelayView.swift */; }; + 647C9E293061362A0032110E /* GetStakeBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647C9E283061362A0032110E /* GetStakeBanner.swift */; }; 647F090E288EA27B00644C40 /* GroupMemberInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */; }; 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; }; 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; }; @@ -179,12 +181,20 @@ 64A77A022DC4AD6100FDEF2F /* ContextPendingMemberActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64A77A012DC4AD6100FDEF2F /* ContextPendingMemberActionsView.swift */; }; 64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; }; 64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; }; + 64C03BF9302F423300072BDE /* BadgesPayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF1302F423300072BDE /* BadgesPayView.swift */; }; + 64C03BFA302F423300072BDE /* BadgesRedeemCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF2302F423300072BDE /* BadgesRedeemCodeView.swift */; }; + 64C03BFB302F423300072BDE /* BadgesHowItWorksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF0302F423300072BDE /* BadgesHowItWorksView.swift */; }; + 64C03BFC302F423300072BDE /* BadgeUserPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF6302F423300072BDE /* BadgeUserPreview.swift */; }; + 64C03BFD302F423300072BDE /* BadgeStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF4302F423300072BDE /* BadgeStore.swift */; }; + 64C03BFE302F423300072BDE /* SupportSimpleXBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF7302F423300072BDE /* SupportSimpleXBanner.swift */; }; + 64C03BFF302F423300072BDE /* BadgesYourLevelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF5302F423300072BDE /* BadgesYourLevelView.swift */; }; + 64C03C00302F423300072BDE /* BadgesSupportSimplexView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF3302F423300072BDE /* BadgesSupportSimplexView.swift */; }; 64C06EB52A0A4A7C00792D4D /* ChatItemInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */; }; 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-7.1.0.4-JuaCEFy75SxELqTayb7fbQ-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-JuaCEFy75SxELqTayb7fbQ-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-JuaCEFy75SxELqTayb7fbQ.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-JuaCEFy75SxELqTayb7fbQ.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.7-KLlSPSHmTF28mDmnoCMp3f-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.7-KLlSPSHmTF28mDmnoCMp3f-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.7-KLlSPSHmTF28mDmnoCMp3f.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.7-KLlSPSHmTF28mDmnoCMp3f.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 */; }; @@ -192,6 +202,8 @@ 64E5E3632DF71A4E00A4D530 /* ContextContactRequestActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64E5E3622DF71A4E00A4D530 /* ContextContactRequestActionsView.swift */; }; 64E5E3672DFC16A900A4D530 /* ContextProfilePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64E5E3662DFC16A900A4D530 /* ContextProfilePickerView.swift */; }; 64E972072881BB22008DBC02 /* CIGroupInvitationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */; }; + 64EB8A99305405C60089FFDF /* BadgesYourBadgeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64EB8A98305405C60089FFDF /* BadgesYourBadgeView.swift */; }; + 64EB8A9B3054347A0089FFDF /* BadgesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64EB8A9A3054347A0089FFDF /* BadgesView.swift */; }; 64EEB0F72C353F1C00972D62 /* ServersSummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64EEB0F62C353F1C00972D62 /* ServersSummaryView.swift */; }; 64F1CC3B28B39D8600CD1FB1 /* IncognitoHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */; }; 64FC8F9D2E3B6DEF0068F384 /* ContextMemberContactActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64FC8F9C2E3B6DEF0068F384 /* ContextMemberContactActionsView.swift */; }; @@ -526,6 +538,7 @@ 6407BA82295DA85D0082BA18 /* CIInvalidJSONView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIInvalidJSONView.swift; sourceTree = ""; }; 6419EC572AB97507004A607A /* CIMemberCreatedContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIMemberCreatedContactView.swift; sourceTree = ""; }; 642BA82C2CE50495005E9412 /* NewServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewServerView.swift; sourceTree = ""; }; + 642E412B3033084000F04EE1 /* BadgeTestProducts.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = BadgeTestProducts.storekit; sourceTree = ""; }; 6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupPreferencesView.swift; sourceTree = ""; }; 643B3B4D2CCFD6400083A2CF /* OperatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperatorView.swift; sourceTree = ""; }; 6440C9FF288857A10062C672 /* CIEventView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIEventView.swift; sourceTree = ""; }; @@ -534,6 +547,7 @@ 6442E0BD2880182D00CEC0F9 /* GroupChatInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupChatInfoView.swift; sourceTree = ""; }; 64466DCB29FFE3E800E3D48D /* MailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailView.swift; sourceTree = ""; }; 6448BBB528FA9D56000D2AB9 /* GroupLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupLinkView.swift; sourceTree = ""; }; + 644CC228305BFAE400D2A571 /* BadgesLedgerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesLedgerView.swift; sourceTree = ""; }; 644EFFDD292BCD9D00525D5B /* ComposeVoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeVoiceView.swift; sourceTree = ""; }; 644EFFDF292CFD7F00525D5B /* CIVoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIVoiceView.swift; sourceTree = ""; }; 644EFFE1292D089800525D5B /* FramedCIVoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FramedCIVoiceView.swift; sourceTree = ""; }; @@ -543,6 +557,7 @@ 646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAuthenticationUtils.swift; sourceTree = ""; }; 647B15E72F4C8D2500EB431E /* AddChannelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddChannelView.swift; sourceTree = ""; }; 647B15E92F4C8D5100EB431E /* ChatRelayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatRelayView.swift; sourceTree = ""; }; + 647C9E283061362A0032110E /* GetStakeBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetStakeBanner.swift; sourceTree = ""; }; 647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMemberInfoView.swift; sourceTree = ""; }; 648010AA281ADD15009009B9 /* CIFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIFileView.swift; sourceTree = ""; }; 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = ""; }; @@ -559,12 +574,20 @@ 64A77A012DC4AD6100FDEF2F /* ContextPendingMemberActionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextPendingMemberActionsView.swift; sourceTree = ""; }; 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextItemView.swift; sourceTree = ""; }; 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedItemView.swift; sourceTree = ""; }; + 64C03BF0302F423300072BDE /* BadgesHowItWorksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesHowItWorksView.swift; sourceTree = ""; }; + 64C03BF1302F423300072BDE /* BadgesPayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesPayView.swift; sourceTree = ""; }; + 64C03BF2302F423300072BDE /* BadgesRedeemCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesRedeemCodeView.swift; sourceTree = ""; }; + 64C03BF3302F423300072BDE /* BadgesSupportSimplexView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesSupportSimplexView.swift; sourceTree = ""; }; + 64C03BF4302F423300072BDE /* BadgeStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeStore.swift; sourceTree = ""; }; + 64C03BF5302F423300072BDE /* BadgesYourLevelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesYourLevelView.swift; sourceTree = ""; }; + 64C03BF6302F423300072BDE /* BadgeUserPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeUserPreview.swift; sourceTree = ""; }; + 64C03BF7302F423300072BDE /* SupportSimpleXBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportSimpleXBanner.swift; sourceTree = ""; }; 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemInfoView.swift; sourceTree = ""; }; 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = ""; }; 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-7.1.0.4-JuaCEFy75SxELqTayb7fbQ-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.4-JuaCEFy75SxELqTayb7fbQ-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-JuaCEFy75SxELqTayb7fbQ.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.4-JuaCEFy75SxELqTayb7fbQ.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.7-KLlSPSHmTF28mDmnoCMp3f-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.7-KLlSPSHmTF28mDmnoCMp3f-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.7-KLlSPSHmTF28mDmnoCMp3f.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.7-KLlSPSHmTF28mDmnoCMp3f.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 = ""; }; @@ -573,6 +596,8 @@ 64E5E3622DF71A4E00A4D530 /* ContextContactRequestActionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextContactRequestActionsView.swift; sourceTree = ""; }; 64E5E3662DFC16A900A4D530 /* ContextProfilePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextProfilePickerView.swift; sourceTree = ""; }; 64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIGroupInvitationView.swift; sourceTree = ""; }; + 64EB8A98305405C60089FFDF /* BadgesYourBadgeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesYourBadgeView.swift; sourceTree = ""; }; + 64EB8A9A3054347A0089FFDF /* BadgesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesView.swift; sourceTree = ""; }; 64EEB0F62C353F1C00972D62 /* ServersSummaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServersSummaryView.swift; sourceTree = ""; }; 64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncognitoHelp.swift; sourceTree = ""; }; 64FC8F9C2E3B6DEF0068F384 /* ContextMemberContactActionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMemberContactActionsView.swift; sourceTree = ""; }; @@ -735,8 +760,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-JuaCEFy75SxELqTayb7fbQ-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-JuaCEFy75SxELqTayb7fbQ.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.7-KLlSPSHmTF28mDmnoCMp3f-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.7-KLlSPSHmTF28mDmnoCMp3f.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -772,6 +797,7 @@ 5C2E260D27A30E2400F70299 /* Views */ = { isa = PBXGroup; children = ( + 64C03BF8302F423300072BDE /* Badges */, B76E6C2F2C5C41C300EC11AA /* Contacts */, 5CB0BA8C282711BC00B3292C /* Onboarding */, 3C714775281C080100CB4D4B /* Call */, @@ -822,8 +848,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-JuaCEFy75SxELqTayb7fbQ-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-JuaCEFy75SxELqTayb7fbQ.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.7-KLlSPSHmTF28mDmnoCMp3f-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.7-KLlSPSHmTF28mDmnoCMp3f.a */, ); path = Libraries; sourceTree = ""; @@ -892,6 +918,7 @@ 5CA059BD279559F40002BEB4 = { isa = PBXGroup; children = ( + 642E412B3033084000F04EE1 /* BadgeTestProducts.storekit */, E5C0BBFD2F82BBC000EA7527 /* Debug.xcconfig */, E5C0BBFE2F82BBC900EA7527 /* Release.xcconfig */, 5C55A92D283D0FDE00C4E99E /* sounds */, @@ -1027,6 +1054,7 @@ 5CB9250B27A942F300ACCCDD /* ChatList */ = { isa = PBXGroup; children = ( + 647C9E283061362A0032110E /* GetStakeBanner.swift */, 5C2E260A27A30CFA00F70299 /* ChatListView.swift */, 5C5346A727B59A6A004DF848 /* ChatHelp.swift */, 5CB9250C27A9432000ACCCDD /* ChatListNavLink.swift */, @@ -1186,6 +1214,24 @@ path = Group; sourceTree = ""; }; + 64C03BF8302F423300072BDE /* Badges */ = { + isa = PBXGroup; + children = ( + 644CC228305BFAE400D2A571 /* BadgesLedgerView.swift */, + 64EB8A9A3054347A0089FFDF /* BadgesView.swift */, + 64C03BF0302F423300072BDE /* BadgesHowItWorksView.swift */, + 64C03BF1302F423300072BDE /* BadgesPayView.swift */, + 64C03BF2302F423300072BDE /* BadgesRedeemCodeView.swift */, + 64C03BF3302F423300072BDE /* BadgesSupportSimplexView.swift */, + 64C03BF4302F423300072BDE /* BadgeStore.swift */, + 64C03BF5302F423300072BDE /* BadgesYourLevelView.swift */, + 64C03BF6302F423300072BDE /* BadgeUserPreview.swift */, + 64C03BF7302F423300072BDE /* SupportSimpleXBanner.swift */, + 64EB8A98305405C60089FFDF /* BadgesYourBadgeView.swift */, + ); + path = Badges; + sourceTree = ""; + }; 8C74C3E92C1B909200039E77 /* Theme */ = { isa = PBXGroup; children = ( @@ -1537,6 +1583,7 @@ 5CB0BA9A2827FD8800B3292C /* HowItWorks.swift in Sources */, 5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */, 644EFFE0292CFD7F00525D5B /* CIVoiceView.swift in Sources */, + 644CC229305BFAE400D2A571 /* BadgesLedgerView.swift in Sources */, 647B15EA2F4C8D5100EB431E /* ChatRelayView.swift in Sources */, 6432857C2925443C00FBE5C8 /* GroupPreferencesView.swift in Sources */, 8CC317462D4FEBA800292A20 /* ScrollViewCells.swift in Sources */, @@ -1602,6 +1649,14 @@ 640417CD2B29B8C200CCB412 /* NewChatMenuButton.swift in Sources */, 5CFE0921282EEAF60002594B /* ZoomableScrollView.swift in Sources */, 5C3A88CE27DF50170060F1C2 /* DetermineWidth.swift in Sources */, + 64C03BF9302F423300072BDE /* BadgesPayView.swift in Sources */, + 64C03BFA302F423300072BDE /* BadgesRedeemCodeView.swift in Sources */, + 64C03BFB302F423300072BDE /* BadgesHowItWorksView.swift in Sources */, + 64C03BFC302F423300072BDE /* BadgeUserPreview.swift in Sources */, + 64C03BFD302F423300072BDE /* BadgeStore.swift in Sources */, + 64C03BFE302F423300072BDE /* SupportSimpleXBanner.swift in Sources */, + 64C03BFF302F423300072BDE /* BadgesYourLevelView.swift in Sources */, + 64C03C00302F423300072BDE /* BadgesSupportSimplexView.swift in Sources */, 5C7505A527B679EE00BE3227 /* NavLinkPlain.swift in Sources */, 5C58BCD6292BEBE600AF9E4F /* CIChatFeatureView.swift in Sources */, 5CB346E72868D76D001FD2EF /* NotificationsView.swift in Sources */, @@ -1643,6 +1698,7 @@ 8C9BC2652C240D5200875A27 /* ThemeModeEditor.swift in Sources */, 647B15E82F4C8D2500EB431E /* AddChannelView.swift in Sources */, 6495D7062F48CFFD0060512B /* ChannelRelaysView.swift in Sources */, + 64EB8A9B3054347A0089FFDF /* BadgesView.swift in Sources */, E5E418022F83D2CA00252B9E /* ChannelWebAccessView.swift in Sources */, 6495D7082F48D0000060512B /* AddGroupRelayView.swift in Sources */, 5CB346E92869E8BA001FD2EF /* PushEnvironment.swift in Sources */, @@ -1683,12 +1739,14 @@ 5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */, 64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */, CE176F202C87014C00145DBC /* InvertedForegroundStyle.swift in Sources */, + 647C9E293061362A0032110E /* GetStakeBanner.swift in Sources */, 5CEBD7482A5F115D00665FE2 /* SetDeliveryReceiptsView.swift in Sources */, 5C9C2DA7289957AE00CC63B1 /* AdvancedNetworkSettings.swift in Sources */, 5CADE79A29211BB900072E13 /* PreferencesView.swift in Sources */, 644EFFE42937BE9700525D5B /* MarkedDeletedItemView.swift in Sources */, 6495D7042F48CFC50060512B /* ChannelMembersView.swift in Sources */, 1841594C978674A7B42EF0C0 /* AnimatedImageView.swift in Sources */, + 64EB8A99305405C60089FFDF /* BadgesYourBadgeView.swift in Sources */, 5C7031162953C97F00150A12 /* CIFeaturePreferenceView.swift in Sources */, 1841538E296606C74533367C /* UserPicker.swift in Sources */, 18415B0585EB5A9A0A7CA8CD /* PressedButtonStyle.swift in Sources */, @@ -2081,7 +2139,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 352; + CURRENT_PROJECT_VERSION = 357; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2131,7 +2189,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 352; + CURRENT_PROJECT_VERSION = 357; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2173,7 +2231,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 352; + CURRENT_PROJECT_VERSION = 357; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2193,7 +2251,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 352; + CURRENT_PROJECT_VERSION = 357; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2218,7 +2276,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 352; + CURRENT_PROJECT_VERSION = 357; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2255,7 +2313,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 352; + CURRENT_PROJECT_VERSION = 357; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2292,7 +2350,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 352; + CURRENT_PROJECT_VERSION = 357; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2343,7 +2401,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 352; + CURRENT_PROJECT_VERSION = 357; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2397,7 +2455,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 352; + CURRENT_PROJECT_VERSION = 357; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2431,7 +2489,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 352; + CURRENT_PROJECT_VERSION = 357; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; diff --git a/apps/ios/SimpleXChat/API.swift b/apps/ios/SimpleXChat/API.swift index 6bf46fb0dd..bc23e53011 100644 --- a/apps/ios/SimpleXChat/API.swift +++ b/apps/ios/SimpleXChat/API.swift @@ -163,6 +163,14 @@ public func chatJsonLength(_ s: String) -> Int { return Int(chat_json_length(&c)) } +// the code in core's canonical form (prefix and 20 characters, no separators), or nil when core rejects it. +// The check character is verified by core via FFI, not re-implemented here, so the two cannot disagree. +public func parseBadgeCode(_ s: String) -> String? { + var c = s.cString(using: .utf8)! + let canonical = fromCString(chat_parse_badge_code(&c)!) + return canonical.isEmpty ? nil : canonical +} + struct ParsedMarkdown: Decodable { var formattedText: [FormattedText]? } diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 33d683288a..d7b8623ab3 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -812,6 +812,7 @@ public enum ChatErrorType: Decodable, Hashable { case agentVersion case agentNoSubResult(agentConnId: String) case commandError(message: String) + case badgeRedeemError(badgeRedeemError: BadgeRedeemError) case serverProtocol case agentCommandError(message: String) case invalidFileDescription(message: String) @@ -823,6 +824,84 @@ public enum ChatErrorType: Decodable, Hashable { case exception(message: String) } +public enum BadgeRedeemError: Decodable, Hashable { + case invalidCode + case serviceNotConfigured + case badgeActive + case serviceError(serviceError: BadgeServiceErrorCode) + case invalidResponse(message: String) + case unknownKeyIndex + case credentialNotVerified +} + +// the service is deployed ahead of clients, so a code this version does not know keeps its tag +public enum BadgeServiceErrorCode: Decodable, Hashable { + case badRequest + case unsupportedVersion + case unknownPurchaseKey + case unknownOfferId + case offerDisabled + case offerMismatch + case productUnavailable + case paymentNotEntitled + case paymentPending + case providerUnavailable + case rateLimited + case codeInvalid + case codeUsed + case codeExpired + case receiptInvalid + case receiptUsed + case internalError + case unknown(String) + + public var text: String { + switch self { + case .badRequest: "bad_request" + case .unsupportedVersion: "unsupported_version" + case .unknownPurchaseKey: "unknown_purchase_key" + case .unknownOfferId: "unknown_offer_id" + case .offerDisabled: "offer_disabled" + case .offerMismatch: "offer_mismatch" + case .productUnavailable: "product_unavailable" + case .paymentNotEntitled: "payment_not_entitled" + case .paymentPending: "payment_pending" + case .providerUnavailable: "provider_unavailable" + case .rateLimited: "rate_limited" + case .codeInvalid: "code_invalid" + case .codeUsed: "code_used" + case .codeExpired: "code_expired" + case .receiptInvalid: "receipt_invalid" + case .receiptUsed: "receipt_used" + case .internalError: "internal" + case let .unknown(s): s + } + } + + public init(from decoder: Decoder) throws { + switch try decoder.singleValueContainer().decode(String.self) { + case "bad_request": self = .badRequest + case "unsupported_version": self = .unsupportedVersion + case "unknown_purchase_key": self = .unknownPurchaseKey + case "unknown_offer_id": self = .unknownOfferId + case "offer_disabled": self = .offerDisabled + case "offer_mismatch": self = .offerMismatch + case "product_unavailable": self = .productUnavailable + case "payment_not_entitled": self = .paymentNotEntitled + case "payment_pending": self = .paymentPending + case "provider_unavailable": self = .providerUnavailable + case "rate_limited": self = .rateLimited + case "code_invalid": self = .codeInvalid + case "code_used": self = .codeUsed + case "code_expired": self = .codeExpired + case "receipt_invalid": self = .receiptInvalid + case "receipt_used": self = .receiptUsed + case "internal": self = .internalError + case let s: self = .unknown(s) + } + } +} + public enum StoreError: Decodable, Hashable { case duplicateName case userNotFound(userId: Int64) @@ -1077,6 +1156,15 @@ public enum SMPAgentError: Decodable, Hashable { case A_CRYPTO case A_DUPLICATE case A_QUEUE(queueErr: String) + case A_SERVICE(serviceError: AgentServiceError) +} + +public enum AgentServiceError: Decodable, Hashable { + case rejected + case timeout + case noPendingRequest + case notDRAddress + case badSignature } public enum ArchiveError: Decodable, Hashable { diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index dee7ea6b32..ae3c8d662b 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -304,11 +304,221 @@ public struct BadgeInfo: Codable, Hashable { public var badgeType: BadgeType public var badgeExpiry: Date public var badgeExtra: String + + public init(badgeType: BadgeType, badgeExpiry: Date, badgeExtra: String = "") { + self.badgeType = badgeType + self.badgeExpiry = badgeExpiry + self.badgeExtra = badgeExtra + } } public struct LocalBadge: Codable, Hashable { public var badge: BadgeInfo public var status: BadgeStatus + + public init(badge: BadgeInfo, status: BadgeStatus) { + self.badge = badge + self.status = status + } +} + +// paidThrough is the only date to show the user: BadgeInfo.badgeExpiry is the credential's expiry, +// which outlives entitlement so the credential's window can cover a renewal. +public struct BadgeState: Codable, Hashable { + public var badgePurchaseId: Int64 + public var purchaseKey: String + public var badgeType: BadgeType + public var shown: Bool + public var monthsLeft: Int + public var paidThrough: Date + public var renewsAt: Date? + public var willRenew: Bool + public var alert: BadgeAlert? + + public var paidThroughText: String { badgeDateText(paidThrough) } +} + +public struct StatementEntry: Codable, Hashable { + public var entryId: String + public var changeMonths: Int + public var balanceMonths: Int + public var balanceStartTs: Date + public var balanceAnchorTs: Date + public var balanceBadgeType: BadgeType + public var wasPausedSince: Date? + public var createdAt: Date + public var entryType: StatementEntryType +} + +public enum StatementEntryType: Codable, Hashable { + case credit(StatementCreditType) + case debit(StatementDebitType) + + enum CodingKeys: String, CodingKey { + case type + case credit + case debit + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + switch type { + case "credit": self = .credit(try container.decode(StatementCreditType.self, forKey: .credit)) + case "debit": self = .debit(try container.decode(StatementDebitType.self, forKey: .debit)) + default: throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "unknown entry type \(type)") + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case let .credit(c): + try container.encode("credit", forKey: .type) + try container.encode(c, forKey: .credit) + case let .debit(d): + try container.encode("debit", forKey: .type) + try container.encode(d, forKey: .debit) + } + } + + public var text: String { + switch self { + case let .credit(c): c.text + case let .debit(d): d.text + } + } +} + +// the service is deployed ahead of clients, so a type this version does not know keeps its tag +public enum StatementCreditType: Codable, Hashable { + case payment(invoiceId: String?) + case code + case charge(chargeId: String) + case support + case transferIn(fromPurchaseKey: String) + case opening + case unknown(type: String) + + enum CodingKeys: String, CodingKey { + case type + case invoiceId + case chargeId + case fromPurchaseKey + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + switch type { + case "payment": self = .payment(invoiceId: try container.decodeIfPresent(String.self, forKey: .invoiceId)) + case "code": self = .code + case "charge": self = .charge(chargeId: try container.decode(String.self, forKey: .chargeId)) + case "support": self = .support + case "transferIn": self = .transferIn(fromPurchaseKey: try container.decode(String.self, forKey: .fromPurchaseKey)) + case "opening": self = .opening + default: self = .unknown(type: type) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(text, forKey: .type) + switch self { + case let .payment(invoiceId): try container.encodeIfPresent(invoiceId, forKey: .invoiceId) + case let .charge(chargeId): try container.encode(chargeId, forKey: .chargeId) + case let .transferIn(fromPurchaseKey): try container.encode(fromPurchaseKey, forKey: .fromPurchaseKey) + case .code, .support, .opening, .unknown: () + } + } + + public var text: String { + switch self { + case .payment: "payment" + case .code: "code" + case .charge: "charge" + case .support: "support" + case .transferIn: "transferIn" + case .opening: "opening" + case let .unknown(type): type + } + } +} + +public enum StatementDebitType: Codable, Hashable { + case refund + case upgrade(toPurchaseKey: String) + case transferOut(toPurchaseKey: String) + case support + case badge + case lapse + case unknown(type: String) + + enum CodingKeys: String, CodingKey { + case type + case toPurchaseKey + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + switch type { + case "refund": self = .refund + case "upgrade": self = .upgrade(toPurchaseKey: try container.decode(String.self, forKey: .toPurchaseKey)) + case "transferOut": self = .transferOut(toPurchaseKey: try container.decode(String.self, forKey: .toPurchaseKey)) + case "support": self = .support + case "badge": self = .badge + case "lapse": self = .lapse + default: self = .unknown(type: type) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(text, forKey: .type) + switch self { + case let .upgrade(toPurchaseKey), let .transferOut(toPurchaseKey): try container.encode(toPurchaseKey, forKey: .toPurchaseKey) + case .refund, .support, .badge, .lapse, .unknown: () + } + } + + public var text: String { + switch self { + case .refund: "refund" + case .upgrade: "upgrade" + case .transferOut: "transferOut" + case .support: "support" + case .badge: "badge" + case .lapse: "lapse" + case let .unknown(type): type + } + } +} + +public struct BadgeAlert: Codable, Hashable { + public var kind: BadgeAlertKind + public var episode: String + public var date: Date + public var price: BadgeAlertPrice? + + public var dateText: String { badgeDateText(date) } +} + +private func badgeDateText(_ date: Date) -> String { + DateFormatter.localizedString(from: date, dateStyle: .long, timeStyle: .none) +} + +public struct BadgeAlertPrice: Codable, Hashable { + public var amount: Int64 + public var currency: String +} + +public enum BadgeAlertKind: String, Codable, Hashable { + case renewalApproaching + case paymentIssue + case subscriptionEnded + case prepaidEnding + case supportEnded } // the wire proof carried on a profile - opaque to the UI, only round-tripped back to the core (apiPrepareContact) @@ -4678,6 +4888,17 @@ extension MsgReaction: Encodable { } } +// set by the core when the file is above the size the sender's badge allows; badgeStatus is nil when no proof was sent +public struct FileProhibited: Decodable, Hashable { + public var maxSize: Int64 + public var badgeStatus: BadgeStatus? + + public init(maxSize: Int64, badgeStatus: BadgeStatus?) { + self.maxSize = maxSize + self.badgeStatus = badgeStatus + } +} + public struct CIFile: Decodable, Hashable { public var fileId: Int64 public var fileName: String @@ -4686,6 +4907,7 @@ public struct CIFile: Decodable, Hashable { public var fileStatus: CIFileStatus public var fileProtocol: FileProtocol public var fileExpires: Date? = nil + public var fileProhibited: FileProhibited? = nil public static func getSample(fileId: Int64 = 1, fileName: String = "test.txt", fileSize: Int64 = 100, filePath: String? = "test.txt", fileStatus: CIFileStatus = .rcvComplete) -> CIFile { let f: CryptoFile? diff --git a/apps/ios/SimpleXChat/FileUtils.swift b/apps/ios/SimpleXChat/FileUtils.swift index c44bcee5bd..a4d4e4ae0c 100644 --- a/apps/ios/SimpleXChat/FileUtils.swift +++ b/apps/ios/SimpleXChat/FileUtils.swift @@ -29,10 +29,13 @@ public let MAX_VIDEO_SIZE_AUTO_RCV: Int64 = 1_047_552 // 1023KB // Spec: spec/services/files.md#MAX_FILE_SIZE_XFTP public let MAX_FILE_SIZE_XFTP: Int64 = 1_073_741_824 // 1GB -// raised XFTP receive limits for files from a sender with a supporter badge (also investor) or a legend badge +// raised XFTP limits for a user with a supporter badge (also investor) or a legend badge public let MAX_FILE_SIZE_XFTP_SUPPORTER: Int64 = 2_147_483_648 // 2GB public let MAX_FILE_SIZE_XFTP_LEGEND: Int64 = 5_368_709_120 // 5GB +// a badge raises the limit at send for this long after its expiry, shorter than the receiver's grace +public let BADGE_SND_GRACE_INTERVAL = TimeInterval(86400) + public let MAX_FILE_SIZE_LOCAL: Int64 = Int64.max public let MAX_FILE_SIZE_SMP: Int64 = 8000000 @@ -277,29 +280,63 @@ public func cleanupFile(_ aChatItem: AChatItem) { } } +public func badgeMaxFileSize(_ badge: LocalBadge) -> Int64 { + badge.badge.badgeType == .legend ? MAX_FILE_SIZE_XFTP_LEGEND : MAX_FILE_SIZE_XFTP_SUPPORTER +} + +// a badge raises the limit at send until one day past its expiry, as the core applies it +public func badgeActiveForSend(_ badge: LocalBadge) -> Bool { + badge.status == .active && badge.badge.badgeExpiry.addingTimeInterval(BADGE_SND_GRACE_INTERVAL) >= Date.now +} + +// in incognito chats and above the largest badge's limit no badge applies, so badgeIssue is not used +public func largeFileMessage(_ fileSize: Int64, incognito: Bool = false, badgeIssue: String = "") -> String { + if incognito { + return String.localizedStringWithFormat( + NSLocalizedString("Files larger than %@ cannot be sent in incognito chats.", comment: "file alert"), + ByteCountFormatter.string(fromByteCount: MAX_FILE_SIZE_XFTP, countStyle: .binary) + ) + } + if fileSize > MAX_FILE_SIZE_XFTP_LEGEND { + return String.localizedStringWithFormat( + NSLocalizedString("Maximum supported file size is %1$@, with a %2$@.", comment: "file alert"), + ByteCountFormatter.string(fromByteCount: MAX_FILE_SIZE_XFTP_LEGEND, countStyle: .binary), + NSLocalizedString("legend badge", comment: "badge required to send a large file") + ) + } + let supporter = fileSize <= MAX_FILE_SIZE_XFTP_SUPPORTER + let message = String.localizedStringWithFormat( + NSLocalizedString("You need a %1$@ to send files larger than %2$@.", comment: "file alert"), + supporter + ? NSLocalizedString("supporter badge", comment: "badge required to send a large file") + : NSLocalizedString("legend badge", comment: "badge required to send a large file"), + ByteCountFormatter.string(fromByteCount: supporter ? MAX_FILE_SIZE_XFTP : MAX_FILE_SIZE_XFTP_SUPPORTER, countStyle: .binary) + ) + return badgeIssue.isEmpty ? message : message + " " + badgeIssue +} + +// the badge lapsed, and while active it would have allowed this file +public func expiredBadgeReason(_ fileSize: Int64, _ senderProfile: LocalProfile?) -> String { + if let badge = senderProfile?.localBadge, !badgeActiveForSend(badge), badgeMaxFileSize(badge) >= fileSize { + NSLocalizedString("Your badge expired.", comment: "file alert") + } else { + "" + } +} + public func getMaxFileSize(_ fileProtocol: FileProtocol, _ senderProfile: LocalProfile? = nil) -> Int64 { switch fileProtocol { case .smp: MAX_FILE_SIZE_SMP case .local: MAX_FILE_SIZE_LOCAL - // a sender's active badge raises the XFTP limit: legend to 5GB, any other (supporter/investor) to 2GB case .xftp: - if let badge = senderProfile?.localBadge, badge.status == .active { - badge.badge.badgeType == .legend ? MAX_FILE_SIZE_XFTP_LEGEND : MAX_FILE_SIZE_XFTP_SUPPORTER + if let badge = senderProfile?.localBadge, badgeActiveForSend(badge) { + badgeMaxFileSize(badge) } else { MAX_FILE_SIZE_XFTP } } } -// the profile of whoever sent a received chat item - the group member, or the direct chat's contact -public func ciSenderProfile(_ ci: ChatItem, _ chatInfo: ChatInfo) -> LocalProfile? { - switch (ci.chatDir, chatInfo) { - case let (.groupRcv(groupMember), _): return groupMember.memberProfile - case let (.directRcv, .direct(contact)): return contact.profile - default: return nil - } -} - public struct RuntimeError: Error { let message: String diff --git a/apps/ios/SimpleXChat/SimpleX.h b/apps/ios/SimpleXChat/SimpleX.h index 5a3541e06d..543dc258e4 100644 --- a/apps/ios/SimpleXChat/SimpleX.h +++ b/apps/ios/SimpleXChat/SimpleX.h @@ -27,6 +27,8 @@ extern char *chat_parse_server(char *str); extern char *chat_parse_uri(char *str, int safe); extern char *chat_password_hash(char *pwd, char *salt); extern char *chat_valid_name(char *name); +// returns the canonical form of a badge code, or an empty string if it does not parse +extern char *chat_parse_badge_code(char *code); extern int chat_json_length(char *str); extern char *chat_encrypt_media(chat_ctrl ctl, char *key, char *frame, int len); extern char *chat_decrypt_media(char *key, char *frame, int len); diff --git a/apps/ios/product/concepts.md b/apps/ios/product/concepts.md index 6d63ee2faf..89884eab43 100644 --- a/apps/ios/product/concepts.md +++ b/apps/ios/product/concepts.md @@ -19,7 +19,7 @@ This document provides a structured mapping between product-level concepts, thei | # | Concept | Product Docs | Spec Docs | Source Files (Swift) | Source Files (Haskell) | |---|---------|-------------|-----------|---------------------|----------------------| -| 1 | Chat List | [views/chat-list.md](views/chat-list.md), [views/onboarding.md](views/onboarding.md) | [spec/client/chat-list.md](../spec/client/chat-list.md) | `Shared/Views/ChatList/ChatListView.swift` | `Controller.hs` (`APIGetChats`) | +| 1 | Chat List | [views/chat-list.md](views/chat-list.md), [views/onboarding.md](views/onboarding.md) | [spec/client/chat-list.md](../spec/client/chat-list.md) | `Shared/Views/ChatList/ChatListView.swift`, `Shared/Views/ChatList/GetStakeBanner.swift` | `Controller.hs` (`APIGetChats`) | | 2 | Direct Chat | [views/chat.md](views/chat.md), [flows/messaging.md](flows/messaging.md) | [spec/client/chat-view.md](../spec/client/chat-view.md) | `Shared/Views/Chat/ChatView.swift`, `ChatInfoView.swift` | `Types.hs` (`Contact`), `Messages.hs` | | 3 | Group Chat | [views/chat.md](views/chat.md), [views/group-info.md](views/group-info.md) | [spec/client/chat-view.md](../spec/client/chat-view.md) | `Shared/Views/Chat/ChatView.swift`, `Group/GroupChatInfoView.swift` | `Types.hs` (`GroupInfo`, `GroupMember`) | | 4 | Message Composition | [views/chat.md](views/chat.md) | [spec/client/compose.md](../spec/client/compose.md) | `ComposeMessage/ComposeView.swift`, `SendMessageView.swift` | `Controller.hs` (`APISendMessages`) | diff --git a/apps/ios/product/views/chat-list.md b/apps/ios/product/views/chat-list.md index 04d19bef9e..dee9e1d427 100644 --- a/apps/ios/product/views/chat-list.md +++ b/apps/ios/product/views/chat-list.md @@ -93,6 +93,7 @@ When a relay address link (`/r` path) is opened via URL deep link, `ContentView. - **One-hand UI card** (`OneHandUICard`): Dismissible card shown to introduce bottom toolbar mode - **Address creation card** (`AddressCreationCard`): Prompts user to create a SimpleX address +- **Crowdfunding banner** (`GetStakeBanner`): Shown to users in the US only, above the chats and below the onboarding pages. Tapping it opens `GetStakeView` (invest on Wefunder). The dismiss X appears once the banner has been tapped and there is at least one chat; dismissing hides it until hints are reset ### Pull-to-Refresh @@ -103,7 +104,7 @@ Triggers `reconnectAllServers()` after user confirmation alert ("Reconnect serve | State | Behavior | |---|---| | Chat database not started | Settings row shows exclamation icon; chat running == false disables interactions | -| No chats | `ChatHelp` view displayed with onboarding guidance | +| No conversations yet | `ConnectOnboardingView` pages replace the list, with the crowdfunding banner below them where it applies | | Connection in progress | `ConnectProgressManager` overlay with connecting text | | Search with no results | Empty list with no special empty-state view | @@ -127,4 +128,5 @@ Triggers `reconnectAllServers()` after user confirmation alert ("Reconnect serve - `Shared/Views/ChatList/ContactRequestView.swift` -- Contact request row rendering - `Shared/Views/ChatList/ContactConnectionView.swift` -- Pending connection row rendering - `Shared/Views/ChatList/OneHandUICard.swift` -- One-hand UI introduction card +- `Shared/Views/ChatList/GetStakeBanner.swift` -- Wefunder crowdfunding banner and shared banner card chrome - `Shared/Views/ChatList/ServersSummaryView.swift` -- Server subscription summary diff --git a/apps/ios/spec/api.md b/apps/ios/spec/api.md index f9a3c35917..56c3c34cb6 100644 --- a/apps/ios/spec/api.md +++ b/apps/ios/spec/api.md @@ -534,7 +534,8 @@ func chatApiSendCmdWithRetry( // SimpleXAPI.swift L127 bgTask: Bool = true, bgDelay: Double? = nil, inProgress: BoxedValue? = nil, - retryNum: Int32 = 0 + retryNum: Int32 = 0, + log: Bool = true ) async -> APIResult? ``` diff --git a/apps/ios/spec/client/chat-list.md b/apps/ios/spec/client/chat-list.md index d35de1f80a..900e0d4341 100644 --- a/apps/ios/spec/client/chat-list.md +++ b/apps/ios/spec/client/chat-list.md @@ -20,6 +20,7 @@ 7. [Swipe Actions](#7-swipe-actions) 8. [UserPicker](#8-userpicker) 9. [Floating Action Button](#9-floating-action-button) +10. [Crowdfunding Banner](#10-crowdfunding-banner) --- @@ -53,7 +54,7 @@ ChatListView --- -## 2. [`ChatListView`](../../Shared/Views/ChatList/ChatListView.swift#L142) {#2-chatlistview} +## 2. [`ChatListView`](../../Shared/Views/ChatList/ChatListView.swift#L154) {#2-chatlistview} **File**: `Shared/Views/ChatList/ChatListView.swift` @@ -62,7 +63,7 @@ The root list view. Key responsibilities: ### Data Source - Reads `ChatModel.shared.chats` (all conversations) - Applies active filter from `ChatTagsModel.shared.activeFilter` -- Applies search query filtering via [`filteredChats()`](../../Shared/Views/ChatList/ChatListView.swift#L480) +- Applies search query filtering via [`filteredChats()`](../../Shared/Views/ChatList/ChatListView.swift#L555) - Sorts by last activity (most recent first), with pinned chats at top ### Layout @@ -79,11 +80,11 @@ The root list view. Key responsibilities: | Function | Line | Description | |----------|------|-------------| -| [`body`](../../Shared/Views/ChatList/ChatListView.swift#L168) | 163 | Main view body | -| [`filteredChats()`](../../Shared/Views/ChatList/ChatListView.swift#L480) | 472 | Applies active filter and search to chat list | -| [`searchString()`](../../Shared/Views/ChatList/ChatListView.swift#L523) | 514 | Normalizes search text for comparison | -| [`unreadBadge()`](../../Shared/Views/ChatList/ChatListView.swift#L454) | 448 | Renders unread count circle badge | -| [`stopAudioPlayer()`](../../Shared/Views/ChatList/ChatListView.swift#L474) | 467 | Stops any playing voice message | +| [`body`](../../Shared/Views/ChatList/ChatListView.swift#L183) | 183 | Main view body | +| [`filteredChats()`](../../Shared/Views/ChatList/ChatListView.swift#L555) | 555 | Applies active filter and search to chat list | +| [`searchString()`](../../Shared/Views/ChatList/ChatListView.swift#L598) | 598 | Normalizes search text for comparison | +| [`unreadBadge()`](../../Shared/Views/ChatList/ChatListView.swift#L529) | 529 | Renders unread count circle badge | +| [`stopAudioPlayer()`](../../Shared/Views/ChatList/ChatListView.swift#L549) | 549 | Stops any playing voice message | --- @@ -171,7 +172,7 @@ Horizontal scrolling tab bar below the navigation bar. Tabs: | Group Reports | `.presetTag(.groupReports)` | Groups with pending reports | | User tags | `.userTag(ChatTag)` | User-defined custom tags | -Filter matching is handled by [`presetTagMatchesChat()`](../../Shared/Views/ChatList/ChatListView.swift#L910) (L910) and the in-view [`TagsView`](../../Shared/Views/ChatList/ChatListView.swift#L705) struct (L705). +Filter matching is handled by [`presetTagMatchesChat()`](../../Shared/Views/ChatList/ChatListView.swift#L1134) and the in-view [`TagsView`](../../Shared/Views/ChatList/ChatListView.swift#L928) struct. ### ChatTagsModel State @@ -194,9 +195,9 @@ class ChatTagsModel: ObservableObject { | Type | File | Line | Description | |------|------|------|-------------| -| [`PresetTag`](../../Shared/Views/ChatList/ChatListView.swift#L36) | ChatListView.swift | 34 | Enum of built-in filter categories | -| [`ActiveFilter`](../../Shared/Views/ChatList/ChatListView.swift#L52) | ChatListView.swift | 49 | Enum wrapping preset, user-tag, or unread filter | -| [`setActiveFilter()`](../../Shared/Views/ChatList/ChatListView.swift#L889) | ChatListView.swift | 878 | Applies a filter and persists selection | +| [`PresetTag`](../../Shared/Views/ChatList/ChatListView.swift#L36) | ChatListView.swift | 36 | Enum of built-in filter categories | +| [`ActiveFilter`](../../Shared/Views/ChatList/ChatListView.swift#L53) | ChatListView.swift | 53 | Enum wrapping preset, user-tag, or unread filter | +| [`setActiveFilter()`](../../Shared/Views/ChatList/ChatListView.swift#L1113) | ChatListView.swift | 1113 | Applies a filter and persists selection | ### Tag Management Commands - `apiCreateChatTag(tag: ChatTagData)` -- create tag @@ -211,7 +212,7 @@ class ChatTagsModel: ObservableObject { Search is available via pull-down gesture or search button in the navigation bar. -**Search bar UI:** [`ChatListSearchBar`](../../Shared/Views/ChatList/ChatListView.swift#L587) (ChatListView.swift L578) +**Search bar UI:** [`ChatListSearchBar`](../../Shared/Views/ChatList/ChatListView.swift#L662) ### Filtering Logic - Filters `ChatModel.chats` by matching search text against: @@ -219,7 +220,7 @@ Search is available via pull-down gesture or search button in the navigation bar - `chatInfo.localAlias` (local alias) - `chatInfo.fullName` (full name) - For deeper message content search, uses `apiGetChat(chatId:, search:)` parameter -- Core logic in [`filteredChats()`](../../Shared/Views/ChatList/ChatListView.swift#L480) (L480) and [`searchString()`](../../Shared/Views/ChatList/ChatListView.swift#L523) (L523) +- Core logic in [`filteredChats()`](../../Shared/Views/ChatList/ChatListView.swift#L555) and [`searchString()`](../../Shared/Views/ChatList/ChatListView.swift#L598) ### Search Results - Matching chats are displayed in the same list format @@ -279,11 +280,43 @@ The FAB (floating action button) in the bottom-right corner opens the new chat f --- +## 10. [`GetStakeBanner`](../../Shared/Views/ChatList/GetStakeBanner.swift#L13) {#10-crowdfunding-banner} + +**File**: `Shared/Views/ChatList/GetStakeBanner.swift` + +Gradient card inviting the user to invest on Wefunder. Shown only when [`isInUS`](../../Shared/Views/Onboarding/WhatsNewView.swift#L45) — the same condition that gates the Wefunder row in settings — and only while `DEFAULT_GET_STAKE_BANNER_DISMISSED` is false. + +### Placement + +| Where | Condition | Layout | +|-------|-----------|--------| +| Chat list | rendered whenever [`chatListContent`](../../Shared/Views/ChatList/ChatListView.swift#L413) is, in the `List` after `OneHandUICard` and before the chats | `.padding(.vertical, 3)`, flipped for one-hand UI, `.zIndex(1)` | +| Onboarding | below [`ConnectOnboardingView`](../../Shared/Views/NewChat/OnboardingCards.swift#L135) when `shouldShowOnboarding` | `.padding(.horizontal, 20)` (the onboarding cards' margin), `.padding(.bottom, 8)` | + +In the onboarding branch the `.scaleEffect` and `ThemedBackground` are applied to the enclosing `VStack` rather than to each child, so the banner stays below the pages in both toolbar modes. + +### Dismissal + +| Default | Set by | Effect | +|---------|--------|--------| +| `DEFAULT_GET_STAKE_BANNER_TAPPED` | [`openGetStake()`](../../Shared/Views/ChatList/ChatListView.swift#L408) | the dismiss X appears from then on, while there are chats | +| `DEFAULT_GET_STAKE_BANNER_DISMISSED` | the dismiss X | hides the banner in both placements | + +Both are in `hintDefaults`, so "Reset all hints" in the developer settings restores the banner. The X is never offered in the onboarding branch, so the banner cannot be dismissed before the user has a chat. + +Tapping the card opens [`GetStakeView`](../../Shared/Views/Onboarding/WhatsNewView.swift#L834) as an `appSheet`. + +### Shared card chrome + +`BannerCard` (a `ViewModifier`: paddings, minimum height scaled by Dynamic Type, gradient background, rounded corners) and `BannerDismissButton` are declared separately from `GetStakeBanner` so other banners can adopt the same chrome. The gradient reuses `OnboardingCardView.gradientPoints`, `lightStops` and `darkStops`. + +--- + ## Source Files | File | Path | Key struct | Line | |------|------|------------|------| -| Chat list view | [`ChatListView.swift`](../../Shared/Views/ChatList/ChatListView.swift) | `ChatListView` | [138](../../Shared/Views/ChatList/ChatListView.swift#L142) | +| Chat list view | [`ChatListView.swift`](../../Shared/Views/ChatList/ChatListView.swift) | `ChatListView` | [154](../../Shared/Views/ChatList/ChatListView.swift#L154) | | Chat preview row | [`ChatPreviewView.swift`](../../Shared/Views/ChatList/ChatPreviewView.swift) | `ChatPreviewView` | [12](../../Shared/Views/ChatList/ChatPreviewView.swift#L13) | | Navigation link wrapper | [`ChatListNavLink.swift`](../../Shared/Views/ChatList/ChatListNavLink.swift) | `ChatListNavLink` | [43](../../Shared/Views/ChatList/ChatListNavLink.swift#L44) | | Tag filter tabs | [`TagListView.swift`](../../Shared/Views/ChatList/TagListView.swift) | `TagListView` | [19](../../Shared/Views/ChatList/TagListView.swift#L20) | @@ -294,3 +327,4 @@ The FAB (floating action button) in the bottom-right corner opens the new chat f | Contact connection view | [`ContactConnectionView.swift`](../../Shared/Views/ChatList/ContactConnectionView.swift) | | | | Server summary | [`ServersSummaryView.swift`](../../Shared/Views/ChatList/ServersSummaryView.swift) | | | | One-hand UI card | [`OneHandUICard.swift`](../../Shared/Views/ChatList/OneHandUICard.swift) | | | +| Crowdfunding banner | [`GetStakeBanner.swift`](../../Shared/Views/ChatList/GetStakeBanner.swift) | `GetStakeBanner` | [13](../../Shared/Views/ChatList/GetStakeBanner.swift#L13) | diff --git a/apps/ios/spec/impact.md b/apps/ios/spec/impact.md index 74acec789e..35528f30d6 100644 --- a/apps/ios/spec/impact.md +++ b/apps/ios/spec/impact.md @@ -52,6 +52,7 @@ | Shared/SimpleXApp.swift | PC1 through PC31 | High | App entry point — initialization affects everything | | Shared/AppDelegate.swift | PC18 | Medium | Push notification registration | | Shared/Views/ChatList/ChatListView.swift | PC1, PC28 | High | Main screen rendering and filtering | +| Shared/Views/ChatList/GetStakeBanner.swift | PC1 | Low | Crowdfunding banner and shared banner card chrome | | Shared/Views/Chat/ChatView.swift | PC2, PC3, PC4, PC5, PC6, PC7, PC8, PC9, PC11, PC31 | High | Core conversation UI — most messaging features, channel message rendering | | Shared/Views/Chat/ComposeMessage/ComposeView.swift | PC4, PC6, PC9, PC11, PC31 | High | Message composition — send path for all messages, channel sendAsGroup | | Shared/Views/Chat/ChatItem/ | PC2, PC3, PC5, PC7, PC8, PC9, PC10, PC11 | Medium | Individual message rendering components | diff --git a/apps/multiplatform/CODE.md b/apps/multiplatform/CODE.md index 67fa676414..30462d18e8 100644 --- a/apps/multiplatform/CODE.md +++ b/apps/multiplatform/CODE.md @@ -240,6 +240,7 @@ desktop/src/jvmMain/kotlin/chat/simplex/desktop/ -- Desktop app (1 file) | common/.../common/ui/theme/Theme.kt | spec/services/theme.md | product/views/settings.md | | common/.../common/ui/theme/Color.kt | spec/services/theme.md | product/views/settings.md | | common/.../common/views/chatlist/ChatListView.kt | spec/client/chat-list.md | product/views/chat-list.md | +| common/.../common/views/chatlist/GetStakeBanner.kt | spec/client/chat-list.md | product/views/chat-list.md | | common/.../common/views/chatlist/ChatListNavLinkView.kt | spec/client/chat-list.md | product/views/chat-list.md | | common/.../common/views/chatlist/ChatPreviewView.kt | spec/client/chat-list.md | product/views/chat-list.md | | common/.../common/views/chatlist/UserPicker.kt | spec/client/chat-list.md | product/views/chat-list.md | diff --git a/apps/multiplatform/android/src/foss/java/chat/simplex/app/PlayStore.kt b/apps/multiplatform/android/src/foss/java/chat/simplex/app/PlayStore.kt index 181fe42389..1d8fc5e151 100644 --- a/apps/multiplatform/android/src/foss/java/chat/simplex/app/PlayStore.kt +++ b/apps/multiplatform/android/src/foss/java/chat/simplex/app/PlayStore.kt @@ -1,4 +1,24 @@ package chat.simplex.app +import chat.simplex.common.platform.Log +import chat.simplex.common.views.badges.BadgeProduct +import chat.simplex.common.views.badges.BadgePurchaseOutcome +import chat.simplex.common.views.badges.BadgeStoreError +import chat.simplex.common.views.badges.BadgeStoreProductId + // Play Billing is only in the google flavor, so the Play country stays unknown here fun loadPlayStoreCountry() {} + +const val hasPlatformStore = false + +// No store in this flavor: no product is offered, so the purchase screen shows nothing to buy +// TODO [badges] this build pays via Stripe/crypto - the badge service catalog replaces these +@Suppress("UNUSED_PARAMETER") +suspend fun loadBadgeProducts(oneTimeIds: List, subscriptionIds: List): List { + Log.w(TAG, "loadBadgeProducts: no store in the foss build, use the google variant to buy from Play") + return emptyList() +} + +@Suppress("UNUSED_PARAMETER") +suspend fun purchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome = + throw BadgeStoreError.StoreUnavailable diff --git a/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt b/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt index a0e7734ff0..4075421e52 100644 --- a/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt +++ b/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt @@ -1,8 +1,16 @@ package chat.simplex.app +import chat.simplex.common.platform.Log import chat.simplex.common.platform.androidAppContext import chat.simplex.common.platform.androidPlayStoreCountry +import chat.simplex.common.platform.mainActivity +import chat.simplex.common.views.badges.* import com.android.billingclient.api.* +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +const val hasPlatformStore = true // Requests the country of the Google Play account into [androidPlayStoreCountry]. // It stays null when Play is unavailable or the user is not signed in. @@ -29,3 +37,187 @@ fun loadPlayStoreCountry() { override fun onBillingServiceDisconnected() = client.endConnection() }) } + +// One long-lived client for badges: ProductDetails obtained from it are passed back to it when the +// purchase is launched, and the purchase result arrives on its listener rather than as a return value. +// volatile: the listener is called on the main thread, the purchase runs on a background dispatcher +@Volatile private var badgeBillingClient: BillingClient? = null +@Volatile private var badgeOffers: Map = emptyMap() +@Volatile private var badgePurchase: CompletableDeferred? = null + +// offerToken is null for one-time products, which have no base plan to choose +private class BadgeOffer( + val id: BadgeStoreProductId, + val product: BadgeProduct, + val details: ProductDetails, + val offerToken: String? +) + +suspend fun loadBadgeProducts(oneTimeIds: List, subscriptionIds: List): List { + val client = connectedBadgeBillingClient() + val details = queryBadgeProducts(client, oneTimeIds, BillingClient.ProductType.INAPP) + + queryBadgeProducts(client, subscriptionIds, BillingClient.ProductType.SUBS) + val detailsByProductId = details.associateBy { it.productId } + val ids = oneTimeIds + subscriptionIds + val offers = ids.mapNotNull { id -> + val productDetails = detailsByProductId[id.productId] + if (productDetails == null) { + Log.w(TAG, "loadBadgeProducts: Play returned no product ${id.productId}") + null + } else { + productDetails.badgeOffer(id) + } + } + if (offers.size < ids.size) { + // a debug build's applicationIdSuffix is a common cause of Play not knowing the package + Log.w(TAG, "loadBadgeProducts: ${offers.size} of ${ids.size} resolved - package ${androidAppContext.packageName}, country ${androidPlayStoreCountry.value ?: "none"}") + } + badgeOffers = offers.associateBy { it.id } + return offers.map { it.product } +} + +suspend fun purchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome { + val activity = mainActivity.get() ?: throw BadgeStoreError.StoreUnavailable + val client = connectedBadgeBillingClient() + val offer = badgeOffers[id] ?: throw BadgeStoreError.ProductUnavailable(id.productId) + val details = offer.details + val productParams = BillingFlowParams.ProductDetailsParams.newBuilder().setProductDetails(details) + offer.offerToken?.let { productParams.setOfferToken(it) } + val params = BillingFlowParams.newBuilder() + .setProductDetailsParamsList(listOf(productParams.build())) + .setObfuscatedAccountId(invoiceId) + .build() + val purchase = CompletableDeferred() + badgePurchase = purchase + try { + val launched = withContext(Dispatchers.Main) { client.launchBillingFlow(activity, params) } + if (launched.responseCode != BillingClient.BillingResponseCode.OK) { + throw BadgeStoreError.BillingError(launched.responseCode, launched.debugMessage) + } + val outcome = purchase.await() + if (outcome is BadgePurchaseOutcome.Purchased) finishBadgePurchase(client, details, outcome.receipt) + return outcome + } finally { + badgePurchase = null + } +} + +private val badgePurchasesUpdatedListener = PurchasesUpdatedListener { result, purchases -> + val pending = badgePurchase ?: return@PurchasesUpdatedListener + when { + result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null -> + pending.complete(badgePurchaseOutcome(purchases)) + result.responseCode == BillingClient.BillingResponseCode.USER_CANCELED -> + pending.complete(BadgePurchaseOutcome.Cancelled) + else -> + pending.completeExceptionally(BadgeStoreError.BillingError(result.responseCode, result.debugMessage)) + } +} + +private fun badgePurchaseOutcome(purchases: List): BadgePurchaseOutcome { + val purchase = purchases.firstOrNull() ?: return BadgePurchaseOutcome.Cancelled + if (purchase.purchaseState == Purchase.PurchaseState.PENDING) return BadgePurchaseOutcome.Pending + return BadgePurchaseOutcome.Purchased( + BadgeStoreReceipt( + token = purchase.purchaseToken, + productId = purchase.products.firstOrNull() ?: "", + orderId = purchase.orderId, + invoiceId = purchase.accountIdentifiers?.obfuscatedAccountId + ) + ) +} + +private suspend fun connectedBadgeBillingClient(): BillingClient { + badgeBillingClient?.let { if (it.isReady) return it } + val client = BillingClient.newBuilder(androidAppContext) + .setListener(badgePurchasesUpdatedListener) + .enablePendingPurchases(PendingPurchasesParams.newBuilder().enableOneTimeProducts().build()) + .build() + val connected = CompletableDeferred() + client.startConnection(object : BillingClientStateListener { + override fun onBillingSetupFinished(result: BillingResult) { + connected.complete(result) + } + + override fun onBillingServiceDisconnected() { + badgeBillingClient = null + connected.complete( + BillingResult.newBuilder().setResponseCode(BillingClient.BillingResponseCode.SERVICE_DISCONNECTED).build() + ) + } + }) + val result = connected.await() + if (result.responseCode != BillingClient.BillingResponseCode.OK) { + client.endConnection() + throw BadgeStoreError.BillingError(result.responseCode, result.debugMessage) + } + badgeBillingClient = client + return client +} + +private suspend fun queryBadgeProducts(client: BillingClient, ids: List, productType: String): List { + // durations of one subscription share a product id, so the same product is queried once + val requested = ids.map { it.productId }.distinct() + val params = QueryProductDetailsParams.newBuilder() + .setProductList( + requested.map { + QueryProductDetailsParams.Product.newBuilder().setProductId(it).setProductType(productType).build() + } + ) + .build() + val queried = CompletableDeferred>() + client.queryProductDetailsAsync(params) { result, productDetailsResult -> + if (result.responseCode == BillingClient.BillingResponseCode.OK) { + queried.complete(productDetailsResult.productDetailsList) + } else { + Log.w(TAG, "queryBadgeProducts: $productType requested $requested, failed ${result.responseCode} ${result.debugMessage}") + queried.complete(emptyList()) + } + } + return queried.await() +} + +private fun ProductDetails.badgeOffer(id: BadgeStoreProductId): BadgeOffer? { + if (id.basePlanId == null) { + val purchase = oneTimePurchaseOfferDetails + if (purchase == null) { + Log.w(TAG, "badgeOffer: ${id.productId} has no one-time purchase price, type is $productType") + return null + } + val product = BadgeProduct(id, purchase.formattedPrice, purchase.priceAmountMicros, purchase.priceCurrencyCode) + return BadgeOffer(id, product, this, offerToken = null) + } + val offer = subscriptionOfferDetails?.firstOrNull { it.basePlanId == id.basePlanId } + if (offer == null) { + Log.w(TAG, "badgeOffer: ${id.productId} has no base plan ${id.basePlanId}, Play has ${subscriptionOfferDetails?.map { it.basePlanId }}") + return null + } + val phase = offer.pricingPhases.pricingPhaseList.firstOrNull { + it.recurrenceMode == ProductDetails.RecurrenceMode.INFINITE_RECURRING + } + if (phase == null) { + Log.w(TAG, "badgeOffer: ${id.productId} base plan ${id.basePlanId} has no recurring price") + return null + } + val product = BadgeProduct(id, phase.formattedPrice, phase.priceAmountMicros, phase.priceCurrencyCode) + return BadgeOffer(id, product, this, offer.offerToken) +} + +// nothing is delivered in this build, so the purchase is finished right away; once the service issues +// credentials it must only be finished after the credential is stored. One-time products are consumed +// so they can be bought again, subscriptions are acknowledged - Play refunds an unacknowledged +// purchase after 3 days. +private suspend fun finishBadgePurchase(client: BillingClient, details: ProductDetails, receipt: BadgeStoreReceipt) { + val done = CompletableDeferred() + if (details.productType == BillingClient.ProductType.SUBS) { + val params = AcknowledgePurchaseParams.newBuilder().setPurchaseToken(receipt.token).build() + client.acknowledgePurchase(params) { done.complete(it) } + } else { + val params = ConsumeParams.newBuilder().setPurchaseToken(receipt.token).build() + client.consumeAsync(params) { result, _ -> done.complete(result) } + } + val result = done.await() + if (result.responseCode != BillingClient.BillingResponseCode.OK) { + Log.e(TAG, "finishBadgePurchase: ${result.responseCode} ${result.debugMessage}") + } +} diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt index ce47d2c5de..1a4c836b2e 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt @@ -26,6 +26,9 @@ import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.badges.BadgeProduct +import chat.simplex.common.views.badges.BadgePurchaseOutcome +import chat.simplex.common.views.badges.BadgeStoreProductId import chat.simplex.common.views.call.* import chat.simplex.common.views.database.deleteOldChatArchive import chat.simplex.common.views.helpers.* @@ -343,6 +346,12 @@ class SimplexApp: Application(), LifecycleEventObserver { override fun androidLoadPlayStoreCountry() = loadPlayStoreCountry() + override val androidHasPlatformStore: Boolean get() = hasPlatformStore + + override suspend fun androidLoadBadgeProducts(oneTimeIds: List, subscriptionIds: List): List = loadBadgeProducts(oneTimeIds, subscriptionIds) + + override suspend fun androidPurchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome = purchaseBadge(id, invoiceId) + @SuppressLint("SourceLockedOrientationActivity") @Composable override fun androidLockPortraitOrientation() { diff --git a/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c b/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c index fd7f71d49c..c0497daacb 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c +++ b/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c @@ -67,6 +67,7 @@ extern char *chat_parse_server(const char *str); extern char *chat_parse_uri(const char *str, const int safe); extern char *chat_password_hash(const char *pwd, const char *salt); extern char *chat_valid_name(const char *name); +extern char *chat_parse_badge_code(const char *code); extern int chat_json_length(const char *str); extern char *chat_write_file(chat_ctrl ctrl, const char *path, char *ptr, int length); extern char *chat_read_file(const char *path, const char *key, const char *nonce); @@ -173,6 +174,14 @@ Java_chat_simplex_common_platform_CoreKt_chatValidName(JNIEnv *env, jclass clazz return res; } +JNIEXPORT jstring JNICALL +Java_chat_simplex_common_platform_CoreKt_chatParseBadgeCode(JNIEnv *env, jclass clazz, jstring code) { + const char *_code = (*env)->GetStringUTFChars(env, code, JNI_FALSE); + jstring res = (*env)->NewStringUTF(env, chat_parse_badge_code(_code)); + (*env)->ReleaseStringUTFChars(env, code, _code); + return res; +} + JNIEXPORT int JNICALL Java_chat_simplex_common_platform_CoreKt_chatJsonLength(JNIEnv *env, jclass clazz, jstring str) { const char *_str = (*env)->GetStringUTFChars(env, str, JNI_FALSE); diff --git a/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c b/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c index 9844a5927d..6bb6ffbe97 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c +++ b/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c @@ -40,6 +40,7 @@ extern char *chat_parse_server(const char *str); extern char *chat_parse_uri(const char *str, const int safe); extern char *chat_password_hash(const char *pwd, const char *salt); extern char *chat_valid_name(const char *name); +extern char *chat_parse_badge_code(const char *code); extern int chat_json_length(const char *str); extern char *chat_write_file(chat_ctrl ctrl, const char *path, char *ptr, int length); extern char *chat_read_file(const char *path, const char *key, const char *nonce); @@ -183,6 +184,14 @@ Java_chat_simplex_common_platform_CoreKt_chatValidName(JNIEnv *env, jclass clazz return res; } +JNIEXPORT jstring JNICALL +Java_chat_simplex_common_platform_CoreKt_chatParseBadgeCode(JNIEnv *env, jclass clazz, jstring code) { + const char *_code = encode_to_utf8_chars(env, code); + jstring res = decode_to_utf8_string(env, chat_parse_badge_code(_code)); + (*env)->ReleaseStringUTFChars(env, code, _code); + return res; +} + JNIEXPORT int JNICALL Java_chat_simplex_common_platform_CoreKt_chatJsonLength(JNIEnv *env, jclass clazz, jstring str) { const char *_str = encode_to_utf8_chars(env, str); 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 cb91c386ce..338e602a45 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 @@ -397,9 +397,11 @@ fun CenterPartOfScreen() { } when (currentChatId.value) { null -> { - if (shouldShowOnboarding()) { + if (rememberUpdatedState(ModalManager.center.hasModalsOpen()).value) { + ModalManager.center.showInView() + } else if (shouldShowOnboarding()) { ConnectOnboardingView() - } else if (!rememberUpdatedState(ModalManager.center.hasModalsOpen()).value) { + } else { Box( Modifier .fillMaxSize() @@ -408,8 +410,6 @@ fun CenterPartOfScreen() { ) { Text(stringResource(if (chatModel.desktopNoUserNoRemote) MR.strings.no_connected_mobile else MR.strings.no_selected_chat)) } - } else { - ModalManager.center.showInView() } } else -> ChatView(chatsCtx = chatModel.chatsContext, currentChatId) {} 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 3ba5bcf6ad..5bbb41e4dd 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 @@ -102,6 +102,34 @@ object ChannelRelaysModel { } } +// The badge of whichever profile it was last loaded for, kept current by the badgeChanged event so +// that a screen already open shows what the renewal worker did with no command behind it. +object BadgeModel { + val rhId = mutableStateOf(null) + val userId = mutableStateOf(null) + val badgeState = mutableStateOf(null) + val alert = mutableStateOf(null) + + // alert follows the state: getUserBadgeState derives it on every read, so a badgeChanged is + // never staler than the alert it carries - the invariant a new alert kind must keep + fun set(rhId: Long?, userId: Long, badgeState: BadgeState?) { + this.rhId.value = rhId + this.userId.value = userId + this.badgeState.value = badgeState + alert.value = badgeState?.alert + } + + fun setAlert(rhId: Long?, userId: Long, alert: BadgeAlert) { + if (isCurrent(rhId, userId)) { + this.alert.value = alert + badgeState.value = badgeState.value?.copy(alert = alert) + } + } + + fun isCurrent(rhId: Long?, userId: Long?): Boolean = + this.rhId.value == rhId && this.userId.value == userId +} + /* * Without this annotation an animation from ChatList to ChatView has 1 frame per the whole animation. Don't delete it * */ @@ -2192,6 +2220,181 @@ data class LocalBadge( val status: BadgeStatus ) +// paidThrough is the only date to show the user: BadgeInfo.badgeExpiry is the credential's expiry, +// which outlives entitlement so the credential's window can cover a renewal. +@Serializable +data class BadgeState( + val badgePurchaseId: Long, + val purchaseKey: String, + val badgeType: BadgeType, + val shown: Boolean, + val monthsLeft: Int, + val paidThrough: Instant, + val renewsAt: Instant? = null, + val willRenew: Boolean, + val alert: BadgeAlert? = null +) { + val paidThroughText: String get() = badgeDateText(paidThrough) +} + +@Serializable +data class StatementEntry( + val entryId: String, + val changeMonths: Int, + val balanceMonths: Int, + val balanceStartTs: Instant, + val balanceAnchorTs: Instant, + val balanceBadgeType: BadgeType, + val wasPausedSince: Instant? = null, + val createdAt: Instant, + val entryType: StatementEntryType +) + +@Serializable +sealed class StatementEntryType { + @Serializable @SerialName("credit") data class Credit(val credit: StatementCreditType): StatementEntryType() + @Serializable @SerialName("debit") data class Debit(val debit: StatementDebitType): StatementEntryType() + + val text: String + get() = when (this) { + is Credit -> credit.text + is Debit -> debit.text + } +} + +// the service is deployed ahead of clients, so a type this version does not know keeps its tag +@Serializable(with = StatementCreditTypeSerializer::class) +sealed class StatementCreditType { + @Serializable data class Payment(val invoiceId: String? = null): StatementCreditType() + object Code: StatementCreditType() + @Serializable data class Charge(val chargeId: String): StatementCreditType() + object Support: StatementCreditType() + @Serializable data class TransferIn(val fromPurchaseKey: String): StatementCreditType() + object Opening: StatementCreditType() + data class Unknown(val type: String): StatementCreditType() + + val text: String + get() = when (this) { + is Payment -> "payment" + is Code -> "code" + is Charge -> "charge" + is Support -> "support" + is TransferIn -> "transferIn" + is Opening -> "opening" + is Unknown -> type + } +} + +object StatementCreditTypeSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("StatementCreditType") + + override fun deserialize(decoder: Decoder): StatementCreditType { + require(decoder is JsonDecoder) + val json = decoder.decodeJsonElement().jsonObject + return when (val type = json["type"]?.jsonPrimitive?.content ?: "") { + "payment" -> decoder.json.decodeFromJsonElement(json) + "code" -> StatementCreditType.Code + "charge" -> decoder.json.decodeFromJsonElement(json) + "support" -> StatementCreditType.Support + "transferIn" -> decoder.json.decodeFromJsonElement(json) + "opening" -> StatementCreditType.Opening + else -> StatementCreditType.Unknown(type) + } + } + + override fun serialize(encoder: Encoder, value: StatementCreditType) { + require(encoder is JsonEncoder) + encoder.encodeJsonElement(buildJsonObject { + put("type", value.text) + when (value) { + is StatementCreditType.Payment -> value.invoiceId?.let { put("invoiceId", it) } + is StatementCreditType.Charge -> put("chargeId", value.chargeId) + is StatementCreditType.TransferIn -> put("fromPurchaseKey", value.fromPurchaseKey) + is StatementCreditType.Code, is StatementCreditType.Support, is StatementCreditType.Opening, is StatementCreditType.Unknown -> {} + } + }) + } +} + +@Serializable(with = StatementDebitTypeSerializer::class) +sealed class StatementDebitType { + object Refund: StatementDebitType() + @Serializable data class Upgrade(val toPurchaseKey: String): StatementDebitType() + @Serializable data class TransferOut(val toPurchaseKey: String): StatementDebitType() + object Support: StatementDebitType() + object Badge: StatementDebitType() + object Lapse: StatementDebitType() + data class Unknown(val type: String): StatementDebitType() + + val text: String + get() = when (this) { + is Refund -> "refund" + is Upgrade -> "upgrade" + is TransferOut -> "transferOut" + is Support -> "support" + is Badge -> "badge" + is Lapse -> "lapse" + is Unknown -> type + } +} + +object StatementDebitTypeSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("StatementDebitType") + + override fun deserialize(decoder: Decoder): StatementDebitType { + require(decoder is JsonDecoder) + val json = decoder.decodeJsonElement().jsonObject + return when (val type = json["type"]?.jsonPrimitive?.content ?: "") { + "refund" -> StatementDebitType.Refund + "upgrade" -> decoder.json.decodeFromJsonElement(json) + "transferOut" -> decoder.json.decodeFromJsonElement(json) + "support" -> StatementDebitType.Support + "badge" -> StatementDebitType.Badge + "lapse" -> StatementDebitType.Lapse + else -> StatementDebitType.Unknown(type) + } + } + + override fun serialize(encoder: Encoder, value: StatementDebitType) { + require(encoder is JsonEncoder) + encoder.encodeJsonElement(buildJsonObject { + put("type", value.text) + when (value) { + is StatementDebitType.Upgrade -> put("toPurchaseKey", value.toPurchaseKey) + is StatementDebitType.TransferOut -> put("toPurchaseKey", value.toPurchaseKey) + is StatementDebitType.Refund, is StatementDebitType.Support, is StatementDebitType.Badge, is StatementDebitType.Lapse, is StatementDebitType.Unknown -> {} + } + }) + } +} + +@Serializable +data class BadgeAlert( + val kind: BadgeAlertKind, + val episode: String, + val date: Instant, + val price: BadgeAlertPrice? = null +) { + val dateText: String get() = badgeDateText(date) +} + +@Serializable +data class BadgeAlertPrice(val amount: Long, val currency: String) + +@Serializable +enum class BadgeAlertKind { + @SerialName("renewalApproaching") RenewalApproaching, + @SerialName("paymentIssue") PaymentIssue, + @SerialName("subscriptionEnded") SubscriptionEnded, + @SerialName("prepaidEnding") PrepaidEnding, + @SerialName("supportEnded") SupportEnded +} + +private fun badgeDateText(date: Instant): String { + val ts = date.toLocalDateTime(TimeZone.currentSystemDefault()) + return ts.toJavaLocalDateTime().format(DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG)) +} + // the wire proof carried on a profile - opaque to the UI, only round-tripped back to the core (apiPrepareContact) @Serializable data class BadgeProof( @@ -4245,6 +4448,13 @@ enum class MREmojiChar(val value: String) { @SerialName("✅") Check("✅"); } +// set by the core when the file is above the size the sender's badge allows; badgeStatus is null when no proof was sent +@Serializable +data class FileProhibited( + val maxSize: Long, + val badgeStatus: BadgeStatus? = null +) + @Serializable data class CIFile( val fileId: Long, @@ -4253,7 +4463,8 @@ data class CIFile( val fileSource: CryptoFile? = null, val fileStatus: CIFileStatus, val fileProtocol: FileProtocol, - val fileExpires: Instant? = null + val fileExpires: Instant? = null, + val fileProhibited: FileProhibited? = null ) { val expired: Boolean = fileExpires != null && fileExpires < Clock.System.now() 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 63d0f4f82a..92f4c6d5c0 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 @@ -192,6 +192,9 @@ class AppPreferences { val showHiddenProfilesNotice = mkBoolPreference(SHARED_PREFS_SHOW_HIDDEN_PROFILES_NOTICE, true) val oneHandUICardShown = mkBoolPreference(SHARED_PREFS_ONE_HAND_UI_CARD_SHOWN, false) val addressCreationCardShown = mkBoolPreference(SHARED_PREFS_ADDRESS_CREATION_CARD_SHOWN, false) + val supporterBannerShown = mkBoolPreference(SHARED_PREFS_SUPPORTER_BANNER_SHOWN, false) + val getStakeBannerTapped = mkBoolPreference(SHARED_PREFS_GET_STAKE_BANNER_TAPPED, false) + val getStakeBannerDismissed = mkBoolPreference(SHARED_PREFS_GET_STAKE_BANNER_DISMISSED, false) val showMuteProfileAlert = mkBoolPreference(SHARED_PREFS_SHOW_MUTE_PROFILE_ALERT, true) val showReportsInSupportChatAlert = mkBoolPreference(SHARED_PREFS_SHOW_REPORTS_IN_SUPPORT_CHAT_ALERT, true) val appLanguage = mkStrPreference(SHARED_PREFS_APP_LANGUAGE, null) @@ -273,6 +276,9 @@ class AppPreferences { hintPref(laNoticeShown, false), hintPref(oneHandUICardShown, false), hintPref(addressCreationCardShown, false), + hintPref(supporterBannerShown, false), + hintPref(getStakeBannerTapped, false), + hintPref(getStakeBannerDismissed, false), hintPref(liveMessageAlertShown, false), hintPref(signMessageAlertShown, false), hintPref(showHiddenProfilesNotice, true), @@ -464,6 +470,9 @@ class AppPreferences { private const val SHARED_PREFS_SHOW_HIDDEN_PROFILES_NOTICE = "ShowHiddenProfilesNotice" private const val SHARED_PREFS_ONE_HAND_UI_CARD_SHOWN = "OneHandUICardShown" private const val SHARED_PREFS_ADDRESS_CREATION_CARD_SHOWN = "AddressCreationCardShown" + private const val SHARED_PREFS_SUPPORTER_BANNER_SHOWN = "SupporterBannerShown" + private const val SHARED_PREFS_GET_STAKE_BANNER_TAPPED = "GetStakeBannerTapped" + private const val SHARED_PREFS_GET_STAKE_BANNER_DISMISSED = "GetStakeBannerDismissed" private const val SHARED_PREFS_SHOW_MUTE_PROFILE_ALERT = "ShowMuteProfileAlert" private const val SHARED_PREFS_SHOW_REPORTS_IN_SUPPORT_CHAT_ALERT = "ShowReportsInSupportChatAlert" private const val SHARED_PREFS_STORE_DB_PASSPHRASE = "StoreDBPassphrase" @@ -559,6 +568,89 @@ object ChatController { suspend fun resetAgentServersStats(rh: Long?): Boolean = sendCommandOkResp(rh, CC.ResetAgentServersStats()) + // log = false because the code is a bearer secret until it is redeemed - it is in the command. + // null when the user cancels the retry alert. + suspend fun apiRedeemBadgeCode(rh: Long?, userId: Long, code: String): BadgeRedeemResult? { + val r = sendCmdWithRetry(rh, CC.ApiRedeemBadgeCode(userId, code), log = false) ?: return null + return when { + // redeemedBadge is dropped: the user's profile carries what is shown + r is API.Result && r.res is CR.BadgeRedeemed -> BadgeRedeemResult.Redeemed(r.res.user.updateRemoteHostId(rh), r.res.newBadge, r.res.badgeState) + r is API.Error -> BadgeRedeemResult.Failed(r.err) + else -> { + // the response type alone - it names a case or a JSON key, never the service's message + Log.e(TAG, "apiRedeemBadgeCode: unexpected ${r.responseType}") + BadgeRedeemResult.Failed(null) + } + } + } + + // localized where the user can act on it; otherwise the error itself, so a screenshot says what happened + fun redeemErrorText(err: ChatError?): String { + if (err is ChatError.ChatErrorChat && err.errorType is ChatErrorType.CEBadgeRedeemError) { + when (val e = err.errorType.badgeRedeemError) { + is BadgeRedeemError.InvalidCode -> return generalGetString(MR.strings.badges_error_invalid_code) + is BadgeRedeemError.ServiceNotConfigured -> return generalGetString(MR.strings.badges_error_service_not_configured) + is BadgeRedeemError.BadgeActive -> return generalGetString(MR.strings.badges_error_already_active) + is BadgeRedeemError.ServiceError -> when (e.serviceError) { + is BadgeServiceErrorCode.CodeInvalid -> return generalGetString(MR.strings.badges_error_code_invalid) + is BadgeServiceErrorCode.CodeUsed -> return generalGetString(MR.strings.badges_error_code_used) + is BadgeServiceErrorCode.CodeExpired -> return generalGetString(MR.strings.badges_error_code_expired) + is BadgeServiceErrorCode.RateLimited -> return generalGetString(MR.strings.badges_error_rate_limited) + is BadgeServiceErrorCode.UnsupportedVersion -> return generalGetString(MR.strings.badges_error_unsupported_version) + else -> {} + } + is BadgeRedeemError.InvalidResponse -> return String.format(generalGetString(MR.strings.badges_error_bad_service_response), e.message) + is BadgeRedeemError.UnknownKeyIndex, is BadgeRedeemError.CredentialNotVerified -> return generalGetString(MR.strings.badges_error_credential_not_verified) + } + } + return "${generalGetString(MR.strings.error_prefix)}: ${err?.string ?: generalGetString(MR.strings.badges_error_unknown)}" + } + + suspend fun apiGetBadgeState(rh: Long?, userId: Long): BadgeState? { + val r = sendCmd(rh, CC.ApiGetBadgeState(userId)) + if (r is API.Result && r.res is CR.BadgeStateR) return r.res.badgeState + throw Exception("apiGetBadgeState: unexpected ${r.responseType}") + } + + suspend fun apiGetBadgeLedger(rh: Long?, userId: Long, badgePurchaseId: Long): List { + val r = sendCmd(rh, CC.ApiGetBadgeLedger(userId, badgePurchaseId)) + if (r is API.Result && r.res is CR.BadgeLedger) return r.res.badgeLedger + throw Exception("apiGetBadgeLedger: unexpected ${r.responseType}") + } + + suspend fun apiAckBadgeAlert(rh: Long?, userId: Long, badgePurchaseId: Long, alertKind: BadgeAlertKind, snooze: Boolean, episode: String): BadgeState? { + val r = sendCmd(rh, CC.ApiAckBadgeAlert(userId, badgePurchaseId, alertKind, snooze, episode)) + if (r is API.Result && r.res is CR.BadgeStateR) return r.res.badgeState + throw Exception("apiAckBadgeAlert: unexpected ${r.responseType}") + } + + // An API call and not a stored flag: the ack is kept on the purchase in core, which then stops + // raising this occurrence on every pass and across restarts, or until a snooze lapses. + suspend fun ackBadgeAlert(snooze: Boolean) { + val rhId = BadgeModel.rhId.value + val userId = BadgeModel.userId.value ?: return + val purchaseId = BadgeModel.badgeState.value?.badgePurchaseId ?: return + val alert = BadgeModel.alert.value ?: return + try { + val badgeState = apiAckBadgeAlert(rhId, userId, purchaseId, alert.kind, snooze = snooze, episode = alert.episode) + withContext(Dispatchers.Main) { BadgeModel.set(rhId, userId, badgeState) } + } catch (e: Exception) { + Log.e(TAG, "ackBadgeAlert: ${e.message}") + } + } + + // Not thrown: a failed badge read must not stop the app starting, and the model is left alone + // rather than set to nil, which would read as "no badge". + private suspend fun loadBadgeState(rhId: Long?) { + try { + val userId = currentUserId("loadBadgeState") + val badgeState = apiGetBadgeState(rhId, userId) + withContext(Dispatchers.Main) { BadgeModel.set(rhId, userId, badgeState) } + } catch (e: Exception) { + Log.e(TAG, "loadBadgeState: ${e.message}") + } + } + private suspend fun currentUserId(funcName: String): Long = changingActiveUserMutex.withLock { val userId = chatModel.currentUser.value?.userId if (userId == null) { @@ -680,6 +772,7 @@ object ChatController { chatModel.userTags.value = if (hasUser) apiGetChatTags(rhId) ?: emptyList() else emptyList() chatModel.activeChatTagFilter.value = null chatModel.updateChatTags(rhId) + if (hasUser) loadBadgeState(rhId) } // Spec: spec/api.md#startReceiver @@ -743,8 +836,8 @@ object ChatController { } } - private suspend fun sendCmdWithRetry(rhId: Long?, cmd: CC, inProgress: MutableState? = null, retryNum: Int = 0): API? { - val r = sendCmd(rhId, cmd, retryNum = retryNum) + private suspend fun sendCmdWithRetry(rhId: Long?, cmd: CC, inProgress: MutableState? = null, retryNum: Int = 0, log: Boolean = true): API? { + val r = sendCmd(rhId, cmd, retryNum = retryNum, log = log) val alert = if (r is API.Error) retryableNetworkErrorAlert(r.err) else null if ((inProgress == null || inProgress.value) && alert != null) { return suspendCancellableCoroutine { cont -> @@ -764,7 +857,7 @@ object ChatController { safeResume( runCatching { coroutineScope { - sendCmdWithRetry(rhId, cmd, inProgress = inProgress, retryNum = retryNum + 1) + sendCmdWithRetry(rhId, cmd, inProgress = inProgress, retryNum = retryNum + 1, log = log) } } ) @@ -827,6 +920,10 @@ object ChatController { else -> null } } + is AgentErrorType.AGENT -> + if (e.agentErr is SMPAgentError.A_SERVICE && e.agentErr.serviceError is AgentServiceError.Timeout) { + return MR.strings.connection_timeout to generalGetString(MR.strings.service_request_timeout_desc) + } else -> return null } return null @@ -3472,6 +3569,22 @@ object ChatController { chatModel.chatsContext.updateContact(rhId, r.contact) } } + is CR.BadgeChanged -> + if (rhId == chatModel.remoteHostId()) { + withContext(Dispatchers.Main) { + // read by core after retiring or presenting, so it carries the profile badge as changed + chatModel.updateUser(r.user.updateRemoteHostId(rhId)) + if (active(r.user)) { + BadgeModel.set(rhId, r.user.userId, r.badgeState) + } + } + } + is CR.BadgeAlertR -> + if (active(r.user)) { + withContext(Dispatchers.Main) { + BadgeModel.setAlert(rhId, r.user.userId, r.badgeAlert) + } + } else -> Log.d(TAG , "unsupported event: ${msg.responseType}") } @@ -3769,6 +3882,12 @@ class SharedPreference(val get: () -> T, set: (T) -> Unit) { } } +sealed class BadgeRedeemResult { + class Redeemed(val user: User, val newBadge: Boolean, val badgeState: BadgeState?): BadgeRedeemResult() + // err is null for a response of an unexpected type, which is logged where it is received + class Failed(val err: ChatError?): BadgeRedeemResult() +} + // ChatCommand // Spec: spec/api.md#CC sealed class CC { @@ -3947,6 +4066,11 @@ sealed class CC { class ApiUploadStandaloneFile(val userId: Long, val file: CryptoFile): CC() class ApiDownloadStandaloneFile(val userId: Long, val url: String, val file: CryptoFile): CC() class ApiStandaloneFileInfo(val url: String): CC() + // badges + class ApiRedeemBadgeCode(val userId: Long, val code: String): CC() + class ApiGetBadgeState(val userId: Long): CC() + class ApiGetBadgeLedger(val userId: Long, val badgePurchaseId: Long): CC() + class ApiAckBadgeAlert(val userId: Long, val badgePurchaseId: Long, val alertKind: BadgeAlertKind, val snooze: Boolean, val episode: String): CC() // misc class ShowVersion(): CC() class ResetAgentServersStats(): CC() @@ -4168,6 +4292,10 @@ sealed class CC { is ApiUploadStandaloneFile -> "/_upload $userId ${file.filePath}" is ApiDownloadStandaloneFile -> "/_download $userId $url ${file.filePath}" is ApiStandaloneFileInfo -> "/_download info $url" + is ApiRedeemBadgeCode -> "/_redeem_badge_code $userId $code" + is ApiGetBadgeState -> "/_badge state $userId" + is ApiGetBadgeLedger -> "/_badge ledger $userId $badgePurchaseId" + is ApiAckBadgeAlert -> "/_badge ack $userId $badgePurchaseId ${badgeAlertKindParam(alertKind)} ${onOff(snooze)} $episode" is ShowVersion -> "/version" is ResetAgentServersStats -> "/reset servers stats" is GetAgentSubsTotal -> "/get subs total $userId" @@ -4348,6 +4476,10 @@ sealed class CC { is ApiUploadStandaloneFile -> "apiUploadStandaloneFile" is ApiDownloadStandaloneFile -> "apiDownloadStandaloneFile" is ApiStandaloneFileInfo -> "apiStandaloneFileInfo" + is ApiRedeemBadgeCode -> "apiRedeemBadgeCode" + is ApiGetBadgeState -> "apiGetBadgeState" + is ApiGetBadgeLedger -> "apiGetBadgeLedger" + is ApiAckBadgeAlert -> "apiAckBadgeAlert" is ShowVersion -> "showVersion" is ResetAgentServersStats -> "resetAgentServersStats" is GetAgentSubsTotal -> "getAgentSubsTotal" @@ -4369,6 +4501,8 @@ sealed class CC { is ApiUnhideUser -> ApiUnhideUser(userId, obfuscate(viewPwd)) is ApiDeleteUser -> ApiDeleteUser(userId, delSMPQueues, obfuscateOrNull(viewPwd)) is TestStorageEncryption -> TestStorageEncryption(obfuscate(key)) + // a code is a bearer secret until it is redeemed, and the terminal shows and copies cmdString + is ApiRedeemBadgeCode -> ApiRedeemBadgeCode(userId, obfuscate(code)) else -> this } @@ -4404,6 +4538,15 @@ sealed class CC { fun onOff(b: Boolean): String = if (b) "on" else "off" +// /_badge ack takes the kind in core's text encoding, not the JSON tag +private fun badgeAlertKindParam(kind: BadgeAlertKind): String = when (kind) { + BadgeAlertKind.RenewalApproaching -> "renewal_approaching" + BadgeAlertKind.PaymentIssue -> "payment_issue" + BadgeAlertKind.SubscriptionEnded -> "subscription_ended" + BadgeAlertKind.PrepaidEnding -> "prepaid_ending" + BadgeAlertKind.SupportEnded -> "support_ended" +} + @Serializable data class NewUser( val profile: Profile?, @@ -6722,6 +6865,13 @@ sealed class CR { @Serializable @SerialName("appSettings") class AppSettingsR(val appSettings: AppSettings): CR() @Serializable @SerialName("agentSubsTotal") class AgentSubsTotal(val user: UserRef, val subsTotal: SMPServerSubs, val hasSession: Boolean): CR() @Serializable @SerialName("agentServersSummary") class AgentServersSummary(val user: UserRef, val serversSummary: PresentedServersSummary): CR() + // badges + // the full user, not UserRef: its profile carries the badge that setUserBadge just stored + @Serializable @SerialName("badgeRedeemed") class BadgeRedeemed(val user: User, val redeemedBadge: LocalBadge, val newBadge: Boolean, val badgeState: BadgeState?): CR() + @Serializable @SerialName("badgeState") class BadgeStateR(val user: UserRef, val badgeState: BadgeState?): CR() + @Serializable @SerialName("badgeLedger") class BadgeLedger(val user: UserRef, val badgeLedger: List): CR() + @Serializable @SerialName("badgeChanged") class BadgeChanged(val user: User, val badgeState: BadgeState?): CR() + @Serializable @SerialName("badgeAlert") class BadgeAlertR(val user: UserRef, val badgeAlert: BadgeAlert): CR() // general @Serializable class Response(val type: String, val json: String): CR() @Serializable class Invalid(val str: String): CR() @@ -6908,6 +7058,11 @@ sealed class CR { is ArchiveExported -> "archiveExported" is ArchiveImported -> "archiveImported" is AppSettingsR -> "appSettings" + is BadgeRedeemed -> "badgeRedeemed" + is BadgeStateR -> "badgeState" + is BadgeLedger -> "badgeLedger" + is BadgeChanged -> "badgeChanged" + is BadgeAlertR -> "badgeAlert" is Response -> "* $type" is Invalid -> "* invalid json" } @@ -7111,6 +7266,11 @@ sealed class CR { is ArchiveExported -> "${archiveErrors.map { it.string } }" is ArchiveImported -> "${archiveErrors.map { it.string } }" is AppSettingsR -> json.encodeToString(appSettings) + is BadgeRedeemed -> withUser(user, "redeemedBadge: ${json.encodeToString(redeemedBadge)}\nnewBadge: $newBadge\nbadgeState: ${json.encodeToString(badgeState)}") + is BadgeStateR -> withUser(user, json.encodeToString(badgeState)) + is BadgeLedger -> withUser(user, json.encodeToString(badgeLedger)) + is BadgeChanged -> withUser(user, json.encodeToString(badgeState)) + is BadgeAlertR -> withUser(user, json.encodeToString(badgeAlert)) is Response -> json is Invalid -> str } @@ -7162,6 +7322,97 @@ sealed class SimplexDomainError { @Serializable @SerialName("unknownDomain") object UnknownDomain : SimplexDomainError() } +@Serializable +sealed class BadgeRedeemError { + val string: String get() = when (this) { + is InvalidCode -> "invalidCode" + is ServiceNotConfigured -> "serviceNotConfigured" + is BadgeActive -> "badgeActive" + is ServiceError -> "serviceError ${serviceError.text}" + is InvalidResponse -> "invalidResponse $message" + is UnknownKeyIndex -> "unknownKeyIndex" + is CredentialNotVerified -> "credentialNotVerified" + } + @Serializable @SerialName("invalidCode") object InvalidCode : BadgeRedeemError() + @Serializable @SerialName("serviceNotConfigured") object ServiceNotConfigured : BadgeRedeemError() + @Serializable @SerialName("badgeActive") object BadgeActive : BadgeRedeemError() + @Serializable @SerialName("serviceError") class ServiceError(val serviceError: BadgeServiceErrorCode) : BadgeRedeemError() + @Serializable @SerialName("invalidResponse") class InvalidResponse(val message: String) : BadgeRedeemError() + @Serializable @SerialName("unknownKeyIndex") object UnknownKeyIndex : BadgeRedeemError() + @Serializable @SerialName("credentialNotVerified") object CredentialNotVerified : BadgeRedeemError() +} + +// the service is deployed ahead of clients, so a code this version does not know keeps its tag +@Serializable(with = BadgeServiceErrorCodeSerializer::class) +sealed class BadgeServiceErrorCode { + object BadRequest: BadgeServiceErrorCode() + object UnsupportedVersion: BadgeServiceErrorCode() + object UnknownPurchaseKey: BadgeServiceErrorCode() + object UnknownOfferId: BadgeServiceErrorCode() + object OfferDisabled: BadgeServiceErrorCode() + object OfferMismatch: BadgeServiceErrorCode() + object ProductUnavailable: BadgeServiceErrorCode() + object PaymentNotEntitled: BadgeServiceErrorCode() + object PaymentPending: BadgeServiceErrorCode() + object ProviderUnavailable: BadgeServiceErrorCode() + object RateLimited: BadgeServiceErrorCode() + object CodeInvalid: BadgeServiceErrorCode() + object CodeUsed: BadgeServiceErrorCode() + object CodeExpired: BadgeServiceErrorCode() + object ReceiptInvalid: BadgeServiceErrorCode() + object ReceiptUsed: BadgeServiceErrorCode() + object Internal: BadgeServiceErrorCode() + data class Unknown(val code: String): BadgeServiceErrorCode() + + val text: String + get() = when (this) { + is BadRequest -> "bad_request" + is UnsupportedVersion -> "unsupported_version" + is UnknownPurchaseKey -> "unknown_purchase_key" + is UnknownOfferId -> "unknown_offer_id" + is OfferDisabled -> "offer_disabled" + is OfferMismatch -> "offer_mismatch" + is ProductUnavailable -> "product_unavailable" + is PaymentNotEntitled -> "payment_not_entitled" + is PaymentPending -> "payment_pending" + is ProviderUnavailable -> "provider_unavailable" + is RateLimited -> "rate_limited" + is CodeInvalid -> "code_invalid" + is CodeUsed -> "code_used" + is CodeExpired -> "code_expired" + is ReceiptInvalid -> "receipt_invalid" + is ReceiptUsed -> "receipt_used" + is Internal -> "internal" + is Unknown -> code + } +} + +object BadgeServiceErrorCodeSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("BadgeServiceErrorCode", PrimitiveKind.STRING) + override fun deserialize(decoder: Decoder): BadgeServiceErrorCode = + when (val v = decoder.decodeString()) { + "bad_request" -> BadgeServiceErrorCode.BadRequest + "unsupported_version" -> BadgeServiceErrorCode.UnsupportedVersion + "unknown_purchase_key" -> BadgeServiceErrorCode.UnknownPurchaseKey + "unknown_offer_id" -> BadgeServiceErrorCode.UnknownOfferId + "offer_disabled" -> BadgeServiceErrorCode.OfferDisabled + "offer_mismatch" -> BadgeServiceErrorCode.OfferMismatch + "product_unavailable" -> BadgeServiceErrorCode.ProductUnavailable + "payment_not_entitled" -> BadgeServiceErrorCode.PaymentNotEntitled + "payment_pending" -> BadgeServiceErrorCode.PaymentPending + "provider_unavailable" -> BadgeServiceErrorCode.ProviderUnavailable + "rate_limited" -> BadgeServiceErrorCode.RateLimited + "code_invalid" -> BadgeServiceErrorCode.CodeInvalid + "code_used" -> BadgeServiceErrorCode.CodeUsed + "code_expired" -> BadgeServiceErrorCode.CodeExpired + "receipt_invalid" -> BadgeServiceErrorCode.ReceiptInvalid + "receipt_used" -> BadgeServiceErrorCode.ReceiptUsed + "internal" -> BadgeServiceErrorCode.Internal + else -> BadgeServiceErrorCode.Unknown(v) + } + override fun serialize(encoder: Encoder, value: BadgeServiceErrorCode) = encoder.encodeString(value.text) +} + data class ConnectionPlanResult( val connLink: CreatedConnLink, val planSimplexName: SimplexNameInfo?, @@ -7567,6 +7818,7 @@ sealed class ChatErrorType { is AgentVersion -> "agentVersion" is AgentNoSubResult -> "agentNoSubResult" is CommandError -> "commandError $message" + is CEBadgeRedeemError -> "badgeRedeemError ${badgeRedeemError.string}" is ServerProtocol -> "serverProtocol" is AgentCommandError -> "agentCommandError" is InvalidFileDescription -> "invalidFileDescription" @@ -7651,6 +7903,7 @@ sealed class ChatErrorType { @Serializable @SerialName("agentVersion") object AgentVersion: ChatErrorType() @Serializable @SerialName("agentNoSubResult") class AgentNoSubResult(val agentConnId: String): ChatErrorType() @Serializable @SerialName("commandError") class CommandError(val message: String): ChatErrorType() + @Serializable @SerialName("badgeRedeemError") class CEBadgeRedeemError(val badgeRedeemError: BadgeRedeemError): ChatErrorType() @Serializable @SerialName("serverProtocol") object ServerProtocol: ChatErrorType() @Serializable @SerialName("agentCommandError") class AgentCommandError(val message: String): ChatErrorType() @Serializable @SerialName("invalidFileDescription") class InvalidFileDescription(val message: String): ChatErrorType() @@ -8085,6 +8338,7 @@ sealed class SMPAgentError { is A_CRYPTO -> "A_CRYPTO" is A_DUPLICATE -> "A_DUPLICATE" is A_QUEUE -> "A_QUEUE" + is A_SERVICE -> "A_SERVICE ${serviceError.string}" } @Serializable @SerialName("A_MESSAGE") object A_MESSAGE: SMPAgentError() @Serializable @SerialName("A_PROHIBITED") class A_PROHIBITED(val prohibitedErr: String): SMPAgentError() @@ -8093,6 +8347,23 @@ sealed class SMPAgentError { @Serializable @SerialName("A_CRYPTO") object A_CRYPTO: SMPAgentError() @Serializable @SerialName("A_DUPLICATE") object A_DUPLICATE: SMPAgentError() @Serializable @SerialName("A_QUEUE") class A_QUEUE(val queueErr: String): SMPAgentError() + @Serializable @SerialName("A_SERVICE") class A_SERVICE(val serviceError: AgentServiceError): SMPAgentError() +} + +@Serializable +sealed class AgentServiceError { + val string: String get() = when (this) { + is Rejected -> "rejected" + is Timeout -> "timeout" + is NoPendingRequest -> "noPendingRequest" + is NotDRAddress -> "notDRAddress" + is BadSignature -> "badSignature" + } + @Serializable @SerialName("rejected") object Rejected: AgentServiceError() + @Serializable @SerialName("timeout") object Timeout: AgentServiceError() + @Serializable @SerialName("noPendingRequest") object NoPendingRequest: AgentServiceError() + @Serializable @SerialName("notDRAddress") object NotDRAddress: AgentServiceError() + @Serializable @SerialName("badSignature") object BadSignature: AgentServiceError() } @Serializable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt index 3805a8e8b7..ad0e9aaa5a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt @@ -33,12 +33,20 @@ external fun chatParseServer(str: String): String external fun chatParseUri(str: String, safe: Int): String external fun chatPasswordHash(pwd: String, salt: String): String external fun chatValidName(name: String): String +external fun chatParseBadgeCode(code: String): String external fun chatJsonLength(str: String): Int external fun chatWriteFile(ctrl: ChatCtrl, path: String, buffer: ByteBuffer): String external fun chatReadFile(path: String, key: String, nonce: String): Array external fun chatEncryptFile(ctrl: ChatCtrl, fromPath: String, toPath: String): String external fun chatDecryptFile(fromPath: String, key: String, nonce: String, toPath: String): String +// the code in core's canonical form (prefix and 20 characters, no separators), or null when core rejects it. +// The check character is verified by core via FFI, not re-implemented here, so the two cannot disagree. +fun parseBadgeCode(s: String): String? { + val canonical = chatParseBadgeCode(s) + return if (canonical.isEmpty()) null else canonical +} + val chatModel: ChatModel get() = chatController.chatModel diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt index b46123c9cf..7d126096e8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt @@ -9,6 +9,10 @@ import androidx.compose.ui.graphics.Color import chat.simplex.common.model.ChatId import chat.simplex.common.model.NotificationsMode import chat.simplex.common.ui.theme.CurrentColors +import chat.simplex.common.views.badges.BadgeProduct +import chat.simplex.common.views.badges.BadgePurchaseOutcome +import chat.simplex.common.views.badges.BadgeStoreError +import chat.simplex.common.views.badges.BadgeStoreProductId import kotlinx.coroutines.Job import java.io.Closeable @@ -31,6 +35,15 @@ interface PlatformInterface { fun androidIsXiaomiDevice(): Boolean = false // Requests the Google Play account country into [androidPlayStoreCountry] fun androidLoadPlayStoreCountry() {} + // Play Billing, only implemented in the google flavor + // TODO [badges] desktop and foss pay via Stripe/crypto - these defaults leave them without any + // product until that path is implemented + val androidHasPlatformStore: Boolean get() = false + suspend fun androidLoadBadgeProducts(oneTimeIds: List, subscriptionIds: List): List { + Log.w(TAG, "androidLoadBadgeProducts: no store on this platform") + return emptyList() + } + suspend fun androidPurchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome = throw BadgeStoreError.StoreUnavailable val androidApiLevel: Int? get() = null // The build distributed via Google Play, which has to follow its policies val androidIsPlayStoreBuild: Boolean get() = false diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt new file mode 100644 index 0000000000..f8a37c39a7 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt @@ -0,0 +1,187 @@ +package chat.simplex.common.views.badges + +import androidx.compose.runtime.mutableStateOf +import chat.simplex.common.platform.* +import java.text.NumberFormat +import java.util.Currency +import java.util.UUID + +// a subscription is one store product containing a base plan per duration, so a purchasable badge +// is identified by both; one-time products have no base plan +data class BadgeStoreProductId(val productId: String, val basePlanId: String? = null) + +// TODO [badges] ids will come from app config and prices from the badge service catalog; +// hardcoded here so the Play Store integration can be tested before the purchase API lands. +fun badgeStoreProductId(level: BadgeLevel, period: BadgePeriod): BadgeStoreProductId = when (level) { + BadgeLevel.Supporter -> when (period) { + BadgePeriod.OneMonth -> BadgeStoreProductId("badge_supporter_01") + BadgePeriod.Monthly -> BadgeStoreProductId("subscr_badge_supporter_01", "subscr-badge-supporter-month-02") + BadgePeriod.Annual -> BadgeStoreProductId("subscr_badge_supporter_01", "subscr-badge-supporter-year-01") + } + BadgeLevel.Legend -> when (period) { + BadgePeriod.OneMonth -> BadgeStoreProductId("badge_legend_01") + BadgePeriod.Monthly -> BadgeStoreProductId("subscr_badge_legend_01", "subscr-badge-legend-month-01") + BadgePeriod.Annual -> BadgeStoreProductId("subscr_badge_legend_01", "subscr-badge-legend-year-01") + } +} + +val badgeStoreProductIds: List = BadgeLevel.entries.flatMap { level -> + BadgePeriod.entries.map { badgeStoreProductId(level, it) } +} + +// TODO [badges] replaced by APIGetBadgeInvoice, which creates the invoice row and returns its id. +// Sent to Play as obfuscatedAccountId and echoed back on the purchase, which is how the service +// learns which invoice a store transaction settles. +fun newBadgeInvoiceId(): String = UUID.randomUUID().toString() + +// what the platform store knows about one product; ProductDetails cannot cross into commonMain +data class BadgeProduct( + val id: BadgeStoreProductId, + val displayPrice: String, + val priceMicros: Long, + val currencyCode: String +) + +sealed class BadgePrice { + object Loading: BadgePrice() + class Price(val price: String): BadgePrice() + object Unavailable: BadgePrice() + + val canPurchase: Boolean + get() = when (this) { + is Price -> true + is Loading, is Unavailable -> false + } +} + +data class BadgeStoreReceipt( + // the token the badge service verifies with the Publisher API + val token: String, + val productId: String, + val orderId: String?, + val invoiceId: String?, + // only set for test products - a real Play purchase has no environment to report + val environment: String? = null +) + +// TODO [badges] Play Billing has no offline product configuration. Set to true to price the screens +// and walk the purchase flow without Play Console products; the purchase is simulated and its +// receipt says so. +const val useBadgeTestProducts = false + +private fun testProduct(level: BadgeLevel, period: BadgePeriod, priceMicros: Long) = + BadgeProduct(badgeStoreProductId(level, period), "\$${priceMicros / 1_000_000}.00", priceMicros, "USD") + +private val testBadgeProducts: List = listOf( + testProduct(BadgeLevel.Supporter, BadgePeriod.OneMonth, 7_000_000), + testProduct(BadgeLevel.Supporter, BadgePeriod.Monthly, 7_000_000), + testProduct(BadgeLevel.Supporter, BadgePeriod.Annual, 42_000_000), + testProduct(BadgeLevel.Legend, BadgePeriod.OneMonth, 70_000_000), + testProduct(BadgeLevel.Legend, BadgePeriod.Monthly, 70_000_000), + testProduct(BadgeLevel.Legend, BadgePeriod.Annual, 420_000_000) +) + +sealed class BadgePurchaseOutcome { + class Purchased(val receipt: BadgeStoreReceipt): BadgePurchaseOutcome() + object Pending: BadgePurchaseOutcome() + object Cancelled: BadgePurchaseOutcome() +} + +sealed class BadgeStoreError: Exception() { + class ProductUnavailable(val productId: String): BadgeStoreError() + class BillingError(val responseCode: Int, val debugMessage: String): BadgeStoreError() + object StoreUnavailable: BadgeStoreError() + + override val message: String + get() = when (this) { + is ProductUnavailable -> "productUnavailable(productId: $productId)" + is BillingError -> "billingError(responseCode: $responseCode, $debugMessage)" + is StoreUnavailable -> "storeUnavailable" + } +} + +object BadgeStore { + private enum class LoadState { NotLoaded, Loading, Loaded, Failed } + + private val state = mutableStateOf(LoadState.NotLoaded) + // snapshot state so a composable reading only the products still recomposes when they arrive + private val products = mutableStateOf>(emptyMap()) + + fun price(level: BadgeLevel, period: BadgePeriod): BadgePrice = when (state.value) { + LoadState.NotLoaded, LoadState.Loading -> BadgePrice.Loading + LoadState.Loaded, LoadState.Failed -> { + val p = products.value[badgeStoreProductId(level, period)] + if (p != null) BadgePrice.Price(compactPrice(p)) else BadgePrice.Unavailable + } + } + + fun annualSavings(level: BadgeLevel): Int? { + val monthly = products.value[badgeStoreProductId(level, BadgePeriod.Monthly)] ?: return null + val annual = products.value[badgeStoreProductId(level, BadgePeriod.Annual)] ?: return null + val year = monthly.priceMicros * 12 + if (year <= 0 || annual.priceMicros >= year) return null + val percent = Math.round((year - annual.priceMicros).toDouble() / year * 100).toInt() + return if (percent > 0) percent else null + } + + suspend fun load() { + if (!startLoading()) return + try { + // TODO [badges] desktop and the foss build will price from the badge service catalog and pay + // via Stripe/crypto instead of a store; only the google build reaches the platform store + val loaded = if (useBadgeTestProducts) testBadgeProducts else platform.androidLoadBadgeProducts( + oneTimeIds = badgeStoreProductIds.filter { it.basePlanId == null }, + subscriptionIds = badgeStoreProductIds.filter { it.basePlanId != null } + ) + val byId = loaded.associateBy { it.id } + val missing = badgeStoreProductIds.filter { !byId.containsKey(it) } + if (missing.isNotEmpty()) { + Log.w(TAG, "BadgeStore.load: no product returned for ${missing.joinToString(", ")}") + } + products.value = byId + state.value = LoadState.Loaded + } catch (e: Exception) { + Log.e(TAG, "BadgeStore.load: ${e.stackTraceToString()}") + state.value = LoadState.Failed + } + } + + suspend fun purchase(level: BadgeLevel, period: BadgePeriod, invoiceId: String): BadgePurchaseOutcome { + val id = badgeStoreProductId(level, period) + if (!products.value.containsKey(id)) throw BadgeStoreError.ProductUnavailable(id.productId) + if (useBadgeTestProducts) { + return BadgePurchaseOutcome.Purchased( + BadgeStoreReceipt( + token = "test-${UUID.randomUUID()}", + productId = id.productId, + orderId = null, + invoiceId = invoiceId, + environment = "test products" + ) + ) + } + return platform.androidPurchaseBadge(id, invoiceId) + } + + private fun startLoading(): Boolean = when (state.value) { + LoadState.NotLoaded, LoadState.Failed -> { + state.value = LoadState.Loading + true + } + LoadState.Loading, LoadState.Loaded -> false + } +} + +// drops the fraction from whole amounts ("$7", not "$7.00") in the product's own currency; +// BadgeProduct.displayPrice remains the exact form for views that need the cents +private fun compactPrice(product: BadgeProduct): String { + if (product.priceMicros % 1_000_000L != 0L) return product.displayPrice + return try { + val format = NumberFormat.getCurrencyInstance() + format.currency = Currency.getInstance(product.currencyCode) + format.maximumFractionDigits = 0 + format.format(product.priceMicros / 1_000_000L) + } catch (e: Exception) { + product.displayPrice + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeUserPreview.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeUserPreview.kt new file mode 100644 index 0000000000..03fabf817e --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeUserPreview.kt @@ -0,0 +1,43 @@ +package chat.simplex.common.views.badges + +import androidx.compose.foundation.layout.* +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import dev.icerock.moko.resources.compose.stringResource +import chat.simplex.common.model.BadgeInfo +import chat.simplex.common.model.BadgeStatus +import chat.simplex.common.model.LocalBadge +import chat.simplex.common.platform.chatModel +import chat.simplex.common.views.helpers.NameWithBadge +import chat.simplex.common.views.helpers.ProfileImage +import chat.simplex.res.MR +import kotlinx.datetime.Instant + +@Composable +fun BadgeUserPreview(level: BadgeLevel, modifier: Modifier = Modifier, trailing: @Composable () -> Unit = {}) { + val user = chatModel.currentUser.value + val displayName = user?.displayName ?: stringResource(MR.strings.badges_preview_my_nickname) + val previewBadge = LocalBadge( + // fabricated for the preview: the status is given here, and NameBadge renders from it alone + badge = BadgeInfo(badgeType = level.badgeType, badgeExpiry = Instant.DISTANT_FUTURE), + status = BadgeStatus.Active + ) + Column(modifier, horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) { + ProfileImage(size = 128.dp, image = user?.image) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + NameWithBadge( + name = displayName, + badge = previewBadge, + style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + trailing() + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowItWorksView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowItWorksView.kt new file mode 100644 index 0000000000..cb300b183d --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowItWorksView.kt @@ -0,0 +1,35 @@ +package chat.simplex.common.views.badges + +import androidx.compose.foundation.layout.* +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import dev.icerock.moko.resources.compose.stringResource +import chat.simplex.common.platform.ColumnWithScrollBar +import chat.simplex.common.views.onboarding.ReadableTextWithLink +import chat.simplex.res.MR + +@Composable +fun BadgesHowItWorksView() { + ColumnWithScrollBar( + Modifier.padding(horizontal = 25.dp).padding(top = 8.dp), + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + stringResource(MR.strings.badges_how_it_works_title), + style = MaterialTheme.typography.h1, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.primary, + modifier = Modifier.padding(bottom = 16.dp) + ) + Text(stringResource(MR.strings.badges_how_it_works_p1), style = MaterialTheme.typography.body1) + Text(stringResource(MR.strings.badges_how_it_works_p2), style = MaterialTheme.typography.body1) + Text(stringResource(MR.strings.badges_how_it_works_p3), style = MaterialTheme.typography.body1) + ReadableTextWithLink(MR.strings.badges_how_it_works_read_more_with_link, "https://simplex.chat/blog/20260919-simplex-supporter-badges.html") + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesLedgerView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesLedgerView.kt new file mode 100644 index 0000000000..2783727621 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesLedgerView.kt @@ -0,0 +1,158 @@ +package chat.simplex.common.views.badges + +import InfoRow +import SectionBottomSpacer +import SectionItemView +import SectionView +import itemHPadding +import androidx.compose.foundation.layout.* +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.encodeToString +import chat.simplex.common.model.BadgeState +import chat.simplex.common.model.StatementCreditType +import chat.simplex.common.model.StatementDebitType +import chat.simplex.common.model.StatementEntry +import chat.simplex.common.model.StatementEntryType +import chat.simplex.common.model.json +import chat.simplex.common.model.localDate +import chat.simplex.common.model.localTimestamp +import chat.simplex.common.platform.ColumnWithScrollBar +import chat.simplex.common.platform.Log +import chat.simplex.common.platform.TAG +import chat.simplex.common.platform.chatModel +import chat.simplex.common.platform.shareText +import chat.simplex.common.views.helpers.AlertManager +import chat.simplex.common.views.helpers.AppBarTitle +import chat.simplex.common.views.helpers.ModalView +import chat.simplex.common.views.helpers.ShareButton +import chat.simplex.common.views.helpers.generalGetString +import chat.simplex.common.views.helpers.withBGApi +import chat.simplex.res.MR +import kotlin.math.abs + +@Composable +fun BadgesLedgerView(badgeState: BadgeState, close: () -> Unit) { + val entries = remember { mutableStateOf?>(null) } + val clipboard = LocalClipboardManager.current + + LaunchedEffect(Unit) { + val user = chatModel.currentUser.value ?: return@LaunchedEffect + withBGApi { + try { + val ledger = chatModel.controller.apiGetBadgeLedger(chatModel.remoteHostId(), user.userId, badgeState.badgePurchaseId) + withContext(Dispatchers.Main) { entries.value = ledger } + } catch (e: Exception) { + Log.e(TAG, "apiGetBadgeLedger: ${e.message}") + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error), e.message) + } + } + } + + ModalView( + close, + cardScreen = true, + endButtons = { + val loaded = entries.value + if (!loaded.isNullOrEmpty()) { + ShareButton { clipboard.shareText(ledgerShareText(loaded)) } + } + } + ) { + ColumnWithScrollBar { + AppBarTitle(stringResource(MR.strings.badges_ledger)) + val loaded = entries.value + if (loaded != null) { + SectionView { + if (loaded.isEmpty()) { + SectionItemView { + Text(stringResource(MR.strings.badges_ledger_no_entries), color = MaterialTheme.colors.secondary) + } + } else { + loaded.forEach { LedgerRow(it) } + } + } + } + SectionBottomSpacer() + } + } +} + +@Composable +private fun LedgerRow(entry: StatementEntry) { + val expanded = remember(entry.entryId) { mutableStateOf(false) } + SectionItemView(click = { expanded.value = !expanded.value }) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(entry.entryType.text) + Text(localDate(entry.createdAt), color = MaterialTheme.colors.secondary, fontSize = 12.sp) + } + Text(changeText(entry), color = MaterialTheme.colors.secondary) + Icon( + painterResource(if (expanded.value) MR.images.ic_chevron_up else MR.images.ic_chevron_down), + contentDescription = null, + tint = MaterialTheme.colors.secondary, + modifier = Modifier.size(20.dp) + ) + } + } + if (expanded.value) { + val indented = PaddingValues(start = 24.dp + itemHPadding, end = itemHPadding) + entryFields(entry).forEach { (label, value) -> InfoRow(label, value, padding = indented) } + } +} + +private fun changeText(entry: StatementEntry): String { + val n = entry.changeMonths + val months = String.format(generalGetString(if (abs(n) == 1) MR.strings.ttl_month else MR.strings.ttl_months), n) + return if (n > 0) "+$months" else months +} + +private fun entryFields(entry: StatementEntry): List> { + val fields = mutableListOf( + generalGetString(MR.strings.badges_ledger_date) to localTimestamp(entry.createdAt), + generalGetString(MR.strings.badges_ledger_balance) to entry.balanceMonths.toString(), + generalGetString(MR.strings.badges_ledger_balance_start) to localTimestamp(entry.balanceStartTs), + generalGetString(MR.strings.badges_ledger_anchor) to localTimestamp(entry.balanceAnchorTs), + generalGetString(MR.strings.badges_ledger_badge_type) to entry.balanceBadgeType.text, + ) + val pausedSince = entry.wasPausedSince + if (pausedSince != null) { + fields.add(generalGetString(MR.strings.badges_ledger_paused_since) to localTimestamp(pausedSince)) + } + fields.add(generalGetString(MR.strings.badges_ledger_entry_id) to entry.entryId) + payloadField(entry.entryType)?.let { fields.add(it) } + return fields +} + +private fun payloadField(entryType: StatementEntryType): Pair? = when (entryType) { + is StatementEntryType.Credit -> when (val credit = entryType.credit) { + is StatementCreditType.Payment -> credit.invoiceId?.let { generalGetString(MR.strings.badges_ledger_invoice_id) to it } + is StatementCreditType.Charge -> generalGetString(MR.strings.badges_ledger_charge_id) to credit.chargeId + is StatementCreditType.TransferIn -> generalGetString(MR.strings.badges_ledger_from_purchase_key) to credit.fromPurchaseKey + is StatementCreditType.Code, is StatementCreditType.Support, is StatementCreditType.Opening, is StatementCreditType.Unknown -> null + } + is StatementEntryType.Debit -> when (val debit = entryType.debit) { + is StatementDebitType.Upgrade -> generalGetString(MR.strings.badges_ledger_to_purchase_key) to debit.toPurchaseKey + is StatementDebitType.TransferOut -> generalGetString(MR.strings.badges_ledger_to_purchase_key) to debit.toPurchaseKey + is StatementDebitType.Refund, is StatementDebitType.Support, is StatementDebitType.Badge, is StatementDebitType.Lapse, is StatementDebitType.Unknown -> null + } +} + +// the JSON as core sent it: English field names and ISO dates, for support +private fun ledgerShareText(entries: List): String = json.encodeToString(entries) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPayView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPayView.kt new file mode 100644 index 0000000000..d7a10f38f7 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPayView.kt @@ -0,0 +1,259 @@ +package chat.simplex.common.views.badges + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.ClipboardManager +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import dev.icerock.moko.resources.StringResource +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.onboarding.OnboardingActionButton +import chat.simplex.res.MR + +// TODO [badges]: replace with types produced by the badge purchase API when it lands. +enum class BadgePeriod { + OneMonth, + Monthly, + Annual; + + val icon: dev.icerock.moko.resources.ImageResource + get() = when (this) { + OneMonth -> MR.images.ic_calendar + Monthly -> MR.images.ic_refresh + Annual -> MR.images.ic_refresh + } + + val label: StringResource + get() = when (this) { + OneMonth -> MR.strings.badges_period_one_month + Monthly -> MR.strings.badges_period_monthly + Annual -> MR.strings.badges_period_annual + } + + @Composable + fun priceText(price: BadgePrice): String = when (price) { + is BadgePrice.Loading -> "…" + is BadgePrice.Unavailable -> "—" + is BadgePrice.Price -> when (this) { + OneMonth -> price.price + Monthly -> stringResource(MR.strings.badges_price_monthly).format(price.price) + Annual -> stringResource(MR.strings.badges_price_annual).format(price.price) + } + } + + fun payLabel(price: BadgePrice): Pair = when (price) { + is BadgePrice.Loading -> MR.strings.badges_price_loading to null + is BadgePrice.Unavailable -> MR.strings.badges_price_unavailable to null + is BadgePrice.Price -> when (this) { + OneMonth -> MR.strings.badges_pay_once to price.price + Monthly -> MR.strings.badges_pay_monthly to price.price + Annual -> MR.strings.badges_pay_annual to price.price + } + } +} + +@Composable +fun BadgesPayView(level: BadgeLevel) { + var selectedPeriod by remember { mutableStateOf(BadgePeriod.Monthly) } + val purchasing = remember { mutableStateOf(false) } + val clipboard = LocalClipboardManager.current + + LaunchedEffect(Unit) { BadgeStore.load() } + + ColumnWithScrollBar( + Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + maxIntrinsicSize = true, + ) { + Text( + stringResource(level.title), + style = MaterialTheme.typography.h1, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.primary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + BadgeUserPreview(level = level, modifier = Modifier.padding(top = 4.dp)) + + Text( + stringResource(level.tagline), + style = MaterialTheme.typography.body1, + color = MaterialTheme.colors.onBackground, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(top = 4.dp) + ) + + Spacer(Modifier.weight(1f).heightIn(min = 8.dp)) + + // IntrinsicSize.Max + fillMaxHeight on children so all three cards match the tallest one - + // only Annual carries a savings line, and prices wrap at large fonts. + Row( + Modifier.fillMaxWidth().height(IntrinsicSize.Max), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + PeriodCard(level, BadgePeriod.OneMonth, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it } + PeriodCard(level, BadgePeriod.Monthly, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it } + PeriodCard(level, BadgePeriod.Annual, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it } + } + + Spacer(Modifier.weight(1f).heightIn(min = 8.dp)) + + // Replicates TextButtonBelowOnboardingButton spacing (7.5dp outer + 5dp inner) without a + // TextButton so the footer has no hover/click affordance. + Column(horizontalAlignment = Alignment.CenterHorizontally) { + PayButton(level, selectedPeriod, purchasing, clipboard) + Box(Modifier.padding(top = 7.5.dp, bottom = 7.5.dp).padding(horizontal = 16.dp, vertical = 8.dp)) { + Text( + stringResource(billingFooter(selectedPeriod)).format(stubBillingDate()), + Modifier.padding(vertical = 5.dp), + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.secondary, + textAlign = TextAlign.Center + ) + } + } + } +} + +@Composable +private fun PeriodCard(level: BadgeLevel, period: BadgePeriod, selectedPeriod: BadgePeriod, modifier: Modifier, onSelect: (BadgePeriod) -> Unit) { + val isSelected = period == selectedPeriod + val borderColor = if (isSelected) MaterialTheme.colors.primary else MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.92f) + // Light: transparent so card matches page background. Dark: subtle gray tint for visible contrast. + val cardBackground = if (isInDarkTheme()) MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f) + else MaterialTheme.colors.background + val shape = RoundedCornerShape(16.dp) + Column( + modifier + .clip(shape) + .background(cardBackground, shape) + .border(2.dp, borderColor, shape) + .clickable { onSelect(period) } + .padding(vertical = 20.dp, horizontal = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + painterResource(period.icon), + contentDescription = null, + tint = if (isSelected) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, + modifier = Modifier.size(32.dp) + ) + Text(stringResource(period.label), style = MaterialTheme.typography.h3, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center) + Text(period.priceText(BadgeStore.price(level, period)), style = MaterialTheme.typography.body1, textAlign = TextAlign.Center) + val percent = savingsPercent(level, period) + if (percent != null) { + Text( + stringResource(MR.strings.badges_savings).format("${percent}%"), + style = MaterialTheme.typography.body2, + color = if (isSelected) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, + textAlign = TextAlign.Center + ) + } + } +} + +private fun savingsPercent(level: BadgeLevel, period: BadgePeriod): Int? = + if (period == BadgePeriod.Annual) BadgeStore.annualSavings(level) else null + +// TODO [badges] on desktop and the foss build there is no store, so every price is Unavailable and +// this button stays disabled - it will offer Stripe/crypto payment instead +@Composable +private fun PayButton(level: BadgeLevel, selectedPeriod: BadgePeriod, purchasing: MutableState, clipboard: ClipboardManager) { + val price = BadgeStore.price(level, selectedPeriod) + val (labelId, labelArg) = selectedPeriod.payLabel(price) + OnboardingActionButton( + modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), + labelId = labelId, + labelArg = labelArg, + onboarding = null, + enabled = price.canPurchase && !purchasing.value, + onclick = { purchase(level, selectedPeriod, purchasing, clipboard) } + ) +} + +private fun purchase(level: BadgeLevel, period: BadgePeriod, purchasing: MutableState, clipboard: ClipboardManager) { + val invoiceId = newBadgeInvoiceId() + purchasing.value = true + // not withBGApi: the purchase waits for the user in the Play sheet and would block chat API calls + withLongRunningApi { + try { + val outcome = BadgeStore.purchase(level, period, invoiceId) + purchasing.value = false + when (outcome) { + is BadgePurchaseOutcome.Purchased -> showPurchasedAlert(outcome.receipt, invoiceId, clipboard) + is BadgePurchaseOutcome.Pending -> AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.badges_purchase_pending), + text = generalGetString(MR.strings.badges_purchase_pending_desc) + ) + is BadgePurchaseOutcome.Cancelled -> {} + } + } catch (e: Exception) { + Log.e(TAG, "BadgesPayView.purchase: ${e.stackTraceToString()}") + purchasing.value = false + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.badges_purchase_error), + text = e.toString() + ) + } + } +} + +// TODO [badges] store integration diagnostics - replaced by the issued badge once the service lands. +private fun showPurchasedAlert(receipt: BadgeStoreReceipt, invoiceId: String, clipboard: ClipboardManager) { + val returnedInvoice = when (receipt.invoiceId) { + null -> "none" + invoiceId -> "yes" + else -> "mismatch: ${receipt.invoiceId}" + } + val lines = mutableListOf( + "Product: ${receipt.productId}", + "Invoice: $invoiceId", + "Invoice returned by Google: $returnedInvoice", + "Order: ${receipt.orderId ?: "none"}" + ) + if (receipt.environment != null) lines.add("Environment: ${receipt.environment}") + lines.add("Token: ${receipt.token.length} bytes") + val summary = lines.joinToString("\n") + Log.d(TAG, "badge purchase succeeded\n$summary") + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.badges_purchase_successful), + text = summary, + confirmText = "Copy token", + onConfirm = { + clipboard.setText(AnnotatedString(receipt.token)) + showToast(generalGetString(MR.strings.copied)) + }, + dismissText = generalGetString(MR.strings.ok), + parseHtml = false + ) +} + +private fun billingFooter(period: BadgePeriod): StringResource = when (period) { + BadgePeriod.Monthly, BadgePeriod.Annual -> MR.strings.badges_billing_footer_subscribe + BadgePeriod.OneMonth -> MR.strings.badges_billing_footer_one_month +} + +// TODO [badges] source the actual date from the purchase state machine when wired. +private fun stubBillingDate(): String { + val date = java.time.LocalDate.of(2026, 7, 22) + val formatter = java.time.format.DateTimeFormatter.ofLocalizedDate(java.time.format.FormatStyle.LONG) + return date.format(formatter) +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt new file mode 100644 index 0000000000..a9d2a4d19c --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt @@ -0,0 +1,246 @@ +package chat.simplex.common.views.badges + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.newchat.QRCodeScanner +import chat.simplex.common.views.onboarding.OnboardingActionButton +import chat.simplex.common.views.onboarding.TextButtonBelowOnboardingButton +import chat.simplex.res.MR + +private const val badgeCodePrefix = "SB" +private const val badgeCodeBodyLength = 20 +private const val badgeCodeGroupLength = 5 + +// Regroups what was typed; validity and the folding of ambiguous characters are core's alone. +private fun formatBadgeCodeInput(s: String): String { + val normalized = StringBuilder() + for (c in s.take(256).uppercase()) { + if (!c.isLetterOrDigit()) continue + normalized.append(c) + if (normalized.length == badgeCodePrefix.length + badgeCodeBodyLength) break + } + if (!normalized.startsWith(badgeCodePrefix)) return normalized.toString() + val groups = mutableListOf(badgeCodePrefix) + var i = badgeCodePrefix.length + while (i < normalized.length) { + val j = minOf(i + badgeCodeGroupLength, normalized.length) + groups.add(normalized.substring(i, j)) + i = j + } + return groups.joinToString("-") +} + +@Composable +fun BadgesRedeemCodeView() { + val rhId = remember { chatModel.remoteHostId() } + val supporterBannerShown = remember { appPrefs.supporterBannerShown } + val code = remember { mutableStateOf(TextFieldValue("")) } + val canonicalCode = remember { mutableStateOf(null) } + val submitting = remember { mutableStateOf(false) } + + // when the text is unchanged, the field's own value is kept: it carries the cursor position and the + // keyboard's composition state, which BasicTextField loses unless they are passed back to it + fun applyCodeInput(v: TextFieldValue) { + val formatted = formatBadgeCodeInput(v.text) + code.value = if (formatted != v.text) TextFieldValue(formatted, selection = TextRange(formatted.length)) else v + canonicalCode.value = parseBadgeCode(formatted) + } + + fun redeem() { + val sending = canonicalCode.value ?: return + val user = chatModel.currentUser.value ?: return + submitting.value = true + withBGApi { + when (val result = chatModel.controller.apiRedeemBadgeCode(rhId, user.userId, sending)) { + null -> withContext(Dispatchers.Main) { submitting.value = false } + is BadgeRedeemResult.Redeemed -> { + val badgeState = result.badgeState + withContext(Dispatchers.Main) { + submitting.value = false + // set before dismissing: BadgesView then switches Support to Your Badge while this screen + // still covers it, so the pop lands on Your Badge instead of showing the switch + BadgeModel.set(rhId, user.userId, badgeState) + chatModel.updateUser(result.user) + if (badgeState != null && !badgeState.shown) { + // a replay adds no purchase; a fresh code's badge can be retired on arrival + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.badges_error_title), + text = generalGetString(if (result.newBadge) MR.strings.badges_error_badge_ended else MR.strings.badges_error_code_used) + ) + } else { + supporterBannerShown.set(true) + ModalManager.start.closeModal() + } + } + } + is BadgeRedeemResult.Failed -> { + Log.e(TAG, "apiRedeemBadgeCode: ${result.err?.string}") + withContext(Dispatchers.Main) { + submitting.value = false + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.badges_error_title), + text = chatModel.controller.redeemErrorText(result.err) + ) + } + } + } + } + } + + ColumnWithScrollBar( + Modifier.padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + maxIntrinsicSize = true, + ) { + Text( + stringResource(MR.strings.badges_redeem_code_title), + style = MaterialTheme.typography.h1, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.primary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + Text( + stringResource(MR.strings.badges_redeem_code_body), + style = MaterialTheme.typography.body1, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + CodeField(code, submitting.value, ::applyCodeInput) + + PasteButton(submitting.value, ::applyCodeInput) + + if (appPlatform.isAndroid) { + QRCodeScanner(padding = PaddingValues(start = 16.dp, top = 12.dp, end = 16.dp)) { text -> + val formatted = formatBadgeCodeInput(text) + when { + submitting.value -> false + parseBadgeCode(formatted) == null -> { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.invalid_qr_code), + text = generalGetString(MR.strings.badges_code_you_scanned_is_not_badge_code) + ) + false + } + else -> { + applyCodeInput(TextFieldValue(formatted, selection = TextRange(formatted.length))) + redeem() + true + } + } + } + } + + Spacer(Modifier.weight(1f)) + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + SubmitButton(enabled = canonicalCode.value != null && !submitting.value, onClick = ::redeem) + TextButtonBelowOnboardingButton("", null) + } + } + + if (submitting.value) { + Box( + Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Surface(Modifier.size(50.dp), color = MaterialTheme.colors.background.copy(0.9f), contentColor = LocalContentColor.current, shape = RoundedCornerShape(50)){} + CircularProgressIndicator( + Modifier + .padding(horizontal = 2.dp) + .size(30.dp), + color = MaterialTheme.colors.secondary, + strokeWidth = 3.dp + ) + } + } +} + +@Composable +private fun CodeField(code: MutableState, submitting: Boolean, applyCodeInput: (TextFieldValue) -> Unit) { + val colors = TextFieldDefaults.textFieldColors( + backgroundColor = MaterialTheme.appColors.sentMessage, + textColor = MaterialTheme.colors.onBackground, + focusedIndicatorColor = Color.Unspecified, + unfocusedIndicatorColor = Color.Unspecified, + ) + BasicTextField( + value = code.value, + onValueChange = applyCodeInput, + enabled = !submitting, + singleLine = true, + textStyle = TextStyle.Default.copy( + color = MaterialTheme.colors.onBackground, + fontFamily = FontFamily.Monospace, + fontSize = MaterialTheme.typography.body1.fontSize, + textAlign = TextAlign.Center + ), + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Characters, autoCorrect = false), + cursorBrush = SolidColor(colors.cursorColor(false).value), + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(colors.backgroundColor(!submitting).value) + .padding(start = 12.dp, top = 14.dp, end = 12.dp, bottom = 14.dp), + decorationBox = { innerTextField -> + Box(contentAlignment = Alignment.Center) { + if (code.value.text.isEmpty()) { + Text(stringResource(MR.strings.badges_redeem_code_placeholder), color = MaterialTheme.colors.secondary, fontFamily = FontFamily.Monospace) + } + innerTextField() + } + } + ) +} + +@Composable +private fun PasteButton(submitting: Boolean, applyCodeInput: (TextFieldValue) -> Unit) { + val clipboard = LocalClipboardManager.current + TextButton( + onClick = { clipboard.getText()?.text?.let { applyCodeInput(TextFieldValue(it, selection = TextRange(it.length))) } }, + enabled = !submitting + ) { + Text(stringResource(MR.strings.paste_button), color = MaterialTheme.colors.primary, fontWeight = FontWeight.Medium) + } +} + +@Composable +private fun SubmitButton(enabled: Boolean, onClick: () -> Unit) { + OnboardingActionButton( + modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), + labelId = MR.strings.badges_redeem, + onboarding = null, + enabled = enabled, + onclick = onClick + ) +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesSupportSimplexView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesSupportSimplexView.kt new file mode 100644 index 0000000000..48ef36ece1 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesSupportSimplexView.kt @@ -0,0 +1,178 @@ +package chat.simplex.common.views.badges + +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import chat.simplex.common.BuildConfigCommon +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.newchat.darkStops +import chat.simplex.common.views.newchat.gradientPoints +import chat.simplex.common.views.newchat.lightStops +import chat.simplex.common.views.onboarding.HowItWorks +import chat.simplex.common.views.onboarding.OnboardingActionButton +import chat.simplex.common.views.onboarding.TextButtonBelowOnboardingButton +import chat.simplex.res.MR + +@Composable +fun BadgesSupportSimplexView() { + ColumnWithScrollBar( + Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + maxIntrinsicSize = true, + ) { + Text( + stringResource(MR.strings.badges_support_simplex_title), + style = MaterialTheme.typography.h1, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.primary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + Text( + stringResource(MR.strings.badges_support_simplex_body), + style = MaterialTheme.typography.body1, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + // TODO [badges] restore WhyBuiltButton() when in-app purchase lands: the level screen + // returns to the flow and HowItWorksButton() moves there, leaving this one alone here. + HowItWorksButton() + + Spacer(Modifier.weight(1f)) + + PhoneSupporterHero(Modifier.fillMaxWidth(0.55f)) + + Spacer(Modifier.weight(1f)) + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + RedeemCodeButton() + GetCodeButton() + } + } +} + +// the in-app purchase path, kept compiling and uncalled until payments return after the MVP +@Composable +private fun ChooseLevelButton() { + OnboardingActionButton( + modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), + labelId = MR.strings.badges_choose_your_level, + onboarding = null, + onclick = { + ModalManager.start.showModal { BadgesYourLevelView() } + } + ) +} + +@Composable +private fun WhyBuiltButton() { + val primary = MaterialTheme.colors.primary + TextButton({ + ModalManager.start.showModal { HowItWorks(user = chatModel.currentUser.value, onboardingStage = null, titleColor = primary) } + }) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Icon(painterResource(MR.images.ic_info), null, tint = MaterialTheme.colors.primary) + Text(stringResource(MR.strings.badges_why_simplex_is_built), color = MaterialTheme.colors.primary, fontWeight = FontWeight.Medium) + } + } +} + +@Composable +private fun HowItWorksButton() { + TextButton({ + ModalManager.start.showModal { BadgesHowItWorksView() } + }) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Icon(painterResource(MR.images.ic_info), null, tint = MaterialTheme.colors.primary) + Text(stringResource(MR.strings.badges_how_it_works_button), color = MaterialTheme.colors.primary, fontWeight = FontWeight.Medium) + } + } +} + +@Composable +private fun RedeemCodeButton() { + OnboardingActionButton( + modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), + labelId = MR.strings.badges_redeem_code_button, + onboarding = null, + onclick = { + ModalManager.start.showModal { BadgesRedeemCodeView() } + } + ) +} + +@Composable +private fun GetCodeButton() { + val uriHandler = LocalUriHandler.current + TextButtonBelowOnboardingButton( + text = stringResource(MR.strings.badges_get_your_code), + onClick = { uriHandler.openExternalLink("https://simplex.chat/badges/") } + ) +} + +@Composable +fun PhoneSupporterHero(modifier: Modifier = Modifier) { + val isDark = isInDarkTheme() + if (BuildConfigCommon.SIMPLEX_ASSETS) { + Image( + painterResource(if (isDark) MR.images.phone_supporter_light else MR.images.phone_supporter), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = modifier.fillMaxWidth() + ) + } else { + var size by remember { mutableStateOf(IntSize.Zero) } + val stops = if (isDark) darkStops else lightStops + val scale = if (isDark) 1.5f else 1.2f + val brush = remember(size, isDark) { + if (size.width > 0 && size.height > 0) { + val aspect = size.height.toFloat() / size.width.toFloat() + val gp = gradientPoints(aspect, scale) + Brush.linearGradient( + colorStops = stops, + start = Offset(gp.startX * size.width, gp.startY * size.height), + end = Offset(gp.endX * size.width, gp.endY * size.height) + ) + } else { + Brush.linearGradient(colorStops = stops) + } + } + Box( + modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(RoundedCornerShape(24.dp)) + .background(brush) + .onSizeChanged { size = it }, + contentAlignment = Alignment.Center + ) { + Image( + painterResource(MR.images.badge_supporter), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.size(96.dp) + ) + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesView.kt new file mode 100644 index 0000000000..0cb128c902 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesView.kt @@ -0,0 +1,29 @@ +package chat.simplex.common.views.badges + +import androidx.compose.animation.* +import androidx.compose.runtime.Composable +import chat.simplex.common.model.BadgeModel +import chat.simplex.common.model.BadgeState +import chat.simplex.common.platform.chatModel +import chat.simplex.common.views.helpers.ModalView + +@OptIn(ExperimentalAnimationApi::class) +@Composable +fun BadgesView(close: () -> Unit) { + val shownBadge: BadgeState? = run { + if (!BadgeModel.isCurrent(chatModel.remoteHostId(), chatModel.currentUser.value?.userId)) return@run null + val badgeState = BadgeModel.badgeState.value + if (badgeState != null && badgeState.shown) badgeState else null + } + + // the card look is a modal setting, so the modal is composed here to follow the screen shown + ModalView(close, cardScreen = shownBadge != null) { + AnimatedContent(targetState = shownBadge, transitionSpec = { fadeIn() with fadeOut() }, contentKey = { it != null }) { badgeState -> + if (badgeState != null) { + BadgesYourBadgeView(badgeState) + } else { + BadgesSupportSimplexView() + } + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourBadgeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourBadgeView.kt new file mode 100644 index 0000000000..035eb64846 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourBadgeView.kt @@ -0,0 +1,101 @@ +package chat.simplex.common.views.badges + +import InfoRow +import SectionItemView +import SectionSpacer +import SectionTextFooter +import SectionView +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import chat.simplex.common.model.BadgeState +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.model.localTimestamp +import chat.simplex.common.platform.ColumnWithScrollBar +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.theme.DEFAULT_PADDING +import chat.simplex.common.views.helpers.AppBarTitle +import chat.simplex.common.views.helpers.ModalManager +import chat.simplex.common.views.helpers.badgeImage +import chat.simplex.common.views.helpers.badgeTypeName +import chat.simplex.common.views.usersettings.SettingsActionItem +import chat.simplex.res.MR + +@Composable +fun BadgesYourBadgeView(badgeState: BadgeState) { + ColumnWithScrollBar { + AppBarTitle(stringResource(MR.strings.badges_your_badge)) + + SectionView { + BadgeSummary(badgeState) + } + SectionSpacer() + SectionView(stringResource(MR.strings.badges_ends)) { + Text(badgeState.paidThroughText, Modifier.padding(horizontal = DEFAULT_PADDING, vertical = 12.dp)) + } + SectionTextFooter(stringResource(MR.strings.badges_prepaid_footer)) + SectionSpacer() + SectionView { + SettingsActionItem( + painterResource(MR.images.ic_info), + stringResource(MR.strings.badges_how_it_works_button), + { ModalManager.start.showModal { BadgesHowItWorksView() } }, + ) + } + SectionSpacer() + if (appPrefs.developerTools.get()) { + val clipboard = LocalClipboardManager.current + SectionView(stringResource(MR.strings.badges_credential)) { + val badge = chatModel.currentUser.value?.profile?.localBadge + if (badge != null) { + InfoRow(stringResource(MR.strings.badges_credential_status), badge.status.name) + InfoRow(stringResource(MR.strings.badges_credential_expires), localTimestamp(badge.badge.badgeExpiry)) + } + InfoRow(stringResource(MR.strings.badges_credential_months_left), badgeState.monthsLeft.toString()) + InfoRow(stringResource(MR.strings.badges_credential_purchase_id), badgeState.badgePurchaseId.toString()) + SectionItemView({ clipboard.setText(AnnotatedString(badgeState.purchaseKey)) }) { + Text(stringResource(MR.strings.badges_copy_purchase_key), color = MaterialTheme.colors.primary) + } + SectionItemView({ ModalManager.start.showCustomModal { close -> BadgesLedgerView(badgeState, close) } }) { + Text(stringResource(MR.strings.badges_ledger)) + } + } + SectionSpacer() + } + } +} + +@Composable +fun BadgeSummary(badgeState: BadgeState) { + Column( + Modifier.fillMaxWidth().padding(vertical = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Image( + painterResource(badgeImage(badgeState.badgeType)), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.padding(bottom = 8.dp).size(68.dp) + ) + + Text(badgeTypeName(badgeState.badgeType), style = MaterialTheme.typography.h3, fontWeight = FontWeight.SemiBold) + + Text( + stringResource(MR.strings.badges_shown_on_your_profile), + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.secondary + ) + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourLevelView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourLevelView.kt new file mode 100644 index 0000000000..c77e4dd61e --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourLevelView.kt @@ -0,0 +1,153 @@ +package chat.simplex.common.views.badges + +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import dev.icerock.moko.resources.StringResource +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import chat.simplex.common.model.BadgeType +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.onboarding.OnboardingActionButton +import chat.simplex.common.views.onboarding.TextButtonBelowOnboardingButton +import chat.simplex.res.MR + +// TODO [badges]: replace with types produced by the badge purchase API when it lands. +enum class BadgeLevel { + Supporter, + Legend; + + val title: StringResource + get() = when (this) { + Supporter -> MR.strings.badges_level_supporter + Legend -> MR.strings.badges_level_legend + } + + val filesDescription: StringResource + get() = when (this) { + Supporter -> MR.strings.badges_level_supporter_files + Legend -> MR.strings.badges_level_legend_files + } + + val tagline: StringResource + get() = when (this) { + Supporter -> MR.strings.badges_level_supporter_tagline + Legend -> MR.strings.badges_level_legend_tagline + } + + val badgeType: BadgeType + get() = when (this) { + Supporter -> BadgeType.Supporter + Legend -> BadgeType.Legend + } +} + +@Composable +fun BadgesYourLevelView() { + var selectedLevel by remember { mutableStateOf(BadgeLevel.Supporter) } + + LaunchedEffect(Unit) { BadgeStore.load() } + + ColumnWithScrollBar( + Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + maxIntrinsicSize = true, + ) { + Text( + stringResource(MR.strings.badges_your_level_title), + style = MaterialTheme.typography.h1, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.primary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + BadgeUserPreview(level = selectedLevel, modifier = Modifier.padding(top = 4.dp)) { + Icon( + painterResource(MR.images.ic_keyboard_arrow_down), + contentDescription = null, + tint = MaterialTheme.colors.primary + ) + } + + Spacer(Modifier.weight(1f).heightIn(min = 20.dp)) + + // IntrinsicSize.Max + fillMaxHeight on children so both cards match the taller card's height + // when 2-line labels at large fonts would otherwise size them differently. + Row( + Modifier.fillMaxWidth().height(IntrinsicSize.Max), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + LevelCard(BadgeLevel.Supporter, selectedLevel, Modifier.weight(1f).fillMaxHeight()) { selectedLevel = it } + LevelCard(BadgeLevel.Legend, selectedLevel, Modifier.weight(1f).fillMaxHeight()) { selectedLevel = it } + } + + Spacer(Modifier.weight(1f).heightIn(min = 20.dp)) + + // Nested Column with no spacing so the TextButtonBelowOnboardingButton sits directly under + // the action button (matches onboarding pattern where its own 7.5dp top padding is the gap). + Column(horizontalAlignment = Alignment.CenterHorizontally) { + ContinueButton(selectedLevel) + TextButtonBelowOnboardingButton( + text = stringResource(MR.strings.badges_how_it_works_button), + icon = painterResource(MR.images.ic_info), + onClick = { ModalManager.start.showModal { BadgesHowItWorksView() } } + ) + } + } +} + +@Composable +private fun LevelCard(level: BadgeLevel, selectedLevel: BadgeLevel, modifier: Modifier, onSelect: (BadgeLevel) -> Unit) { + val isSelected = level == selectedLevel + val borderColor = if (isSelected) MaterialTheme.colors.primary else MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.92f) + // Light: transparent so card matches page background. Dark: subtle gray tint for visible contrast. + val cardBackground = if (isInDarkTheme()) MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f) + else MaterialTheme.colors.background + val shape = RoundedCornerShape(16.dp) + Column( + modifier + .clip(shape) + .background(cardBackground, shape) + .border(2.dp, borderColor, shape) + .clickable { onSelect(level) } + .padding(vertical = 20.dp, horizontal = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Image( + painterResource(badgeImage(level.badgeType)), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.size(60.dp) + ) + Text(stringResource(level.title), style = MaterialTheme.typography.h3, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center) + Text(stringResource(level.filesDescription), style = MaterialTheme.typography.body2, color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center) + Text(BadgePeriod.Monthly.priceText(BadgeStore.price(level, BadgePeriod.Monthly)), style = MaterialTheme.typography.body1, textAlign = TextAlign.Center) + } +} + +@Composable +private fun ContinueButton(selectedLevel: BadgeLevel) { + OnboardingActionButton( + modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), + labelId = MR.strings.badges_continue, + onboarding = null, + onclick = { + ModalManager.start.showModal { BadgesPayView(selectedLevel) } + } + ) +} + diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/SupportSimpleXBanner.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/SupportSimpleXBanner.kt new file mode 100644 index 0000000000..94203e2169 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/SupportSimpleXBanner.kt @@ -0,0 +1,172 @@ +package chat.simplex.common.views.badges + +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import chat.simplex.common.BuildConfigCommon +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.newchat.darkStops +import chat.simplex.common.views.newchat.gradientPoints +import chat.simplex.common.views.newchat.lightStops +import chat.simplex.res.MR + +@Composable +fun SupportSimpleXBanner( + title: String = generalGetString(MR.strings.badges_banner_title), + subtitle: String = generalGetString(MR.strings.badges_banner_subtitle), + onTap: () -> Unit, + onDismiss: () -> Unit +) { + val cardCornerRadius = 16.dp + // grows linearly with system font but never shrinks below the default so small-font users see the + // same baseline; the card Row uses heightIn(min = cardHeight) and grows further when 2-line text + // wraps at very large fonts. Hero stays fixed so its above-card overhang shrinks at very large fonts. + val cardHeight = (72.dp * fontSizeMultiplier).coerceAtLeast(72.dp) + // matches OneHandUICard's segment icon leading so the text aligns with it in the list + val cardLeadingPadding = 16.dp + val cardTrailingPadding = 8.dp + val heroWidth = 110.dp + // shorter than the natural drawn height so ContentScale.Crop slices the phone body at card bottom + val heroVisibleHeight = 108.dp + // hero right edge sits at the dismiss X's icon left edge (X: outer 4pt + inner-pad 8 + half of 16pt icon) + val heroTrailingPadding = 28.dp + val textToHeroGap = 6.dp + + val isDark = isInDarkTheme() + var cardSize by remember { mutableStateOf(IntSize.Zero) } + val brush = remember(isDark, cardSize) { gradientBrush(isDark, cardSize) } + + // Layout sizes to the card; hero is placed at y = cardHeight - heroHeight (negative → hero + // overhangs above card at normal fonts, 0/positive → hero fits inside card at large fonts). + Layout(content = { + Box(Modifier.fillMaxWidth()) { + Row( + Modifier + .fillMaxWidth() + .heightIn(min = cardHeight) + .clip(RoundedCornerShape(cardCornerRadius)) + .background(brush) + .clickable(onClick = onTap) + .onSizeChanged { cardSize = it } + .padding( + start = cardLeadingPadding, + end = cardTrailingPadding + heroWidth + heroTrailingPadding + textToHeroGap, + top = 12.dp, + bottom = 12.dp + ), + verticalAlignment = Alignment.CenterVertically + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + title, + style = MaterialTheme.typography.body1, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colors.primary, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Text( + subtitle, + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.onBackground, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + + // Same X pattern as OneHandUICard: circle-clipped clickable region with inner padding for hit area. + Icon( + painterResource(MR.images.ic_close), + contentDescription = stringResource(MR.strings.icon_descr_close_button), + tint = if (isDark) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(end = 4.dp, top = 4.dp) + .clip(CircleShape) + .clickable(onClick = onDismiss) + .padding(8.dp) + .size(16.dp) + ) + } + + HeroThumbnail( + heroWidth = heroWidth, + heroVisibleHeight = heroVisibleHeight, + cardHeight = cardHeight, + trailingPadding = heroTrailingPadding + ) + }) { measurables, constraints -> + val cardPlaceable = measurables[0].measure(constraints) + val heroPlaceable = measurables[1].measure(constraints.copy(minWidth = 0, minHeight = 0)) + layout(cardPlaceable.width, cardPlaceable.height) { + cardPlaceable.place(0, 0) + heroPlaceable.place(cardPlaceable.width - heroPlaceable.width, cardPlaceable.height - heroPlaceable.height) + } + } +} + +@Composable +private fun HeroThumbnail(heroWidth: Dp, heroVisibleHeight: Dp, cardHeight: Dp, trailingPadding: Dp) { + if (BuildConfigCommon.SIMPLEX_ASSETS) { + // draws at natural aspect, top-aligned in a shorter slot; ContentScale.Crop cuts the overflow at card bottom + Image( + painterResource(if (isInDarkTheme()) MR.images.phone_supporter_light else MR.images.phone_supporter), + contentDescription = null, + contentScale = ContentScale.Crop, + alignment = Alignment.TopCenter, + modifier = Modifier.padding(end = trailingPadding).size(width = heroWidth, height = heroVisibleHeight) + ) + } else { + val badgeSize = 48.dp + Image( + painterResource(MR.images.badge_supporter), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier + .padding(end = trailingPadding + 12.dp, top = (cardHeight - badgeSize) / 2, bottom = (cardHeight - badgeSize) / 2) + .size(badgeSize) + ) + } +} + +// Geometry-aware gradient with asymmetric scale: start (dark) pushed further below the card than +// end (warm) is above, so card-middle lands at the bright/mid-transition stop, not the dark region. +private fun gradientBrush(isDark: Boolean, size: IntSize): Brush { + val stops = if (isDark) darkStops else lightStops + if (size.width == 0 || size.height == 0) return Brush.linearGradient(colorStops = stops) + val w = size.width.toFloat() + val h = size.height.toFloat() + val startScale = if (isDark) 3.0f else 2.5f + val endScale = if (isDark) 2.1f else 1.7f + val gp = gradientPoints(h / w, 1.0f) + val sx = 0.5f + (gp.startX - 0.5f) * startScale + val sy = 0.5f + (gp.startY - 0.5f) * startScale + val ex = 0.5f + (gp.endX - 0.5f) * endScale + val ey = 0.5f + (gp.endY - 0.5f) * endScale + return Brush.linearGradient( + colorStops = stops, + start = Offset(sx * w, sy * h), + end = Offset(ex * w, ey * h) + ) +} 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 cb36d86230..7e43db2e98 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 @@ -502,7 +502,7 @@ fun ChatView( groupMembersJob = scope.launch(Dispatchers.Default) { val r = chatModel.controller.apiGroupMemberInfo(chatRh, groupInfo.groupId, member.groupMemberId) val stats = r?.second - val (updatedMember, code) = if (member.memberActive) { + val (updatedMember, code) = if ((member.memberActive || (groupInfo.useRelays && member.memberCurrent)) && member.memberRole != GroupMemberRole.Relay) { val memCode = chatModel.controller.apiGetGroupMemberCode(chatRh, groupInfo.apiId, member.groupMemberId) (memCode?.first ?: r?.first ?: member) to memCode?.second } 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 6bfbad52ef..a03cb63525 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 @@ -115,7 +115,8 @@ data class ComposeState( val useLinkPreviews: Boolean, val mentions: MentionedMembers = emptyMap(), // the max file size the user may attach, raised by their active badge unless the chat is incognito; kept in sync on chat switch - val maxFileSize: Long = getMaxFileSize(FileProtocol.XFTP) + val maxFileSize: Long = getMaxFileSize(FileProtocol.XFTP), + val sendIncognito: Boolean = false ) { constructor(editingItem: ChatItem, liveMessage: LiveMessage? = null, useLinkPreviews: Boolean): this( ComposeMessage( @@ -331,7 +332,7 @@ fun MutableState.processPickedFile(uri: URI?, text: String?) { } else if (fileSize != null) { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.large_file), - String.format(generalGetString(MR.strings.maximum_supported_file_size), formatBytes(maxFileSize)) + largeFileMessage(fileSize, value.sendIncognito, expiredBadgeReason(fileSize, chatModel.currentUser.value?.profile)) ) } else { showWrongUriAlert() @@ -360,7 +361,7 @@ suspend fun MutableState.processPickedMedia(uris: List, text: bitmap = null AlertManager.shared.showAlertMsg( generalGetString(MR.strings.large_file), - String.format(generalGetString(MR.strings.maximum_supported_file_size), formatBytes(maxFileSize)) + largeFileMessage(fileSize ?: 0, value.sendIncognito, expiredBadgeReason(fileSize ?: 0, chatModel.currentUser.value?.profile)) ) null } @@ -1420,7 +1421,10 @@ fun ComposeView( // keep the attach size limit in sync with the chat: the user's active badge raises it, but not in incognito chats where no badge is presented LaunchedEffect(chat.chatInfo) { val incognito = if (chat.chatInfo.profileChangeProhibited) chat.chatInfo.incognito else chatModel.controller.appPrefs.incognito.get() - composeState.value = composeState.value.copy(maxFileSize = getMaxFileSize(FileProtocol.XFTP, if (incognito) null else chatModel.currentUser.value?.profile)) + composeState.value = composeState.value.copy( + maxFileSize = getMaxFileSize(FileProtocol.XFTP, if (incognito) null else chatModel.currentUser.value?.profile), + sendIncognito = incognito + ) } if (appPlatform.isDesktop) { // the same ComposeView is reused when switching chats, so `chat` captured by onDispose would be the chat opened first, not the current one diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt index 8d107ae32a..fb4a2cdaea 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt @@ -38,7 +38,6 @@ fun CIFileView( showTimestamp: Boolean, showMenu: MutableState, smallView: Boolean = false, - senderProfile: LocalProfile?, receiveFile: (Long) -> Unit ) { val saveFileLauncher = rememberSaveFileLauncher(ciFile = file) @@ -77,13 +76,11 @@ fun CIFileView( if (file != null) { when { file.fileStatus is CIFileStatus.RcvInvitation || file.fileStatus is CIFileStatus.RcvAborted -> { - if (fileSizeValid(file, senderProfile)) { - receiveFile(file.fileId) + val prohibited = file.fileProhibited + if (prohibited != null) { + showProhibitedFileAlert(file, prohibited) } else { - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.large_file), - String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol, senderProfile))) - ) + receiveFile(file.fileId) } } file.fileStatus is CIFileStatus.RcvAccepted -> @@ -157,7 +154,7 @@ fun CIFileView( is CIFileStatus.SndError -> fileIcon(innerIcon = painterResource(MR.images.ic_close)) is CIFileStatus.SndWarning -> fileIcon(innerIcon = painterResource(MR.images.ic_warning_filled)) is CIFileStatus.RcvInvitation -> - if (!fileSizeValid(file, senderProfile)) + if (!fileSizeValid(file)) fileIcon(innerIcon = painterResource(MR.images.ic_priority_high), color = WarningOrange) else if (file.expired) fileIcon(innerIcon = painterResource(MR.images.ic_close)) @@ -239,9 +236,21 @@ fun CIFileView( } } -// whether a received file is within the size we accept from its sender -fun fileSizeValid(file: CIFile, senderProfile: LocalProfile?): Boolean = - file.fileSize <= getMaxFileSize(file.fileProtocol, senderProfile) +// the core decides whether a received file is above the size the sender's badge allows +fun fileSizeValid(file: CIFile): Boolean = file.fileProhibited == null + +fun showProhibitedFileAlert(file: CIFile, prohibited: FileProhibited) { + val badgeIssue = when (prohibited.badgeStatus) { + null, BadgeStatus.Active -> "" + BadgeStatus.Expired, BadgeStatus.ExpiredOld -> generalGetString(MR.strings.badge_expired) + BadgeStatus.Failed -> generalGetString(MR.strings.badge_verification_failed) + BadgeStatus.UnknownKey -> generalGetString(MR.strings.badge_no_key) + } + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.large_file), + largeFileMessage(file.fileSize, badgeIssue = badgeIssue) + ) +} fun showFileErrorAlert(err: FileError, file: CIFile? = null, temporary: Boolean = false) { val fileExpires = file?.fileExpires diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt index fe6a1bf4b7..61b6e450c5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt @@ -38,7 +38,6 @@ fun CIImageView( imageProvider: () -> ImageGalleryProvider, showMenu: MutableState, smallView: Boolean, - senderProfile: LocalProfile?, receiveFile: (Long) -> Unit ) { val blurred = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) } @@ -84,7 +83,7 @@ fun CIImageView( is CIFileStatus.SndError -> fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file) is CIFileStatus.SndWarning -> fileIcon(painterResource(MR.images.ic_warning_filled), MR.strings.icon_descr_file) is CIFileStatus.RcvInvitation -> - if (file.expired && fileSizeValid(file, senderProfile)) + if (file.expired && fileSizeValid(file)) fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file) else fileIcon(painterResource(MR.images.ic_arrow_downward), MR.strings.icon_descr_asked_to_receive) @@ -220,13 +219,10 @@ fun CIImageView( if (file != null) { when { file.fileStatus is CIFileStatus.RcvInvitation || file.fileStatus is CIFileStatus.RcvAborted -> - if (fileSizeValid(file, senderProfile)) { - receiveFile(file.fileId) + if (file.fileProhibited != null) { + showProhibitedFileAlert(file, file.fileProhibited) } else { - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.large_file), - String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol, senderProfile))) - ) + receiveFile(file.fileId) } file.fileStatus is CIFileStatus.RcvAccepted -> when (file.fileProtocol) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt index 20d9d4417a..556b386900 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt @@ -35,7 +35,6 @@ fun CIVideoView( imageProvider: () -> ImageGalleryProvider, showMenu: MutableState, smallView: Boolean = false, - senderProfile: LocalProfile?, receiveFile: (Long) -> Unit ) { val blurred = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) } @@ -99,7 +98,7 @@ fun CIVideoView( if (file != null) { when (file.fileStatus) { CIFileStatus.RcvInvitation, CIFileStatus.RcvAborted -> - receiveFileIfValidSize(file, senderProfile, receiveFile) + receiveFileIfValidSize(file, receiveFile) CIFileStatus.RcvAccepted -> when (file.fileProtocol) { FileProtocol.XFTP -> @@ -129,16 +128,16 @@ fun CIVideoView( DurationProgress(file, remember { mutableStateOf(false) }, remember { mutableStateOf(duration * 1000L) }, remember { mutableStateOf(0L) }/*, soundEnabled*/) } if (showDownloadButton(file?.fileStatus) && !blurred.value && file != null) { - PlayButton(error = false, sizeMultiplier, { showMenu.value = true }) { receiveFileIfValidSize(file, senderProfile, receiveFile) } + PlayButton(error = false, sizeMultiplier, { showMenu.value = true }) { receiveFileIfValidSize(file, receiveFile) } } } } // Do not show download icon when the view is blurred if (!smallView && (!showDownloadButton(file?.fileStatus) || !blurred.value)) { - fileStatusIcon(file, false, senderProfile) + fileStatusIcon(file, false) } else if (smallView && file?.showStatusIconInSmallView == true) { Box(Modifier.align(Alignment.Center)) { - fileStatusIcon(file, true, senderProfile) + fileStatusIcon(file, true) } } } @@ -486,7 +485,7 @@ private fun progressCircle(progress: Long, total: Long) { } @Composable -private fun fileStatusIcon(file: CIFile?, smallView: Boolean, senderProfile: LocalProfile?) { +private fun fileStatusIcon(file: CIFile?, smallView: Boolean) { if (file != null) { Box( Modifier @@ -526,7 +525,7 @@ private fun fileStatusIcon(file: CIFile?, smallView: Boolean, senderProfile: Loc } ) is CIFileStatus.RcvInvitation -> - if (file.expired && fileSizeValid(file, senderProfile)) + if (file.expired && fileSizeValid(file)) fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file) else fileIcon(painterResource(MR.images.ic_arrow_downward), MR.strings.icon_descr_video_asked_to_receive) @@ -565,14 +564,11 @@ private fun fileStatusIcon(file: CIFile?, smallView: Boolean, senderProfile: Loc private fun showDownloadButton(status: CIFileStatus?): Boolean = status is CIFileStatus.RcvInvitation || status is CIFileStatus.RcvAborted -private fun receiveFileIfValidSize(file: CIFile, senderProfile: LocalProfile?, receiveFile: (Long) -> Unit) { - if (fileSizeValid(file, senderProfile)) { - receiveFile(file.fileId) +private fun receiveFileIfValidSize(file: CIFile, receiveFile: (Long) -> Unit) { + if (file.fileProhibited != null) { + showProhibitedFileAlert(file, file.fileProhibited) } else { - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.large_file), - String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol, senderProfile))) - ) + receiveFile(file.fileId) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index bab6576646..c38bc1fe08 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -449,7 +449,7 @@ fun ChatItemView( } if (cItem.file != null && (getLoadedFilePath(cItem.file) != null || (chatModel.connectedToRemote() && cachedRemoteReqs[cItem.file.fileSource] != false && cItem.file.loaded))) { SaveContentItemAction(cItem, saveFileLauncher, showMenu) - } else if (cItem.file != null && cItem.file.fileStatus is CIFileStatus.RcvInvitation && fileSizeValid(cItem.file, ciSenderProfile(cItem, chat.chatInfo))) { + } else if (cItem.file != null && cItem.file.fileStatus is CIFileStatus.RcvInvitation && fileSizeValid(cItem.file)) { ItemAction(stringResource(MR.strings.download_file), painterResource(MR.images.ic_arrow_downward), onClick = { withBGApi { Log.d(TAG, "ChatItemView downloadFileAction") diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt index c919859b1f..68aec09456 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -219,7 +219,7 @@ fun FramedItemView( @Composable fun ciFileView(ci: ChatItem, text: String) { - CIFileView(ci.file, ci.meta, chatTTL, showViaProxy, showTimestamp, showMenu, false, ciSenderProfile(ci, chatInfo), receiveFile) + CIFileView(ci.file, ci.meta, chatTTL, showViaProxy, showTimestamp, showMenu, false, receiveFile) if (text != "" || ci.meta.isLive) { CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode = linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } @@ -364,7 +364,7 @@ fun FramedItemView( } else { when (val mc = ci.content.msgContent) { is MsgContent.MCImage -> { - CIImageView(image = mc.image, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, false, ciSenderProfile(ci, chatInfo), receiveFile) + CIImageView(image = mc.image, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, false, receiveFile) if (mc.text == "" && !ci.meta.isLive) { metaColor = Color.White } else { @@ -372,7 +372,7 @@ fun FramedItemView( } } is MsgContent.MCVideo -> { - CIVideoView(image = mc.image, mc.duration, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, smallView = false, senderProfile = ciSenderProfile(ci, chatInfo), receiveFile = receiveFile) + CIVideoView(image = mc.image, mc.duration, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, smallView = false, receiveFile = receiveFile) if (mc.text == "" && !ci.meta.isLive) { metaColor = Color.White } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt index 7e66c15937..da64862c03 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt @@ -622,7 +622,7 @@ fun stripFormattedTextLink(ft: List?, link: String): List= 0 && result[i].format == null && result[i].text.endsWith("\n")) { result[i] = FormattedText(result[i].text.dropLast(1), null) - if (result[i].text.isEmpty()) result.removeLast() + if (result[i].text.isEmpty()) result.removeAt(result.lastIndex) } return result.ifEmpty { null } } 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 68fa25d553..3d0e7c7d2e 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 @@ -36,11 +36,13 @@ import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatController.stopRemoteHostAndReloadHosts import chat.simplex.common.ui.theme.* +import SectionItemView import chat.simplex.common.views.helpers.* import chat.simplex.common.platform.* import chat.simplex.common.views.call.Call import chat.simplex.common.views.chat.item.* import chat.simplex.common.views.chat.topPaddingToContent +import chat.simplex.common.views.badges.* import chat.simplex.common.views.newchat.* import chat.simplex.common.views.onboarding.* import chat.simplex.common.views.usersettings.* @@ -62,6 +64,41 @@ sealed class ActiveFilter { data object Unread: ActiveFilter() } +private fun showSupportEndedDismissAlert() { + AlertManager.shared.showAlertDialogButtonsColumn( + title = generalGetString(MR.strings.badges_support_ended), + buttons = { + Column { + SectionItemView({ + AlertManager.shared.hideAlert() + withBGApi { chatModel.controller.ackBadgeAlert(snooze = true) } + }) { + Text(stringResource(MR.strings.badges_remind_me_later), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + SectionItemView({ + AlertManager.shared.hideAlert() + withBGApi { chatModel.controller.ackBadgeAlert(snooze = false) } + }) { + Text(stringResource(MR.strings.badges_dismiss), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + SectionItemView({ + AlertManager.shared.hideAlert() + }) { + Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + } + ) +} + +private fun showSupportSimpleXDismissAlert() { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.badges_banner_title), + text = generalGetString(MR.strings.badges_banner_dismiss_message), + onConfirm = { appPrefs.supporterBannerShown.set(true) } + ) +} + private fun showNewChatSheet(oneHandUI: State) { connectProgressManager.cancelConnectProgress() ModalManager.start.closeModals() @@ -912,6 +949,11 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat val oneHandUI = remember { appPrefs.oneHandUI.state } val oneHandUICardShown = remember { appPrefs.oneHandUICardShown.state } val addressCreationCardShown = remember { appPrefs.addressCreationCardShown.state } + val supporterBannerShown = remember { appPrefs.supporterBannerShown.state } + val getStakeBannerTapped = remember { appPrefs.getStakeBannerTapped.state } + val getStakeBannerDismissed = remember { appPrefs.getStakeBannerDismissed.state } + // read here rather than in the LazyColumn: it launches an effect, so it needs a composable scope + val crowdfunding = crowdfundingAvailable() val activeFilter = remember { chatModel.activeChatTagFilter } LaunchedEffect(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) { @@ -1000,6 +1042,39 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat ToggleChatListCard() } } + // one slot: a badge the user paid for ending outranks the pitch to get one + val alert = BadgeModel.alert.value + if (supportEnded() && alert != null) { + item { + Box(Modifier.zIndex(1f).padding(16.dp)) { + SupportSimpleXBanner( + title = stringResource(MR.strings.badges_support_ended), + subtitle = String.format(stringResource(MR.strings.badges_support_ended_on), alert.dateText), + onTap = { ModalManager.start.showCustomModal { close -> BadgesView(close) } }, + onDismiss = ::showSupportEndedDismissAlert + ) + } + } + } else if (!supporterBannerShown.value && !hasShownBadge() && chatModel.chats.value.size > 3) { + item { + Box(Modifier.zIndex(1f).padding(16.dp)) { + SupportSimpleXBanner( + onTap = { ModalManager.start.showCustomModal { close -> BadgesView(close) } }, + onDismiss = ::showSupportSimpleXDismissAlert + ) + } + } + } else if (crowdfunding && !getStakeBannerDismissed.value) { + item { + Box(Modifier.zIndex(1f).padding(16.dp)) { + GetStakeBanner( + showDismiss = getStakeBannerTapped.value && chatModel.chats.value.isNotEmpty(), + onTap = { openGetStake(ModalManager.start) }, + onDismiss = { appPrefs.getStakeBannerDismissed.set(true) } + ) + } + } + } itemsIndexed(chats, key = { _, chat -> chat.remoteHostId to chat.id }) { index, chat -> val nextChatSelected = remember(chat.id, chats) { derivedStateOf { chatModel.chatId.value != null && chats.getOrNull(index + 1)?.id == chatModel.chatId.value 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 fbc4c7e336..e6046d6c00 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 @@ -324,13 +324,13 @@ fun ChatPreviewView( } } is MsgContent.MCImage -> SmallContentPreview { - CIImageView(image = mc.image, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true, senderProfile = ciSenderProfile(ci, chat.chatInfo)) { + CIImageView(image = mc.image, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true) { val user = chatModel.currentUser.value ?: return@CIImageView withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) } } } is MsgContent.MCVideo -> SmallContentPreview { - CIVideoView(image = mc.image, mc.duration, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true, senderProfile = ciSenderProfile(ci, chat.chatInfo)) { + CIVideoView(image = mc.image, mc.duration, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true) { val user = chatModel.currentUser.value ?: return@CIVideoView withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) } } @@ -342,7 +342,7 @@ fun ChatPreviewView( } } is MsgContent.MCFile -> SmallContentPreviewFile { - CIFileView(ci.file, ci.meta, cInfo.timedMessagesTTL, showViaProxy = false, showTimestamp = true, showMenu = remember { mutableStateOf(false) }, smallView = true, senderProfile = ciSenderProfile(ci, chat.chatInfo)) { + CIFileView(ci.file, ci.meta, cInfo.timedMessagesTTL, showViaProxy = false, showTimestamp = true, showMenu = remember { mutableStateOf(false) }, smallView = true) { val user = chatModel.currentUser.value ?: return@CIFileView withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/GetStakeBanner.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/GetStakeBanner.kt new file mode 100644 index 0000000000..81ddf8ae53 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/GetStakeBanner.kt @@ -0,0 +1,130 @@ +package chat.simplex.common.views.chatlist + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.ui.theme.isInDarkTheme +import chat.simplex.common.views.helpers.ModalManager +import chat.simplex.common.views.helpers.fontSizeMultiplier +import chat.simplex.common.views.newchat.darkStops +import chat.simplex.common.views.newchat.gradientPoints +import chat.simplex.common.views.newchat.lightStops +import chat.simplex.common.views.onboarding.GetStakeView +import chat.simplex.res.MR + +// Spec: spec/client/chat-list.md#GetStakeBanner +@Composable +fun GetStakeBanner(showDismiss: Boolean, onTap: () -> Unit, onDismiss: () -> Unit) { + Box(Modifier.fillMaxWidth()) { + Row( + Modifier + .bannerCard(onTap) + // the logo sits roughly where the phone of the badge banner's hero is, 40dp from the card edge + .padding(start = 16.dp, end = 40.dp, top = 12.dp, bottom = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + stringResource(MR.strings.invest_banner_title), + style = MaterialTheme.typography.body1, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colors.primary, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Text( + stringResource(MR.strings.invest_banner_subtitle), + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.onBackground, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + Image(painterResource(MR.images.wefunder_logo), contentDescription = null, modifier = Modifier.size(48.dp)) + } + + if (showDismiss) { + BannerDismissButton(Modifier.align(Alignment.TopEnd), onDismiss) + } + } +} + +fun openGetStake(modalManager: ModalManager) { + appPrefs.getStakeBannerTapped.set(true) + modalManager.showModalCloseable(cardScreen = true) { close -> + GetStakeView(showFirstImage = true, inCenterOfWindow = modalManager === ModalManager.center, close = close) + } +} + +@Composable +fun Modifier.bannerCard(onTap: () -> Unit): Modifier { + // grows linearly with system font but never shrinks below the default, and the card grows further + // when 2-line text wraps at very large fonts + val cardHeight = (72.dp * fontSizeMultiplier).coerceAtLeast(72.dp) + val isDark = isInDarkTheme() + var cardSize by remember { mutableStateOf(IntSize.Zero) } + val brush = remember(isDark, cardSize) { gradientBrush(isDark, cardSize) } + return this + .fillMaxWidth() + .heightIn(min = cardHeight) + .clip(RoundedCornerShape(16.dp)) + .background(brush) + .clickable(onClick = onTap) + .onSizeChanged { cardSize = it } +} + +// Same X pattern as OneHandUICard: circle-clipped clickable region with inner padding for hit area. +@Composable +fun BannerDismissButton(modifier: Modifier, onDismiss: () -> Unit) { + Icon( + painterResource(MR.images.ic_close), + contentDescription = stringResource(MR.strings.icon_descr_close_button), + tint = if (isInDarkTheme()) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary, + modifier = modifier + .padding(end = 4.dp, top = 4.dp) + .clip(CircleShape) + .clickable(onClick = onDismiss) + .padding(8.dp) + .size(16.dp) + ) +} + +// Geometry-aware gradient with asymmetric scale: start (dark) pushed further below the card than +// end (warm) is above, so card-middle lands at the bright/mid-transition stop, not the dark region. +private fun gradientBrush(isDark: Boolean, size: IntSize): Brush { + val stops = if (isDark) darkStops else lightStops + if (size.width == 0 || size.height == 0) return Brush.linearGradient(colorStops = stops) + val w = size.width.toFloat() + val h = size.height.toFloat() + val startScale = if (isDark) 3.0f else 2.5f + val endScale = if (isDark) 2.1f else 1.7f + val gp = gradientPoints(h / w, 1.0f) + val sx = 0.5f + (gp.startX - 0.5f) * startScale + val sy = 0.5f + (gp.startY - 0.5f) * startScale + val ex = 0.5f + (gp.endX - 0.5f) * endScale + val ey = 0.5f + (gp.endY - 0.5f) * endScale + return Brush.linearGradient( + colorStops = stops, + start = Offset(sx * w, sy * h), + end = Offset(ex * w, ey * h) + ) +} 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 31a6be5500..fcae2e6b47 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 @@ -207,9 +207,17 @@ private fun BadgeGlyph(badge: LocalBadge, modifier: Modifier, onBadgeClick: (() } } +// verbatim for an unknown type: it is the service's string, and must not be looked up as a localised key +fun badgeTypeName(t: BadgeType): String = when (t) { + is BadgeType.Supporter -> generalGetString(MR.strings.badges_level_supporter) + is BadgeType.Legend -> generalGetString(MR.strings.badges_level_legend) + is BadgeType.Investor -> generalGetString(MR.strings.badges_type_investor) + is BadgeType.Unknown -> t.type +} + fun showBadgeInfoAlert(name: String, badge: LocalBadge, uriHandler: UriHandler) { // a verified badge's type is signed and can't be faked, so the real (possibly unknown) type name is the title - val title = badge.badge.badgeType.text.replaceFirstChar { it.uppercase() } + val title = badgeTypeName(badge.badge.badgeType) when { badge.status == BadgeStatus.Failed -> AlertManager.shared.showAlertMsg( @@ -244,7 +252,7 @@ fun showBadgeInfoAlert(name: String, badge: LocalBadge, uriHandler: UriHandler) } } -private fun badgeImage(t: BadgeType): ImageResource = when (t) { +fun badgeImage(t: BadgeType): ImageResource = when (t) { is BadgeType.Legend -> MR.images.badge_legend is BadgeType.Investor -> MR.images.badge_investor else -> MR.images.badge_supporter // Supporter + Unknown diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt index 3128c63234..2d6e70eae9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -18,6 +18,7 @@ import com.charleskorn.kaml.decodeFromStream import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* import kotlinx.coroutines.flow.* +import kotlinx.datetime.Clock import kotlinx.serialization.encodeToString import java.io.* import java.net.URI @@ -27,6 +28,8 @@ import java.text.SimpleDateFormat import java.util.* import java.util.concurrent.Executors import kotlin.math.* +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days private val singleThreadDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() @@ -123,10 +126,13 @@ const val MAX_FILE_SIZE_SMP: Long = 8000000 const val MAX_FILE_SIZE_XFTP: Long = 1_073_741_824 // 1GB -// raised XFTP receive limits for files from a sender with a supporter badge (also investor) or a legend badge +// raised XFTP limits for a user with a supporter badge (also investor) or a legend badge const val MAX_FILE_SIZE_XFTP_SUPPORTER: Long = 2_147_483_648 // 2GB const val MAX_FILE_SIZE_XFTP_LEGEND: Long = 5_368_709_120 // 5GB +// a badge raises the limit at send for this long after its expiry, shorter than the receiver's grace +val BADGE_SND_GRACE_INTERVAL: Duration = 1.days + const val MAX_FILE_SIZE_LOCAL: Long = Long.MAX_VALUE expect fun getAppFileUri(fileName: String): URI @@ -474,25 +480,46 @@ fun directoryFileCountAndSize(dir: String): Pair { // count, size in return fileCount to bytes } +fun badgeMaxFileSize(badge: LocalBadge): Long = + if (badge.badge.badgeType == BadgeType.Legend) MAX_FILE_SIZE_XFTP_LEGEND else MAX_FILE_SIZE_XFTP_SUPPORTER + +// a badge raises the limit at send until one day past its expiry, as the core applies it +fun badgeActiveForSend(badge: LocalBadge): Boolean = + badge.status == BadgeStatus.Active && badge.badge.badgeExpiry + BADGE_SND_GRACE_INTERVAL >= Clock.System.now() + +// in incognito chats and above the largest badge's limit no badge applies, so badgeIssue is not used +fun largeFileMessage(fileSize: Long, incognito: Boolean = false, badgeIssue: String = ""): String = + if (incognito) { + String.format(generalGetString(MR.strings.large_file_incognito), formatBytes(MAX_FILE_SIZE_XFTP)) + } else if (fileSize > MAX_FILE_SIZE_XFTP_LEGEND) { + String.format(generalGetString(MR.strings.max_file_size_with_badge), formatBytes(MAX_FILE_SIZE_XFTP_LEGEND), generalGetString(MR.strings.legend_badge)) + } else { + val supporter = fileSize <= MAX_FILE_SIZE_XFTP_SUPPORTER + val message = String.format( + generalGetString(MR.strings.large_file_requires_badge), + generalGetString(if (supporter) MR.strings.supporter_badge else MR.strings.legend_badge), + formatBytes(if (supporter) MAX_FILE_SIZE_XFTP else MAX_FILE_SIZE_XFTP_SUPPORTER) + ) + if (badgeIssue.isEmpty()) message else message + " " + badgeIssue + } + +// the badge lapsed, and while active it would have allowed this file +fun expiredBadgeReason(fileSize: Long, senderProfile: LocalProfile?): String { + val badge = senderProfile?.localBadge + return if (badge != null && !badgeActiveForSend(badge) && badgeMaxFileSize(badge) >= fileSize) { + generalGetString(MR.strings.your_badge_expired) + } else "" +} + fun getMaxFileSize(fileProtocol: FileProtocol, senderProfile: LocalProfile? = null): Long = when (fileProtocol) { FileProtocol.SMP -> MAX_FILE_SIZE_SMP FileProtocol.LOCAL -> MAX_FILE_SIZE_LOCAL - // a sender's active badge raises the XFTP limit: legend to 5GB, any other (supporter/investor) to 2GB FileProtocol.XFTP -> { val badge = senderProfile?.localBadge - if (badge == null || badge.status != BadgeStatus.Active) MAX_FILE_SIZE_XFTP - else if (badge.badge.badgeType == BadgeType.Legend) MAX_FILE_SIZE_XFTP_LEGEND - else MAX_FILE_SIZE_XFTP_SUPPORTER + if (badge != null && badgeActiveForSend(badge)) badgeMaxFileSize(badge) else MAX_FILE_SIZE_XFTP } } -// the profile of whoever sent a received chat item - the group member, or the direct chat's contact -fun ciSenderProfile(ci: ChatItem, chatInfo: ChatInfo): LocalProfile? = when (val dir = ci.chatDir) { - is CIDirection.GroupRcv -> dir.groupMember.memberProfile - is CIDirection.DirectRcv -> (chatInfo as? ChatInfo.Direct)?.contact?.profile - else -> null -} - expect suspend fun getBitmapFromVideo(uri: URI, timestamp: Long? = null, random: Boolean = true, withAlertOnException: Boolean = true): VideoPlayerInterface.PreviewAndDuration // Whether the file really contains a video track. Reads container metadata only, without decoding a frame. diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasscodeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasscodeView.kt index 9b25e9b5e0..669847c3a9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasscodeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasscodeView.kt @@ -64,7 +64,7 @@ fun PasscodeView( @Composable fun VerticalLayout() { Column( - Modifier.handleKeyboard().focusRequester(focusRequester), + Modifier.systemBarsPadding().handleKeyboard().focusRequester(focusRequester), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.SpaceEvenly ) { @@ -74,7 +74,7 @@ fun PasscodeView( Text(reason, Modifier.padding(top = 5.dp), style = MaterialTheme.typography.subtitle1) } } - PasscodeEntry(passcode, true) + PasscodeEntry(passcode, true, Modifier.weight(1f, fill = false)) Row(Modifier.heightIn(min = 70.dp), verticalAlignment = Alignment.CenterVertically) { SimpleButton(generalGetString(MR.strings.cancel_verb), icon = painterResource(MR.images.ic_close), disabled = !buttonsEnabled.value, click = cancel) Spacer(Modifier.size(20.dp)) @@ -85,9 +85,9 @@ fun PasscodeView( @Composable fun HorizontalLayout() { - Row(Modifier.padding(horizontal = DEFAULT_PADDING).handleKeyboard().focusRequester(focusRequester), horizontalArrangement = Arrangement.Center) { + Row(Modifier.systemBarsPadding().padding(horizontal = DEFAULT_PADDING).handleKeyboard().focusRequester(focusRequester), horizontalArrangement = Arrangement.Center) { Column( - Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, top = DEFAULT_PADDING), + Modifier.weight(1f, fill = false).padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, top = DEFAULT_PADDING), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.SpaceBetween ) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasswordEntry.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasswordEntry.kt index f76b82c31e..d16f0a2a38 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasswordEntry.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasswordEntry.kt @@ -22,8 +22,9 @@ import chat.simplex.res.MR fun PasscodeEntry( password: MutableState, vertical: Boolean, + modifier: Modifier = Modifier, ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { + Column(modifier, horizontalAlignment = Alignment.CenterHorizontally) { PasscodeView(password) BoxWithConstraints { if (vertical) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/OnboardingCards.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/OnboardingCards.kt index 26007c74af..a1cc8676de 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/OnboardingCards.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/OnboardingCards.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import dev.icerock.moko.resources.compose.painterResource @@ -32,7 +33,10 @@ import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chatlist.GetStakeBanner +import chat.simplex.common.views.chatlist.openGetStake import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.onboarding.crowdfundingAvailable import chat.simplex.common.views.usersettings.UserAddressView import chat.simplex.res.MR import kotlinx.coroutines.launch @@ -42,13 +46,21 @@ import kotlin.math.sin private const val CARD_HEIGHT_RATIO = 0.75f private const val GRADIENT_ANGLE_RAD = 80.0 * Math.PI / 180.0 +// the onboarding cards replace the whole chat list, and the support-ended banner lives in the +// list - a lapsed supporter is not a newcomer, and must be told even with no conversations yet @Composable fun shouldShowOnboarding(): Boolean { val addressCreationCardShown = remember { appPrefs.addressCreationCardShown.state } val chats = chatModel.chats.value - return !addressCreationCardShown.value && chats.isNotEmpty() && !hasConversations(chats) + return !addressCreationCardShown.value && chats.isNotEmpty() && !hasConversations(chats) && !supportEnded() } +fun supportEnded(): Boolean = + BadgeModel.alert.value?.kind == BadgeAlertKind.SupportEnded && BadgeModel.isCurrent(chatModel.remoteHostId(), chatModel.currentUser.value?.userId) + +fun hasShownBadge(): Boolean = + BadgeModel.badgeState.value?.shown == true && BadgeModel.isCurrent(chatModel.remoteHostId(), chatModel.currentUser.value?.userId) + fun hasConversations(chats: List): Boolean = chats.any { chat -> when (val c = chat.chatInfo) { @@ -407,6 +419,27 @@ fun ConnectOnboardingView() { } } + val getStakeBannerDismissed = remember { appPrefs.getStakeBannerDismissed.state } + val showGetStakeBanner = crowdfundingAvailable() && !getStakeBannerDismissed.value + // on desktop the pages span the window, but the banner keeps the width it has in the chat list + val bannerMaxWidth = if (appPlatform.isDesktop) DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier else Dp.Unspecified + val content = @Composable { + Column(Modifier.fillMaxSize()) { + Box(Modifier.weight(1f).fillMaxWidth()) { + pager() + } + if (showGetStakeBanner) { + Box(Modifier.align(Alignment.CenterHorizontally).widthIn(max = bannerMaxWidth).padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = 8.dp)) { + GetStakeBanner( + showDismiss = false, + onTap = cardClickOverride ?: { openGetStake(if (appPlatform.isDesktop) ModalManager.center else ModalManager.start) }, + onDismiss = {} + ) + } + } + } + } + if (appPlatform.isDesktop) { val maxContentWidth = DEFAULT_WINDOW_WIDTH - DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier Box( @@ -414,12 +447,12 @@ fun ConnectOnboardingView() { contentAlignment = Alignment.Center ) { Box(Modifier.widthIn(max = maxContentWidth).fillMaxHeight()) { - pager() + content() } } } else { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - pager() + content() } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt index 703d295523..9d82395740 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt @@ -7,6 +7,7 @@ import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalUriHandler import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.* @@ -22,12 +23,12 @@ import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource @Composable -fun HowItWorks(user: User?, onboardingStage: SharedPreference? = null) { +fun HowItWorks(user: User?, onboardingStage: SharedPreference? = null, titleColor: Color = Color.Unspecified) { Column(Modifier.fillMaxSize().padding(horizontal = if (appPlatform.isDesktop) DEFAULT_PADDING * 2 else DEFAULT_PADDING)) { Spacer(Modifier.statusBarsPadding().padding(top = AppBarHeight * fontSizeSqrtMultiplier)) val paraPadding = PaddingValues(bottom = if (appPlatform.isDesktop) 10.dp else 12.dp) Column(Modifier.weight(1f).padding(bottom = DEFAULT_PADDING).verticalScroll(rememberScrollState())) { - Text(stringResource(MR.strings.why_built_heading), style = MaterialTheme.typography.h1, modifier = Modifier.padding(bottom = DEFAULT_PADDING)) + Text(stringResource(MR.strings.why_built_heading), style = MaterialTheme.typography.h1, color = titleColor, modifier = Modifier.padding(bottom = DEFAULT_PADDING)) ReadableText(MR.strings.why_built_p1, padding = paraPadding) ReadableText(MR.strings.why_built_p2, padding = paraPadding) ReadableText(MR.strings.why_built_p3, padding = paraPadding) @@ -45,6 +46,11 @@ fun HowItWorks(user: User?, onboardingStage: SharedPreference? OnboardingActionButton(user, onboardingStage, onclick = { ModalManager.fullscreen.closeModal() }) TextButtonBelowOnboardingButton("", null) } + } else { + // No button below — add breathing room at the bottom on both platforms. + // Android also gets nav-bar inset so content doesn't run under the gesture bar. + Spacer(Modifier.height(DEFAULT_PADDING)) + if (appPlatform.isAndroid) Spacer(Modifier.navigationBarsPadding()) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt index 74dadcd671..d97d709953 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt @@ -190,12 +190,14 @@ expect fun OnboardingActionButton(user: User?, onboardingStage: SharedPreference fun OnboardingActionButton( modifier: Modifier = Modifier, labelId: StringResource, + labelArg: String? = null, onboarding: OnboardingStage?, enabled: Boolean = true, icon: Painter? = null, iconColor: Color = Color.White, onclick: (() -> Unit)? ) { + val label = if (labelArg != null) stringResource(labelId).format(labelArg) else stringResource(labelId) Button( onClick = { onclick?.invoke() @@ -211,9 +213,9 @@ fun OnboardingActionButton( colors = ButtonDefaults.buttonColors(MaterialTheme.colors.primary, disabledBackgroundColor = MaterialTheme.colors.secondary) ) { if (icon != null) { - Icon(icon, stringResource(labelId), Modifier.padding(end = DEFAULT_PADDING_HALF), tint = iconColor) + Icon(icon, label, Modifier.padding(end = DEFAULT_PADDING_HALF), tint = iconColor) } - Text(stringResource(labelId), style = MaterialTheme.typography.h2, color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Medium) + Text(label, style = MaterialTheme.typography.h2, color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Medium) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt index ea7d935efe..0fa53ea6de 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt @@ -969,8 +969,56 @@ private val versionDescriptions: List = listOf( ), ) ), + // TODO [badges] restore when badges ship, with final copy and the Read more link. + /* + VersionDescription( + version = "v7.1", + post = null, + features = listOf( + VersionFeature.FeatureView( + icon = null, + titleId = MR.strings.v7_1_supporter_badge_title, + view = { SupporterBadgeWhatsNew() } + ) + ) + ), + */ ) +@Composable +private fun SupporterBadgeWhatsNew() { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.Top) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + stringResource(MR.strings.v7_1_supporter_badge_title), + style = MaterialTheme.typography.h3, + fontWeight = FontWeight.Bold + ) + Text( + stringResource(MR.strings.v7_1_supporter_badge_body), + style = MaterialTheme.typography.body1, + maxLines = 10 + ) + } + val isDark = isInDarkTheme() + if (BuildConfigCommon.SIMPLEX_ASSETS) { + Image( + painterResource(if (isDark) MR.images.phone_supporter_light else MR.images.phone_supporter), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.size(110.dp) + ) + } else { + Image( + painterResource(MR.images.badge_supporter), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.size(72.dp) + ) + } + } +} + private val lastVersion = versionDescriptions.last().version fun setLastVersionDefault(m: ChatModel) { @@ -1040,7 +1088,7 @@ fun isInUs(): Boolean = @Composable private fun InvestInSimpleXChatView(modalManager: ModalManager) { if (!crowdfundingAvailable()) return - val showGetStake = { modalManager.showModalCloseable(cardScreen = true) { close -> GetStakeView(fromSettings = false, inCenterOfWindow = crowdfundingLayout.inCenterOfWindow(modalManager), close = close) } } + val showGetStake = { modalManager.showModalCloseable(cardScreen = true) { close -> GetStakeView(showFirstImage = false, inCenterOfWindow = crowdfundingLayout.inCenterOfWindow(modalManager), close = close) } } Column(modifier = Modifier.padding(bottom = 12.dp)) { Text( generalGetString(MR.strings.v7_0_invest), @@ -1122,7 +1170,7 @@ private val getStakeSlides: List = listOf( ) @Composable -fun GetStakeView(fromSettings: Boolean, inCenterOfWindow: Boolean = false, close: () -> Unit) { +fun GetStakeView(showFirstImage: Boolean, inCenterOfWindow: Boolean = false, close: () -> Unit) { val uriHandler = LocalUriHandler.current val stopped = chatModel.chatRunning.value == false @@ -1152,7 +1200,7 @@ fun GetStakeView(fromSettings: Boolean, inCenterOfWindow: Boolean = false, close val title = "Get a stake in\nSimpleX Chat" AppBarTitle(if (inCenterOfWindow) title.replace("\n", " ") else title, withPadding = false) // What's new already shows the image of the first slide, above the link that opens this page - if (fromSettings) { + if (showFirstImage) { slideImage(getStakeSlides[0]) } Text( @@ -1165,7 +1213,7 @@ fun GetStakeView(fromSettings: Boolean, inCenterOfWindow: Boolean = false, close } } }, - Modifier.padding(top = if (fromSettings) 8.dp else 0.dp), + Modifier.padding(top = if (showFirstImage) 8.dp else 0.dp), lineHeight = 24.sp ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt index 8c134cb361..9e1a6eb4ad 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt @@ -26,6 +26,7 @@ import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.badges.BadgesView import chat.simplex.common.views.database.DatabaseView import chat.simplex.common.views.helpers.* import chat.simplex.common.views.migration.MigrateFromDeviceView @@ -103,6 +104,18 @@ fun SettingsLayout( } SectionDividerSpaced() + SectionView { + // Direct showModal (no settings / cardScreen flags) — settings-style card chrome would render + // a gray top bar / back button that badges views don't want (they have their own inline titles). + SectionItemView(click = { ModalManager.start.showCustomModal { close -> BadgesView(close) } }) { + val badgeType = chatModel.currentUser.value?.profile?.localBadge?.badge?.badgeType ?: BadgeType.Supporter + Image(painterResource(badgeImage(badgeType)), stringResource(MR.strings.supporter_perks), Modifier.size(24.dp)) + TextIconSpaced() + Text(stringResource(MR.strings.supporter_perks)) + } + } + SectionDividerSpaced() + SectionView(stringResource(MR.strings.advanced_settings)) { SettingsActionItem(painterResource(MR.images.ic_wifi_tethering), stringResource(MR.strings.network_and_servers), showCustomModal { _, close -> NetworkAndServersView(close) }, disabled = stopped) if (appPlatform == AppPlatform.ANDROID) { @@ -119,7 +132,7 @@ fun SettingsLayout( SettingsActionItem( painterResource(MR.images.ic_redeem), stringResource(MR.strings.v7_0_crowdfunding), - { ModalManager.start.showModalCloseable(cardScreen = true) { close -> GetStakeView(fromSettings = true, close = close) } } + { ModalManager.start.showModalCloseable(cardScreen = true) { close -> GetStakeView(showFirstImage = true, close = close) } } ) } } 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 2ab1933418..4a3f7870fe 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml @@ -14,20 +14,15 @@ اقبل لا يمكن التراجع عن هذا الإجراء - سيتم فقد ملف تعريفك وجهات اتصالك ورسائلك وملفاتك بشكل نهائي. هذه المجموعة لم تعد موجودة. - رمز QR هذا ليس رابطًا! - مستقبل المُراسلة لا يمكن التراجع عن هذا الإجراء - سيتم حذف جميع الملفات والوسائط المستلمة والمرسلة. ستبقى الصور منخفضة الدقة. لا يمكن التراجع عن هذا الإجراء - سيتم حذف الرسائل المرسلة والمستلمة قبل التحديد. قد تأخذ عدة دقائق. ينطبق هذا الإعداد على الرسائل الموجودة في ملف تعريف الدردشة الحالي الخاص بك - منصة الرسائل والتطبيقات تحمي خصوصيتك وأمنك. يتم مشاركة ملف التعريف مع جهات اتصالك فقط. سيتم تغيير الدور إلى "%s". سيتم إبلاغ كل فرد في المجموعة. سيتم تغيير الدور إلى "%s". سيستلم العضو دعوة جديدة. خوادم الاتصالات الجديدة لملف تعريف الدردشة الحالي الخاص بك سيتم تغيير عنوان الاستلام إلى خادم مختلف. سيتم إكمال تغيير العنوان بعد اتصال المرسل بالإنترنت. - هذا الرابط ليس رابط اتصال صالح! اسمح - أضِف خوادم مُعدة مسبقًا أضِف إلى جهاز آخر ستُحذف جميع الدردشات والرسائل - لا يمكن التراجع عن هذا! الوصول إلى الخوادم عبر وسيط SOCKS على المنفذ %d؟ يجب بدء تشغيل الوسيط قبل تفعيل هذا الخيار. @@ -108,8 +103,6 @@ انتهت المكالمة غيِّر لون إضافي ثانوي - " -\nمتوفر في v5.1" مكالمات الصوت/الفيديو ممنوعة. عن طريق ملف تعريف الدردشة (افتراضي) أو عن طريق الاتصال (تجريبي). يمكن تعطيله عبر الإعدادات - سيستمر عرض الإشعارات أثناء تشغيل التطبيق.]]> @@ -167,7 +160,6 @@ خطأ في حذف جهة الاتصال جهة الاتصال مخفية: انسخ - اتصل متصل انضمام إلى المجموعة؟ اتصل عبر رابط لمرة واحدة؟ @@ -305,15 +297,11 @@ متصل سيتم حذف جهة الاتصال وجميع الرسائل - لا يمكن التراجع عن هذا! الحد الأقصى لحجم الملف المدعوم حاليًا هو %1$s. - تواصل عبر الرابط / رمز QR - أنشئ رابط دعوة لمرة واحدة تحقق من عنوان الخادم وحاول مرة أخرى. امحُ التحقُّق - أنشئ عنوانًا للسماح للأشخاص بالتواصل معك. أدخل الخادم يدويًا ملون لدى جهة الاتصال التعمية بين الطرفين - أنشئ أنشئ ملف تعريفك مكالمة جارية فعّل التدمير الذاتي @@ -387,7 +375,6 @@ حذف جهة الاتصال؟ احذف بالنسبة لي وقت مخصّص - لامركزي عبارة مرور قاعدة البيانات عبارة المرور الحالية… سيتم تحديث عبارة مرور تعمية قاعدة البيانات وتخزينها في Keystore. @@ -483,7 +470,6 @@ ساعات السجل سيتم استلام الصورة عندما يكتمل جهة اتصالك من رفعِها. - اعرض رمز QR في مكالمة الفيديو، أو شارك الرابط.]]> ثبّت SimpleX Chat لطرفية إذا أكّدت، فستتمكن خوادم المُراسلة من رؤية عنوان IP الخاص بك ومزود خدمتك - أي الخوادم التي تتصل بها. إخفاء: @@ -505,8 +491,6 @@ معلومات إخفاء شاشة التطبيق في التطبيقات الحديثة. تحسن الخصوصية والأمان - مسح رمز QR في مكالمة الفيديو، أو يمكن لجهة الاتصال مشاركة رابط الدعوة.]]> - محصن ضد الإزعاج (spam) التخفي عبر رابط لمرة واحدة أرسلت صورة صورة @@ -547,7 +531,6 @@ واجهة أستخدام يابانية وبرتغالية يمكن أن يحدث ذلك عندما تستخدم أنت أو اتصالك النُسخة الاحتياطية القديمة لقاعدة البيانات. انضم ك%s - رمز QR غير صالح الواجهة الإيطالية الرابط غير صالح! دعوة الأصدقاء @@ -647,18 +630,12 @@ %d ثانية قُطع الاتصال رسالة تختفي - لا تنشئ عنوانًا خطأ في تحديث تضبيط الشبكة خطأ في استلام الملف خطأ في تبديل ملف التعريف! حافظ على اتصالاتك - تأكد من أن عناوين خادم XFTP بالتنسيق الصحيح، وأن تكون مفصولة بأسطر وليست مكررة. عُلّم محذوف القوائم والتنبيهات - خطأ في حفظ خوادم XFTP - خطأ في تحميل خوادم SMP - خطأ في تحميل خوادم XFTP - تأكد من أن عناوين خادم SMP في التنسيق الصحيح، وأن تكون مفصولة بأسطر وليست مكررة. مساعدة ماركداون عضو ماركداون في الرسائل @@ -690,7 +667,6 @@ إصلاح التعمية بعد استعادة النُسخ الاحتياطية. اجعل رسالة واحدة تختفي خطأ في تفعيل إيصالات التسليم! - خطأ في حفظ خوادم SMP خطأ في إرسال الرسالة خطأ في الانضمام إلى المجموعة خطأ في مزامنة الاتصال @@ -777,7 +753,6 @@ ستكون مضيفات البصل مطلوبة للاتصال. \nيُرجى ملاحظة: أنك لن تتمكن من الاتصال بالخوادم بدون عنوان onion. اسم عرض جديد: عبارة مرور جديدة… - قيد الانتظار عبارة المرور مطلوبة ألصِق الرابط الذي استلمته فقط مالكي المجموعة يمكنهم تفعيل الملفات والوسائط. @@ -808,12 +783,9 @@ افتح وحدة تحكم الدردشة إدخال رمز المرور افتح SimpleX Chat لقبول المكالمة - يمكن لأي شخص استضافة الخوادم. كلمة المرور للإظهار ندّ لِندّ - أنت تقرر مَن يمكنه الاتصال. مكالمة قيد الانتظار - تقوم أجهزة العميل فقط بتخزين ملفات تعريف المستخدمين وجهات الاتصال والمجموعات والرسائل. صفّر الألوان احفظ عنوان الخادم المُعد مسبقًا @@ -836,7 +808,6 @@ قيم التطبيق منفذ احفظ إعدادات عنوان SimpleX - إعادة تعريف الخصوصية يُرجى إبلاغ المطوِّرين بذلك. الخصوصية والأمان أزل @@ -850,7 +821,6 @@ ستتم إزالة خوادم WebRTC ICE المحفوظة. امنع إرسال الملفات والوسائط. استلمت إجابة… - مستودع GitHub.]]> ارفض يحمي خادم المُرحل عنوان IP الخاص بك، ولكن يمكنه مراقبة مُدّة المكالمة. الرجاء إدخال كلمة المرور السابقة بعد استعادة نسخة احتياطية لقاعدة البيانات. لا يمكن التراجع عن هذا الإجراء. @@ -922,7 +892,6 @@ رمز QR صفّر المنفذ %d - خادم مُعد مسبقًا يُستخدم خادم المُرحل فقط إذا لزم الأمر. يمكن لطرف آخر مراقبة عنوان IP الخاص بك. حفظ وإشعار جهة الاتصال إعادة التشغيل @@ -934,7 +903,6 @@ رمز المرور للتدمير الذاتي إرسال الملفات غير مدعوم بعد أُلغيَ المرسل نقل الملف. - (امسح أو ألصق من الحافظة) ثانية حذف المُرسل طلب الاتصال. امسح رمز QR @@ -1007,7 +975,6 @@ قفل SimpleX لم يتحقق من %s قفل SimpleX - خوادم SMP مشاركة الوسائط… رسائل SimpleX Chat قفل SimpleX غير مفعّل! @@ -1024,7 +991,6 @@ إيقاف الدردشة؟ أظهر حدثت بعض الأخطاء غير الفادحة أثناء الاستيراد: - وسيط SOCKS تم تدقيق أمان SimpleX Chat بواسطة Trail of Bits. إيقاف أظهر المعاينة @@ -1077,20 +1043,15 @@ يجلب التطبيق الرسائل الجديدة بشكل دوري - يستخدم نسبة قليلة من البطارية يوميًا. لا يستخدم التطبيق إشعارات الدفع - لا يتم إرسال البيانات من جهازك إلى الخوادم. سيتم إلغاء الاتصال الذي قبلته! لن تتمكن جهة الاتصال التي شاركت هذا الرابط معها من الاتصال! - هذا النص متاح في الإعدادات - لحماية خصوصيتك، يستخدم SimpleX معرّفات منفصلة لكل جهة اتصال لديك. لحماية معلوماتك، فعّل قفل SimpleX \nسيُطلب منك إكمال المصادقة قبل تفعيل هذه الميزة. عزل النقل بفضل المستخدمين - ساهِم عبر Weblate! دعم البلوتوث وتحسينات أخرى. بفضل المستخدمين - ساهِم عبر Weblate! يتم تشغيل SimpleX في الخلفية بدلاً من استخدام إشعارات push.]]> - انقر لبدء محادثة جديدة - (للمشاركة مع جهة اتصالك) للتواصل عبر الرابط للاتصال، يمكن لجهة الاتصال مسح رمز QR أو استخدام الرابط في التطبيق. اختبر الخوادم - لا معرّفات مُستخدم دعم SimpleX Chat بدِّل العنوان الرئيسي @@ -1143,7 +1104,6 @@ ستتصل بجميع أعضاء المجموعة. ملفات تعريف دردشتك عنوان SimpleX الخاص بك - خوادم SMP الخاصة بك عندما يكون التطبيق قيد التشغيل عبر المُرحل لقد انضممت إلى هذه المجموعة @@ -1155,7 +1115,6 @@ سيتم استلام الفيديو عند اكتمال رفع جهة اتصالك. تحقق من رمز الأمان رسائل صوتية - عندما يطلب الأشخاص الاتصال، يمكنك قبوله أو رفضه. ستكون متصلاً بالمجموعة عندما يكون جهاز مضيف المجموعة متصلاً بالإنترنت، يُرجى الانتظار أو التحقق لاحقًا! ستكون متصلاً عندما يتم قبول طلب اتصالك، يُرجى الانتظار أو التحقق لاحقًا! تستخدم خوادم SimpleX Chat. @@ -1164,9 +1123,7 @@ استخدام وسيط SOCKS؟ عندما تكون متاحة ستبقى جهات اتصالك متصلة. - لا نقوم بتخزين أي من جهات اتصالك أو رسائلك (بمجرد تسليمها) على الخوادم. يمكنك استخدام تخفيض السعر لتنسيق الرسائل: - استخدم الدردشة أنت خطأ غير معروف غيّرتَ دور نفسك إلى %s @@ -1182,12 +1139,9 @@ في انتظار الملف عرض رمز الأمان لن تفقد جهات اتصالك إذا حذفت عنوانك لاحقًا. - خوادم XFTP الخاصة بك استخدم خوادم SimpleX Chat - خوادم XFTP خوادم ICE الخاصة بك هل تستخدم اتصالاً مباشرًا بالإنترنت؟ - أنت تتحكم في الدردشة! مكالماتك عبارة مرور خاطئة! تحذير: قد تفقد بعض البيانات! @@ -1211,7 +1165,6 @@ سيتم إرسال ملف تعريف الدردشة الخاص بك إلى أعضاء المجموعة مرحبًا! %1$s يريد الاتصال بك! - خوادم ICE الخاصة بك خصوصيتك حُدثت ملف تعريف المجموعة أنت: %1$s @@ -1233,7 +1186,6 @@ سيُطلب منك المصادقة عند بدء تشغيل التطبيق أو استئنافه بعد 30 ثانية في الخلفية. رسالة صوتية (%1$s) إعداداتك - تحديث وضع عزل النقل؟ يُخزن ملف التعريف وجهات الاتصال والرسائل التي سلُمت على جهازك. سيتم حذف قاعدة بيانات الدردشة الحالية واستبدالها بالقاعدة المستوردة. \nلا يمكن التراجع عن هذا الإجراء - سيتم فقد ملف التعريف وجهات الاتصال والرسائل والملفات الخاصة بك بشكل نهائي. @@ -1245,13 +1197,10 @@ ملف تعريفك الحالي عبر %1$s غير مقروءة - مرحبًا! في انتظار الصورة فيديو في انتظار الفيديو فيديو - يمكنك مشاركة عنوانك كرابط أو رمز QR - يمكن لأي شخص الاتصال بك. - يمكنك إنشاؤه لاحقًا أنت تحاول دعوة جهة اتصال شاركت ملف تعريف متخفي معها إلى المجموعة التي تستخدم فيها ملف تعريفك الرئيسي ألغِ الكتم ألغِ الكتم @@ -1259,7 +1208,6 @@ إلغاء إخفاء ملف تعريف يجب أن تكون جهة الاتصال متصلة بالإنترنت حتى يكتمل الاتصال. \nيمكنك إلغاء هذا الاتصال وإزالة جهة الاتصال (والمحاولة لاحقًا باستخدام رابط جديد). - فتح في تطبيق الجوّال.]]> استخدم للاتصالات الجديدة استخدم الخادم عنوان خادمك @@ -1281,7 +1229,6 @@ رسالة الترحيب عبر رابط عنوان الاتصال أُزيلت جهة اتصالك هذا الرابط، أو أنه كان رابطًا لمرة واحدة وقد استُخدِم بالفعل.\nللتواصل، اطلب من جهة اتصالك إنشاء رابط جديد. - سيتم إرسال ملف تعريف دردشتك\nإلى جهة اتصالك إلغاء الإخفاء ملفك التعريفي العشوائي ستستمر في استلام المكالمات والإشعارات من الملفات التعريفية المكتومة عندما تكون نشطة. @@ -1298,7 +1245,6 @@ تحتاج إلى السماح لجهة اتصالك بإرسال رسائل صوتية لتتمكن من إرسالها. أرسلت جهة اتصالك ملفًا أكبر من الحجم الأقصى المعتمد حاليًا (%1$s). الاتصال بمطوِّري SimpleX Chat لطرح أي أسئلة وتلقي التحديثات.]]> - خادمك يُخزن ملف تعريفك على جهازك ومشاركته فقط مع جهات اتصالك. لا تستطيع خوادم SimpleX رؤية ملف تعريفك. الفيديو مقفل الفيديو مُشغَّل @@ -1323,13 +1269,11 @@ مجموعات صغيرة (الحد الأقصى 20) تواصل مباشرةً؟ سيتم إرسال طلب الاتصال لعضو المجموعة هذا. - اتصال متخفي استخدم ملف التعريف الحالي عطّل الإشعارات افتح إعدادات التطبيق لا يمكن تشغيل SimpleX في الخلفية. ستستلم الإشعارات فقط عندما يكون التطبيق قيد التشغيل. سيتم مشاركة ملف تعريف عشوائي جديد. - ألصِق الرابط المُستلَم للتواصل مع جهة اتصالك… ستتم مشاركة ملفك التعريفي %1$s. قد يغلق التطبيق بعد دقيقة واحدة في الخلفية. اسمح @@ -1498,7 +1442,6 @@ خطأ في إظهار المحتوى خطأ في إظهار الرسالة انتهت المكالمة %1$s - يمكنك جعله مرئيًا لجهات اتصال SimpleX الخاصة بك عبر الإعدادات. لا يتم إرسال التاريخ إلى الأعضاء الجدد. حاول مجددًا الكاميرا غير متوفرة @@ -1754,12 +1697,10 @@ غير محمي السماح بالرجوع إلى إصدار سابق لا تستخدم التوجيه الخاص. - وضع توجيه الرسائل لا عندما يكون IP مخفيًا لا ترسل رسائل مباشرةً، حتى لو كان خادمك أو خادم الوجهة لا يدعم التوجيه الخاص. أرسل الرسائل مباشرة عندما لا يدعم الخادم الوجهة الخاص بك أو الخادم الوجهة التوجيه الخاص. - احتياطي توجيه الرسالة أظهِر حالة الرسالة احمِ عنوان IP بدون تور أو VPN، سيكون عنوان IP الخاص بك مرئيًا لخوادم الملفات. @@ -1775,7 +1716,6 @@ خوادم غير معروفة! بدون تور أو VPN، سيكون عنوان IP الخاص بك مرئيًا لمُرحلات XFTP هذه: \n%1$s. - أظهِر قائمة الدردشة في نافذة جديدة ألوان الدردشة سمة الدردشة تلقيت رد @@ -1848,10 +1788,6 @@ رسالة مُحوّلة لا يوجد اتصال مباشر حتى الآن، الرسالة مُحوّلة بواسطة المُدير. ألصِق رابط / امسح - خوادم SMP المهيأة - خوادم SMP أخرى - خوادم XFTP المهيأة - خوادم XFTP أخرى أظهِر النسبة المئوية مُعطَّل مستقرّ @@ -1897,7 +1833,6 @@ الملفات التي نُزّلت أخطاء التنزيل منتهية الصلاحيّة - افتح إعدادات الخادم أخرى موّكل مؤمن @@ -1962,9 +1897,7 @@ متوسط ناعم مكالمة - اتصل مراسلة - افتح بحث الإعدادات فيديو @@ -2107,9 +2040,6 @@ عنوان SimpleX أو رابط لمرة واحدة؟ مُشغلي الشبكة يمكنك تضبيط الخوادم عبر الإعدادات. - حدد مشغلي الشبكة الذين تريد استخدامهم. - يمكنك تضبيط المُشغلين في إعدادات الشبكة والخوادم. - حدّث تابع الشروط المتفق عليها راجع الشروط @@ -2121,7 +2051,6 @@ مُشغل الشبكة المُشغل %s خوادم - الموقع الإلكتروني سيتم قبول الشروط في: %s. قُبل الشروط في: %s. استخدم %s @@ -2151,14 +2080,12 @@ فعّل flux في إعدادات الشبكة والخوادم لتحسين خصوصية البيانات الوصفية. اللامركزية الشبكية المُشغل المُعد مسبقًا الثاني في التطبيق! - لتحسين خصوصية البيانات الوصفية. تحسين التنقل في الدردشة اعرض الشروط المُحدثة اقبل الشروط أُضيفت خوادم الرسائل عنوان أو رابط لمرة واحدة؟ مع جهة اتصال واحدة فقط - المشاركة شخصيًا أو عبر أي مُراسل.]]> - سيتم قبول الشروط للمُشغلين المفعّلين بعد 30 يومًا. مُشغلي الخادم لا يمكن تحميل نص الشروط الحالية، يمكنك مراجعة الشروط عبر هذا الرابط: خطأ في قبول الشروط @@ -2171,12 +2098,10 @@ لا يوجد خوادم لاستلام الرسائل. - فتح الدردشة عند أول رسالة غير مقروءة.\n- الانتقال إلى الرسائل المقتبسة. يمكنك تعيين اسم الاتصال، لتتذكر الأشخاص الذين شاركتَ الرابط معهم. - راجع لاحقًا تغيّر بروتوكول الخادم. شارك العنوان علناً شارك عنوان SimpleX على وسائل التواصل الاجتماعي. عنوان SimpleX والروابط لمرة واحدة آمنة للمشاركة عبر أي برنامج مُراسلة. - انقر فوق أنشئ عنوان SimpleX في القائمة لإنشائه لاحقًا. حُذفت هذه الرسالة أو لم تُستلم بعد. استخدم للرسائل يحمي التطبيق خصوصيتك من خلال استخدام مُشغلين مختلفين في كل محادثة. @@ -2191,7 +2116,6 @@ أضف أعضاء فريقك إلى المحادثات. يُمنع إرسال الرسائل المباشرة بين الأعضاء في هذه الدردشة. أجهزة Xiaomi: يُرجى تفعيل التشغيل التلقائي (Autostart) في إعدادات النظام لكي تعمل الإشعارات.]]> - مُعمَّاة بين الطرفين، مع أمان ما بعد الكم في الرسائل المباشرة.]]> تحقق من الرسائل كل 10 دقائق يُمنع إرسال الرسائل المباشرة بين الأعضاء. الدردشة @@ -2207,7 +2131,6 @@ الإشعارات والبطارية فقط مالكي الدردشة يمكنهم تغيير التفضيلات. الخصوصية لعملائك. - الجوّالات عن بُعد ادعُ للدردشة مغادرة المجموعة؟ سيتم إزالة العضو من الدردشة - لا يمكن التراجع عن هذا! @@ -2287,7 +2210,6 @@ بلّغ عن إزعاج (spam): سيراه مُشرفو المجموعة فقط. أرشفة البلاغ؟ سبب الإبلاغ؟ - سيتم أرشفة البلاغ لك. إزعاج (spam) نعم اسأل @@ -2312,7 +2234,6 @@ استخدم منفذ الويب منفذ TCP للمُراسلة اكتم الكل - ذّكورات غير مقروءة يمكنك ذكر ما يصل إلى %1$s من الأعضاء في الرسالة الواحدة! السماح بالإبلاغ عن الرسائل إلى المُشرفين. امنع الإبلاغ عن الرسائل للمُشرفين. @@ -2438,7 +2359,6 @@ إرسال طلب جهة اتصال؟ أرسل طلب أرسل طلب دون رسالة - أُرسل إلى جهة اتصالك بعد الاتصال. ترقية رابط المجموعة؟ رقِّ ترقية العنوان؟ @@ -2494,7 +2414,6 @@ طلبات التواصل في المجموعات حُذف العضو - لا يمكن قبول الطلب طُلب اتصال من المجموعة %1$s - هذا الإعداد لملف تعريفك الحالي اسمح بالملفات والوسائط فقط إذا سمح جهة اتصالك بذلك. اسمح لجهات اتصالك بإرسال الملفات والوسائط. روبوت @@ -2526,7 +2445,6 @@ فشل الاتصال فشل ملفات - تصفية صور روابط ابحث عن ملفات @@ -2562,7 +2480,6 @@ قناة اسم القناة بالكامل رابط القناة - أعضاء القناة اسم القناة يُخزّن ملف تعريف القناة على أجهزة المشتركين وعلى مُرحلات الدردشة. حُدِّث ملف تعريف القناة @@ -2590,7 +2507,6 @@ أدخل اسم المُرحل… خطأ في إضافة المُرحل خطأ في إنشاء القناة - خطأ في فتح القناة خطأ: %s خطأ في حفظ ملف تعريف القناة فشل @@ -2610,7 +2526,6 @@ لا مُرحلات دردشة مفعّلة. ليس كل المُرحلات متصلة افتح قناة - افتح قناة جديدة المالك المالكين والمساهمين عنوان المُرحل مسبق الضبط @@ -2779,7 +2694,6 @@ عنوانك العام لقد وُلدت دون حساب تعريفي. قناتك الجديدة %1$s متصلة بـ %2$d من أصل %3$d مُرحلات. إذا ألغيت، ستُحذف القناة - يمكنك إنشاؤها مرة أخرى. - أضف أضف مُرحل أضف مُرحلات ألغِ واحذف القناة @@ -2814,14 +2728,11 @@ الحالة %1$d مالك %1$d مالكون - عن خيارات متقدّمة إعدادات متقدّمة اسمح لأي شخص بالتضمين يمكن لأي صفحة ويب عرض المعاينة. بيانات الدردشة - يتطلب الاتصال عبر اسم القناة إصدارًا أحدث من التطبيق. - يتطلب الاتصال عبر اسم جهة الاتصال إصدارًا أحدث من التطبيق. تواصل مساهم انسخ الرمز @@ -2833,7 +2744,6 @@ سيتم عرضه للمشتركين واستخدامه للسماح بتحميل المعاينة. مزيد من الخصوصية لا يمكن عرض المعاينة إلا على صفحتك المذكورة أعلاه. - يُرجى ترقية التطبيق. %1$d مالكين ومساهمين %1$s دعم تطبيق SimpleX Chat. انتهت صلاحية الشارة في %2$s. قائمة المعترف بهم @@ -2849,8 +2759,6 @@ سيتم تغيير الدور إلى "%s". وسيتم إشعار جميع المشاركين في القناة بذلك. تعذّر التحقق من صحة هذه الشارة، وقد لا تكون أصلية. تتطلب هذه المجموعة إصدارًا أحدث من التطبيق. يُرجى تحديث التطبيق للانضمام إليها. - اسم قناة غير مدعوم - اسم جهة اتصال غير مدعوم شارة غير متحقق منها لا تدعم خوادم ترحيل الدردشة المستعملة صفحات الويب. رمز صفحة الويب @@ -2865,7 +2773,6 @@ خطأ المحلّل: %1$s الخادم %1$s لا يدعم تحليل الأسماء. اضبط الخوادم أو استخدم رابط الاتصال. عيِّن اسم SimpleX - اسم SimpleX خطأ في اسم SimpleX لم يتحقق مِن اسم SimpleX اسم SimpleX %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 622ff9d9dc..feed7b3c8e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -12,8 +12,6 @@ Use incognito profile Your profile will be sent to the contact that you received this link from. You will connect to all group members. - Connect - Connect incognito Open chat Join channel %s Connect to %s @@ -128,12 +126,6 @@ Another reason - Error saving SMP servers - Error saving XFTP servers - Make sure SMP server addresses are in correct format, line separated and are not duplicated. - Make sure XFTP server addresses are in correct format, line separated and are not duplicated. - Error loading SMP servers - Error loading XFTP servers Error updating network configuration Failed to load chat Failed to load chats @@ -178,6 +170,7 @@ Forwarding server %1$s failed to connect to destination server %2$s. Please try later. Destination server address of %1$s is incompatible with forwarding server %2$s settings. Destination server version of %1$s is incompatible with forwarding server %2$s. + The service did not respond. Please try again. Please try later. Error sending message Error forwarding messages @@ -203,11 +196,6 @@ Please check that you used the correct link or ask your contact to send you another one. Unsupported connection link This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. - Unsupported channel name - Unsupported contact name - Connecting via channel name requires a newer app version. - Connecting via contact name requires a newer app version. - Please upgrade the app. SimpleX name error None of your servers are set to resolve SimpleX names. Configure servers, or use a connection link. Server %1$s does not support name resolution. Configure servers, or use a connection link. @@ -373,7 +361,6 @@ Archive report? Archive %d reports? Archive all reports? - The report will be archived for you. All reports will be archived for you. For me For all moderators @@ -424,7 +411,6 @@ Files Links All messages - Filter Archive Archive report Archive reports @@ -492,8 +478,6 @@ Welcome %1$s! - Welcome! - This text is available in settings Chats Settings connecting… @@ -503,7 +487,6 @@ Join as %s rejected connecting… - Tap to start a new chat Chat with the developers You have no chats Talk to someone @@ -528,7 +511,6 @@ contact should accept… Connect with %1$s? Search or paste SimpleX link - Tap Create SimpleX address in the menu to create it later. No selected chat @@ -687,6 +669,15 @@ File Large file! Your contact sent a file that is larger than currently supported maximum size (%1$s). + supporter badge + legend badge + You need a %1$s to send files larger than %2$s. + Maximum supported file size is %1$s, with a %2$s. + Contact\'s badge expired. + Contact\'s badge verification failed. + No key to verify contact\'s badge. + Your badge expired. + Files larger than %1$s cannot be sent in incognito chats. Currently maximum supported file size is %1$s. Waiting for file File will be received when your contact completes uploading it. @@ -717,8 +708,6 @@ Delete chat messages from your device. - connect - open message call search @@ -741,7 +730,6 @@ Connected Disconnected Error - Pending Change receiving address? Receiving address will be changed to a different server. Address change will complete after sender comes online. Abort changing address? @@ -803,12 +791,8 @@ Start new chat - Create one-time invitation link - Connect via link / QR code Scan QR code Create secret group - (to share with your contact) - (scan or paste from clipboard) (only stored by group members) @@ -871,7 +855,6 @@ Unmute Favorite Unfavorite - Unread mentions Create list @@ -921,19 +904,12 @@ Show QR code - Invalid QR code - This QR code is not a link! Invalid link! - This link is not a valid connection link! Connection request sent! You will be connected to group when the group host\'s device is online, please wait or check later! You will be connected when your connection request is accepted, please wait or check later! You will be connected when your contact\'s device is online, please wait or check later! - show QR code in the video call, or share the link.]]> - Your chat profile will be sent\nto your contact - scan QR code in the video call, or your contact can share an invitation link.]]> Share 1-time link - Paste the link you received to connect with your contact… Learn more About SimpleX address @@ -947,7 +923,6 @@ Share address publicly Share SimpleX address on social media. - You can share your address as a link or QR code - anybody can connect to you. You won\'t lose your contacts if you later delete your address. Share 1-time link with a friend with one contact only - share in person or via any messenger.]]> @@ -955,7 +930,6 @@ Connection security SimpleX address and 1-time links are safe to share via any messenger. To protect against your link being replaced, you can compare contact security codes. - When people request to connect, you can accept or reject it. User Guide.]]> Address or 1-time link? @@ -964,7 +938,6 @@ Connect Paste This string is not a connection link! - Open in mobile app button.]]> New chat @@ -978,7 +951,6 @@ Verify name Verify SimpleX names SimpleX name not verified - SimpleX name Your SimpleX name Set SimpleX name Error saving name @@ -1046,11 +1018,7 @@ SimpleX Lock Chat console Message servers - SMP servers - Configured SMP servers - Other SMP servers Preset server address - Add preset servers Add server Test server Test servers @@ -1060,8 +1028,6 @@ Scan server QR code Enter server manually New server - Preset server - Your server Your server address Use server Use for new connections @@ -1072,9 +1038,6 @@ The servers for new connections of your current chat profile Save servers? Media & file servers - XFTP servers - Configured XFTP servers - Other XFTP servers Show percentage Install SimpleX Chat for terminal Reset all hints @@ -1082,8 +1045,6 @@ Contribute Rate the app Use SimpleX Chat servers? - Your SMP servers - Your XFTP servers Using SimpleX Chat servers. How to How to use your servers @@ -1133,7 +1094,6 @@ New SOCKS credentials will be used every time you start the app. New SOCKS credentials will be used for each server. for each contact and group member.\nPlease note: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail.]]> - Update transport isolation mode? Use .onion hosts to No if SOCKS proxy does not support them.]]> Please note: message and file relays are connected via SOCKS proxy. Calls use direct connection.]]> Private routing @@ -1145,7 +1105,6 @@ Use private routing with unknown servers. Use private routing with unknown servers when IP address is not protected. Do NOT use private routing. - Message routing mode Allow downgrade Yes When IP hidden @@ -1153,7 +1112,6 @@ Send messages directly when your or destination server does not support private routing. Send messages directly when IP address is protected and your or destination server does not support private routing. Do NOT send messages directly, even if your or destination server does not support private routing. - Message routing fallback Show message status To protect your IP address, private routing uses your SMP servers to deliver messages. TCP port for messaging @@ -1211,7 +1169,6 @@ All your contacts will remain connected. Profile update will be sent to your contacts. Share link Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts. - Create an address to let people connect with you. Create SimpleX address Share with SimpleX contacts Share address with SimpleX contacts? @@ -1220,7 +1177,6 @@ Stop sharing Auto-accept Group invitations - Sent to your contact after connection. Welcome message Enter welcome message… (optional) Save settings? @@ -1253,9 +1209,6 @@ Continue - Don\'t create address - You can create it later - You can make it visible to your SimpleX contacts via Settings. Invite @@ -1289,16 +1242,12 @@ Error saving user password - You control your chat! - The messaging and application platform protecting your privacy and security. - We don\'t store any of your contacts or messages (once delivered) on the servers. Create profile Your profile, contacts and delivered messages are stored on your device. The profile is only shared with your contacts. Display name cannot contain whitespace. Enter your name: Your bio: - Create Create profile Create Invalid name! @@ -1354,13 +1303,6 @@ Error initializing WebView. Make sure you have WebView installed and it\'s supported architecture is arm64.\nError: %s - The future of messaging - Privacy redefined - No user identifiers. - Immune to spam - You decide who can connect. - Decentralized - Anybody can host servers. Create your profile Make a private connection Migrate from another device @@ -1386,13 +1328,8 @@ The oldest human freedom — to speak to another person without being watched — built on infrastructure that cannot betray it. Because we destroyed the power to know who you are. So that your power can never be taken. Be free in your network. - To protect your privacy, SimpleX uses separate IDs for each of your contacts. - Only client devices store user profiles, contacts, groups, and messages. - end-to-end encrypted, with post-quantum security in direct messages.]]> - GitHub repository.]]> - Use chat Private notifications How it affects battery When app is running @@ -1423,13 +1360,8 @@ When more than one operator is enabled, none of them has metadata to learn who communicates with whom. For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server. About operators - Select network operators to use. How it helps privacy You can configure servers via settings. - Conditions will be accepted for enabled operators after 30 days. - You can configure operators in Network & servers settings. - Review later - Update Continue @@ -1467,7 +1399,6 @@ Accept Show Disable - Your ICE servers WebRTC ICE servers Relay server protects your IP address, but it can observe the duration of the call. Relay server is only used if necessary. Another party can observe your IP address. @@ -1570,7 +1501,6 @@ An empty chat profile with the provided name is created, and the app opens as usual. If you enter this passcode when opening the app, all app data will be irreversibly removed! Set passcode - This setting is for your current profile These settings are for your current profile They can be overridden in contact and group settings. Contacts @@ -1617,7 +1547,6 @@ Files Send delivery receipts to Contact requests in groups - About Contact Support the project Chat data @@ -1628,7 +1557,6 @@ Shutdown Developer tools Experimental features - SOCKS proxy Interface LANGUAGE App icon @@ -1651,7 +1579,6 @@ Your chat database Run chat - Remote mobiles Chat is running Chat is stopped Chat database @@ -1785,7 +1712,6 @@ Top bar You can change it in Appearance settings. Show console in new window - Show chat list in new window Invalid migration confirmation Upgrade and open chat Downgrade and open chat @@ -2028,7 +1954,6 @@ Invite Link Chat with admins - Channel members Chat relays @@ -2199,7 +2124,6 @@ Operator %s servers Network operator - Website Conditions accepted on: %s. Conditions will be accepted on: %s. Use servers @@ -2378,7 +2302,6 @@ SimpleX links Visible history Audio/video calls - \nAvailable in v5.1 enabled enabled for you enabled for contact @@ -2705,7 +2628,6 @@ Network decentralization The second preset operator in the app! Enable Flux in Network & servers settings for better metadata privacy. - for better metadata privacy. Improved chat navigation - Open chat on the first unread message.\n- Jump to quoted messages. Business chats @@ -2756,6 +2678,8 @@ Crowdfunding on Wefunder. Crowdfunding on Wefunder Learn more on Wefunder + Get a stake in SimpleX Chat! + Invest on Wefunder from $100 SimpleX public names (BETA) Public names for your channel or business. Better channels 📢 @@ -3047,7 +2971,6 @@ Downloaded files Download errors Server address - Open server settings You can mention up to %1$s members per message! @@ -3166,7 +3089,6 @@ Your profile %1$s will be shared with channel relays and subscribers.\nRelays can access channel messages. Configure relays failed - Add Add relay Add relays No available relays @@ -3184,12 +3106,10 @@ Relay address This is a chat relay address, it cannot be used to connect. Open channel - Open new channel You are a subscriber You are a contributor Your channel %1$s!]]> - Error opening channel Unblock subscriber for all? @@ -3220,4 +3140,94 @@ This badge could not be verified and may not be genuine. Badge cannot be verified The badge is signed with a key that this version of the app does not recognize. Update the app to verify this badge. + Supporter + Legend + Send 2GB files + Send 5GB files + %1$s/month + %1$s/year + Pay %1$s/month + Pay %1$s/year + Pay %1$s + Loading… + Not available + Save %1$s + Optional profile badge\nand 2GB files + Optional profile badge\nand 5GB files + 1 month + Monthly + Annual + Renews on %1$s. Cancel anytime. + Ends on %1$s. + Your level + Support SimpleX + Get a badge to send larger files (2-5GB) that stay available longer (7-21 days), and to show it on your profile. + Why SimpleX is built. + Choose your level + Redeem badge code + Continue + How badges protect your privacy + How badges protect your privacy + A badge is an anonymous credential stored in your profile on your device. This credential is never sent to anyone. + To prove to a contact or a server that you have a badge, the app generates a new proof that reveals only the badge type and the expiry date, rounded to a week. + Nobody can link two different proofs to each other or to the purchase. + our blog.]]> + My nickname + Support SimpleX + Get badge + better files + You can support SimpleX later in Settings. + Purchase successful + Purchase pending + The purchase is awaiting approval. This build does not deliver purchases approved later. + Purchase error + Get your code + Redeem code + Paste the code you received. + SB-XXXXX-XXXXX-XXXXX-XXXXX + Redeem + Cannot redeem code + This code is not valid. + This app version cannot redeem badge codes. + This profile already has a badge. Redeem the code on another profile, or once this badge ends. + This code was not recognised. + This code has already been used. + This code has expired. + Too many attempts. Please try again later. + The badge service sent an unexpected response: %1$s + This app version cannot verify this badge. Please update the app. + This app version is too old for the badge service. Please update the app. + The code was accepted, but the badge it grants has already ended. + The code could not be redeemed. + The code you scanned is not a badge code. + Your badge + Your badge expired + shown on your profile + Ends + Prepaid months have no billing date. The badge is reissued each month from the balance you already paid for, and ends when it runs out. + Investor + Your badge expired on %1$s. + Remind me later + Dismiss + Credential + Status + Expires + Months left + Purchase ID + Copy purchase key + Badge ledger + No entries + Date + Balance + Balance start + Anchor + Badge type + Paused since + Entry ID + Invoice ID + Charge ID + From purchase key + To purchase key + Supporter perks + Supporter badge ❤️ + Help keep the network running — send files up to 2 GB. \ No newline at end of file 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 c79aa07a1e..5bf3ab7754 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml @@ -33,7 +33,6 @@ Приеми Приеми инкогнито За SimpleX Chat - Добави предварително зададени сървъри Добави към друго устройство Разширени мрежови настройки За SimpleX @@ -48,7 +47,6 @@ Приеми Допълнителен акцент Админите могат да създадат линкове за присъединяване към групи. - покажете QR кода във видеоразговора или споделете линка.]]> Отворете SimpleX Chat, за да приемете повикването Поддръжка на bluetooth и други подобрения. Relay сървърът защитава вашия IP адрес, но може да наблюдава продължителността на разговора. @@ -103,7 +101,6 @@ Лош хеш на съобщението Деактивиране повикване… - да сканирате QR код във видеоразговора или вашият контакт може да сподели линк за покана.]]> Услугата във фонов режим винаги работи – известията ще се показват веднага щом съобщенията са налични. Може да бъде деактивирано през настройките – известията ще продължат да се показват, докато приложението работи.]]> SimpleX Chat разговори @@ -189,8 +186,6 @@ Позволи на вашите контакти да изпращат изчезващи съобщения. винаги Аудио/видео разговори - " -\nДостъпно в v5.1" Фон Позволи реакции на съобщения. Позволи изпращането на лични съобщения до членовете. @@ -248,7 +243,6 @@ Задайте го вместо системната идентификация. Код за достъп за самоунищожение Още няколко неща - Свързване свързан свързване връзка %1$d @@ -385,12 +379,9 @@ Грешка при декодиране Изтрий контакт Изтрий контакт\? - Свърване чрез линк/QR код Копирано в клипборда - Създай линк за еднократна покана Създай тайна група Персонализирано време - (сканирай или постави от клипборда) Контактът все още не е свързан! Изтрий Изтрий @@ -400,18 +391,15 @@ Продължи Версия на ядрото: v%s Създай адрес - Създайте адрес, за да позволите на хората да се свързват с вас. Създай SimpleX адрес Персонализирай темата Идентификатори в базата данни и опция за изолация на транспорта. Изтрий адрес Изтрий адрес\? Цветове на интерфейса - Създай Създай профил Изтрий изображение Създай профил - Децентрализиран контактът има e2e криптиране контактът няма e2e криптиране Изтрий всички файлове @@ -438,9 +426,7 @@ Сканирай QR кода на сървъра Въведи сървъра ръчно Изтрий сървър - Не създавай адрес Име на профила: - Само потребителските устройства съхраняват потребителски профили, контакти, групи и съобщения. Деактивирай потвърждениeто\? Активиране (запазване на промените) Активирай потвърждениeто\? @@ -594,7 +580,6 @@ Грешка при изтриване на група Грешка при изтриване на чакащата контактна връзка Грешка при запазване на ICE сървърите - Чакаща връзка със сървъра Вашият контакт трябва да бъде онлайн, за да осъществите връзката. \nМожете да откажете тази връзка и да премахнете контакта (и да опитате по -късно с нов линк). Грешка при експортиране на базата данни @@ -603,9 +588,6 @@ Грешка при импортиране на базата данни Грешка при отстраняване на член Грешка при запазване на профила на групата - Грешка при зареждане на SMP сървъри - Грешка при зареждане на XFTP сървъри - Грешка при запазване на SMP сървърите Грешка при присъединяване към група Грешка при зареждане на подробности Грешка при получаване на файл @@ -613,7 +595,6 @@ Грешка при запазване на потребителска парола Грешка при стартиране на чата Грешка при спиране на чата - Грешка при запазване на XFTP сървърите Грешка при изпращане на съобщение Грешка при задаване на адрес Грешка при смяна на профил! @@ -694,7 +675,6 @@ Само вашият контакт може да изпраща изчезващи съобщения. Забрани изпращането на изчезващи съобщения. Членовете могат да изпращат изчезващи съобщения. - Невалиден QR код Невалиден линк! Неправилен код за сигурност! Невалиден адрес на сървъра! @@ -764,7 +744,6 @@ Информация Инсталирай SimpleX Chat за терминал Как работи - Защитен от спам Игнорирай Покани членове Необратимото изтриване на съобщения е забранено в този чат. @@ -801,7 +780,6 @@ Няма се използват Onion хостове. Нека да поговорим в SimpleX Chat Парола за показване - GitHub хранилище.]]> Когато приложението работи Периодично Постави получения линк @@ -842,7 +820,6 @@ Записът е актуализиран на Научете повече Постави - Предварително зададен сървър Предварително зададен адрес на сървъра Поверителени известия Изключено @@ -875,7 +852,6 @@ (съхранено само на устройствата на членовете на групата) Паролата не е намерена в KeyStore, моля, въведете я ръчно. Това може да се е случило, ако възстановите данните на приложението с помощта на инструмент за резервни копия. Ако не е така, моля, свържете се с разработчиците. Моля, свържете се с груповия администартор. - Когато хората искат да се свържат с вас, можете да ги приемете или отхвърлите. Актуализацията на профила ще бъде изпратена до вашите контакти. Забрани необратимото изтриване на съобщения. Забрани изпращането на лични съобщения до членовете. @@ -937,8 +913,6 @@ х Отваряне на база данни… НА ЖИВО - Уверете се, че адресите на SMP сървъра са в правилен формат, разделени на редове и не са дублирани. - Уверете се, че адресите на XFTP сървъра са в правилен формат, разделени на редове и не са дублирани. маркирано като изтрито модерирано модерирано от %s @@ -977,9 +951,6 @@ Разрешение е отказано! профилно изображение запазено място за профилно изображение - Всеки може да оперира сървъри. - Вие решавате кой може да се свърже с вас. - Поверителността преосмислена Добави поверителна връзка Отвори Реле сървър се използва само ако е необходимо. Друга страна може да наблюдава вашия IP адрес. @@ -1036,7 +1007,6 @@ SimpleX Адрес SimpleX Лого SimpleX Екип - SMP сървъри Сподели адреса с контактите? Сподели линк Сподели с контактите @@ -1093,7 +1063,6 @@ Сподели медия… SimpleX адрес Сигурността на SimpleX Chat беше одитирана от Trail of Bits. - SOCKS прокси Рестартиране Изключване Рестартирайте приложението, за да създадете нов чат профил. @@ -1141,7 +1110,6 @@ За свързване чрез линк Връзката, която приехте, ще бъде отказана! Контактът, с когото споделихте този линк, НЯМА да може да се свърже! - Този линк не е валиден линк за връзка! Вашите чат профили Сървърите за нови връзки на текущия ви чат профил За да покажете скрития профил, въведете пълната парола в полето за търсене на страницата "Вашите чат профили". @@ -1150,18 +1118,13 @@ Докосни за активиране на профил. Системен Заглавие - (за споделяне с вашия контакт) Тази група вече не съществува. Подкрепете SimpleX Chat Вашият контакт изпрати файл, който е по-голям от поддържания в момента максимален размер (%1$s). Тази функция все още не се поддържа. Опитайте следващата версия. - Докосни за започване на нов чат - Този текст е достъпен в настройките Твърде много изображения! - Този QR код не е линк! Тествай сървър Транспортна изолация - Платформата за съобщения и приложения, защитаваща вашата поверителност и сигурност. Смени Благодарение на потребителите – допринесете през Weblate! Видео и файлове до 1gb @@ -1179,7 +1142,6 @@ Този текст не е линк за връзка! Твърде много видеоклипове! Тази настройка се прилага за съобщения в текущия ви профил - За да се защити поверителността, SimpleX използва идентификатори за опашки от съобщения, отделни за всеки от вашите контакти. Опит за свързване със сървъра, използван за получаване на съобщения от този контакт. Опит за свързване със сървъра, използван за получаване на съобщения от този контакт (грешка: %1$s). Тестът е неуспешен на стъпка %s. @@ -1188,8 +1150,6 @@ Докосни бутона Благодарим Ви, че инсталирахте SimpleX Chat! Запази и уведоми контактите - Бъдещето на комуникацията - Няма потребителски идентификатори. Системна Неправилно ID на следващото съобщение (по-малко или еднакво с предишното). \nТова може да се случи поради някаква грешка или когато връзката е компрометирана. @@ -1203,11 +1163,9 @@ Настройки SimpleX адрес Използвай за нови връзки - Вашите XFTP сървъри Използвай сървърите на SimpleX Chat\? Използват се сървърите на SimpleX Chat. Вашите ICE сървъри - Вашите SMP сървъри Използвай SOCKS прокси Използвай SOCKS прокси\? Използване на директна интернет връзка\? @@ -1215,7 +1173,6 @@ Когато са налични Вашият профил, контакти и доставени съобщения се съхраняват на вашето устройство. Можете да използвате markdown за форматиране на съобщенията: - Използвай чата Актуализация Трябва да въвеждате парола при всяко стартиране на приложението - тя не се съхранява на устройството. Неизвестна грешка в базата данни: %s @@ -1246,17 +1203,12 @@ Гласово съобщение… Гласовите съобщения са забранени. непрочетено - Добре дошли! Добре дошли %1$s! Нямате чатове вие сте наблюдател - Вашият сървър Използвай сървър Вашият адрес на сървъра - Можете да го създадете по-късно Вашите контакти ще останат свързани. - Ние не съхраняваме вашите контакти или съобщения (веднъж доставени) на сървърите. - Вие контролирате своя чат! Грешна парола! Гласови съобщения Настройки @@ -1269,11 +1221,8 @@ премахнахте %1$s Вашият автоматично генериран профил Уведомявай - Можете да споделите адреса си като линк или QR код - всеки може да се свърже с вас. променихте адреса получаване за %s Вашият чат профил ще бъде изпратен на членовете на групата - Вашият чат профил ще бъде изпратен -\nдо вашия контакт Вашата текуща база данни ще бъде ИЗТРИТА и ЗАМЕНЕНА с импортираната. \nТова действие не може да бъде отменено - вашият профил, контакти, съобщения и файлове ще бъдат безвъзвратно загубени. Актуализирането на настройките ще свърже отново клиента към всички сървъри. актуализиран профил на групата @@ -1309,15 +1258,11 @@ Видео се свържете с разработчиците на SimpleX Chat, за да задавате въпроси и да получавате актуализации;.]]> иска да се свърже с вас! - Отваряне в мобилно приложение.]]> - XFTP сървъри - Актуализиране на режима на изолация на транспорта\? Вашият текущ профил Вашият профил се съхранява на вашето устройство и се споделя само с вашите контакти. SimpleX сървърите не могат да видят вашия профил. Видеото е изключено Видеото е включено WebRTC ICE сървъри - Вашите ICE сървъри Вашата поверителност Трябва да използвате най-новата версия на вашата чат база данни САМО на едно устройство, в противен случай може да спрете да получавате съобщения от някои контакти. вие: %1$s @@ -1327,7 +1272,6 @@ - гласови съобщения до 5 минути. \n- персонализирано време за изчезване. \n- история на редактиране. - Свързване инкогнито Отвори настройките на приложението Позволи Нов автоматично генериран профил ще бъде споделен. @@ -1335,7 +1279,6 @@ Без фонови разговори Свързване директно\? Използвай текущия профил - Поставете линка, който сте получили, за да се свържете с вашия контакт… Вашият профил %1$s ще бъде споделен. SimpleX не може да работи във фонов режим. Ще получавате известията само когато приложението работи. Приложението може да се затвори след 1 минута във фонов режим. @@ -1506,7 +1449,6 @@ Потвърди връзка Няма свързано мобилно устройство блокиран - Можете да го направите видим за вашите контакти в SimpleX чрез Настройки. Историята не се изпраща на нови членове. Опитай отново Камерата е неодстъпна @@ -1780,7 +1722,6 @@ Препращане на съобщенията без файловете? Неизвестни сървъри Файлове - Показване на списъка на чатовете в нов прозорец Системна Тъмна Черна @@ -1833,7 +1774,6 @@ Винаги Не Никога - Режим за рутиране на съобщения Използвай поверително рутиране с неизвестни сървъри, когато IP адресът не е защитен. Светла Да @@ -1858,14 +1798,12 @@ Свържете отново сървъра, за да принудите доставката на съобщенията. Това използва допълнителен трафик. Грешка при нулиране на статистиката Сканирай / Постави линк - Конфигурирани XFTP сървъри Размер на шрифта Статус на съобщението: %s Задай тема по подразбиране Потвърди файлове от неизвестни сървъри. Изпратени съобщения Грешка при стартиране на WebView. Актуализирайте системата си до новата версия. Моля, свържете се с разработчиците.\nГрешка: %s - Други XFTP сървъри Мащабиране Всички цветови режими Светъл режим @@ -1939,8 +1877,6 @@ Получени грешки Моля, проверете дали мобилното и настолното устройство са свързани към една и съща локална мрежа и дали защитната стена на настолното устройство позволява връзката.\nМоля, споделете всички други проблеми с разработчиците. Временна файлова грешка - Конфигурирани SMP сървъри - Други SMP сървъри Подобрена доставка на съобщения опити изтекли @@ -2053,11 +1989,9 @@ Избрано %d Нищо не е избрано Съобщение - отвори съобщение обаждане Контактът ще бъде изтрит - това не може да бъде отменено! - свързване Изтрий без известие Разговорът е изтрит! Все още ще можете да видите разговора с %1$s в списъка с чатовете. @@ -2073,7 +2007,6 @@ Контактът е изтрит! Архивирани контакти Достъпни панели - Отвори настройките на сървъра Продължи Отвори местоположението на файла Инсталиране на актуализация @@ -2125,8 +2058,6 @@ Например, ако контактът ви получава съобщения чрез SimpleX Chat сървър, приложението ви ще ги достави чрез Flux сървър. Сървърни оператори Мрежови оператори - Преглед по-късно - Можете да конфигурирате операторите в Мрежа и сървъри. Условията ще бъдат автоматично приети за активираните оператори на: %s. Вашите сървъри Условията ще бъдат приети на: %s. @@ -2140,16 +2071,13 @@ Добавени сървъри за съобщения Използвай за файлове - Отворете чата при първото непрочетено съобщение.\n- Преминете към цитирани съобщения. - Резервно рутиране на съобщения Активирай логовете Добавете членовете на вашия екип към разговорите. задраскан Продължи Добави приятели %s.]]> - Уеб страница Условия, приети на: %s. - Дистанционни мобилни устройства Предварително зададени сървъри %s.]]> Отвори промените @@ -2160,7 +2088,6 @@ Персонализирана форма на съобщенията. По-добри дати на съобщението. Активиране на Flux в настройките Мрежа и сървъри за по -добра поверителност на метаданните. - за по-добра поверителност на метаданните. Подобрена навигация в чата Грешка при запазване на базата данни Вашият профил ще бъде изпратен до членовете @@ -2174,14 +2101,11 @@ Xiaomi устройства : моля, активирайте Autostart в системните настройки, за да работят известията.]]> Няма съобщение Или сподели лично - криптирани от край до край, с постквантова сигурност в директните съобщения.]]> Без фонова услуга Проверявай за съобщения на всеки 10 минути Приложението винаги работи във фонов режим Известия и батерия - Условията ще бъдат приети за активираните оператори след 30 дни. Можете да конфигурирате сървърите през настройките. - Актуализация %s.]]> Използвай за съобщения Личните съобщения между членовете са забранени. @@ -2195,7 +2119,6 @@ Грешка при зареждането на чат списъка Няма чатове в списъка %s. Няма непрочетени чатове - Докосни Създаване на SimpleX адрес в менюто, за да го създадете по-късно. Няма чатове Няма намерени чатове Любими @@ -2267,7 +2190,6 @@ Връзката изисква предоговаряне на криптирането. Сподели еднократен линк за връзка с приятел Настройки на адреса - Изберете мрежови оператори за използване. Как помага за поверителността Ще спрете да получавате съобщения от този чат. Историята на чата ще бъде запазена. Добави членове на екипа @@ -2455,7 +2377,6 @@ Изпрати заявка Изпрати заявка без съобщение Изпрати личната си обратна връзка до групи. - Изпратено до вашия контакт след осъществяване на връзка. Задаване на име на чат… Задаване на достъп за членове Задаване на срок на валидност на съобщенията в чатовете. @@ -2474,14 +2395,12 @@ Докосни Свързване за изпращане на заявка Докосни Присъединяване към групата TCP порт за съобщения - Докладът ще бъде архивиран за вас. Подателят НЯМА да бъде уведомен. Това действие не може да бъде отменено - съобщенията, изпратени и получени в този чат по-рано от избраното, ще бъдат изтрити. Тази връзка изисква по-нова версия на приложението. Моля, актуализирайте приложението или помолете контакта да ви изпрати съвместим линк. Времето за изчезване е зададено само за нови контакти. За да използвате друг профил след опит за връзка, изтрийте чата и използвайте линка отново. Отблокиране за всички членове? - Непрочетени споменавания Неподдържан линк за връзка Актуализирани условия Актуализирайте адреса си @@ -2508,7 +2427,6 @@ Заявки за контакт от групи Членът е изтрит - не може да се приеме заявката заявка за връзка от група %1$s - Тази настройка е за текущия профил Разреши файлове и медия само ако вашият контакт ги разрешава. Позволи на вашите контактите да изпращат файлове и медия. Бот diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/bn/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/bn/strings.xml index dfcfb1685c..552df8e66e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/bn/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/bn/strings.xml @@ -21,7 +21,6 @@ গ্রহণ করুন সংযোগের অনুরোধটি গ্রহণ করবেন কি\? কলটি গৃহীত হয়েছে - পূর্বনির্ধারিত সার্ভারগুলি যুক্ত করুন অ্যাডমিন আপনি একজন অ্যাডমিন স্বাগত বার্তা যুক্ত করুন 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 3d49c533ff..9763bbaf88 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml @@ -69,8 +69,6 @@ cancel·la la vista prèvia de l\'enllaç Es compartirà un nou perfil aleatori. només amb un contacte: compartiu-lo en persona o mitjançant qualsevol missatger.]]> - escanejar el codi QR a la videotrucada o el vostre contacte pot compartir un enllaç d\'invitació.]]> - mostra el codi QR a la videotrucada o comparteix l\'enllaç.]]> Contactes arxivats Guia de l\'usuari.]]> Configuració de xarxa avançada @@ -98,12 +96,9 @@ trucada en curs Càmera Càmera i micròfon - Qualsevol pot allotjar servidors. Bluetooth - xifrats d\'extrem a extrem, amb seguretat postquàntica als missatges directes.]]> El millor per a la bateria. Només rebràs notificacions quan l\'aplicació s\'està executant (Sense servei en segon pla).]]> Bo per a la bateria. L\'aplicació revisa els missatges cada 10 minuts. Podeu perdre trucades o missatges urgents.]]> - repositori GitHub.]]> %1$s vol connectar amb tu mitjançant Feu servir sempre el retransmisor App always runs in background @@ -171,7 +166,6 @@ Barres d\'eines d\'aplicació Desenfocar Trucades d\'àudio/vídeo - \nDisponible en v5.1 Permet trucades només si el vostre contacte ho permet. Permet la desaparició de missatges només si el vostre contacte ho permet. "Permet la supressió irreversible del missatge només si el teu contacte t\'ho permet. (24 hores)" @@ -237,7 +231,6 @@ s\'està connectant blocat k - Connecta error vostè EN DIRECTE @@ -291,7 +284,6 @@ Enllaç d\'un sol ús Afegiu un contacte Adreça o enllaç d\'un sol ús? - Afegiu servidors predefinits Afegeix servidor Quant a SimpleX trucada acceptada @@ -374,7 +366,6 @@ Esborrar adreça Suprimeix la imatge El nom mostrat no pot contenir espais en blanc. - Descentralitzada Desactivar Desactivar per a tothom Desactiva per a tots els grups @@ -485,7 +476,6 @@ Suprimides Errors d\'eliminació Mòbil connectat - Connectar en mode incògnit connexió %1$d Comprova si hi ha missatges cada 10 minuts durant un minut com a màxim Canvia el codi d\'accés @@ -493,7 +483,6 @@ Confirmeu les credencials Xats Parleu amb els desenvolupadors - connecta Confirmeu l\'eliminació del contacte? Connectat Confirma @@ -502,7 +491,6 @@ Connecta Consola del xat Comproveu l\'adreça del servidor de i torneu a provar. - Servidors SMP configurats Configura els servidors ICE Perfil del xat Connexió @@ -548,7 +536,6 @@ Error en desar el fitxer Canviar l\'adreça de recepció? connectar amb els desenvolupadors de SimpleX Chat per fer qualsevol pregunta i rebre actualitzacions.]]> - Obre a l\'aplicació mòbil.]]> Error en desar els servidors ICE Error en desar el servidor intermediari Base de dades dels xats @@ -606,8 +593,6 @@ Introduïu la contrasenya error en mostrar el contingut error en mostrar el missatge - Error en desar els servidors SMP - Error en desar els servidors XFTP Error en enviar el missatge Errors en la configuració dels servidors. Error en canviar el perfil! @@ -659,8 +644,6 @@ Error en crear el missatge Error en crear el perfil! Error en reenviar els missatges - Error en carregar els servidors SMP - Error en carregar els servidors XFTP Error en afegir membre(s) Error en crear l\'adreça Error en unir-se al grup @@ -701,8 +684,6 @@ Crea un grup tot fent servir un perfil aleatori. Perfil actual Crea un enllaç d\'un sol ús - No crear cap adreça - Crea Crea perfil Cantonada Frase de pas actual… @@ -720,11 +701,9 @@ Crea un fitxer Codi d\'accés actual Ara per ara, la mida màxima per als fitxers és %1$s. - Crea un enllaç d\'invitació d\'un sol ús Crea un grupo secreto Temps personalitzat Crea un perfil de xat - Creeu una adreça perquè la gent pugui connectar amb vós. Crea una adreça SimpleX Crea perfil creador @@ -740,13 +719,11 @@ Configuració Tanca s\'està connectant… - Les condicions s\'acceptaran per als operadors habilitats després de 30 dies. SimpleX no pot funcionar en segon pla. Només rebreu les notificacions quan obriu l\'aplicació. Trucades de SimpleX Chat Missatges de xat de SimpleX enviat per llegir - Benvinguts! Envia Envia Restableix @@ -826,7 +803,6 @@ Conserva la conversa Marcar com ha llegit Enllaç no vàlid - El codi QR no és vàlid Més informació El codi QR no és vàlid Codi de seguretat incorrecte! @@ -913,11 +889,9 @@ Connectar amb %1$s? Descarregar La connexió requereix una renegociació del xifratge. - Connectar mitjançant enllaç/codi QR Habilita l\'accés a la càmera Seguretat de connexió Creant enllaç… - Servidors XFTP configurats No utilitzeu credencials amb servidor intermediari. Versió del nucli: v%s Personalitzar tema @@ -1006,7 +980,6 @@ Fitxers Correcció no suportada pel contacte Arreglar - per a una millor privadesa de les metadades. Bona tarda! El fitxer exportat no existeix Reenvia i desa missatges @@ -1090,7 +1063,6 @@ Micròfon Trucada perduda Esborrany - Mòbils remots Elimina Expulsa t\'ha expulsat @@ -1161,7 +1133,6 @@ Canvia l\'aspecte dels teus xats! Màxim 40 segons, rebut a l\'instant. Només vos podreu suprimir els missatges de manera irreversible (el vostre contacte pot marcar-los per suprimir-los). (24 hores) - Obrir configuració del servidor altres errors - Notificació opcional als contactes suprimits.\n- Noms de perfil amb espais.\n- I més! Pendents @@ -1188,8 +1159,6 @@ Desar la configuració d\'adreça SimpleX Deixar de compartir O per compartir en privat - Podeu crear-la més tard - Podeu fer-lo visible per als vostres contactes de SimpleX mitjançant Configuració. Nom del perfil: Desa i notifica el contacte El teu perfil actual @@ -1200,7 +1169,6 @@ Contrasenya per mostrar Desa la contrasenya del perfil Per revelar el vostre perfil ocult introduïu una contrasenya completa al camp de cerca de la pàgina Els vostres perfils de xat. - Tu controles el teu xat! Pots utilitzar la sintaxi markdown per donar format als teus missatges: Com utilitzar la sintaxis markdown trucada rebutjada @@ -1210,13 +1178,6 @@ esperant confirmació… confirmació rebuda… Un navegador web predeterminat és necessari per a les trucades. Configura\'n un al sistema i comparteix més informació amb els desenvolupadors. - Privadesa redefinida - Tu decideixes qui es pot connectar. - Immune al correu brossa - Sense identificadors d\'usuari. - Per protegir la vostra privadesa SimpleX utilitza identificadors separats per a cadascun dels vostres contactes.. - Obrir SimpleX - Només els dispositius client emmagatzemen perfils d\'usuari, contactes, grups i missatges. Notificacions privades Com afecta la bateria Periòdic @@ -1226,9 +1187,6 @@ L\'aplicació protegeix la teva privadesa utilitzant diferents operadors en cada conversa. Quan hi ha més d\'un operador habilitat, cap d\'ells té metadades per saber qui es comunica amb qui. Com ajuda a la privadesa - Revisar més tard - Actualitzar - Podeu configurar els operadors a la configuració: Xarxa i Servidors. videotrucada (sense xifrar) Ignorar Rebutjar @@ -1269,7 +1227,6 @@ Enviar els rebus de lliurament a L\'enviament de rebuts està desactivat per a %d grups Reiniciar - Servidor intermediari SOCKS Imatges de perfil Temes Cua @@ -1307,7 +1264,6 @@ El rol es canviarà a %s. El membre rebrà una nova invitació. Estat de la xarxa Operador - Lloc web Per a enviar Restablir valors predeterminats Compte PING @@ -1587,8 +1543,6 @@ mitjançant enllaç d\'un sol ús has compartit un enllaç d\'un sol ús Nom mostrat no vàlid! - Assegureu-vos que les adreces del servidor SMP estiguin en el format correcte, que estiguin separades per línies i que no estiguin duplicades. - Assegureu-vos que les adreces del servidor XFTP estiguin en el format correcte, que estiguin separades per línies i que no estiguin duplicades. No hi ha servidors multimèdia ni de fitxers. No hi ha servidors de missatges. No hi ha servidors per a l\'encaminament de missatges privats. @@ -1689,11 +1643,9 @@ Encara no hi ha connexió directa, el missatge el reenvia l\'administrador. Revocar fitxer enviament no autoritzat - Aquest text està disponible a la configuració Benvingut %1$s! Entrar com a %s enviar per connectar - Toca per iniciar un xat nou Carregant xats… No hi ha xats filtrats Cerqueu o enganxeu l\'enllaç SimpleX @@ -1746,7 +1698,6 @@ El vostre contacte ha enviat un fitxer més gran que la mida màxima admesa actualment (%1$s). Carregant el fitxer Notificacions - obrir Si us plau, espereu mentre es carrega el fitxer des del mòbil enllaçat Error de fitxer temporal Missatge de veu @@ -1754,7 +1705,6 @@ Missatge de veu (%1$s ) Esperant el fitxer Només suprimeix la conversa - Pendente L\'adreça de recepció es canviarà per un servidor diferent. El canvi d\'adreça es completarà quan el remitent estigui en línia. Estableix el nom del contacte… Pots enviar missatges a %1$s des dels contactes arxivats. @@ -1778,7 +1728,6 @@ Envia un missatge que desapareix Envia missatge en directe Toqueu per escanejar - (per compartir amb el teu contacte) Missatges de veu prohibits! Gràcies per instal·lar SimpleX Xat! Per iniciar un xat nou @@ -1800,22 +1749,16 @@ Has convidat un contacte El vostre contacte ha d\'estar en línia perquè la connexió es completi.\nPots cancel·lar aquesta connexió i eliminar el contacte (i provar-ho més tard amb un enllaç nou). Mostrar codi QR - Aquest no és un enllaç de connexió vàlid! - Aquest codi QR no és un enllaç! Et connectaràs al grup quan el dispositiu de l\'amfitrió estigui en línia. Espereu o comproveu més tard! Et connectaràs quan s\'accepti la teva sol·licitud de connexió, si us plau, espera o consulta més tard! Si no pots trobar-te en persona, mostra el codi QR en una videotrucada o comparteix l\'enllaç. - Enganxeu l\'enllaç que heu rebut per connectar amb el vostre contacte… Compartir enllaç d\'un sol ús Compartir enllaç d\'un sol ús amb un amic Compartir adreça públicament Comparteix l\'adreça SimpleX a les xarxes socials. Per connectar-se, el vostre contacte pot escanejar el codi QR o utilitzar l\'enllaç de l\'aplicació. Per protegir-vos de la substitució del vostre enllaç, podeu comparar els codis de seguretat de contacte. - Quan algú sol·liciti la connexió, pots acceptar-la o rebutjar-la. Podeu definir el nom de la connexió per recordar amb qui s\'ha compartit l\'enllaç. - Pots compartir la teva adreça com a enllaç o codi QR; qualsevol es pot connectar amb tu. - S\'enviarà el teu perfil de xat\nal teu contacte El teu perfil %1$s es compartirà. Et connectaràs quan el dispositiu del teu contacte estigui en línia, si us plau, espera o consulta més tard! Si més tard decideixes eliminar la teva adreça els contactes no es perdran. @@ -1844,9 +1787,6 @@ Servidors de fitxers i mitjans Servidors de missatges Nou servidor - Altres servidors SMP - Altres servidors XFTP - Servidor preestablert Adreça predeterminada del servidor Preguntes i idees Contacta via email @@ -1861,7 +1801,6 @@ Utilitzar per a noves connexions Utilitzar servidor Perfils de xat - El teu servidor L\'adreça del teu servidor Configuració La teva adreça SimpleX @@ -1878,8 +1817,6 @@ Utilitzar servidors SimpleX Xat? Usant servidors SimpleX Xat. Servidors ICE - Servidors SMP - Servidors XFTP Xarxa i servidors Autenticació d\'intermediari Servidor intermediari SOCKS @@ -1896,7 +1833,6 @@ Encaminament privat Servidor Aïllament de transport - Actualitzar el mode d\'aïllament de transport? Utilitza credencials de servidors intermediari diferents per a cada connexió. Utilitza credencials de servidor intermediari diferents per a cada perfil. Utilitzar connexió a Internet directa? @@ -1905,8 +1841,6 @@ Si disponibles Les vostres credencials es podrien enviar sense xifrar. Colors de la interfície - Alternativa d\'encaminament de missatges - Mode d\'encaminament de missatges Obrir ubicació del fitxer Envieu missatges directament quan l\'adreça IP està protegida i el vostre servidor de destinació no admet l\'encaminament privat. Enviar missatges directament quan el vostre servidor de destinació no admet l\'encaminament privat. @@ -1927,23 +1861,18 @@ Mostrar trucades lentes d\'API Per rebre notificacions sobre les noves versions activeu la comprovació periòdica de les versions Estable o Beta. Compartir amb contactes - La plataforma de missatgeria i aplicacions que protegeix la vostra privadesa i seguretat. El perfil només es comparteix amb els teus contactes. - No emmagatzemem cap dels vostres contactes o missatges (un cop lliurats) als servidors. El vostre perfil, contactes i missatges lliurats s\'emmagatzemen al vostre dispositiu. Obrir configuració - El futur de la missatgeria Operadors de xarxa Notificacions i bateria La contrasenya aleatòria s\'emmagatzema a la configuració com a text pla.\nPodeu canviar-ho més tard. - Seleccioneu els operadors de xarxa que voleu utilitzar. Operadors de servidor Configura la contrasenya de la base de dades Utilitzar contrasenya aleatòria Obrir SimpleX Chat per acceptar la trucada El servidor de retransmissió protegeix la vostra adreça IP, però pot veure la durada de la trucada. Servidors WebRTC ICE - Servidors ICE Privacitat i seguretat Protegeix la pantalla de l\'aplicació Protegir l\'adreça IP @@ -1978,7 +1907,6 @@ No s\'ha trobat la contrasenya a Keystore, introduïu-la manualment. Això pot haver passat si vau restaurar les dades de l\'aplicació mitjançant una eina de còpia de seguretat. Si no és el cas, poseu-vos en contacte amb els desenvolupadors. Barres d\'eines d\'aplicacions accessible Barres d\'eines de xat accessible - Mostra la llista de xat en una finestra nova Mostrar consola en finestra nova Podeu canviar-la a la configuració de l\'aparença. Actualitzar i obrir el xat @@ -2135,7 +2063,6 @@ IU en hongarès i turc Codi d\'accés no canviat! Protegiu la vostra adreça IP dels servidors de retransmissió de missatgeria escollits pels vostres contactes.\nActiva a la configuració de *Xarxa i servidors*. - Toqueu Crea adreça SimpleX al menú per crear-la més tard. Per protegir la zona horària els fitxers d\'imatge/veu utilitzen UTC. Desbloca La càrrega ha fallat @@ -2174,7 +2101,6 @@ Funció lenta Estat convidats(des) a un grup cerca - (escaneja o enganxa del porta-retalls) Escaneja un codi QR Comença una conversa nova Logo de SimpleX @@ -2185,8 +2111,6 @@ Codi de seguretat Voleu desar els servidors? Escaneja el codi QR del servidor - Servidors SMP - Servidors XFTP Altaveu Vós %s segon(s) @@ -2262,7 +2186,6 @@ Contingut inadequat Perfil inadequat Suprimeix l\'informe - L\'informe s\'arxivarà. Informar Spam El contingut infringeix les condicions d\'ús @@ -2300,7 +2223,6 @@ Emprar port web Emprar el port TCP %1$s quan no se n\'especifica cap. Silenciar tot - Mencions no llegides Podeu mencionar fins a %1$s membres per missatge! Permetre denunciar missatges a moderació. No permetre denunciar missatges a moderació. @@ -2427,7 +2349,6 @@ Enviar sol·licitud de contacte? Enviar sol·licitud Enviar sol·licitud sense missatge - Enviat al contacte després de la connexió. Actualitzar l\'enllaç del grup? Actualitzar Actualitzar l\'adreça? @@ -2482,7 +2403,6 @@ Sol·licituds de contacte de grups Membre eliminat(da); no es pot acceptar la sol·licitud. connexió sol·licitada del grup %1$s - Aquesta configuració és per al perfil actual Permetre fitxers i contingut multimèdia només si el vostre contacte ho permet. Permetre que els contactes enviïn fitxers i contingut multimèdia. Bot 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 ff712dfec5..1d404ef94b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml @@ -5,7 +5,6 @@ Hlasové zprávy povoleny. Správci mohou vytvářet odkazy pro připojení ke skupinám. Přijmout - Přidat přednastavené servery Pokročilá nastavení sítě Přijmout Přidat server @@ -115,7 +114,6 @@ Úplný odkaz Prostřednictvím prohlížeče Otevření odkazu v prohlížeči může snížit soukromí a bezpečnost připojení. Nedůvěryhodné odkazy SimpleX budou červené. - Chyba ukládání serverů SMP Chyba změny konfigurace sítě Nepodařilo se načíst chat Nepodařilo se načíst chaty @@ -145,8 +143,6 @@ Kopírovat nepřečteno Vítejte %1$s! - Vítejte! - Tento text je k dispozici v nastavení odeslání se nezdařilo Sdílet soubor… Připojit @@ -179,7 +175,6 @@ Zkopírováno Nová konverzace Vytvořit tajnou skupinu - (sdílet s kontaktem) (uloženo pouze členy skupiny) Oprávnění zamítnuto! Fotoaparát @@ -210,7 +205,6 @@ Bezpečnostní kód %s ověřen Chat konzole - SMP servery Přednastavená adresa serveru Test serveru se nezdařil! Některé servery neprošly testem: @@ -234,15 +228,12 @@ Vytvořit adresu Uložit a upozornit členy skupiny Ukončit bez uložení - Platforma pro zasílání zpráv a aplikace chránící vaše soukromí a bezpečnost. Vytvořit profil Profil je sdílen pouze s vašimi kontakty. Zobrazované jméno nesmí obsahovat bílé znaky. tučně probíhající hovor - Decentralizovaná Jak to funguje - Pouze klientská zařízení ukládají uživatelské profily, kontakty, skupiny a zprávy. Soukromé oznámení Pravidelné Ignorovat @@ -264,7 +255,6 @@ Zařízení Konverzace Experimentální funkce - SOCKS proxy Ikona aplikace Témata Zprávy a soubory @@ -335,15 +325,12 @@ Instalovat SimpleX Chat terminál Hvězdu na GitHubu Ohodnoťte aplikaci - Vaše servery SMP Pokud potvrdíte, budou servery zasílající zprávy vidět vaši IP adresu a váš poskytovatel - ke kterým serverům se připojujete. barevné tajné volání… připojen ukončen - Budoucnost soukromé komunikace - Rozhodněte, kdo se může připojit. špatný kontrolní součet zprávy Databáze chatu importována Nová přístupová fráze… @@ -361,7 +348,6 @@ Smazat čekající připojení\? Nastavení QR kód - videohovoru QR kód nebo sdílejte odkaz.]]> Skenovat kód Nesprávný bezpečnostní kód! Naskenujte bezpečnostní kód z aplikace vašeho kontaktu. @@ -377,7 +363,6 @@ Servery ICE (jeden na řádek) Pro připojení budou vyžadováni Onion hostitelé. \nVezměte prosím na vědomí: nebudete mít možnost připojení k serverům bez adresy .onion. - Aktualizovat režim dopravní izolace\? Sestavení aplikace: %s Sdílet odkaz Smazat adresu @@ -423,13 +408,10 @@ Hlasové zprávy jsou zakázány! Prosím, požádejte kontaktní osobu, aby umožnila odesílání hlasových zpráv. Poslat živou zprávu - zpráva se bude aktualizovat pro příjemce během psaní. - Vytvořit jednorázovou pozvánku Skenovat QR kód - ( skenovat nebo vložit ze schránky) Upravit obrázek Smazat obrázek chyba volání - Servery může provozovat kdokoli. Vytvořte si svůj profil Vytvořte si soukromé připojení Videohovor šifrovaný e2e @@ -490,7 +472,6 @@ Izolace přenosu Podle profilu chatu (výchozí) nebo podle připojení (BETA). 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). označeno jako smazáno @@ -501,7 +482,6 @@ neplatný formát zprávy ŽIVĚ inkognito přes skupinový odkaz - Ujistěte se, že adresy serverů SMP jsou ve správném formátu, oddělené na řádcích a nejsou duplicitní. Chyba vytváření profilu! Duplicitní zobrazované jméno! Chyba přepínání profilu! @@ -549,7 +529,6 @@ Jste pozváni do skupiny Připojit jako %s připojuji… - Začněte nový chat Chat s vývojáři Nemáte žádné konverzace Zrušit náhled obrázku @@ -560,13 +539,11 @@ Čekání na soubor Oznámení Smazat kontakt\? - Čeká na vyřízení Ověřit bezpečnostní kód Odeslat zprávu Pouze majitelé skupin mohou povolit zasílání hlasových zpráv. Odeslat živou zprávu Živá zpráva! - Připojit se prostřednictvím odkazu / QR kódu Děkujeme za instalaci SimpleX Chat! připojit k SimpleX Chat vývojářům, položit jim případné dotazy a získat aktualizace.]]> Připojení prostřednictvím odkazu @@ -592,24 +569,17 @@ E-mail Více Zobrazit QR kód - Neplatný QR kód - Tento QR kód není odkaz! Neplatný odkaz! - Tento odkaz není platným odkazem pro připojení! - skenovat QR kód ve videohovoru nebo může váš kontakt sdílet pozvánku.]]> Připojte se prostřednictvím odkazu Připojit Vložit Tento řetězec není odkazem na připojení! - Otevřít v mobilní aplikaci.]]> %s neověřen Návod k použití Nápověda k markdown Uložit servery Markdown ve zprávách Testovací servery - Přednastavený server - Váš server Adresa vašeho serveru Použít server Použít pro nová připojení @@ -640,11 +610,8 @@ Uložit předvolby\? Uložit a upozornit kontakt Uložit a upozornit kontakty - Kontrolujete konverzaci! - Na serverech neukládáme žádné vaše kontakty ani zprávy (po doručení). Váš profil, kontakty a doručené zprávy jsou uloženy ve vašem zařízení. Zadejte vaše jméno: - Vytvořit Jak používat markdown K formátování zpráv můžete použít markdown: kurzíva @@ -659,12 +626,6 @@ obdržel odpověď… obdržel potvrzení… připojování… - Nové vymezení soukromí - Bez uživatelských identifikátorů - Odolná vůči spamu - K ochraně soukromí, SimpleX používá ID pro každý z vašich kontaktů. - úložišti GitHub.]]> - Použijte chat Jak ovlivňuje baterii Když aplikace běží Okamžité @@ -682,7 +643,6 @@ Spojení přes relé Zobrazit Zakázat - Vaše servery ICE WebRTC servery ICE Přenosový server chrání vaši IP adresu, ale může sledovat dobu trvání hovoru. Přenosový server se používá pouze v případě potřeby. Jiná strana může sledovat vaši IP adresu. @@ -921,7 +881,6 @@ Italské rozhraní Díky uživatelům - překládejte prostřednictvím Weblate! Budete připojeni, jakmile bude zařízení vašeho kontaktu online, vyčkejte prosím nebo se podívejte později! - Váš profil chatu bude odeslán \nvašemu kontaktu Konverzace Sdílet jednorázovou pozvánku koncově šifrované @@ -1014,17 +973,12 @@ Video bude přijato, až kontakt dokončí jeho nahrávání. Video obdržíte, až bude váš kontakt online, vyčkejte prosím nebo zkontrolujte později! Čekám na video - Chyba načítání serverů SMP - 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. Porovnat soubor Vytvořit soubor Smazat soubor Stáhnout soubor Nahrát soubor - XFTP servery Použít SOCKS proxy Host port %d @@ -1047,7 +1001,6 @@ Zámek SimpleX můžete zapnout v Nastavení. Port Použít .onion hostitele na Ne, pokud je SOCKS proxy nepodporuje.]]> - Vaše XFTP servery Povolit zámek Zamknout po Režim zámku @@ -1077,8 +1030,6 @@ Povolte hovory, pouze pokud je váš kontakt povolí. Povolte svým kontaktům vám volat. Audio/video hovory - " -\nDostupné ve verzi 5.1" Volat můžete vy i váš kontakt. Volat můžete pouze vy. Zákaz audio/video hovorů. @@ -1165,7 +1116,6 @@ Otevírání databáze… Chyba nastavení adresy Pro připojení může váš kontakt naskenovat QR kód, nebo použít odkaz v aplikaci. - Když někdo požádá o připojení, můžete žádost přijmout nebo odmítnout. Uživatelské příručce.]]> Adresa SimpleX Barvy motivu @@ -1173,12 +1123,9 @@ Aktualizace profilu bude zaslána vašim kontaktům. Sdílet adresu s kontakty? Přestat sdílet adresu\? - Vytvořit adresu, aby se s vámi lidé mohli spojit. Uložit nastavení SimpleX adresy Přestat sdílet Automaticky přijmout - Můžete vytvořit později - Nevytvářet adresu Ahoj! \nSpojte se se mnou přes SimpleX Chat: %s Promluvme si v SimpleX Chatu @@ -1224,7 +1171,6 @@ Reakce na zprávy může přidávat pouze váš kontakt. Vaše kontakty zůstanou připojeny. Tuto adresu můžete sdílet se svými kontakty, aby se mohli připojit k %s. - Svou adresu můžete sdílet jako odkaz nebo QR kód - kdokoli se k vám může připojit. Pokud později adresu odstraníte, o kontakty nepřijdete. Přístupový kód aplikace je nahrazen sebedestrukčním přístupovým heslem. žádný text @@ -1309,7 +1255,6 @@ Druhé zaškrtnutí jsme přehlédli! ✅ Přijímací adresa bude změněna na jiný server. Změna adresy bude dokončena po připojení odesílatele. Vybrat soubor - Spojit se inkognito Povolit Vypnout upozornění Otevřít nastavení aplikace @@ -1320,7 +1265,6 @@ Nový náhodný profil bude sdílen. Povolit pro všechny skupiny Žádný vybraný chat - Vložte odkaz který jste obdrželi, pro spojení se svým kontaktem… Žádné informace o doručení Odesílání doručenky je zakázáno pro %d skupin Vypnout pro všechny skupiny @@ -1421,7 +1365,6 @@ Obnovit Nový telefon Pouze jedno zařízení může pracovat současně - Můžete ji svým SimpleX kontaktům zviditelnit v Nastavení. %1$s.]]> Propojit mobilní a stolní aplikace! 🔗 To je váš vlastní jednorázový odkaz! @@ -1781,8 +1724,6 @@ Nelze odeslat zprávu Chyba souboru NEpoužívat soukromé směrování. - Záložní směrování zpráv - Režim přeposílání zpráv Nikdy Chyba inicializace WebView. Aktualizujte systém na novou verzi. Prosím kontaktujte vývojáře. \nChyba: %s @@ -1849,8 +1790,6 @@ Chyba připojení k přeposílajícímu serveru %1$s. Prosím, zkuste to později. Předávacímu serveru %1$s se nepodařilo připojit k cílovému serveru %2$s. Prosím, zkuste to později. Vybrané nastavení chatu zakazuje tuto zprávu. - Jiné SMP servery - Nastavené SMP servery Probíhá Části nahrány %1$d chyba souboru:\n%2$s @@ -1920,7 +1859,6 @@ Potvrzeno duplikáty Smazán - Otevřít nastavení serveru Nový zážitek z chatu 🎉 Nové možnosti médií Nová zpráva @@ -1937,7 +1875,6 @@ Lepší datování zpráv. Lepší zabezpečení ✅ Části odstraněny - připojení Aktuální profil Chyba znovu připojení serveru Chyba při opětovném připojování serverů @@ -1949,7 +1886,6 @@ Chyba přepínání profilu Vyberte profil chatu zpráva - otevřít Kontakt smazán! neaktivní Detaily @@ -2026,10 +1962,8 @@ Vybrány %d Střední Příjem zpráv - Nastavené XFTP servery Servery médií a souborů Servery zpráv - Jiné FXTP servery Pozvat Pošlete zprávu pro povolení volání. Přijmout podmínky @@ -2058,7 +1992,6 @@ Žádné servery pro příjem zpráv. Všechny chaty budou ze seznamu %s odebrány, a seznam bude smazán Pro sociální sítě - Vzdálené telefony %s.]]> %s.]]> %s.]]> @@ -2144,7 +2077,6 @@ Panel nástrojů aplikace Rozmazání Přímé zprávy mezi členy jsou zakázány. - pro lepší ochranu metadat. Chat již existuje Přes proxy Firemní chaty @@ -2191,13 +2123,11 @@ %s.]]> %1$s.]]> Relace aplikace - Podmínky budou přijaty pro povolené operátory po 30 dnech. Připojení vyžaduje opětovné vyjednání šifrování. Probíhá opětovné vyjednávání o šifrování. Opravit Opravit připojení? Nový server - koncovým šifrováním, s post-quantovým zabezpečením v přímých zprávách.]]> Žádné služba na pozadí Kontrolovat zprávy každých 10 minut Chyba vytváření hlášení @@ -2206,7 +2136,6 @@ Použijte náhodné přihlašovací údaje Nahlásit spam: uvidí pouze skupinový moderátoři. Přečíst podmínky - Webové stránky Odebírán požádáno o připojení Spam @@ -2222,24 +2151,20 @@ Nedoručené zprávy Tato zpráva byla smazána, nebo dosud nebyla přijata. Důvod nahlášení? - Report bude archivován. Zprávy budou pro všechny členy označeny jako moderované. Reporty Nahlašte profil člena: uvidí pouze skupinový moderátoři. Nahlásit porušení: Uvidí pouze moderátoři skupiny. Zobrazit stav zpráv Přeskočit tuto verzi - Zobrazit seznam v novém okně Aktualizovat aplikaci automaticky Některé soubory nebyly exportovány - Nepřečtené zmínky Sdílet adresu veřejně Sdílent SimpleX adresu na sociálních médiích. Sdílejte 1 rázový odkaz s přítelem SOCKS Proxy Dostupná aktualizace: %s Stabilní - Můžete nastavit operátory v nastavení sítě a serverů. Ocas Zastavíte přijímání zpráv z tohoto chatu. Chat historie bude zachována. Servery pro nové soubory vašeho aktuálního profilu chatu @@ -2274,7 +2199,6 @@ Nahrané soubory Odběr ignorován Verze serveru není kompatibilní s nastavením sítě. - Pro pozdější vytvoření adresy, klepněte v menu na Vytvořit SimpleX adresu. Vaše servery Zobrazit aktualizované podmínky 1 report @@ -2301,8 +2225,6 @@ Chyby nahrávání Soukromé směrování se používá k doručování zpráv těmto serverům, protože k nim nejste připojeni. Velikost - Přečíst později - Aktualizovat %s servery Celkem Verze serveru není kompatibilní s vaší aplikací: %1$s. @@ -2319,7 +2241,6 @@ Vaše kontakty Od %s.\nJsou všechna data uchovávána ve vašem zařízení. Serverový operátoři - Vybrat provozovatele sítě pro použití. Můžete nakonfigurovat servery v nastavení. Zvuk ztlumen Používat aplikaci jednou rukou. @@ -2496,7 +2417,6 @@ 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 @@ -2511,7 +2431,6 @@ 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. @@ -2532,7 +2451,6 @@ Smazat zprávy člena? Smazat zprávy Soubory - Filtr Obrázky Odkazy Zprávy člena budou smazány - nelze zrušit! @@ -2582,7 +2500,6 @@ Kanál nemá aktivní relé.\nProsím, zkuste se připojit později. Odkaz kanálu Odkaz kanálu - Členové kanálu Název kanálu Vlastnosti kanálu kanál @@ -2637,7 +2554,6 @@ Chyba Chyba přidávání relé Chyba vytváření kanálu - Chyba otevírání kanálu chyba: %s Chyba ukládání profilu kanálu Chyba sdílení kanálu @@ -2657,7 +2573,6 @@ Připojit ke kanálu Opustit kanál Opustit kanál? - Přidat Přidat relé Přidat relé Povolit odběratelům odesílání přímých zpráv. @@ -2709,7 +2624,6 @@ Na vašem telefonu, ne na serverech. Otevřít kanál Otevřít externí odkaz? - Otevřít nový kanál Nebo ukažte QR osobně nebo prostřednictvím videohovoru. Nebo použijte tento QR kód - tisk nebo zobrazit online. MAJITEL @@ -2776,7 +2690,6 @@ %1$d vlastníci %1$d vlastníků & přispěvatelů %1$s podpořilo SimpleX Chat. Odznak prošel %2$s. - O aplikaci odmítnuto odmítnuto operátorem relé Pokročilé možnosti 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 82f8ae5770..9706c1b552 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml @@ -66,7 +66,6 @@ Yderligere sekundær Tilføj liste Tilføj besked - Tilføj forudindstillede servere Tilføj profil Adresse Adresseændringen bliver annulleret. Den gamle modtageradresse bliver brugt. @@ -101,8 +100,6 @@ Brug ny inkognito -profil Din profil sendes til den kontakt, du har modtaget dette link fra. Du opretter forbindelse til alle gruppemedlemmer. - Forbinde - Tilslut inkognito Alle chats og meddelelser slettes - dette kan ikke fortrydes! Alle chats fjernes fra listen %s, og listen slettes Alle farvetilstande @@ -156,7 +153,6 @@ En ny tilfældig profil deles. En anden grund Svaropkald - Alle kan være vært for servere. App App løber altid i baggrunden App Build: %s @@ -241,12 +237,6 @@ Upassende indhold Overtrædelse af retningslinjer for fællesskabet Upassende profil - Fejl under lagring af SMP-servere - Fejl ved lagring af XFTP-servere - Sørg for, at SMP-serveradresserne er i korrekt format, linjeseparerede og ikke duplikerede. - Sørg for, at XFTP-serveradresserne er i korrekt format, linjeseparerede og ikke duplikerede. - Fejl ved indlæsning af SMP-servere - Fejl ved indlæsning af XFTP-servere Fejl ved opdatering af netværkskonfigurationen Chatten kunne ikke indlæses Kunne ikke indlæse chats @@ -438,7 +428,6 @@ Arkivér rapport? Arkivér %d rapporter? Arkivér alle rapporter? - Rapporten vil blive arkiveret for dig. For mig Til alle moderatorer Fejl: %1$s @@ -521,8 +510,6 @@ afsendelse mislykkedes ulæst Velkommen %1$s! - Velkomst! - Denne tekst er tilgængelig i indstillinger Chats Indstillinger forbinder… @@ -532,7 +519,6 @@ Deltag som %s afvist forbinder… - Tryk for at starte en ny chat Chat med udviklerne Du har ingen chats Indlæser chats… @@ -547,7 +533,6 @@ kontakten skal acceptere… Forbinde med %1$s? Søg eller indsæt SimpleX-link - Tryk på Opret SimpleX-adresse i menuen for at oprette den senere. Ingen chat valgt Intet er valgt Valgte %d @@ -679,8 +664,6 @@ Denne handling kan ikke fortrydes - beskeder sendt og modtaget i denne chat tidligere end valgt vil blive slettet. Deaktiver sletning af beskeder Slet chatbeskeder fra din enhed. - forbinde - åbn besked opkald søg @@ -703,7 +686,6 @@ Forbundet Afbrudt Fejl - Indtil Ændre modtageradresse? Modtageradressen vil blive ændret til en anden server. Adresseændringen vil blive gennemført, når afsenderen er online. Genforhandle kryptering? @@ -754,7 +736,6 @@ Auto-accept Auto-accept-kontaktanmodninger Auto-accept-billeder - \nFås i v5.1 Tilbage Baggrund Dårlig skrivebordsadresse @@ -828,7 +809,6 @@ 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.]]> @@ -874,7 +854,6 @@ %1$d ejer %1$d ejere aktiv - Tilføj Tilføj relay Tilføj relays Tilføj relays for at genoprette beskedleveringen. @@ -894,7 +873,6 @@ %1$d abonnent %1$d abonnenter %1$s støttede SimpleX Chat. Mærket udløb den %2$s. - Om accepteret Tilføj bidragsydere. Tilføj beskrivelse @@ -925,11 +903,6 @@ Fingeraftrykket i serveradressen matcher ikke certifikatet: %1$s. Fingeraftrykket i destinations-serveradressen matcher ikke certifikatet: %1$s. Fejl ved markering som læst - Ikke-understøttet navn på kanal - Ikke-understøttet navn på kontakt - Forbindelse gennem kanal-navnet kræver en nyere version af appen. - Forbindelse gennem kontakt-navnet kræver en nyere version af appen. - Opgrader appen. Fejl i SimpleX-navn Ingen af dine servere er sat op til at slå SimpleX-navne op. Konfigurer serverne, eller anvend et forbindelseslink. Navn ikke fundet 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 a057fa5275..6ea5d7686d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml @@ -8,7 +8,6 @@ Der Gruppe beitreten? Ihr Profil wird an den Kontakt gesendet, von dem Sie diesen Link erhalten haben. Sie werden mit allen Gruppenmitgliedern verbunden. - Verbinden Verbunden Fehler @@ -48,8 +47,6 @@ Über den Browser Das Öffnen des Links über den Browser kann die Privatsphäre und Sicherheit der Verbindung reduzieren. SimpleX-Links, denen nicht vertraut wird, werden rot markiert. - Fehler beim Speichern der SMP-Server - Stellen Sie sicher, dass die SMP-Server-Adressen das richtige Format haben, zeilenweise getrennt und nicht doppelt vorhanden sind. Fehler bei der Aktualisierung der Netzwerkkonfiguration. Verbindungszeitüberschreitung @@ -168,14 +165,11 @@ Ungelesen Willkommen %1$s! - Willkommen! - Dieser Text ist in den Einstellungen verfügbar. Chats Verbinde… Sie sind zu der Gruppe eingeladen Beitreten als %s Verbinde… - Tippen, um einen neuen Chat zu starten Chatten Sie mit den Entwicklern Sie haben keine Chats @@ -223,7 +217,6 @@ Verbunden Getrennt Fehler - Ausstehend Empfängeradresse wechseln\? Die Empfängeradresse wird auf einen anderen Server geändert. Der Adresswechsel wird abgeschlossen, wenn der Absender wieder online ist. @@ -245,12 +238,8 @@ In die Zwischenablage kopiert Neuen Chat starten - Einmal-Einladungslink teilen - Per Link / QR-Code verbinden QR-Code scannen Geheime Gruppe erstellen - (zum Teilen mit Ihrem Kontakt) - (Scannen oder Einfügen aus der Zwischenablage) (Wird nur von Gruppenmitgliedern gespeichert) Berechtigung verweigert! @@ -319,25 +308,17 @@ QR-Code anzeigen - Ungültiger QR-Code - Dieser QR-Code beschreibt keinen Link! Ungültiger Link! - Dieser Link ist kein gültiger Verbindungslink! Verbindungsanfrage gesendet! Sie werden mit der Gruppe verbunden, sobald das Endgerät des Gruppen-Hosts online ist. Bitte warten oder schauen Sie später nochmal nach! Sie werden verbunden, sobald Ihre Verbindungsanfrage angenommen wird. Bitte warten oder schauen Sie später nochmal nach! Sie werden verbunden, sobald das Endgerät Ihres Kontakts online ist. Bitte warten oder schauen Sie später nochmal nach! - den QR-Code während eines Videoanrufs anzeigen oder einen Einladungslink über einen anderen Kanal mit Ihrem Kontakt teilen.]]> - Ihr Chat-Profil wird -\nan Ihren Kontakt gesendet - den QR-Code während eines Videoanrufs scannen oder Ihr Kontakt kann einen Einladungslink über einen anderen Kanal mit Ihnen teilen.]]> Einmal-Einladungslink teilen Über einen Link verbinden Verbinden Einfügen Diese Zeichenfolge entspricht keinem gültigen Verbindungslink! - In mobiler App öffnen“.]]> Einmal-Einladungslink @@ -352,9 +333,7 @@ Senden Sie uns eine E-Mail SimpleX-Sperre Chat-Konsole - SMP-Server Voreingestellte Serveradresse - Voreingestellte Server hinzufügen Server hinzufügen Teste Server Teste alle Server @@ -363,8 +342,6 @@ Einige Server haben den Test nicht bestanden: Scannen Sie den QR-Code des Servers Geben Sie den Server manuell ein - Voreingestellter Server - Ihr Server Ihre Serveradresse Server nutzen Für neue Verbindungen nutzen @@ -377,7 +354,6 @@ Unterstützen Sie uns Bewerten Sie die App Verwenden Sie SimpleX-Chat-Server\? - Ihre SMP-Server Verwendung von SimpleX-Chat-Servern. Anleitung Wie Sie Ihre Server nutzen @@ -423,15 +399,11 @@ Speichern und Gruppenmitglieder benachrichtigen Beenden ohne Speichern - Sie haben volle Kontrolle über Ihren Chat! - Die Messaging- und Anwendungsplattform zum Schutz Ihrer Privatsphäre und Sicherheit. - Wir speichern auf den Servern keinen Ihrer Kontakte und keine Ihrer Nachrichten (sobald einmal zugestellt). Profil erstellen Ihr Profil, Ihre Kontakte und zugestellten Nachrichten werden auf Ihrem Gerät gespeichert. Das Profil wird nur mit Ihren Kontakten geteilt. Der angezeigte Name darf keine Leerzeichen enthalten. Geben Sie Ihren Namen ein: - Erstellen Über SimpleX Wie Sie Markdowns anwenden @@ -461,20 +433,10 @@ Verbunden Beendet - Die Zukunft des Messagings - Datenschutz neu definiert - Keine Benutzerkennungen. - Immun gegen Spam - Sie entscheiden, wer sich mit Ihnen verbinden kann. - Dezentral - Jeder kann seine eigenen Server aufsetzen. Ihr Profil erstellen Stellen Sie eine private Verbindung her Wie es funktioniert - SimpleX nutzt individuelle Kennungen für jeden Ihrer Kontakte, um Ihre Privatsphäre zu schützen. - Nur die Endgeräte speichern Benutzerprofile, Kontakte, Gruppen und Nachrichten. - GitHub-Repository mehr dazu.]]> Fügen Sie den erhaltenen Link ein @@ -499,7 +461,6 @@ Akzeptieren Anzeigen Deaktivieren - Ihre ICE-Server WebRTC-ICE-Server Relais-Server schützen Ihre IP-Adresse, können aber die Anrufdauer erfassen. Relais-Server werden nur genutzt, wenn sie benötigt werden. Ihre IP-Adresse kann von Anderen erfasst werden. @@ -556,7 +517,6 @@ Chats Entwicklertools Experimentelle Funktionen - SOCKS-Proxy App-Icon Design Nachrichten und Dateien @@ -933,7 +893,6 @@ %s wurde noch nicht überprüft Um die Ende-zu-Ende-Verschlüsselung mit Ihrem Kontakt zu überprüfen, müssen Sie den Sicherheitscode in Ihren Apps vergleichen oder scannen. Private Benachrichtigungen - Chat verwenden Ihr Kontakt und Sie können beide verschwindende Nachrichten versenden. %dh Gruppen-Links @@ -974,7 +933,6 @@ Es werden alle Chats und Nachrichten gelöscht. Dies kann nicht rückgängig gemacht werden! Chat-Profil löschen für PING-Zähler - Transport-Isolations-Modus aktualisieren\? Nachrichten-Server für neue Verbindungen über Ihr aktuelles Chat-Profil Dateien und Medien Transport-Isolation @@ -1098,10 +1056,7 @@ Auf das Video warten Auf das Video warten Das Video wird heruntergeladen, wenn Ihr Kontakt online ist. Bitte warten oder überprüfen Sie es später! - Ihre XFTP-Server Host - Fehler beim Speichern der XFTP-Server - Fehler beim Laden der SMP-Server Der Server erfordert zum Hochladen eine Autorisierung. Bitte überprüfen Sie das Passwort. Datei herunterladen Datei vergleichen @@ -1119,7 +1074,6 @@ Sperrmodus ändern Zugangscode wurde nicht geändert! Entschlüsselungsfehler - Fehler beim Laden der XFTP-Server Eingabe des Zugangscodes Zugangscode eingeben Kein App-Zugangscode @@ -1138,7 +1092,6 @@ Ungültiger Nachrichten-Hash Authentifizierung fehlgeschlagen Falsche Nachrichten-ID - Stellen Sie sicher, dass die XFTP-Server-Adressen das richtige Format haben, zeilenweise getrennt und nicht doppelt vorhanden sind. SOCKS-Proxy nutzen SimpleX-Sperrmodus SimpleX-Sperre ist nicht aktiviert! @@ -1146,7 +1099,6 @@ Bestätigen System Sie können nicht überprüft werden – bitte versuchen Sie es nochmal. - XFTP-Server Die ID der nächsten Nachricht ist falsch (kleiner oder gleich der vorherigen). \nDies kann passieren, wenn es einen Fehler gegeben hat oder die Verbindung kompromittiert wurde. %1$d Nachrichten konnten nicht entschlüsselt werden. @@ -1159,8 +1111,6 @@ Erlauben Sie Anrufe nur dann, wenn es Ihr Kontakt ebenfalls erlaubt. Erlaubt es Ihren Kontakten Sie anzurufen. Audio-/Video-Anrufe - " -\nVerfügbar in v5.1" Sowohl Sie als auch Ihr Kontakt können Anrufe tätigen. Nur Sie können Anrufe tätigen. Audio-/Video-Anrufe nicht erlauben. @@ -1192,13 +1142,11 @@ Mehr erfahren Um eine Verbindung herzustellen, kann Ihr Kontakt den QR-Code scannen oder den Link in der App verwenden. SimpleX-Adresse - Wenn Personen eine Verbindung anfordern, können Sie diese annehmen oder ablehnen. Sie werden Ihre damit verbundenen Kontakte nicht verlieren, wenn Sie diese Adresse später löschen. Design anpassen Interface-Farben Fügen Sie die Adresse Ihrem Profil hinzu, damit Ihre SimpleX-Kontakte sie mit anderen Personen teilen können. Es wird eine Profilaktualisierung an Ihre SimpleX-Kontakte gesendet. Alle Ihre Kontakte bleiben verbunden. Es wird eine Profilaktualisierung an Ihre Kontakte gesendet. - Erstellen Sie eine Adresse, damit sich Personen mit Ihnen verbinden können. SimpleX-Adresse erstellen Mit SimpleX-Kontakten teilen Ihre Kontakte bleiben weiterhin verbunden. @@ -1212,10 +1160,8 @@ Die Adresse mit SimpleX-Kontakten teilen? Teilen beenden Das Teilen der Adresse beenden\? - Keine Adresse erstellt Hallo! \nVerbinden Sie sich per SimpleX Chat mit mir: %s - Sie können dies später erstellen Geben Sie eine Begrüßungsmeldung ein … Vorschau Sie können diese Adresse mit Ihren Kontakten teilen, um sie mit %s verbinden zu lassen. @@ -1236,7 +1182,6 @@ Benutzeranleitung.]]> Stellen Sie sicher, dass die Datei die korrekte YAML-Syntax hat. Exportieren Sie das Design, um ein Beispiel für die Dateistruktur des Designs zu erhalten. Chat-Profile wechseln - Sie können Ihre Adresse als Link oder QR-Code teilen – jede Person kann sich mit Ihnen verbinden. Werden die App-Daten komplett gelöscht. Es wurde ein leeres Chat-Profil mit dem eingegebenen Namen erstellt und die App öffnet wie gewohnt. Wenn Sie Ihren Selbstzerstörungs-Zugangscode während des Öffnens der App eingeben: @@ -1412,11 +1357,9 @@ Es werden keine Empfangsbestätigungen gesendet, da diese Gruppe über %1$d Mitglieder hat. An dieses Gruppenmitglied wird eine Verbindungsanfrage gesendet. Direkt verbinden\? - Inkognito verbinden Aktuelles Chat-Profil nutzen Neues Inkognito-Profil nutzen App-Akkuverbrauch / Unbeschränkt , um Anrufe im Hintergrund zu führen.]]> - Fügen Sie den erhaltenen Link ein, um sich mit Ihrem Kontakt zu verbinden… Es wird ein neues Zufallsprofil geteilt. Ihr Profil %1$s wird geteilt. Erlauben @@ -1586,7 +1529,6 @@ Ansicht abgestürzt Fehler beim Anzeigen des Inhalts Fehler beim Anzeigen der Nachricht - Sie können sie über Einstellungen für Ihre SimpleX-Kontakte sichtbar machen. Der Nachrichtenverlauf wird nicht an neue Gruppenmitglieder gesendet. Wiederholen Kamera nicht verfügbar @@ -1848,11 +1790,9 @@ Ungeschützt Bei unbekannten Servern privates Routing nutzen. KEIN privates Routing nutzen. - Modus für das Nachrichten-Routing Ja Nein Wenn die IP-Adresse versteckt ist - Fallback für das Nachrichten-Routing Nachrichtenstatus anzeigen Herabstufung erlauben Immer privates Routing nutzen. @@ -1893,7 +1833,6 @@ Chat-Farben Chat-Design Passend - Chat-Liste in einem neuen Fenster anzeigen Guten Nachmittag! Guten Morgen! Farben für die dunkle Variante @@ -1936,10 +1875,6 @@ Fehler beim privaten Routing Die Nachricht kann später zugestellt werden, wenn das Mitglied aktiv wird. Bisher keine direkte Verbindung. Nachricht wird von einem Admin weitergeleitet. - Konfigurierte SMP-Server - Konfigurierte XFTP-Server - Andere SMP-Server - Andere XFTP-Server Abgeschlossen Inaktiv Verbunden @@ -1972,7 +1907,6 @@ Fehler beim Herunterladen Duplikate Abgelaufen - Server-Einstellungen öffnen Andere Fehler Proxy-vermittelt Fehler beim Empfang @@ -2058,9 +1992,7 @@ Stark Mittel Aus - Verbinden Nachricht - Öffnen Chat-Inhalte gelöscht! Nur die Chat-Inhalte löschen Suchen @@ -2209,14 +2141,9 @@ Server-Betreiber Netzwerk-Betreiber Die App verwendet für jede Unterhaltung Server von unterschiedlichen Betreibern, um Ihre Privatsphäre zu schützen. - Die Nutzungsbedingungen der aktivierten Betreiber werden nach 30 Tagen akzeptiert. Wenn Ihr Kontakt beispielsweise Nachrichten über einen SimpleX-Chat-Server empfängt, wird Ihre App diese über einen Flux-Server versenden. - Später einsehen - Wählen sie die zu nutzenden Netzwerk-Betreiber aus. - Sie können die Betreiber in den Netzwerk- und Servereinstellungen konfigurieren. Sie können die Server über die Einstellungen konfigurieren. Weiter - Aktualisieren Voreingestellte Server Nutzungsbedingungen einsehen Die Nutzungsbedingungen der aktivierten Betreiber werden am %s automatisch akzeptiert. @@ -2225,7 +2152,6 @@ %s Server Netzwerk-Betreiber Verwende Server - Webseite Verwende %s %s.]]> %s.]]> @@ -2267,12 +2193,10 @@ %s zu nutzen, müssen Sie dessen Nutzungsbedingungen akzeptieren.]]> Fehler beim Akzeptieren der Nutzungsbedingungen Fehler beim Aktualisieren des Servers - für einen besseren Metadatenschutz. Neuer Server Keine Nachricht Keine Server für privates Nachrichten-Routing. Die Adresse öffentlich teilen - Tippen Sie im Menü auf SimpleX-Adresse erstellen, um sie später zu erstellen. Diese Verbindung hat das Limit der nicht ausgelieferten Nachrichten erreicht. Ihr Kontakt ist möglicherweise offline. Diese Nachricht wurde gelöscht oder bisher noch nicht empfangen. Zum Schutz vor dem Austausch Ihres Links können Sie die Sicherheitscodes Ihrer Kontakte vergleichen. @@ -2280,10 +2204,8 @@ %s.]]> Die Nutzungsbedingungen wurden am %s akzeptiert. Der Text der aktuellen Nutzungsbedingungen konnte nicht geladen werden. Sie können die Nutzungsbedingungen unter diesem Link einsehen: - Ferngesteuerte Mobiltelefone Oder importieren Sie eine Archiv-Datei Hinweis für Geräte von Xiaomi: Bitte aktivieren Sie in den System-Einstellungen die Option "Autostart", damit Benachrichtigungen funktionieren.]]> - Ende-zu-Ende-verschlüsselt versendet. In Direktnachrichten sogar mit Post-Quantum-Security.]]> Team-Mitglieder aufnehmen Freunde aufnehmen Einladung angenommen @@ -2372,7 +2294,6 @@ Spam Archiv Grund der Meldung? - Die Meldung wird für Sie archiviert. Mitgliederprofil melden: Nur Gruppenmoderatoren werden es sehen. Anderes melden: Nur Gruppenmoderatoren werden es sehen. Verstoß melden: Nur Gruppenmoderatoren werden es sehen. @@ -2409,7 +2330,6 @@ TCP-Port für Nachrichtenübermittlung Sie können bis zu %1$s Mitglieder pro Nachricht erwähnen! Alle stummschalten - Ungelesene Erwähnungen Melden von Nachrichten ist in dieser Gruppe nicht erlaubt. Alle Meldungen archivieren? Archiviere %d Meldungen? @@ -2534,7 +2454,6 @@ Kontakt-Anfrage senden? Anfrage senden Anfrage ohne Nachricht senden - Wird nach der Verbindung an Ihren Kontakt gesendet. Gruppen-Link aktualisieren? Aktualisieren Adresse aktualisieren? @@ -2590,7 +2509,6 @@ Kontaktanfragen in Gruppen Mitglied ist gelöscht - Anfrage kann nicht angenommen werden Angefragte Verbindung von Gruppe %1$s - Diese Einstellung gilt für Ihr aktuelles Profil Erlauben Sie Dateien und Medien nur dann, wenn es Ihr Kontakt ebenfalls erlaubt. Erlauben Sie Ihren Kontakten Dateien und Medien zu senden. Bot @@ -2620,7 +2538,6 @@ Mitglied entfernen und Nachrichten löschen Alle Nachrichten Dateien - Filter Bilder Links Dateien suchen @@ -2650,7 +2567,6 @@ Kanal Kanal Kanallink - Kanal-Mitglieder Kanalname Der Kanal wird für alle Abonnenten gelöscht. Dies kann nicht rückgängig gemacht werden! Der Kanal wird für Sie gelöscht. Dies kann nicht rückgängig gemacht werden! @@ -2680,7 +2596,6 @@ Relais-Name eingeben… Fehler beim Hinzufügen des Relais Fehler beim Erstellen des Kanals - Fehler beim Öffnen des Kanals Fehlgeschlagen Fehlgeschlagen Link erhalten @@ -2697,7 +2612,6 @@ Es sind keine Chat-Relais aktiviert. Es sind nicht alle Relais verbunden Kanal öffnen - Neuen Kanal öffnen Eigentümer Eigentümer und Mitwirkende Voreingestellte Relais-Adresse @@ -2876,7 +2790,6 @@ Die Linkvorschau wird über einen SOCKS-Proxy angefordert. DNS-Abfragen können dennoch lokal über Ihren DNS-Resolver erfolgen. Obere Leiste Ihr neuer Kanal %1$s ist mit %2$d von %3$d Relais verbunden.\nBei Abbruch, wird der Kanal gelöscht. Sie können ihn später neu erstellen. - Hinzufügen Relais hinzufügen Relais hinzufügen Kanal abbrechen und löschen @@ -2913,7 +2826,6 @@ %1$d Eigentümer %1$d Eigentümer und Mitwirkende %1$s hat SimpleX Chat unterstützt. Das Abzeichen ist am %2$s abgelaufen. - Über Bestätigter Relaisbestand Fügen Sie diesen Code in Ihre Webseite ein. Er zeigt die Vorschau Ihres Kanals / Ihrer Gruppe an. Erweiterte Optionen @@ -2924,8 +2836,6 @@ Abzeichen ist nicht verifizierbar Kanal-Webseite Chat-Daten - Die Verbindung über den Kanalnamen erfordert eine neuere App‑Version. - Die Verbindung über den Kontaktnamen erfordert eine neuere App‑Version. Kontakt Mitwirkender Code kopieren @@ -2937,7 +2847,6 @@ Dies wird Abonnenten angezeigt und zum Laden der Vorschau genutzt. Weitere Privatsphäre Nur Ihre oben genannte Seite kann die Vorschau anzeigen. - Bitte die App aktualisieren. %s hat sich am SimpleX Chat-Crowdfunding beteiligt. %s unterstützt SimpleX Chat. Abonnent @@ -2946,8 +2855,6 @@ Die Rolle wird auf %s geändert. Alle Abonnenten werden benachrichtigt. Dieses Abzeichen konnte nicht verifiziert werden und ist möglicherweise nicht echt. Diese Gruppe erfordert eine neuere App‑Version. Bitte aktualisieren Sie die App, um beizutreten. - Kanalname wird nicht unterstützt - Kontaktname wird nicht unterstützt Abzeichen nicht verifiziert Die verwendeten Chat‑Relais unterstützen keine Webseiten. Webseiten-Code @@ -2962,7 +2869,6 @@ Namensauflösungs-Fehler: %1$s Der Server %1$s unterstützt keine Namensauflösung. Konfigurieren Sie Server oder verwenden Sie einen Verbindungslink. SimpleX-Name einrichten - SimpleX-Name Fehler beim SimpleX-Namen SimpleX-Name ist nicht verifiziert Der SimpleX-Name %1$s wurde registriert, aber er hat keinen gültigen Link. 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 73abd7d876..30b5dad519 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml @@ -17,7 +17,6 @@ Επέτρεψε Αποδοχή Αποδοχή ανώνυμης περιήγησης - Προσθήκη προκαθορισμένου διακομιστή Προσθήκη σε άλλη συσκευή Όλες οι επαφές σου θα παραμείνουν ενεργές. Αποδοχή @@ -61,11 +60,9 @@ Αντίγραφο δεδομένων εφαρμογής 5 λεπτά Θα συνδεθείς όταν η συσκευή της επαφής σου είναι συνδεδεμένη, παρακαλώ περίμενε ή έλεγξε αργότερα! - Ο ICE διακομιστής σου Έκδοση εφαρμογής Έστειλες πρόσκληση ομάδας 1 λεπτό - Ο διακομιστής σου Διεύθυνση Ακύρωση Πίσω @@ -73,10 +70,8 @@ Θα συνδεθείς με όλα τα μέλη της ομάδας. %1$s θέλει να συνδεθεί μαζί σου μέσω Επέτρεψε τις αντιδράσεις σε μηνύματα. - Ο διακομιστής XFTP σου Η διεύθυνση του διακομιστή σου Το προφίλ, επαφές και παραδομένα μηνύματα σου είναι αποθηκευμένα στην συσκευή σου. - Ο διακομιστής SMP σου Επιτρέπεται να σταλούν αρχεία και μέσα. Το τυχαίο προφίλ σου %1$s ΜΕΛΗ @@ -103,7 +98,6 @@ Αλλαγή διεύθυνσης λήψης Πιστοποίηση μη διαθέσιμη Άλλαξε - \nΔιαθέσιμο στην έκδοση 5.1 Τέλος κλήσης Κλήσεις Αυτόματη αποδοχή @@ -146,7 +140,6 @@ Οι επαφές μπορούν να επισημάνουν μηνύματα προς διαγραφή, τα οποία θα μπορείς να τα δεις. Σύνδεση μέσω μιας εφάπαξ σύνδεσης; Δημιουργία σύνδεσμου - Σύνδεση μέσω σύνδεσμο/κωδικό γρήγορης ανταπόκρισης Σφάλμα σύνδεσης (πιστοποίηση) συνδέεται συνδέεται… @@ -169,7 +162,6 @@ Σύνδεση με %1$s? Δημιουργία του προφίλ σου συνδέεται… - Δημιουργία Προτιμήσεις επαφής Συνδεδεμένο κινητό Σύνδεση @@ -177,7 +169,6 @@ συνδέεται… Σύνδεση τερματίστηκε Συνδε - Σύνδεση ανώνυμης περιήγησης σύνδεση επετεύχθη η επαφή δεν έχει κρυπτογράφηση από άκρη-σε-άκρη Η επαφή επιτρέπει @@ -196,13 +187,11 @@ αιτούμενη σύνδεση Η ιδιωτικότητά σου Η επαφή σου έστειλε ένα αρχείο το οποίο είναι μεγαλύτερο από το παρόν υποστηριζόμενο μέγεθος (%1$s). - Το προφίλ της συνομιλίας σου θα σταλεί\nστην επαφή σου Χρήση νέου ανώνυμου προφίλ Ήδη συνδέεται Απέρριψες την πρόσκληση της ομάδας Σύνδεση μέσω διεύθυνση επαφής Χρήση του τρέχοντος προφίλ - Συνδέσου Το τρέχον προφίλ σου αφαίρεσες %1$s Συμμετοχή ομάδας; @@ -276,7 +265,6 @@ Πάντα Η ενημέρωση της εφαρμογής κατέβηκε Έλεγχος για ενημερώσεις - Οποιοσδήποτε μπορεί να φιλοξενήσει διακομιστές. κλήση ήχου (χωρίς κρυπτογράφηση e2e) Κλήσεις στην οθόνη κλειδώματος: Κλήση ήχου @@ -461,7 +449,6 @@ δεν μπορείς να στείλεις μηνύματα Καταλανικά, Ινδονησιακά, Ρουμανικά και Βιετναμέζικα – ευχαριστούμε τους χρήστες μας! με μία μόνο επαφή - προσωπικό διαμοιρασμό ή μέσω οποιασδήποτε εφαρμογής μηνυμάτων.]]> - με κρυπτογράφηση από άκρη-σε-άκρη και με μετα-κβαντική ασφάλεια σε άμεσα μηνύματα.]]> Επέτρεψε το στο επόμενο παράθυρο διαλόγου για να λαμβάνεις ειδοποιήσεις άμεσα.]]> Συσκευές Xiaomi: ενεργοποίησε το Αυτόματο Ξεκίνημα στις ρυθμίσεις συστήματος για να λειτουργούν οι ειδοποιήσεις.]]> %s.]]> @@ -471,8 +458,6 @@ %s βρίσκεται σε κακή κατάσταση]]> Σάρωση QR κωδικού.]]> %s με τον λόγο: %s]]> - σαρώσεις τον QR κωδικό στη βιντεοκλήση, ή η επαφή σου να διαμοιραστεί ένα σύνδεσμο πρόσκλησης.]]> - δείξε τον QR κωδικό στη βιντεοκλήση, ή μοιράσου το σύνδεσμο.]]> (νέο)]]> (αυτή η συσκευή v%s)]]> κρυπτογράφηση από άκρη-σε-άκρη.]]> @@ -487,7 +472,6 @@ Άνοιγμα στην εφαρμογή κινητού, μετά πάτα Σύνδεση μέσα στην εφαρμογή.]]> Χρήση από τον υπολογιστή στην εφαρμογή του κινητού και σκάναρε τον QR κωδικό.]]> Οδηγό Χρήσης.]]> - αποθετήριό μας στο GitHub.]]> Χρήση .onion hosts σε Όχι, αν ο διακομιστής μεσολάβησης SOCKS δεν τα υποστηρίζει.]]> %s.]]> %s.]]> @@ -504,7 +488,6 @@ %1$s.]]> %1$s.]]> πρέπει να χρησιμοποιείς την ίδια βάση δεδομένων σε δύο συσκευές.]]> - Άνοιγμα στην εφαρμογή κινητού κουμπί.]]> συνδεθείς με τους δημιουργούς του SimpleX Chat για να κάνεις ερωτήσεις και να λαμβάνεις ενημερώσεις.]]> μόνο αφού γίνει αποδεκτό το αίτημά σου.]]> Να αλλάξεις την αυτόματη διαγραφή μηνυμάτων; @@ -543,11 +526,8 @@ Ολοκληρωμένο Οι όροι έγιναν αποδεκτοί στις: %s. Όροι χρήσης - Οι όροι θα γίνουν αποδεκτοί για τους ενεργούς χειριστές μετά από 30 ημέρες. Οι όροι θα γίνουν αποδεκτοί στις: %s. Οι όροι θα γίνουν αυτόματα αποδεκτοί για τους ενεργούς χειριστές στις: %s. - Διαμορφωμένοι SMP διακομιστές - Διαμορφωμένοι XFTP διακομιστές Διαμορφωμένοι ICE διακομιστές Επιβεβαίωσε Επιβεβαίωση διαγραφής επαφής; @@ -560,7 +540,6 @@ Επιβεβαίωσε ότι θυμάσαι τη φράση πρόσβασης της βάσης δεδομένων για να τη μεταφέρεις. Επιβεβαίωση μεταφόρτωσης Επιβεβαίωση των διαπιστευτηρίων σου - σύνδεση Σύνδεση Σύνδεση Σύνδεση @@ -617,13 +596,11 @@ Γωνία Δημιουργία Δημιουργία συνδέσμου 1-χρήσης - Δημιούργησε μια διεύθυνση για να μπορούν οι άλλοι να συνδεθούν μαζί σου. Δημιουργήθηκε Δημιουργήθηκε στις Δημιουργήθηκε στις: %s Δημιουργία λίστας Δημιουργία νέου προφίλ στην εφαρμογή υπολογιστή. 💻 - Δημιουργία συνδέσμου 1-χρήσης Δημιουργία ουράς Δημιούργησε τη διεύθυνσή σου Δημιουργία συνδέσμου αρχειοθέτησης @@ -675,7 +652,6 @@ %d ημέρα %d ημέρες Αποστολή για αποσφαλμάτωση - Αποκεντρωμένο Σφάλμα αποκωδικοποίησης Σφάλμα αποκρυπτογράφησης σφάλματα αποκρυπτογράφησης @@ -819,7 +795,6 @@ ΜΗΝ στέλνεις μηνύματα απευθείας, ακόμα κι αν ο δικός σου διακομιστής ή ο διακομιστής προορισμού δεν υποστηρίζει ιδιωτική δρομολόγηση. Μην χρησιμοποιείς διαπιστευτήρια με το διακομιστή μεσολάβησης (proxy). ΜΗΝ χρησιμοποιείς ιδιωτική δρομολόγηση. - Μην δημιουργήσεις διεύθυνση Μην ενεργοποιήσεις Μην χάσεις σημαντικά μηνύματα. Να μην εμφανιστεί ξανά @@ -925,7 +900,6 @@ Αντιστροφή κάμερας Μέγεθος γραμματοσειράς Για όλους τους διαχειριστές - για καλύτερη ιδιωτικότητα μεταδεδομένων Για το προφίλ συνομιλίας %s: Για κονσόλα Για όλους @@ -1026,7 +1000,6 @@ Η εικόνα θα ληφθεί όταν η επαφή σου ολοκληρώσει τη μεταφόρτωσή της. Η εικόνα θα ληφθεί όταν η επαφή σου είναι συνδεδεμένη, περίμενε ή έλεγξε αργότερα! Άμεσα - Ανοσοποιημένο στο spam Εισαγωγή Εισαγωγή βάσης δεδομένων συνομιλίας; Εισαγωγή βάσης δεδομένων @@ -1081,7 +1054,6 @@ μη έγκυρη διαμόρφωση μηνύματος Μη έγκυρη επιβεβαίωση μετεγκατάστασης Μη έγκυρο όνομα! - Μη έγκυρος QR κωδικός Μη έγκυρος QR κωδικός Μη έγκυρη διεύθυνση διακομιστή! Η πρόσκληση έληξε! @@ -1161,10 +1133,8 @@ Εξαφάνισε ένα μήνυμα Κάνε το προφίλ ιδιωτικό! Βεβαιώσου ότι έχεις σωστή διαμόρφωση του διακομιστή μεσολάβησης. - Βεβαιώσου ότι οι διευθύνσεις του διακομιστή SMP έχουν σωστή μορφή, διαχωρίζονται με νέα γραμμή και δεν είναι διπλότυπες. Βεβαιώσου ότι το αρχείο έχει σωστή σύνταξη YAML. Κάνε εξαγωγή ενός θέματος για να έχεις παράδειγμα της δομής αρχείου των θεμάτων. "Βεβαιώσου ότι οι διευθύνσεις των διακομιστών WebRTC ICE έχουν σωστή μορφή, διαχωρίζονται με νέα γραμμή και δεν είναι διπλότυπες." - Βεβαιώσου ότι οι διευθύνσεις των διακομιστών XFTP έχουν σωστή μορφή, διαχωρίζονται με νέα γραμμή και δεν είναι διπλότυπες. Κάνε τις συνομιλίες σου να ξεχωρίζουν! Βοήθεια στη Markdown σύνταξη Σύνταξη Markdown στα μηνύματα @@ -1216,8 +1186,6 @@ Απαγορεύονται οι αντιδράσεις στα μηνύματα. Απαγορεύονται οι αντιδράσεις στα μηνύματα σε αυτήν τη συνομιλία. Λήψη μηνυμάτων - Εναλλακτική δρομολόγηση μηνυμάτων - Λειτουργία δρομολόγησης μηνυμάτων Μηνύματα Μηνύματα και αρχεία Διακομιστές μηνυμάτων @@ -1289,7 +1257,6 @@ Πάτα Σύνδεση για να συνομιλήσεις Πάτα Σύνδεση για αποστολή αιτήματος Πάτα Σύνδεση για να χρησιμοποιήσεις το μποτ - Πατήστε Δημιουργία διεύθυνσης SimpleX στο μενού, για να τη δημιουργήσετε αργότερα. Πάτα Συμμετοχή στην ομάδα Πάτα για να ενεργοποιήσεις το προφίλ. Πάτα για Σύνδεση @@ -1297,7 +1264,6 @@ Πάτα για ανώνυμη συμμετοχή Πάτα για επικόλληση συνδέσμου Πάτα για σάρωση - Πάτα για να ξεκινήσεις μία νέα συνομιλία Σύνδεση TCP Χρόνος λήξης σύνδεσης TCP στο παρασκήνιο Χρόνος λήξης σύνδεσης TCP @@ -1326,7 +1292,6 @@ Για τις κλήσεις απαιτείται ο προεπιλεγμένος περιηγητής. Ρύθμισε τον προεπιλεγμένο περιηγητή στο σύστημα σου και μοιράσου περισσότερες πληροφορίες με τους προγραμματιστές. Το όνομα της συσκευής θα κοινοποιηθεί στην εφαρμογή του συνδεδεμένου κινητού. Η κρυπτογράφηση λειτουργεί και η νέα κρυπτογράφηση δεν είναι απαραίτητη. Μπορεί να προκαλέσει σφάλματα σύνδεσης! - Το μέλλον στην ανταλλαγή μηνυμάτων Ο κωδικός ελέγχου του προηγούμενου μηνύματος είναι διαφορετικός. Ο αναγνωριστικός κωδικός του επόμενου μηνύματος είναι λανθασμένος (μικρότερος ή ίσος με τον προηγούμενο).\nΑυτό μπορεί να συμβεί λόγω κάποιου σφάλματος ή όταν η σύνδεση έχει παραβιαστεί. Η εικόνα δεν μπορεί να αποκωδικοποιηθεί. Δοκίμασε μια άλλη εικόνα ή επικοινώνησε με τους προγραμματιστές. @@ -1337,11 +1302,9 @@ Τα μηνύματα θα επισημαίνονται ως ελεγχόμενα για όλα τα μέλη. Το μήνυμα θα διαγραφεί για όλα τα μέλη. Το μήνυμα θα επισημανθεί ως υπό έλεγχο για όλα τα μέλη. - Η πλατφόρμα μηνυμάτων και εφαρμογών που προστατεύει το απόρρητο και την ασφάλειά σου. Η φράση πρόσβασης αποθηκεύεται στις ρυθμίσεις ως απλό κείμενο. Η φράση πρόσβασης θα αποθηκευτεί στις ρυθμίσεις ως απλό κείμενο μετά την αλλαγή της ή την επανεκκίνηση της εφαρμογής. Το προφίλ κοινοποιείται μόνο στις επαφές σου. - Η αναφορά θα αρχειοθετηθεί για εσένα. Ο ρόλος θα αλλάξει σε %s. Όλοι οι συμμετέχοντες στη συνομιλία θα ειδοποιηθούν. Ο ρόλος θα αλλάξει σε %s. Όλα τα μέλη της ομάδας θα ενημερωθούν. Ο ρόλος θα αλλάξει σε %s. Το μέλος θα λάβει νέα πρόσκληση. @@ -1369,15 +1332,11 @@ Αυτή η ομάδα δεν υπάρχει πλέον. Αυτός είναι ο δικός σου σύνδεσμος 1-χρήσης! Αυτή είναι η διεύθυνση σου SimpeX! - Αυτός ο σύνδεσμος δεν είναι έγκυρος! Αυτός ο σύνδεσμος απαιτεί νεότερη έκδοση της εφαρμογής. Αναβάθμισε την εφαρμογή ή ζήτησε από την επαφή σου να σου στείλει ένα συμβατό σύνδεσμο. Αυτός ο σύνδεσμος χρησιμοποιήθηκε με άλλη κινητή συσκευή. Δημιούργησε ένα νέο σύνδεσμο στον υπολογιστή σου. Αυτό το μήνυμα διαγράφηκε ή δεν έχει ληφθεί ακόμα. - Αυτός ο κωδικός QR δεν είναι σύνδεσμος! Αυτή η ρύθμιση ισχύει για τα μηνύματα στο τρέχον προφίλ συνομιλίας σου. - Αυτή η ρύθμιση αφορά το τρέχον προφίλ σου. Αυτό το κείμενο δεν είναι σύνδεσμος! - Αυτό το κείμενο είναι διαθέσιμο στις ρυθμίσεις Εξαντλήθηκε ο χρόνος αναμονής κατά τη σύνδεση με τον υπολογιστή Ο χρόνος εξαφάνισης ορίζεται μόνο για τις νέες επαφές. Τίτλος @@ -1394,13 +1353,11 @@ Για την προστασία της ζώνης ώρας, τα αρχεία εικόνας/φωνής χρησιμοποιούν UTC ώρα. Για να προστατεύσεις τις πληροφορίες σου, ενεργοποίησε το SimpleX Lock.\nΘα σου ζητηθεί να ολοκληρώσεις την επαλήθευση ταυτότητας πριν ενεργοποιηθεί αυτή η λειτουργία. Για την προστασία της IP διεύθυνσής σου, η ιδιωτική δρομολόγηση χρησιμοποιεί τους διακομιστές SMP για την παράδοση μηνυμάτων. - Για την προστασία της ιδιωτικότητάς σου, το SimpleX χρησιμοποιεί ξεχωριστά αναγνωριστικά για κάθε μία από τις επαφές σου. Για λήψη Για να λαμβάνεις ειδοποιήσεις, παρακαλώ εισήγαγε τη φράση πρόσβασης της βάσης δεδομένων. Για να αποκαλύψεις το κρυφό προφίλ σου, εισήγαγε έναν πλήρη κωδικό στο πεδίο αναζήτησης στη σελίδα Τα προφίλ συνομιλίας σου. Για αποστολή Για αποστολή εντολών, θα πρέπει να είσαι συνδεδεμένος. - (για διαμοιρασμό με την επαφή σου) Για να ξεκινήσεις μία νέα συνομιλία Συνολικά Για να χρησιμοποιήσεις άλλο προφίλ μετά την προσπάθεια σύνδεσης, διέγραψε τη συνομιλία και χρησιμοποίησε ξανά τον σύνδεσμο. @@ -1438,11 +1395,9 @@ Απενεργοποίηση σίγασης Απροστάτευτο αδιάβαστο - Μη αναγνωσμένες αναφορές Μη υποστηριζόμενος σύνδεσμος σύνδεσης Αναβάθμιση Αναβάθμιση - Αναβάθμιση Διαθέσιμη αναβάθμιση: %s Ενημέρωση φράσης πρόσβασης της βάσης δεδομένων Ενημερωμένοι όροι @@ -1450,7 +1405,6 @@ Η λήψη της ενημέρωσης ακυρώθηκε ενημερωμένο προφίλ Ενημέρωση ρυθμίσεων δικτύου; - Ενημέρωση της λειτουργίας απομόνωσης μεταφοράς; Ενημέρωσε τη διεύθυνσή σου Η ενημέρωση των ρυθμίσεων θα επανασυνδέσει την εφαρμογή με όλους τους διακομιστές. Αναβάθμιση @@ -1467,7 +1421,6 @@ Ανέβασμα αρχείου Ανεβαίνει το αρχείο αρχειοθέτησης Τα τελευταία 100 μηνύματα αποστέλλονται στα νέα μέλη. - Χρήση συνομιλίας Χρησιμοποίησε διαφορετικά διαπιστευτήρια διακομιστή μεσολάβησης για κάθε σύνδεση. Χρησιμοποίησε διαφορετικά διαπιστευτήρια διακομιστή μεσολάβησης για κάθε προφίλ. Χρήση απευθείας σύνδεσης στο Διαδίκτυο; @@ -1532,8 +1485,6 @@ Σφάλμα κατά τη συμμετοχή στην ομάδα Σφάλμα κατά τη φόρτωση των λιστών συνομιλιών Σφάλμα κατά τη φόρτωση των λεπτομερειών - Σφάλμα κατά τη φόρτωση των διακομιστών SMP - Σφάλμα κατά τη φόρτωση των διακομιστών XFTP Σφάλμα επισήμανσης ως αναγνωσμένου Σφάλμα κατά το άνοιγμα του προγράμματος περιήγησης Σφάλμα κατά το άνοιγμα της συνομιλίας @@ -1555,9 +1506,7 @@ Σφάλμα κατά την αποθήκευση διακομιστών Σφάλμα κατά την αποθήκευση των ρυθμίσεων Σφάλμα κατά την αποθήκευση των ρυθμίσεων - Σφάλμα κατά την αποθήκευση των διακομιστών SMP Σφάλμα κατά την αποθήκευση του κωδικού πρόσβασης χρήστη - Σφάλμα κατά την αποθήκευση διακομιστών XFTP Σφάλμα κατά την αποστολή της πρόσκλησης Σφάλμα κατά την αποστολή του μηνύματος Σφάλμα κατά τη ρύθμιση της διεύθυνσης @@ -1695,7 +1644,6 @@ Οι ειδοποιήσεις θα σταματήσουν να λειτουργούν μέχρι να επανεκκινήσεις την εφαρμογή. μη συγχρονισμένο Δεν υπάρχουν μη αναγνωσμένες συνομιλίες - Χωρίς αναγνωριστικά χρήστη. Τώρα οι διαχειριστές μπορούν:\n- να διαγράφουν τα μηνύματα των μελών.\n- να απενεργοποιούν μέλη (ρόλος παρατηρητή) παρατηρητής κλειστό` @@ -1716,7 +1664,6 @@ Μπορούν να σταλούν μόνο 10 εικόνες ταυτόχρονα Μπορούν να σταλούν μόνο 10 βίντεο ταυτόχρονα Μόνο οι ιδιοκτήτες του chat μπορούν να αλλάξουν τις προτιμήσεις. - Μόνο οι συσκευές αποθηκεύουν προφίλ χρηστών, επαφές, ομάδες και μηνύματα. Διαγραφή μόνο της συνομιλίας Μόνο οι ιδιοκτήτες ομάδων μπορούν να αλλάξουν τις προτιμήσεις της ομάδας. Μόνο οι ιδιοκτήτες ομάδων μπορούν να ενεργοποιήσουν αρχεία και πολυμέσα. @@ -1737,7 +1684,6 @@ Μόνο η επαφή σου μπορεί να στείλει μηνύματα που εξαφανίζονται. Μόνο η επαφή σου μπορεί να στείλει αρχεία και πολυμέσα. Μόνο η επαφή σου μπορεί να στείλει φωνητικά μηνύματα. - άνοιγμα Άνοιξε Άνοιγμα Άνοιξε τις ρυθμίσεις της εφαρμογής @@ -1760,7 +1706,6 @@ Άνοιξε νέα ομάδα Άνοιγμα θύρας στο τείχος προστασίας Άνοιξε τις Ρυθμίσεις Safari / Ιστοσελίδες / Μικρόφωνο και στη συνέχεια επέλεξε Να επιτρέπεται για το localhost. - Άνοιγμα ρυθμίσεων διακομιστή Άνοιγμα ρυθμίσεων Άνοιξε το SimpleX Chat για να αποδεχθείς την κλήση Άνοιξε για να αποδεχθείς @@ -1782,8 +1727,6 @@ άλλο Άλλο άλλα σφάλματα - Άλλοι διακομιστές SMP - Άλλοι διακομιστές XFTP ιδιοκτήτης ιδιοκτήτες Κωδικός πρόσβασης @@ -1803,10 +1746,8 @@ Επικόλληση συνδέσμου Επικόλλησε το σύνδεσμο για να συνδεθείς! Επικόλλησε το σύνδεσμο που έλαβες - Επικόλλησε το σύνδεσμο που έλαβες για να συνδεθείς με την επαφή σου… από άκρη-σε-άκρη εκκρεμής - Εκκρεμής Εκκρεμής σε αναμονή έγκρισης Εκκρεμής κλήση @@ -1849,7 +1790,6 @@ Προετοιμασία λήψης Προετοιμασία μεταφόρτωσης Διατήρηση του τελευταίου πρόχειρου μηνύματος, με τα συνημμένα. - Προκαθορισμένος διακομιστής Διεύθυνση προκαθορισμένου διακομιστή Προκαθορισμένοι διακομιστές Προκαθορισμένοι διακομιστές @@ -1857,7 +1797,6 @@ Προηγούμενοι συνδεδεμένοι διακομιστές Προστασία της ιδιωτικότητας των πελατών σου. Πολιτική απορρήτου και όροι χρήσης. - Επαναπροσδιορισμός της ιδιωτικότητας Απόρρητο & ασφάλεια Οι ιδιωτικές συνομιλίες, οι ομάδες και οι επαφές σου δεν είναι προσβάσιμες στους χειριστές του διακομιστή. Ιδιωτικά ονόματα αρχείων @@ -1955,7 +1894,6 @@ Ο διακομιστής αναμετάδοσης χρησιμοποιείται μόνο αν είναι απαραίτητο. Οι άλλοι μπορούν να δουν τη διεύθυνση IP σου. Ο διακομιστής αναμετάδοσης προστατεύει τη διεύθυνση IP σου, αλλά μπορεί να παρακολουθεί τη διάρκεια της κλήσης. Υπενθύμιση αργότερα - Απομακρυσμένα κινητά τηλέφωνα Κατάργηση Κατάργηση Κατάργηση και διαγραφή μηνυμάτων @@ -2025,7 +1963,6 @@ Προϋποθέσεις ελέγχου ελέγχθηκε από τους διαχειριστές Έλεγχος μελών ομάδας - Έλεγχος αργότερα Έλεγχος μελών Έλεγχος μελών πριν την αποδοχή τους (knocking). Ανάκληση @@ -2070,7 +2007,6 @@ Κλιμάκωση στην οθόνη Σάρωση κωδικού Σάρωση από κινητό - (σάρωσε ή επικόλλησε από το πρόχειρο) Σάρωση / Επικόλληση συνδέσμου Σάρωσε τον κωδικό QR από τον υπολογιστή %s συνδέθηκε @@ -2088,7 +2024,6 @@ Επέλεξε επαφές Οι επιλεγμένες προτιμήσεις συνομιλίας απαγορεύουν αυτό το μήνυμα. Επιλέχθηκαν %d - Επέλεξε τους χειριστές δικτύου που θέλεις να χρησιμοποιήσεις. Αυτοκαταστροφή Κωδικός αυτοκαταστροφής Κωδικός αυτοκαταστροφής @@ -2140,7 +2075,6 @@ Τα αποσταλμένα μηνύματα θα διαγραφούν μετά από καθορισμένο χρονικό διάστημα. Απεσταλμένη απάντηση Σύνολο απεσταλμένων - Αποστέλλεται στην επαφή σου μετά τη σύνδεση. Αποστολή μέσω διακομιστή μεσολάβησης Διακομιστής Ο διακομιστής προστέθηκε στο χειριστή %s. @@ -2204,7 +2138,6 @@ Σύντομη διεύθυνση SimpleX Εμφάνιση Εμφάνιση: - Εμφάνιση λίστας μηνυμάτων σε νέο παράθυρο Εμφάνιση κονσόλας τερματικού σε νέο παράθυρο Εμφάνιση επαφής και μηνύματος Εμφάνιση επιλογών για προγραμματιστές @@ -2258,9 +2191,7 @@ Αργή λειτουργία Μικρές ομάδες (μέγιστο 20 άτομα) Διακομιστής SMP - Διακομιστές SMP Διακομιστής μεσολάβησης SOCKS - Διακομιστής μεσολάβησης SOCKS Ρυθμίσεις διακομιστή μεσολάβησης SOCKS Απαλό Κάποιο/α αρχείο/α δεν εξήχθησαν @@ -2377,10 +2308,7 @@ Προειδοποίηση: η έναρξη συνομιλίας σε πολλαπλές συσκευές δεν υποστηρίζεται και θα προκαλέσει σφάλματα στην παράδοση των μηνυμάτων. Προειδοποίηση: ενδέχεται να χάσεις ορισμένα δεδομένα! Διακομιστές WebRTC ICE - Ιστοσελίδα - Δεν αποθηκεύουμε καμία από τις επαφές ή τα μηνύματά σου (αφού παραδοθούν) στους διακομιστές. εβδομάδες - Καλωσόρισες! Καλωσόρισες %1$s! Μήνυμα καλωσορίσματος Μήνυμα καλωσορίσματος @@ -2393,7 +2321,6 @@ Κατά τη σύνδεση κλήσεων ήχου και βίντεο. Όταν η IP είναι κρυφή Όταν είναι ενεργοποιημένοι περισσότεροι από ένας χειριστές, κανένας από αυτούς δεν διαθέτει μεταδεδομένα για να μάθει ποιος επικοινωνεί με ποιον. - Όταν κάποιος ζητήσει να συνδεθεί, μπορείς να αποδεχτείς ή να απορρίψεις το αίτημα. Όταν μοιράζεσε ένα ανώνυμο προφίλ με κάποιον, αυτό το προφίλ θα χρησιμοποιείται για τις ομάδες στις οποίες σε προσκαλούν. WiFi Θα ενεργοποιηθεί στις άμεσες συνομιλίες! @@ -2409,7 +2336,6 @@ Λανθασμένο κλειδί ή άγνωστη διεύθυνση τμήματος αρχείου - πιθανότατα το αρχείο έχει διαγραφεί. Λανθασμένη φράση πρόσβασης! Διακομιστής XFTP - Διακομιστές XFTP ναι Ναι Ναι @@ -2437,22 +2363,18 @@ είσαι παρατηρητής μπλόκαρες %s Μπορείς να το αλλάξεις στις ρυθμίσεις Εμφάνισης. - Μπορείς να διαμορφώσεις τους χειριστές στις ρυθμίσεις Δικτύου & διακομιστών. Μπορείς να διαμορφώσεις τους διακομιστές μέσω των ρυθμίσεων. Μπορείς να αντιγράψεις και να μειώσεις το μέγεθος του μηνύματος για να το στείλεις. - Μπορείς να το δημιουργήσεις αργότερα Μπορείς να το ενεργοποιήσεις αργότερα μέσω των Ρυθμίσεων. Μπορείς να τις ενεργοποιήσεις αργότερα μέσω των ρυθμίσεων απορρήτου και ασφάλειας της εφαρμογής. Μπορείς να δοκιμάσεις ξανά. Μπορείς να δοκιμάσεις ξανά. Μπορείς να αποκρύψεις ή να σιγάσεις ένα προφίλ χρήστη - κράτησέ το πατημένο για να εμφανιστεί το μενού. - Μπορείς να το κάνεις ορατό στις επαφές σου στο SimpleX μέσω των Ρυθμίσεων. Μπορείς να αναφέρεις εώς και %1$s μέλη ανά μήνυμα! Μπορείς να στείλεις μηνύματα στην επαφή %1$s από τις αρχειοθετημένες επαφές. Μπορείς να ορίσεις το όνομα της σύνδεσης για να θυμάσε με ποιον μοιράστηκες το σύνδεσμο. Μπορείς να μοιραστείς ένα σύνδεσμο ή έναν κωδικό QR - οποιοσδήποτε θα μπορεί να συμμετάσχει στην ομάδα. Δεν θα χάσεις μέλη της ομάδας αν τον διαγράψεις αργότερα. Μπορείς να μοιραστείς αυτήν τη διεύθυνση με τις επαφές σου για να τους επιτρέψεις να συνδεθούν με την επαφή %s. - Μπορείς να διαμοιραστείς τη διεύθυνσή σου ως σύνδεσμο ή κωδικό QR - οποιοσδήποτε θα μπορεί να συνδεθεί μαζί σου. Μπορείς να ξεκινήσεις τη συνομιλία μέσω της εφαρμογής Ρυθμίσεις / Βάση δεδομένων ή επανεκκινώντας την εφαρμογή. Μπορείς ακόμα να δεις τη συνομιλία με την επαφή %1$s, στη λίστα των συνομιλιών. Δεν μπορείς να στείλεις μηνύματα! @@ -2464,9 +2386,7 @@ άλλαξες διεύθυνση για %s άλλαξες ρόλο για τον εαυτό σου σε %s άλλαξες το ρόλο του μέλους %s σε %s - Έχεις τον έλεγχο της συνομιλίας σου! Δεν ήταν δυνατή η επαλήθευση. Παρακαλώ, δοκίμασε ξανά. - Εσύ αποφασίζεις ποιος μπορεί να συνδεθεί. Έχεις ήδη ζητήσει σύνδεση μέσω αυτής της διεύθυνσης! Δεν έχεις συνομιλίες Πρέπει να εισάγεις τη φράση πρόσβασης κάθε φορά που ξεκινά η εφαρμογή - δεν αποθηκεύεται στη συσκευή. @@ -2513,7 +2433,6 @@ Μεγέθυνση Όλα τα μηνύματα Αρχεία - ΦΙλτράρισμα Εικόνες Σύνδεσμοι Αναζήτηση αρχείων 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 9274b7a30d..59b99534cd 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml @@ -12,7 +12,6 @@ 1 semana Se permiten los mensajes temporales pero sólo si tu contacto también los permite. Añadir servidores mediante el escaneo de códigos QR. - Añadir servidores predefinidos Todos los miembros del grupo permanecerán conectados. Se permite la eliminación irreversible de mensajes pero sólo si tu contacto también lo permite. (24 horas) Android Keystore se usará para almacenar la frase de contraseña de forma segura después de cambiarla o reiniciar la aplicación - permitirá recibir notificaciones. @@ -77,7 +76,6 @@ ¡Consume más energía! La aplicación está siempre en segundo plano y las notificaciones se muestran de inmediato.]]> Tanto tú como tu contacto podéis eliminar los mensajes enviados de forma irreversible. (24 horas) Tanto tú como tu contacto podéis enviar mensajes temporales. - Crear Crea grupo secreto La contraseña de cifrado de la base de datos será actualizada. ID base de datos @@ -135,7 +133,6 @@ conectando (aceptado) conectando (anunciado) conexión %1$d - Conecta vía enlace / Código QR El contacto y todos los mensajes serán eliminados. ¡No puede deshacerse! Contacto verificado el contacto dispone de cifrado de extremo a extremo @@ -157,7 +154,6 @@ Desconectado Conectado Copiado en portapapeles - Crea enlace de invitación de un uso. Escanear código QR ]]> Eliminar Eliminar @@ -188,7 +184,6 @@ ¿Eliminar la dirección\? Nombre del perfil: conectando… - Descentralizada La base de datos será cifrada. Crear enlace de grupo Eliminar enlace @@ -225,7 +220,6 @@ ¿Conectar mediante dirección de contacto? ¿Te unes al grupo? ¿Conectar mediante enlace de invitación? - Conectar conectado conectando conectando… @@ -315,14 +309,12 @@ Cómo usar la sintaxis markdown en modo incógnito mediante enlace de un solo uso Dirección de contacto SimpleX - Error al guardar servidores SMP Abrir el enlace en el navegador puede reducir la privacidad y seguridad de la conexión. Los enlaces de SimpleX que no son de confianza aparecerán en rojo. Error al actualizar la configuración de red Error al crear dirección Error al eliminar perfil Activar Bloqueo SimpleX Enlace de invitación de un solo uso - Servidores SMP Características experimentales Error al importar base de datos Error al cambiar configuración @@ -348,8 +340,6 @@ Si has recibido un enlace de invitación a SimpleX Chat puedes abrirlo en tu navegador: Si seleccionas rechazar, el remitente NO será notificado. ¡Enlace no válido! - escanear el código QR por videollamada, o tu contacto puede compartir un enlace de invitación.]]> - muestra el código QR por videollamada, o comparte el enlace.]]> Cómo Ignorar Error al eliminar base de datos @@ -380,7 +370,6 @@ Error al guardar servidores ICE Servidores ICE (uno por línea) Nombre completo: - Tu decides quién se conecta. Colgar Archivos y multimedia ¡Grupo no encontrado! @@ -436,10 +425,8 @@ Invitar al grupo Para verificar el cifrado de extremo a extremo con tu contacto, compara (o escanea) el código en ambos dispositivos. La base de datos no está cifrada. Escribe una contraseña para protegerla. - Asegúrate de que las direcciones del servidor SMP tienen el formato correcto, están separadas por líneas y no están duplicadas. Notificaciones instantáneas Configuración avanzada - Sólo los dispositivos cliente almacenan perfiles de usuario, contactos, grupos y mensajes. Cómo afecta a la batería Instantánea Unirme @@ -469,7 +456,6 @@ Abrir en aplicación móvil, después pulsa Conectar en la aplicación.]]> Marcar como leído Marcar como no leído - Código QR no válido ¡Código de seguridad incorrecto! Sintaxis Markdown No @@ -501,7 +487,6 @@ Asegúrate de que las direcciones del servidor WebRTC ICE tienen el formato correcto, están separadas por líneas y no duplicadas. Se requieren hosts .onion para la conexión \nRecuerda: no podrás conectarte a servidores que no tengan dirección .onion. - Inmune al spam Videollamada entrante has salido has cambiado de servidor @@ -595,7 +580,6 @@ secreto Abrir SimpleX Chat para aceptar llamada Reiniciar a valores predetarminados - Pendiente Notificaciones periódicas Guarda la contraseña de forma segura, NO podrás cambiarla si la pierdes. Reinicia la aplicación para crear un perfil nuevo. @@ -604,7 +588,6 @@ imagen del perfil No se permiten mensajes de voz. Proteger pantalla de la aplicación - repositorio GitHub .]]> Grabar mensaje de voz ha expulsado a %1$s Enviar previsualizacion de enlaces @@ -622,7 +605,6 @@ Rechazar Obligatorio Guardar y notificar contactos - Cualquiera puede alojar servidores. Rol Intervalo PING Contador PING @@ -635,7 +617,6 @@ Mensaje en vivo Escanear código QR Enviar - (escanear o pegar desde el portapapeles) Espacio reservado para la imagen del perfil Código QR Consultas y sugerencias @@ -646,7 +627,6 @@ respuesta recibida… confirmación recibida… Periódico - Privacidad redefinida Rechazar Abrir Llamada pendiente @@ -688,7 +668,6 @@ Escanea el código de seguridad desde la aplicación de tu contacto. Guardar servidores Escanear código QR - Servidor predefinido Guardar y notificar contacto ¿Guardar preferencias\? Guardar y notificar grupo @@ -696,14 +675,11 @@ La aplicación recoge nuevos mensajes periódicamente lo que consume un pequeño porcentaje de batería al día. La aplicación no usa notificaciones push por tanto los datos de tu dispositivo no se envían a los servidores push. Bloqueo SimpleX Desbloquear - Este texto está disponible en Configuración La dirección de recepción pasará a otro servidor. El cambio se completará cuando el remitente esté en línea. Bloqueo SimpleX Usando servidores SimpleX Chat. Aislamiento de transporte tachado - Abrir SimpleX - Proxy SOCKS Temas Parar Esta acción es irreversible. Tu perfil, contactos, mensajes y archivos se perderán. @@ -724,15 +700,11 @@ Cámara ¡El contacto con el que has compartido este enlace NO podrá conectarse! Mostrar código QR - ¡El enlace no es un enlace de conexión válido! - ¡El código QR no es un enlace! Compartir enlace de un uso - ¿Actualizar el modo de aislamiento de transporte\? Altavoz activado ¡La conexión que has aceptado se cancelará! La base de datos no funciona correctamente. Pulsa para conocer más El mensaje será marcado como moderado para todos los miembros. - El futuro de la mensajería Esta acción es irreversible. Se eliminarán todos los archivos y multimedia recibidos y enviados. Las imágenes de baja resolución permanecerán. Esta acción es irreversible. Los mensajes enviados y recibidos anteriores a la selección serán eliminados. Podría tardar varios minutos. Esta configuración se aplica a los mensajes del perfil actual @@ -743,7 +715,6 @@ Inciar chat nuevo Para exportar, importar o eliminar la base de datos debes parar SimpleX. Mientra tanto no podrás enviar ni recibir mensajes. ¡Gracias por instalar SimpleX Chat! - Para proteger tu privacidad, SimpleX usa identificadores distintos para cada uno de tus contactos. 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. @@ -766,15 +737,12 @@ ¿Usa proxy SOCKS\? Usar hosts .onion simplexmq: v%s (%2s) - La plataforma de mensajería y aplicaciones que protege tu privacidad y seguridad. - Sin identificadores de usuario. Este grupo ya no existe. Establecer 1 día ¡Agradecimiento a los colaboradores! Puedes contribuir a través de Weblate. ¡Agradecimiento a los colaboradores! Puedes contribuir a través de Weblate. Para proteger la zona horaria, los archivos de imagen/voz usan la hora UTC. Aislamiento de transporte - (para compartir con tu contacto) Activar sonido %s está verificado %s no está verificado @@ -807,7 +775,6 @@ formato de mensaje desconocido 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… Compartir medios… Mostrar @@ -839,9 +806,6 @@ Mensajes de voz no permitidos. Comprobar la seguridad de la conexión ¡Ya estás conectado con %1$s. - ¡Bienvenido! - Tu perfil será enviado -\na tu contacto Servidores ICE Has rechazado la invitación del grupo. has cambiado el rol de %s a %s @@ -852,7 +816,6 @@ Servidores WebRTC ICE %1$s quiere conectarse contigo mediante Ya tienes un perfil con este nombre mostrado. Por favor, selecciona otro nombre. - Abrir en aplicación móvil.]]> ponerte en contacto con los desarrolladores de SimpleX Chat para consultas y para recibir actualizaciones.]]> eres observador Puedes usar la sintaxis markdown para dar formato a tus mensajes: @@ -875,7 +838,6 @@ Esperando imagen Mensaje de voz (%1$s ) Si disponibles - No almacenamos ninguno de tus contactos o mensajes (una vez entregados) en los servidores. Has sido invitado a un grupo. Únete para conectar con sus miembros. has expulsado a %1$s Tú: %1$s @@ -889,15 +851,12 @@ Has invitado a tu contacto Te conectarás al grupo cuando el dispositivo anfitrión esté en línea, por favor espera o revisa más tarde. Configuración - Servidores SMP - ¡Tú controlas tu chat! Tu perfil, contactos y mensajes se almacenan en tu dispositivo. esperando respuesta… esperando confirmación… Cuando la aplicación se está ejecutando Videollamada Llamadas - Servidores ICE Privacidad Mis datos Base de datos @@ -933,7 +892,6 @@ Equipo SimpleX Mis perfiles Mi dirección SimpleX - Tu servidor Dirección de tu servidor Tu perfil actual Tu perfil se almacena en tu dispositivo y se comparte sólo con tus contactos. Los servidores SimpleX no pueden ver tu perfil. @@ -1011,17 +969,11 @@ El vídeo se recibirá cuando el contacto termine de subirlo. Sólo se pueden enviar 10 vídeos de forma simultánea Esperando el vídeo - Error al guardar servidores SMP - 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. Comparar archivo Crear archivo Eliminar archivo Subir archivo - Servidores XFTP - Servidores XFTP Puerto puerto %d Usar hosts .onion debe estar a No si el proxy SOCKS no los admite.]]> @@ -1083,8 +1035,6 @@ Permites que tus contactos te llamen. Llamadas y videollamadas Las llamadas y videollamadas no están permitidas. - " -\nDisponible en v5.1" Tanto tú como tu contacto podéis realizar llamadas. Sólo tú puedes realizar llamadas. Sólo tu contacto puede realizar llamadas. @@ -1107,7 +1057,6 @@ Guía de Usuario.]]> Enlace de un solo uso Dirección SimpleX - Cuando alguien solicite conectarse podrás aceptar o rechazar su solicitud. Compartir dirección… Deja un mensaje de bienvenida… SimpleX @@ -1124,8 +1073,6 @@ Tema oscuro Personalizar tema Deja un mensaje de bienvenida… (opcional) - Crea una dirección para que otras personas puedan conectar contigo. - No crear dirección SimpleX ¡Hola! \nConecta conmigo a través de SimpleX Chat: %s Importar tema @@ -1136,14 +1083,12 @@ La actualización del perfil se enviará a tus contactos SimpleX. Mensaje recibido Guardar configuración de dirección SimpleX - Puedes compartir tu dirección como enlace o código QR para que cualquiera pueda conectarse contigo. ¿Guardar configuración\? Secundario Mensaje enviado Dejar de compartir ¿Dejar de compartir la dirección\? Colores de la interfaz - Puedes crearla más tarde ¿Compartir la dirección con los contactos SimpleX? Compartir con contactos SimpleX Título @@ -1329,11 +1274,9 @@ Este grupo tiene más de %1$d miembros, no se enviarán confirmaciones de entrega. ¿Conectar directamente\? La solicitud se enviará a este miembro del grupo. - Conectar en incógnito Permitir Abrir configuración Compartirás un perfil nuevo aleatorio. - Pega el enlace recibido para conectar con tu contacto… El perfil %1$s será compartido. Desactivar notificaciones Sin llamadas en segundo plano. @@ -1507,7 +1450,6 @@ Error aplicación error al mostrar el contenido error al mostrar mensaje - Puedes hacerlo visible para tus contactos de SimpleX en Configuración. El historial no se envía a miembros nuevos. Reintentar Cámara no disponible @@ -1761,8 +1703,6 @@ Enrutamiento privado Servidores desconocidos NO usar enrutamiento privado. - Enrutamiento de mensajes alternativo - Modo de enrutamiento de mensajes No Estado del mensaje Usar enrutamiento privado con servidores desconocidos cuando tu dirección IP no está protegida. @@ -1816,7 +1756,6 @@ Color adicional 2 Configuración avanzada Tema del perfil - Listado del chat en ventana nueva Color imagen de fondo información cola del servidor: %1$s \n @@ -1861,8 +1800,6 @@ Suscripciones ignoradas Para ser notificado sobre versiones nuevas, activa el chequeo periódico para las versiones Estable o Beta. Beta - Servidores SMP configurados - Servidores XFTP configurados Servidores conectados Conectando Perfil actual @@ -1906,8 +1843,6 @@ Descargado Servidor SMP Aún no hay conexión directa, los mensajes son reenviados por el administrador. - Otros servidores SMP - Otros servidores XFTP Pegar enlace / Escanear Mostrar porcentaje Desactivar @@ -1949,7 +1884,6 @@ Errores de descarga duplicados caducados - Abrir configuración del servidor otros otros errores Como proxy @@ -1984,13 +1918,11 @@ Suave Barra de herramientas accesible llamar - conectar ¿Eliminar %d mensajes de miembros? mensaje Mensaje Nada seleccionado Los mensajes se marcarán para eliminar. El destinatario o destinatarios podrán revelar estos mensajes. - abrir Seleccionar Seleccionados %d Configuración @@ -2123,8 +2055,6 @@ ¿Dirección SimpleX o enlace de un solo uso? Operadores de servidores Operadores de red - Las condiciones de los operadores habilitados serán aceptadas después de 30 días. - Revisar más tarde Condiciones aceptadas el: %s. Operador de red Operador @@ -2156,7 +2086,6 @@ Activa Flux en Servidores y Red para mejorar la privacidad de los metadatos. Error al aceptar las condiciones Error al actualizar el servidor - para mejorar la privacidad de los metadatos. Ningún mensaje Servidor nuevo Sin servidores para archivos y multimedia. @@ -2164,11 +2093,8 @@ Sin servidores para recibir mensajes. Servidor del operador O para compartir en privado - 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 - Sitio web Tus servidores Usar %s Usar servidores @@ -2183,7 +2109,6 @@ solamente con un contacto - comparte en persona o mediante cualquier aplicación de mensajería.]]> Puedes añadir un nombre a la conexión para recordar a quién corresponde. La aplicación protege tu privacidad mediante el uso de diferentes operadores en cada conversación. - Puedes configurar los operadores desde los ajustes de Servidores y Red. %s.]]> %s.]]> %s.]]> @@ -2198,10 +2123,8 @@ %s.]]> %s.]]> Por ejemplo, si tu contacto recibe a través de un servidor de SimpleX Chat, tu aplicación enviará a través de un servidor de Flux. - Pulsa Crear dirección SimpleX en el menú para crearla más tarde. La conexión ha alcanzado el límite de mensajes no entregados. es posible que tu contacto esté desconectado. El mensaje ha sido eliminado o aún no se ha recibido. - Móvil remoto O importa desde un archivo Mensajes directos entre miembros de este chat no permitidos. En dispositivos Xiaomi: por favor, habilita el Autoinicio en los ajustes del sistema para que las notificaciones funcionen.]]> @@ -2240,7 +2163,6 @@ ¡El chat ya existe! Acerca de los operadores La aplicación siempre funciona en segundo plano - cifrados de extremo a extremo y con seguridad postcuántica en mensajes directos.]]> ¡Mensaje demasiado largo! Simplex Chat y Flux han acordado incluir en la aplicación servidores operados por Flux. Activar registros @@ -2281,7 +2203,6 @@ Error al guardar la configuración informe archivado por %s ¿Archivar informe? - El informe será archivado para ti. Informar Eliminar informe 1 informe @@ -2329,7 +2250,6 @@ Se usa el puerto TCP %1$s cuando no se ha especificado otro. Usar puerto web Silenciar todo - Menciones sin leer ¡Puedes mencionar hasta %1$s miembros por mensaje! Los miembros pueden informar de mensajes a los moderadores. No se permite informar de mensajes a los moderadores. @@ -2466,7 +2386,6 @@ Enviar solicitud Enviar solicitud sin mensaje Envía tu comentario privado a los grupos. - Enviadp a tu contacto tras la conexión. ¿Actualizar enlace de grupo? Actualizar ¿Actualizar la dirección? @@ -2510,7 +2429,6 @@ Actualizar enlace de grupo Solicitudes de contacto en grupos conexión solicitada desde el grupo %1$s - Esta configuración se aplica al perfil actual Miembro eliminado, no puede aceptar solicitudes Se permiten archivos y multimedia pero sólo si tu contacto también los permite. Permes que tus contactos envíen archivos y multimedia. @@ -2541,7 +2459,6 @@ Eliminar miembro y sus mensajes Todos los mensajes Archivos - Filtro Imágenes Enlaces Buscar archivos @@ -2592,7 +2509,6 @@ Canal Título completo: Enlace del canal - Miembros canal Título del canal perfil del canal actualizado El canal será eliminado para todos los suscriptores. ¡No puede deshacerse! @@ -2608,7 +2524,6 @@ caído (%1$d intentos) Editar perfil del canal Error al crear el canal - Error al abrir el canal error:%s Error al guardar el perfil del canal fallo @@ -2629,7 +2544,6 @@ Ningún servidor de chat activado. Hay servidores no conectados Abrir canal - Abrir canal nuevo Propietario Propietarios y colaboradores Direcciones predefinidas @@ -2804,7 +2718,6 @@ Las previsualizaciones de enlaces se solicitan a través del proxy SOCKS. Las peticiónes DNS aún pueden usar el DNS local del sistema. Menú superior Tu nuevo canal %1$s está conectado a %2$d de %3$d servidores.\nSi cancelas, el canal será eliminado. Puedes crearlo de nuevo. - Añadir Añadir servidor Añadir servidores Cancelar y eliminar el canal @@ -2843,7 +2756,6 @@ Se mostrará a los suscriptores y se usará para permitir la carga de la vista previa. Más privacidad Solo la página superior puede mostrar la vista previa. - Por favor, actualiza la aplicación. rechazado rechazado por el operador del servidor %s ha participado en la financiación colectiva de SimpleX Chat. @@ -2858,7 +2770,6 @@ %1$d propietarios %1$d propietarios y colaboradores %1$s ha apoyado a SimpleX Chat. La insignia caducó el %2$s. - Acerca de lista confirmada Añade este código a tu web. Mostrará una vista previa de tu canal o grupo. Opciones avanzadas @@ -2871,10 +2782,6 @@ No se pudo verificar la insignia Web del canal Datos del chat - Nombre de canal no compatible - Nombre de contacto no compatible - Para conectarte mediante el nombre del canal es necesaria una versión más reciente de la aplicación. - Para conectarse mediante el nombre de un contacto es necesaria una versión más reciente de la aplicación. Este grupo requiere una versión más reciente de la aplicación. Por favor, actualizala para unirte. Código web Los servidores usados no admiten páginas web. @@ -2892,7 +2799,6 @@ Error de resolución: %1$s El servidor %1$s no admite la resolución de nombres. Configura un servidor, o usa un enlace para conectarte. Escribe el nombre SimpleX - Nombre SimpleX Error del nombre SimpleX Nombre SimpleX no verificado El nombre SimpleX %1$s está registrado, pero no tiene un enlace válido. 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 b65fc5ab93..65f1769787 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml @@ -16,7 +16,6 @@ استفاده از پروفایل ناشناس جدید در حال باز کردن پایگاه داده… پروفایل شما به مخاطبی که این لینک را از او دریافت کردید، فرستاده خواهد شد. - اتصال ناشناس شما یک مسیر نامعتبر فایل به اشتراک گذاشتید. موضوع را به توسعه‌دهندگان برنامه گزارش دهید. هنوز از دریافت فایل پشتیبانی نمی‌شود قالب پیام نامعتبر @@ -32,7 +31,6 @@ به گروه می‌پیوندید؟ استفاده از پروفایل کنونی به تمام اعضای گروه متصل خواهید شد. - متصل شدن مسیر نامعتبر فایل برنامه از کار افتاد در حال تلاش برای اتصال به سرور مورد استفاده برای دریافت پیام‌ها از این مخاطب (خطا: %1$s). @@ -66,8 +64,6 @@ توصیف لینک کامل لینک‌های SimpleX - خطا در ذخیره کردن سرورهای SMP - خطا در ذخیره کردن سرورهای XFTP خطای تجدید مذاکره رمزنگاری اتصال %1$d اتصال برقرار شد @@ -82,7 +78,6 @@ دعوت یک بار مصرف SimpleX در حال اتصال… باز کردن لینک در مرورگر ممکن است حریم خصوصی و امنیت اتصال را کاهش دهد. لینک‌های SimpleX غیر قابل اعتماد به رنگ قرمز خواهند بود. - خطا در بارگیری سرورهای XFTP خطا در ایجاد پروفایل! خطا در تعویض پروفایل! توقف اتصال @@ -94,7 +89,6 @@ فرستنده انتقال فایل را لغو کرد. شما از قبل به %1$s متصل هستید. خطا در اتصال (تصدیق) - خطا در بارگیری سرورهای SMP عدم موفقیت در بارگیری چت‌ها لطفا برنامه را به‌روزرسانی کنید و با توسعه‌دهندگان تماس بگیرید. نام نمایشی همسان! @@ -106,13 +100,11 @@ مخاطب از قبل وجود دارد لینک اتصال نامعتبر لطفا بررسی کنید که از لینک صحیح استفاده کردید یا از مخاطبتان بخواهید لینک دیگری برایتان بفرستد. - مطمئن شوید قالب نشانی‌های سرور SMP صحیح است، در خط‌های جدا نوشته شده و تکرار نشده‌اند. خطا در به‌روزرسانی پیکربندی شبکه عدم موفقیت در بارگیری چت نام نمایشی نامعتبر! شما یک پروفایل چت با نام نمایشی یکسان دارید، لطفا نام دیگری انتخاب کنید. خطا در ایجاد نشانی - مطمئن شوید قالب نشانی‌های سرور XFTP صحیح است، در خط‌های جدا نوشته شده و تکرار نشده‌اند. خطا در پذیرش درخواست مخاطب فرستنده ممکن است درخواست اتصال را حذف کرده باشد. خطا در حذف یادداشت‌های خصوصی @@ -227,7 +219,6 @@ پیام برای تمام اعضا به عنوان حذف شده علامت‌گذاری خواهد شد. توقف دریافت فایل متوقف خواهد شد. - خوش آمدید! خطا در کدبرداری ارسال پیام مستقیم برای اتصال لطفا تا زمانی که فایل در حال بارگیری از موبایل متصل است، منتظر باشید. @@ -237,7 +228,6 @@ ارسال پیام ناپدید شونده لینک دعوت یک‌بارمصرف ایجاد گروه محرمانه - (برای اشتراک‌گذاری با مخاطبتان) اگر لینک دعوت SimpleX Chat دریافت کردید، می‌توانید آن را در مرورگر خود باز کنید: ورود کد عبور %d ثانیه @@ -297,7 +287,6 @@ ارسال غیرمجاز خوانده نشده خوش آمدید، %1$s! - این متن در تنظیمات دردسترس است چت با توسعه‌دهندگان چت فیلتر شده‌ای نیست به %1$s متصل شوید؟ @@ -370,13 +359,11 @@ شما به گروه دعوت شده‌اید پیوستن به عنوان %s جستجو یا الصاق لینک SimpleX - برای شروع چت جدید ضربه بزنید اشتراک‌گذاری پیام… در انتظار تصویر پیام صوتی… تعیین نام مخاطب… قطع شد - در حال انتظار نشانی‌ دریافت تغییر کند؟ تغییر نشانی‌ لغو خواهد شد. نشانی‌ دریافت پیشین استفاده خواهد شد. مذاکره مجدد @@ -384,7 +371,6 @@ تایید بازنشاندن اسکن کد QR - (اسکن یا الصاق از کلیپ بورد) (تنها ذخیره شده توسط اعضای گروه) فعال کردن دسترسی دوربین اجازه داده نشد! @@ -403,10 +389,8 @@ ضبط پیام صوتی ارسال پیام بدون جزئیات - اتصال به وسیله لینک / کد QR دوربین موجود نیست تصویر - ایجاد لینک دعوت یک‌بارمصرف برای پرسش هر سوال و دریافت اطلاعات به توسعه‌دهندگان SimpleX Chat متصل شوید.]]> افزودن مخاطب: برای ایجاد لینک دعوت جدید، یا اتصال از طریق لینکی که دریافت کردید.]]> اتصال از طریق لینک @@ -477,13 +461,11 @@ یا کد QR را اسکن کنید تلاش مجدد عبارت عبور و اکسپورت پایگاه داده - افزودن سرورهای از پیش تنظیم شده افزودن به دستگاه دیگر حذف سرور چگونه از سرورهای خود استفاده کنید تصویر پیش‌نمایش لینک پروفایل‌های چت شما - سرورهای SMP SimpleX Chat را برای ترمینال نصب کنید در GitHub ستاره بدهید همکاری کنید @@ -505,23 +487,17 @@ کمک لوگوی SimpleX ایمیل - این لینک، یک لینک اتصال معتبر نیست! درخواست اتصال ارسال شد! وقتی دستگاه میزبان گروه آنلاین شد، به گروه متصل خواهید شد، لطفا صبر کنید یا بعدا بررسی کنید! - لینکی که دریافت کردید را الصاق کنید تا به مخاطبتان متصل شوید… پروفایل شما %1$s به اشتراک گذاشته خواهد شد. برای اتصال، مخاطبتان می‌تواند کد QR را اسکن یا از لینک در برنامه استفاده کند. اگر نمی‌توانید ملاقات حضوری داشته باشید، کد QR را در یک تماس تصویری نمایش دهید، یا لینک را به اشتراک بگذارید. - می‌توانید نشانی خود را به صورت لینک یا کد QR به اشتراک بگذارید - هر کسی می‌تواند به شما متصل شود. - وقتی اشخاص درخواست اتصال کنند، شما می‌توانید آن را بپذیرید یا رد کنید. یا این کد را نشان دهید می‌توانید دوباره لینک دعوت را در جزئیات اتصال مشاهده کنید. نگه‌داشتن در حال ایجاد لینک… لینکی که دریافت کردید را الصاق کنید متن الصاقی شما یک لینک SimpleX نیست. - سرور شما - سرور از پیش تنظیم شده نشانی‌ سرور نامعتبر! به‌کارگیری برای اتصال‌های جدید کد QR نامعتبر @@ -530,13 +506,11 @@ علامت‌گذاری به عنوان تایید شده %s تایید نشده است برای تایید رمزنگاری انتها به انتها، روی دستگاه‌های خود، کد را با مخاطبتان مقایسه(یا اسکن) کنید. - سرورهای XFTP شما روش استفاده در حال استفاده از سرورهای SimpleX Chat. تنظیم سرورهای ICE می‌خواهد به شما متصل شود! وقتی دستگاه مخاطبتان آنلاین شد، متصل خواهید شد، لطفا صبر کنید یا بعدا بررسی کنید! - کد QR را در تماس تصویری اسکن کنید، یا مخاطبتان می‌تواند یک لینک دعوت به اشتراک بگذارد.]]> دعوت استعمال نشده نگه داشته شود؟ نشانی‌ SimpleX شما مارکداون در پیام‌ها @@ -549,14 +523,11 @@ عدم موفقیت آزمایش سرور! برخی از سرورها در تست ناموفق بودند: اسکن کد QR سرور - سرورهای SMP شما - سرورهای XFTP از سرورهای SimpleX Chat استفاده شود؟ سرورهای WebRTC ICE ذخیره شده حذف خواهند شد. سرورهای ICE شما اتصال الصاق - باز کردن در برنامه موبایل کلیک کنید.]]> افزودن مخاطب نشانی‌ SimpleX پاک‌سازی تایید @@ -571,7 +542,6 @@ لازم است مخاطبتان آنلاین باشد تا اتصال کامل شود. \nمی‌توانید این اتصال را لغو و مخاطب را حذف کنید (و بعدا با یک لینک جدید امتحان کنید). نشانی‌ SimpleX - این کد QR یک لینک نیست! راهنمای کاربر.]]> این رشته متن، یک لینک اتصال نیست! کدی که اسکن کردید یک کد QR لینک SimpleX نیست. @@ -579,10 +549,7 @@ تصویر پروفایل بیشتر نمایش کد QR - کد QR نامعتبر وقتی درخواست اتصال شما پذیرفته شد، متصل خواهید شد، لطفا صبر کنید یا بعدا بررسی کنید! - کد QR را در تماس تصویری نمایش دهید، یا لینک را به اشتراک بگذارید.]]> - پروفایل چت شما ارسال خواهد شد \nبه مخاطبتان اطلاعات بیشتر اگر بعدا نشانی‌ خود را حذف کنید، مخاطبان خود را از دست نخواهید داد. این لینک دعوت یک‌بارمصرف را به اشتراک بگذارید @@ -640,7 +607,6 @@ اگر تایید کنید، سرورهای پیام‌رسانی خواهند توانست نشانی‌ IP، و فراهم‌کننده شما را ببینند - و این که به چه سرورهایی متصل می‌شوید. مطمئن شوید قالب نشانی‌های سرور WebRTC ICE صحیح است، در خط‌های جدا نوشته شده و تکرار نشده‌اند. برای هر مخاطب و عضو گروه استفاده خواهد شد. \nلطفا توجه داشته باشید: اگر اتصال‌های زیادی داشته باشید، مصرف باتری و ترافیک شما می‌تواند به شکل قابل توجه بالاتر باشد و بعضی اتصال‌ها ممکن است با موفقیت انجام نشوند.]]> - حالت انزوای ترابری به روز شود؟ ویرایش تصویر ایجاد نشانی‌ SimpleX اشتراک‌گذاری با مخاطبان @@ -652,7 +618,6 @@ حذف نشانی سلام! \nبه وسیله SimpleX Chat به من متصل شوید: %s - نشانی ایجاد نشود ادامه پروفایل فعلی شما نام کامل: @@ -664,8 +629,6 @@ ذخیره کلمه عبور پروفایل اشتراک‌گذاری نشانی متوقف شود؟ توقف اشتراک‌گذاری - می‌توانید بعدا آن را ایجاد کنید - می‌توانید آن را از طریق تنظیمات برای مخاطبان SimpleX خود قابل رویت کنید. نام پروفایل: حذف تصویر ذخیره کردن و اطلاع به اعضای گروه @@ -677,7 +640,6 @@ کلمه عبور پروفایل پنهان به پروفایل خود نشانی اضافه کنید، تا مخاطبانتان بتوانند آن را با اشخاص دیگر به اشتراک بگذارند. به‌روزرسانی پروفایل به مخاطبانتان ارسال خواهد شد. نشانی با مخاطبان به اشتراک گذاشته شود؟ - یک نشانی ایجاد کنید تا اشخاص بتوانند به شما متصل شوند. پروفایل شما روی دستگاهتان ذخیره شده و فقط با مخاطبانتان به اشتراک گذاشته می‌شود. سرورهای SimpleX قادر به دیدن پروفایل شما نیستند. خطا در ذخیره کردن کلمه عبور کاربر تماس‌های صوتی و تصویری @@ -727,15 +689,11 @@ اکسپورت پایگاه داده پروفایل چت حذف شود؟ نام خود را وارد کنید: - ایجاد روش استفاده از مارکداون می‌توانید از مارکداون برای آرایش پیام‌ها استفاده کنید: تماس رد شده تماس پذیرفته - نامتمرکز پروفایل خود را ایجاد کنید - مخزن GitHub ما.]]> - استفاده از چت بهترین گزینه برای باتری. شما اعلان‌ها را فقط وقتی دریافت می‌کنید که برنامه در حال اجراست (بدون سرویس پس‌زمینه).]]> تماس‌ها روی صفحه قفل: پذیرفتن @@ -771,11 +729,9 @@ حذف پایگاه داده پایگاه داده چت ایمپورت شد %d فایل با اندازه کل %s - فقط دستگاه‌های کلاینت پروفایل‌های کاربری، مخاطبان، گروه‌ها و پیام‌ها را ذخیره می‌کنند. نادیده گرفتن بلوتوث وارد کردن پایگاه داده - شما تصمیم می‌گیرید که چه کسی می‌تواند متصل شود. تماس از پیش پایان یافته! هش پیام ناصحیح پذیرفتن خودکار تصاویر @@ -786,18 +742,15 @@ ارسال رسید برای %d گروه فعال است ارسال رسید برای %d گروه غیرفعال است حمایت از SimpleX Chat - پروکسی SOCKS استفاده از کامپیوتر آرشیو پایگاه داده جدید آرشیو پایگاه داده قدیمی خطا در شروع چت - مصونیت در برابر هرزنامه چگونه کار می‌کند تماس تصویری گوشی بلندگو هدفون‌ها - آینده پیام‌رسانی پایان یافت خطا در باز کردن مرورگر عبارت عبور برای اکسپورت را تعیین کنید @@ -808,7 +761,6 @@ چت متوقف شده است خطا در حذف پایگاه داده چت خطا در ایمپورت کردن پایگاه داده چت - شما چت خود را کنترل می‌کنید! پروفایل، مخاطبان و پیام‌های تحویل داده شده شما روی دستگاهتان ذخیره می‌شوند. پروفایل فقط با مخاطبانتان به اشتراک گذاشته می‌شود. نام نمایشی نمی‌تواند شامل نویسه‌های فاصله باشد. @@ -823,7 +775,6 @@ تعیین عبارت عبور پایگاه داده استفاده از عبارت عبور تصادفی تماس صوتی - سرورهای ICE شما تماس در جریان است شناسه پیام ناصحیح حریم خصوصی و امنیت @@ -839,7 +790,6 @@ آن‌ها در تنظیمات مخاطب و گروه قابل جایگزینی هستند. گروه‌های کوچک (حداکثر ۲۰) تنظیمات - هیچ شناسه کاربری وجود ندارد اعلان‌های خصوصی عبارت عبور تصادفی در تنظیمات به صورت متن آشکار ذخیره می‌شود. \nمی‌توانید بعدا آن را تغییر دهید. @@ -858,7 +808,6 @@ کد عبور تغییر نکرد! ایجاد پروفایل مورب - ما هیچکدام از مخاطبان و پیام‌های(وقتی تحویل داده شدند) شما را روی سرورها ذخیره نمی‌کنیم. رنگی محرمانه در حال تماس… @@ -882,7 +831,6 @@ اتصال شبکه آیکون برنامه پایگاه داده چت - بن‌سازه پیام‌رسانی و کاربردی که از حریم خصوصی و امنیت شما محافظت می‌کند. گزینه خوب برای باتری. سرویس پس‌زمینه هر ۱۰ دقیقه پیام‌ها را بررسی می‌کند. ممکن است تماس‌ها یا پیام‌های ضروری را از دست دهید.]]> پیام‌های نادیده گرفته شده مرورگر وب پیش‌فرض برای تماس‌ها لازم است. لطفا مرورگر پیش‌فرض را در سیستم تنظیم کنید، و اطلاعات بیشتر را با توسعه‌دهندگان به اشتراک بگذارید. @@ -919,7 +867,6 @@ اعطای اجازه‌ها در تنظیمات این مجوز را در تنظیمات اندروید پیدا و به صورت دستی آن را اعطا کنید. باز کردن تنظیمات - هر کسی می‌تواند سرویس‌دهنده میزبانی کند. رسیدها غیرفعال شوند؟ فعال کردن (نگه‌داشتن مقدارهای جایگزین شده) اعطای اجازه‌ها @@ -927,8 +874,6 @@ دوربین دوربین و میکروفون اعطای اجازه‌ها برای برقراری تماس‌ها - تعریف مجدد حریم خصوصی - برای حفظ حریم خصوصی شما، SimpleX از شناسه‌های جداگانه برای هر یک از مخاطبان شما استفاده می‌کند. از باتری بیشتر استفاده می‌کند! سرویس پس‌زمینه همیشه در حال اجراست - اعلان‌ها به محض موجود شدن، نمایش داده می‌شوند.]]> وقتی می‌تواند اتفاق بیفتد که: \n۱. پیام‌ها در کلاینت فرستنده بعد از ۲ روز یا روی سرور بعد از ۳۰ روز منقضی شده باشند. @@ -1339,8 +1284,6 @@ تنظیمات گفت‌و‌گو فایل‌ها و رسانه تماس‌های صوتی/تصویری - " -\nموجود در نسخه 5.1" به مخاطبان خود اجازه ارسال پیام‌های صوتی می‌دهید. فقط زمانی اجازه حذف پیام‌ها به صورت غیرقابل برگشت را می‌دهید که مخاطب شما این اجازه را به شما بدهد. (۲۴ ساعت) پیام‌های ناپدید شونده ممنوع است. @@ -1576,10 +1519,8 @@ هرگز استفاده از مسیریابی خصوصی با سرورهای ناشناخته. استفاده از مسیریابی خصوصی با سرورهای ناشناخته وقتی نشانی IP محافظت نشده است. - حالت مسیریابی پیام بله پیام‌ها مستقیما فرستاده نشود، حتی اگر سرور مقصد شما از مسیریابی خصوصی پشتیبانی نکند. - گزینه پس‌رفت مسیریابی پیام نمایش وضعیت پیام برای محافظت از نشانی IP شما، مسیریابی خصوصی از سرورهای SMP شما به منظور تحویل پیام‌ها استفاده می‌کند. مسیریابی پیام خصوصی @@ -1797,7 +1738,6 @@ اطلاعات صف سرور: %1$s \n \nآخرین پیام دریافتی: %2$s - نمایش فهرست چت در پنجره جدید حالت تاریک سیاه حالت رنگ @@ -1972,7 +1912,6 @@ برای مجوز میکروفون، روی آیکون اطلاعات کنار نوار آدرس کلیک کنید تکمیل شد تایید حذف مخاطب؟ - اتصال اتصال متصل اتصال سریع تر! 🚀 @@ -1986,7 +1925,6 @@ باز کردن چت جدید باز کردن گروه جدید به منوی تنظیمات سافاری / وب‌سایت‌ها / و سپس میکروفن رفته و گزینه \"اجازه دادن برای localhost\" را انتخاب کنید. - باز کردن تنظیمات سرور باز کردن لینک باز کردن لینک‌ها از لیست چت‌ها باز کردن برای پذیرش @@ -2001,8 +1939,6 @@ یا به‌طور خصوصی به اشتراک بگذارید سایر سایر خطاها - سایر سرورهای SMP - سایر سرورهای XFTP عبارت عبور در کی‌استور قابل خواندن نیست، لطفاً آن را به‌صورت دستی وارد کنید. این ممکن است پس از یک به‌روزرسانی سیستم که با برنامه سازگار نیست، اتفاق افتاده باشد. اگر این مورد نیست، لطفاً با توسعه‌دهندگان تماس بگیرید. عبارت عبور در مخزن کلید قابل خواندن نیست. این ممکن است پس از به‌روزرسانی سیستم که با برنامه سازگار نیست، اتفاق افتاده باشد. اگر این طور نیست، لطفا با توسعه دهندگان تماس بگیرید. کلمه عبور @@ -2049,7 +1985,6 @@ رد شده رد کردن عضو؟ بعداً یادآوری کن - موبایل‌های از راه دور حذف کردن آرشیو؟ از گروه حذف شد اعضا حذف شوند؟ @@ -2075,7 +2010,6 @@ بررسی شرایط بررسی شده توسط مدیران بررسی اعضای گروه - بعداً بررسی شود بررسی اعضا بررسی اعضا قبل از پذیرش (کوبیدن). آیا تنظیمات پذیرش ذخیره شود؟ @@ -2089,7 +2023,6 @@ انتخاب پروفایل چت تنظیمات برگزیده چت، این پیام را ممنوع می‌کند. %d انتخاب شده - اپراتورهای شبکه را برای استفاده انتخاب کنید. تنظیم پذیرش اعضا تنظیم انقضای پیام در چت‌ها. تنظیمات @@ -2131,7 +2064,6 @@ انتها برای چت، روی \"اتصال\" ضربه بزنید. برای ارسال درخواست، روی \"اتصال\" ضربه بزنید. - برای ایجاد آدرس SimpleX در آینده، در منو روی \"ایجاد آدرس SimpleX\" ضربه بزنید. روی \"پیوستن به گروه\" ضربه بزنید. اتصال TCP وقفه زمانی اتصال TCP در پس‌زمینه @@ -2160,7 +2092,6 @@ با استفاده از SimpleX Chat شما موافقت می‌کنید که:\n- فقط محتوای قانونی را در گروه‌های عمومی ارسال کنید.\n- به سایر کاربران احترام بگذارید - از ارسال هرزنامه خودداری کنید. تماس با یک مخاطب استفاده شود - آن را به صورت حضوری یا از طریق هر پیام‌رسانی به اشتراک بگذارید.]]> - به صورت رمزنگاری انتها به انتهاو با امنیت پساکوانتومی در پیام‌های مستقیم ارسال می‌شوند.]]> %s.]]> %s.]]> رمزنگاری انتها به انتها محافظت می‌شوند.]]> @@ -2181,11 +2112,8 @@ بخش‌ها بارگذاری شدند شرایط در %s پذیرفته شد. شرایط استفاده - شرایط برای اپراتورهای فعال پس از ۳۰ روز پذیرفته خواهد شد. شرایط در: %s پذیرفته خواهد شد. شرایط به‌طور خودکار برای اپراتورهای فعال در: %s پذیرفته خواهد شد. - سرورهای SMP پیکربندی‌شده - سرورهای XFTP پیکربندی‌شده سرورهای متصل شده اتصال نیاز به تجدید مذاکره رمزنگاری دارد. اتصالات @@ -2307,7 +2235,6 @@ خاموش فقط مالکین چت می‌توانند تنظیمات را تغییر دهند. فقط گفتگو حذف شود - باز کردن باز کردن تغییرات - باز کردن چت برای اولین پیام خوانده نشده.\n- پرش به پیام‌های نقل‌قول شده. شرایط باز کردن @@ -2354,7 +2281,6 @@ اتصال اصلاح شود؟ اندازه فونت برای همه مدیران - برای بهبود حریم خصوصی فراداده. به عنوان مثال، اگر مخاطب شما پیام‌ها را از طریق یک سرور چت SimpleX دریافت کند، برنامه شما آن‌ها را از طریق یک سرور Flux تحویل خواهد داد. برای من برای مسیریابی خصوصی @@ -2407,7 +2333,6 @@ مستقیم ارسال شد پیام‌های ارسال شده تعداد کل ارسال شده - پس از اتصال به مخاطب شما ارسال شد. توسط پروکسی ارسال شد سرور سرور به اپراتور %s اضافه شد. @@ -2418,7 +2343,6 @@ اطلاعات سرورها آمار سرورها بازنشانی خواهد شد - این عمل غیرقابل بازگشت است! نام چت را تنظیم کنید… - گزارش برای شما بایگانی خواهد شد. نقش به %s تغییر خواهد کرد. همه در چت مطلع خواهند شد. دومین اپراتور پیش‌فرض در برنامه! فرستنده مطلع نخواهد شد. @@ -2437,8 +2361,6 @@ شفافیت جلسات Transport رفع انسداد اعضا برای همه؟ - منشن‌های خوانده نشده - به‌روزرسانی به‌روزرسانی موجود است: %s شرایط به‌روزرسانی شده دانلود به‌روزرسانی لغو شد @@ -2461,7 +2383,6 @@ ویدئو مشاهده شرایط مشاهده شرایط به‌روزرسانی شده - وب‌سایت پیام خوش‌آمدگویی زمانی که بیش از یک اپراتور فعال است، هیچ‌کدام از آن‌ها فراداده‌ای برای فهمیدن اینکه چه کسی با چه کسی ارتباط برقرار می‌کند، ندارند. سرور XFTP @@ -2469,7 +2390,6 @@ شما این عضو را پذیرفتید شما به این سرورها متصل نیستید. از مسیریابی خصوصی برای ارسال پیام‌ها به آن‌ها استفاده می‌شود. شما می‌توانید آن را در تنظیمات ظاهر تغییر دهید. - شما می‌توانید اپراتورها را در تنظیمات شبکه و سرورها پیکربندی کنید. شما می‌توانید سرورها را از طریق تنظیمات پیکربندی کنید. شما می‌توانید پیام را کپی کرده و اندازه آن را کاهش دهید تا ارسال شود. شما می‌توانید در هر پیام تا %1$s عضو را منشن کنید! @@ -2510,7 +2430,6 @@ ۴ زبان جدید رابط کاربری کاتالان، اندونزیایی، رومانیایی و ویتنامی - با تشکر از کاربران ما! عضو حذف شده است - نمی‌توان درخواست را قبول کرد - این تنظیمات برای پروفایل فعلی شماست درخواست‌های تماس از گروه‌ها درخواست اتصال از گروه %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 e5e7c525a5..5036d874d7 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml @@ -50,7 +50,6 @@ Yhteyden aikakatkaisu Kontaktin asetukset Jatka - Luo osoite, jolla ihmiset voivat ottaa sinuun yhteyttä. Tietokannan salauksen tunnuslause päivitetään ja tallennetaan Keystoreen. Poista keskusteluprofiili käyttäjälle poistettu @@ -96,7 +95,6 @@ Yhdistetäänkö kontaktilinkin kautta\? Liitytäänkö ryhmään? Yhdistetäänkö kutsulinkin kautta\? - Yhdistä yhdistää Kuvaus Yhdistä @@ -106,7 +104,6 @@ Dekoodauksen virhe peruuta linkin esikatselu Tietoja SimpleX:stä - Hajautettu Ääni pois päältä Vaihda rooli Poista kaikilta @@ -166,7 +163,6 @@ Poista ryhmä\? Tumma Tunnistautuminen epäonnistui - Lisää esiasetettuja palvelimia poistettu ryhmä yhdistää yhdistäminen (hyväksytty) @@ -215,8 +211,6 @@ Chat on pysähtynyt Vahvista uusi tunnuslause… Poista keskusteluprofiili\? - " -\nSaatavilla v5.1:ssä" Yksityisviestit Yhdistetty Vahvista @@ -235,7 +229,6 @@ oletus (%s) Katoavat viestit Kopioitu leikepöydälle - Luo kertaluonteinen kutsulinkki tietokantaversio on uudempi kuin sovellus, mutta ei alaspäin siirtymistä: %s erilainen siirto sovelluksessa/tietokannassa: %s / %s Hyväksy yhteydenottopyynnöt automaattisesti @@ -263,7 +256,6 @@ Kontakti ja kaikki viestit poistetaan - tätä ei voi perua! Katkaistu Takaisin - Yhdistä linkillä / QR-koodilla Tyhjennä Skannaa QR-koodi.]]> Poistetaanko odottava yhteys\? @@ -272,7 +264,6 @@ Tietoja SimpleX Chatistä Tietokannan tunnuslause ja vienti Luo profiili - Luo Poista käytöstä Ääni päällä Vastaa puheluun @@ -346,11 +337,8 @@ Poista jälkeen %d tunti päivää - Virhe XFTP-palvelimien tallentamisessa Päällekkäinen näyttönimi! Virhe profiilin luomisessa! - Virhe SMP-palvelimia ladattaessa - Virhe XFTP-palvelimia ladattaessa Virhe profiilin vaihdossa! Virhe ryhmään liittymisessä Virhe käyttäjäprofiilin poistamisessa @@ -370,14 +358,12 @@ Ryhmälinkki Otetaanko automaattinen viestien poisto käyttöön\? Tiedosto vastaanotetaan, kun kontakti on online-tilassa, odota tai tarkista myöhemmin! - skannata QR-koodin videopuhelussa , tai kontaktisi voi jakaa kutsulinkin.]]> Jos päätät hylätä, lähettäjälle EI ilmoiteta. Jos syötät tämän pääsykoodin sovellusta avatessasi, kaikki sovelluksen tiedot poistetaan peruuttamattomasti! Ryhmäprofiili tallennetaan jäsenten laitteille, ei palvelimille. Tuo teema t virhe - Virhe SMP-palvelimien tallentamisessa Koko linkki Virhe verkkoasetuksia päivitettäessä Keskustelun lataaminen epäonnistui @@ -494,7 +480,6 @@ Kutsu ystäviä Hei! \nOta minuun yhteyttä SimpleX Chatin kautta: %s - Älä luo osoitetta Muokkaa kuvaa Poistu tallentamatta Virhe käyttäjän salasanan tallentamisessa @@ -548,8 +533,6 @@ Kuva Galleriasta Virheellinen linkki! - Virheellinen QR-koodi - näytä QR-koodi videopuhelussa tai jaa linkki.]]> Virheellinen palvelimen osoite! Kirjoita nimesi: Käännä kamera @@ -573,7 +556,6 @@ Kuva Sähköposti Näyttönimi ei saa sisältää välilyöntejä. - Immuuni roskapostille ja väärinkäytöksille Virhe vietäessä keskustelujen tietokantaa Piilota Konsoliin @@ -629,7 +611,6 @@ Aseta pääsykoodi SimpleX-linkit Linkin avaaminen selaimessa voi heikentää yhteyden yksityisyyttä ja turvallisuutta. Epäluotetut SimpleX-linkit näkyvät punaisina. - Varmista, että SMP-palvelinosoitteet ovat oikeassa muodossa, rivieroteltuina ja että ne eivät ole päällekkäisiä. Lähettäjä peruutti tiedoston siirron. Säännölliset ilmoitukset Viestejä vastaanotetaan… @@ -697,11 +678,9 @@ Paljasta Lähetetty viesti Vain ryhmän omistajat voivat ottaa ääniviestit käyttöön. - Vireillä Tallenna ääniviesti Lähetä katoava viesti Lähetä live-viesti - Aloita uusi keskustelu napauttamalla Lähetä Verkkoasetukset profiilikuvan paikka @@ -715,7 +694,6 @@ Itsetuho Itsetuhoutuva pääsykoodi vaihdettu! Itsetuhoutuva pääsykoodi käytössä! - SOCKS välityspalvelin Uusi tietokanta-arkisto Ei vastaanotettuja tai lähetettyjä tiedostoja Poistetaanko tunnuslause Keystoresta\? @@ -776,7 +754,6 @@ Skannaa QR-koodi Lue lisää %s on vahvistettu - SMP-palvelimet Esikatselu Tallenna sek @@ -817,7 +794,6 @@ Tallenna palvelimet Palvelintesti epäonnistui! Jotkut palvelimet epäonnistuivat testissä: - Esiasetettu palvelin Arvioi sovellus Skannaa palvelimen QR-koodi Profiilipäivitys lähetetään kontakteillesi. @@ -828,9 +804,7 @@ Tallenna asetukset\? Jutellaan SimpleX Chatissa vahvistus saatu… - GitHub-arkistostamme.]]> Säännölliset - 2-kerroksisella päästä päähän -salauksella.]]> Liitä vastaanotettu linkki Välityspalvelin suojaa IP-osoitteesi, mutta se voi tarkkailla puhelun kestoa. Avaa SimpleX Chat hyväksyäksesi puhelun @@ -845,7 +819,6 @@ Kiitos käyttäjille – osallistu Weblaten kautta! Lähetä Lupa evätty! - (skannaa tai liitä leikepöydältä) Napauta -painike Näytä QR-koodi Kertakutsulinkki @@ -888,7 +861,6 @@ Edellisen viestin tarkiste on erilainen. Live-viestit Suojaa keskusteluprofiilisi salasanalla! - Varmista, että XFTP-palvelinosoitteet ovat oikeassa muodossa, rivieroteltuina ja että ne eivät ole päällekkäisiä. SimpleX Chat -viestit SimpleX Chat -puhelut Näytä kontakti ja viesti @@ -946,10 +918,6 @@ QR-koodi Skannaa koodi alkaa… - Yksityisyys uudelleen määritettynä - Ensimmäinen alusta ilman käyttäjätunnisteita – suunniteltu yksityiseksi. - Avoimen lähdekoodin protokolla ja koodi - kuka tahansa voi käyttää palvelimia. - Ihmiset voivat ottaa sinuun yhteyttä vain jakamiesi linkkien kautta. Luo yksityinen yhteys Ilmoita siitä kehittäjille. Suojaa sovellusnäyttö @@ -1017,8 +985,6 @@ Ääniviestit kielletty! Palvelimet nykyisen keskusteluprofiilisi uusille yhteyksille Käytä palvelinta - XFTP-palvelimet - Palvelimesi Palvelimesi osoite Tätä toimintoa ei voi kumota - valittua aikaisemmin lähetetyt ja vastaanotetut viestit poistetaan. Tämä voi kestää useita minuutteja. Uusimmat @@ -1057,7 +1023,6 @@ Video vastaanotetaan, kun kontaktisi on ladannut sen. Keskustelusi Käytä SOCKS-välityspalvelinta\? - Hallitset keskustelujasi! Nykyinen profiilisi Profiilisi tallennetaan laitteeseesi ja jaetaan vain kontaktiesi kanssa. SimpleX -palvelimet eivät näe profiiliasi. Teemat @@ -1089,10 +1054,8 @@ Näytä turvakoodi Ääniviesti (%1$s) Video - Voit jakaa osoitteesi linkkinä tai QR-koodina - kuka tahansa voi muodostaa yhteyden sinuun. Voit tarkistaa päästä päähän -salauksen kontaktisi kanssa vertaamalla (tai skannaamalla) laitteidenne koodia. Kontaktisi pysyvät yhdistettyinä. - Viestintä- ja sovellusalusta, joka suojaa yksityisyyttäsi ja tietoturvaasi. videopuhelu %1$s JÄSENET Rooli muuttuu muotoon "%s". Kaikille ryhmän jäsenille ilmoitetaan asiasta. @@ -1110,17 +1073,10 @@ Kontaktin tulee olla online-tilassa, jotta yhteys voidaan muodostaa. \nVoit peruuttaa tämän yhteyden ja poistaa kontaktin (ja yrittää myöhemmin uudella linkillä). Kontaktisi voi muodostaa yhteyden skannaamalla QR-koodin tai käyttämällä sovelluksessa olevaa linkkiä. - Kun ihmiset pyytävät yhteyden muodostamista, voit hyväksyä tai hylätä sen. Asetuksesi - SMP-palvelimesi - XFTP-palvelimesi Käytä SimpleX Chat palvelimia\? Käyttöliittymän värit - Päivitä kuljetuksen eristystila\? - Voit luoda sen myöhemmin Voit paljastaa piilotetun profiilisi kirjoittamalla koko salasanan Keskusteluprofiilit-sivun hakukenttään. - Emme tallenna mitään kontaktejasi tai viestejäsi (kun ne on toimitettu) palvelimille. - Yksityisyyden suojaamiseksi kaikkien muiden alustojen käyttämien käyttäjätunnusten sijaan SimpleX käyttää viestijonojen tunnisteita, jotka ovat kaikille kontakteille erillisiä. %1$s haluaa olla yhteydessä sinuun kautta WebRTC ICE -palvelimet Keskustelut-tietokantasi @@ -1134,9 +1090,6 @@ Valinnaisella tervetuloviestillä. Sinulla ei ole keskusteluja Liikaa kuvia! - (jaa kontaktisi kanssa) - Keskusteluprofiilisi lähetetään -\nkontakteillesi ICE-palvelimesi Kun saatavilla Käytä .onion-isäntiä @@ -1158,12 +1111,9 @@ \n- muokattava kesto katoaville. \n- muokkaushistoria. Voit ottaa SimpleX Lockin käyttöön Asetusten kautta. - Tämä teksti on saatavilla asetuksista - Tervetuloa! lukematon SimpleX Logo SimpleX Tiimi - Tämä linkki ei ole kelvollinen yhteyslinkki! Keskusteluprofiilisi Lataa tiedosto SimpleX-taustapalvelu – se kuluttaa muutaman prosentin akusta päivässä.]]> @@ -1179,19 +1129,15 @@ Vastaanotto-osoite vaihdetaan toiseen palvelimeen. Osoitteenmuutos tehdään sen jälkeen, kun lähettäjä tulee verkkoon. Sinun on sallittava kontaktiesi lähettää ääniviestejä, jotta voit lähettää niitä. olla yhteydessä SimpleX Chatin -kehittäjiin kysyäksesi kysymyksiä ja saadaksesi päivityksiä.]]> - Tämä QR-koodi ei ole linkki! Sinut yhdistetään ryhmään, kun ryhmän isännän laite on online-tilassa, odota tai tarkista myöhemmin! Sinut yhdistetään, kun yhteyspyyntösi on hyväksytty, odota tai tarkista myöhemmin! Sinut yhdistetään, kun kontaktisi laite on online-tilassa, odota tai tarkista myöhemmin! - Avaa mobiilisovelluksessa -painiketta.]]> SimpleX-osoitteesi Käytä uusiin yhteyksiin Käytä SOCKS-välityspalvelinta Kuljetuksen eristäminen Profiilisi, kontaktisi ja toimitetut viestit tallennetaan laitteellesi. Profiili jaetaan vain kontaktiesi kanssa. - Käytä chattia - ICE-palvelimesi Video pois päältä Video päällä Et enää saa viestejä tästä ryhmästä. Keskusteluhistoria säilytetään. @@ -1209,7 +1155,6 @@ Varoitus: saatat menettää joitain tietoja! SimpleX Osoite odottaa vastaamista… - Seuraavan sukupolven yksityisviestit odottaa vahvistusta… %1$d viestit ohitettu. Seuraavan viestin tunnus on väärä (pienempi tai yhtä suuri kuin edellisen). @@ -1247,7 +1192,6 @@ Protokollan aikakatkaisu per KB Tiedostot ja media ovat tässä ryhmässä kiellettyjä. Ryhmän jäsenet voivat lähettää tiedostoja ja mediaa. - Yhdistä Incognito Käytä nykyistä profiilia Käytä uutta incognito-profiilia Salli @@ -1263,7 +1207,6 @@ Suosikki Epäsuosikki Uusi satunnainen profiili jaetaan. - Liitä linkki, jonka sait yhteydenottoon kontaktisi kanssa… Profiilisi %1$s jaetaan. Kuittaukset pois käytöstä ryhmiltä\? Tulossa pian! 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 bfbe32e819..51cfa90ff1 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml @@ -5,7 +5,6 @@ SimpleX Votre profil va être envoyé au contact qui vous a envoyé ce lien. Vous vous connecterez à tous les membres du groupe. - Se connecter Rejoindre le groupe ? Se connecter via un lien unique ? erreur @@ -45,7 +44,6 @@ vous avez partagé un lien unique via un lien unique mode incognito via un lien unique - Assurez-vous que les adresses des serveurs SMP sont au bon format, séparées par des lignes et ne sont pas dupliquées. Erreur lors de la mise à jour de la configuration réseau Erreur lors de la création de l\'adresse Contact déjà existant @@ -55,7 +53,6 @@ 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 Impossible de recevoir le fichier Lien de connection invalide Délai de connexion @@ -137,11 +134,9 @@ Échec d’initialisation de la base de données Pour recevoir des notifications, veuillez entrer la phrase secrète de la base de données service SimpleX Chat - Ce texte est disponible dans les paramètres Rejoindre en tant que %s Vous êtes invité·e au groupe Discuter avec les développeurs - Appuyez ici pour démarrer une nouvelle conversation Vous n\'avez aucune conversation Trop d’images ! Partager le fichier… @@ -150,7 +145,6 @@ Annuler l’aperçu du fichier échec d’envoi non lu - Bienvenue ! connexion… connexion… Partager le message… @@ -192,11 +186,8 @@ aucun détail Lien d\'invitation unique Copié dans le presse-papiers - Créer un lien d\'invitation unique Commencer une nouvelle conversation - Se connecter via un lien / code QR Scanner un QR code - (à partager avec votre contact) Créer un groupe secret Depuis la Phototèque Fichier @@ -233,19 +224,14 @@ Équipe SimpleX Plus Afficher le code QR - Code QR invalide - Ce code QR n\'est pas un lien ! Vous serez connecté·e lorsque votre demande de connexion sera acceptée, veuillez attendre ou vérifier plus tard ! Vous serez connecté·e lorsque l\'appareil de votre contact sera en ligne, veuillez attendre ou vérifier plus tard ! - Votre profil de messagerie sera envoyé\nà votre contact Partager un lien unique Coller Cette chaîne n\'est pas un lien de connexion ! - Ouvrir dans l\'app mobile.]]> Définir le nom du contact… Déconnecté Erreur - En attente Accepter la demande de connexion \? Effacer Effacer la conversation @@ -261,7 +247,6 @@ Confirmer Réinitialisation OK - (scanner ou coller depuis le presse-papiers) (uniquement stocké par les membres du groupe) Votre contact a besoin d\'être en ligne pour completer la connexion. \nVous pouvez annuler la connexion et supprimer le contact (et réessayer plus tard avec un autre lien). @@ -297,7 +282,6 @@ Réactiver le son Le contact avec lequel vous avez partagé ce lien NE pourra PAS se connecter ! Lien invalide ! - Ce lien n\'est pas un lien de connexion valide ! Demande de connexion envoyée ! Le fichier sera reçu lorsque votre contact sera en ligne, veuillez patienter ou vérifier plus tard ! Message vocal… @@ -306,13 +290,10 @@ Notifications L\'adresse de réception sera changée pour un autre serveur. Le changement d\'adresse sera terminé lorsque l\'expéditeur sera en ligne. Vous serez connecté·e au groupe lorsque l\'appareil de l\'hôte sera en ligne, veuillez attendre ou vérifier plus tard ! - montrez le code QR lors d\'un appel vidéo, ou partagez le lien.]]> Scannez le code de sécurité depuis l\'application de votre contact. Pour vérifier le chiffrement de bout en bout avec votre contact, comparez (ou scannez) le code sur vos appareils. - scanner un QR code lors d\'un appel vidéo, ou votre contact peut partager un lien d\'invitation.]]> Ajouter un serveur Markdown dans les messages - Ajouter des serveurs prédéfinis Utiliser les serveurs SimpleX Chat \? Supprimer le serveur Assurez-vous que les adresses des serveurs WebRTC ICE sont au bon format et ne sont pas dupliquées, un par ligne. @@ -321,7 +302,6 @@ Vos paramètres Verrouillage SimpleX Console de la messagerie - Serveurs SMP Tester les serveurs Enregistrer les serveurs Scanner un QR code de serveur @@ -332,7 +312,6 @@ Star sur GitHub Contribuer Évaluer l\'app - Vos serveurs SMP Comment utiliser vos serveurs Les serveurs WebRTC ICE sauvegardés seront supprimés. Vos serveurs ICE @@ -354,7 +333,6 @@ Votre profil, vos contacts et les messages reçus sont stockés sur votre appareil. Le profil n\'est partagé qu\'avec vos contacts. Le nom d\'affichage ne peut pas contenir d\'espace. - Créer À propos de SimpleX Vous pouvez utiliser le format markdown pour mettre en forme les messages : gras @@ -366,10 +344,7 @@ réponse reçu… confimation reçu… connexion… - N\'importe qui peut heberger un serveur. - Pour protéger votre vie privée, SimpleX utilise des identifiants distincts pour chacun de vos contacts. Collez le lien que vous avez reçu - Utiliser la messagerie Notifications privées Comment il affecte la batterie Quand l\'application fonctionne @@ -398,8 +373,6 @@ Échec du test du serveur ! Certains serveurs n\'ont pas réussi le test : Entrer un serveur manuellement - Serveur prédéfini - Votre serveur Votre adresse de serveur Adresse de serveur invalide ! Vérifiez l\'adresse du serveur et réessayez. @@ -420,9 +393,6 @@ Votre profil est stocké sur votre appareil et partagé uniquement avec vos contacts. Les serveurs SimpleX ne peuvent pas voir votre profil. Supprimer l\'image Enregistrer les préférences ? - Vous contrôlez votre messagerie ! - La plateforme de messagerie et d\'applications qui protège votre vie privée et votre sécurité. - Nous ne stockons aucun de vos contacts ou messages (une fois délivrés) sur les serveurs. Créer le profil Saisissez votre nom : Comment utiliser markdown @@ -435,17 +405,9 @@ en attente de confirmation… connecté terminé - L\'avenir de la messagerie - La vie privée redéfinie - Aucun identifiant d\'utilisateur. - Protégé du spam - Vous choisissez qui peut se connecter. - Décentralisé Créez votre profil Établir une connexion privée Comment ça fonctionne - Seuls les appareils clients stockent les profils des utilisateurs, les contacts, les groupes et les messages. - GitHub repository.]]> Batterie peu utilisée. L\'app vérifie les messages toutes les 10 minutes. Vous risquez de manquer des appels ou des messages urgents.]]> Consomme davantage de batterie ! L’application fonctionne toujours en arrière-plan — les notifications sont affichées instantanément.]]> %1$d message(s) manqué(s) @@ -516,7 +478,6 @@ Appels audio et vidéo chiffré de bout en bout Fonctionnalités expérimentales - SOCKS proxy Thèmes Messages et fichiers Appels @@ -570,7 +531,6 @@ Appels sur l\'écran de verrouillage : Afficher Désactiver - Vos serveurs ICE Serveurs WebRTC ICE Le serveur relais protège votre adresse IP, mais il peut observer la durée de l\'appel. Le serveur relais n\'est utilisé que si nécessaire. Un tiers peut observer votre adresse IP. @@ -896,7 +856,6 @@ Effacer les fichiers de tous les profils de messagerie Profil et connexions au serveur Transport isolé - Mettre à jour le mode d\'isolement du transport \? pour chaque contact et membre de groupe. \nVeuillez noter : si vous avez de nombreuses connexions, votre consommation de batterie et de réseau peut être nettement plus élevée et certaines liaisons peuvent échouer.]]> Profil de messagerie Ajouter un profil @@ -1014,16 +973,10 @@ Seulement 10 vidéos peuvent être envoyées en même temps Créer un fichier Supprimer le fichier - Erreur lors de la sauvegarde des serveurs XFTP - Assurez-vous que les adresses des serveurs XFTP sont au bon format, séparées par des lignes et qu\'elles ne sont pas dupliquées. Le serveur requiert une autorisation pour téléverser, vérifiez le mot de passe. Téléverser le fichier - Serveurs XFTP - Vos serveurs XFTP Comparer le fichier Télécharger le fichier - Erreur lors du chargement des serveurs SMP - Erreur lors du chargement des serveurs XFTP Héberger Port port %d @@ -1064,7 +1017,6 @@ Autoriser les appels que si votre contact les autorise. Autorise vos contacts à vous appeler. Appels audio/vidéo - \nDisponible dans la v5.1 Interdire les appels audio/vidéo. Le fichier sera supprimé des serveurs. Révoquer @@ -1098,10 +1050,8 @@ Ouverture de la base de données… À propos de l\'adresse SimpleX En savoir plus - Vous pouvez partager votre adresse sous la forme d\'un lien ou d\'un code QR - tout le monde peut l\'utiliser pour vous contacter. Vous ne perdrez pas vos contacts si vous supprimez votre adresse ultérieurement. Adresse SimpleX - Vous pouvez accepter ou refuser les demandes de contacts. Couleurs de l\'interface Vos contacts resteront connectés. Partager l\'adresse avec les contacts SimpleX ? @@ -1112,7 +1062,6 @@ Inviter des amis Discutons sur SimpleX Chat Enregistrer les paramètres ? - Ne pas créer d\'adresse Adresse Partager l\'adresse… Vous pouvez partager cette adresse avec vos contacts pour leur permettre de se connecter avec %s. @@ -1136,9 +1085,7 @@ Personnaliser le thème Continuer Erreur lors du réglage de l\'adresse - Vous pouvez créer une adresse pour permettre aux autres utilisateurs de vous contacter. Entrez un message de bienvenue… - Vous pouvez la créer plus tard Salut !\nConnectez-vous avec moi via SimpleX Chat : %s Si vous ne pouvez pas vous rencontrer en personne, montrez le code QR lors d\'un appel vidéo ou partagez le lien. Modifier les profils de messagerie @@ -1333,10 +1280,8 @@ Désactiver les notifications Utilisation de la batterie de l\'app / Sans restriction dans les paramètres de l\'app.]]> Votre profil %1$s sera partagé. - Se connecter incognito Pas d\'appels en arrière-plan Ouvrir les paramètres de l\'app - Collez le lien que vous avez reçu pour vous connecter à votre contact… SimpleX ne peut pas fonctionner en arrière-plan. Vous ne recevrez les notifications que lorsque l\'application sera en cours d\'exécution. L\'application peut se fermer après 1 minute en arrière-plan. Utilisation de la batterie de l\'app / Sans restriction dans les paramètres de l\'app.]]> @@ -1504,7 +1449,6 @@ Ouvrir le port de votre pare-feu erreur d\'affichage de contenu erreur d\'affichage de message - Vous pouvez le rendre visible à vos contacts SimpleX via les Paramètres. L\'historique n\'est pas envoyé aux nouveaux membres. Réessayer Caméra non disponible @@ -1764,13 +1708,11 @@ Toujours Routage privé Autoriser la rétrogradation - Mode de routage des messages Jamais Serveurs inconnus Non Lorsque l\'IP est masquée Oui - Rabattement du routage des messages Afficher le statut du message Protection de l\'adresse IP Fichiers @@ -1803,7 +1745,6 @@ Protégez votre adresse IP des relais de messagerie choisis par vos contacts. \nActivez-le dans les paramètres *Réseau et serveurs*. Réinitialiser au thème de l\'utilisateur - Afficher la liste des conversations dans une nouvelle fenêtre Teinte du fond d\'écran Fond d\'écran info sur la file du serveur : %1$s @@ -1900,14 +1841,10 @@ Bêta Vérifier les mises à jour Vérifier les mises à jour - Serveurs SMP configurés - Serveurs XFTP configurés Désactivé Installé avec succès Installer la mise à jour Ouvrir l\'emplacement du fichier - Autres serveurs SMP - Autres serveurs XFTP Veuillez redémarrer l\'application. Rappeler plus tard Afficher le pourcentage @@ -1957,7 +1894,6 @@ Erreurs de suppression doublons expiré - Ouvrir les paramètres du serveur autre autres erreurs Sécurisées @@ -1979,9 +1915,7 @@ Off Léger Paramètres - se connecter message - ouvrir Confirmer la suppression du contact ? Le contact sera supprimé — cette action est irréversible ! Supprimer sans notification @@ -2139,7 +2073,6 @@ %s.]]> Les conditions seront acceptées le : %s. %s, acceptez les conditions d\'utilisation.]]> - chiffrés de bout en bout, avec une sécurité post-quantique dans les messages directs.]]> Réception des messages toutes les 10 minutes %s.]]> %s.]]> @@ -2171,7 +2104,6 @@ Appareils Xiaomi : veuillez activer le démarrage automatique dans les paramètres du système pour que les notifications fonctionnent.]]> La conversation sera supprimée pour tous les membres — cette action est irréversible ! La conversation sera supprimée pour vous — cette action est irréversible ! - Les conditions seront acceptées pour les opérateurs activés après 30 jours. La connexion nécessite une renégociation du chiffrement. avec un seul contact - partagez en personne ou via n\'importe quelle messagerie.]]> Adresse ou lien unique ? @@ -2188,7 +2120,6 @@ Pas de serveurs pour recevoir des messages. Pas de message Pour les réseaux sociaux - Vérifier plus tard Serveurs prédéfinis Signaler autre : seuls les modérateurs du groupe le verront. Activer les journaux (logs) @@ -2216,7 +2147,6 @@ Le protocole du serveur a été modifié. Activez Flux dans les paramètres Réseau et serveurs pour une meilleure confidentialité des métadonnées. Ce message a été supprimé ou n\'a pas encore été reçu. - Appuyez sur Créer une adresse SimpleX dans le menu pour la créer ultérieurement. Partager publiquement votre adresse Par exemple, si votre contact reçoit des messages via un serveur SimpleX Chat, votre application les transmettra via un serveur Flux. Seuls les propriétaires peuvent modifier les préférences. @@ -2245,7 +2175,6 @@ Pas de serveurs pour envoyer des fichiers. Veuillez réduire la taille du message et envoyer le à nouveau. Veuillez réduire la taille du message ou supprimer le média et renvoyer le message. - pour une meilleure protection des métadonnées. - Premier message non lu à l\'ouverture.\n- Sauter aux messages cités. Respect de la vie privée de vos clients. Le deuxième opérateur prédéfini de l\'application ! @@ -2260,7 +2189,6 @@ La connexion a atteint la limite des messages non délivrés, votre contact est peut-être hors ligne. Serveur ajouté à l\'opérateur %s. L\'application protège votre vie privée en utilisant des opérateurs différents pour chaque conversation. - Sélectionnez les opérateurs de réseau à utiliser. SimpleX Chat et Flux ont conclu un accord pour intégrer à l’application des serveurs opérés par Flux. Notes Ou à partager en privé @@ -2272,17 +2200,14 @@ Pour vous protéger contre le remplacement de votre lien, vous pouvez comparer les codes de sécurité des contacts. Comment il contribue à la protection de la vie privée Motif du signalement ? - Le rapport sera archivé pour vous. Contenu inapproprié Nouveau serveur Vous pouvez configurer les serveurs via les paramètres. Pour envoyer Profil inapproprié - Site web Liste Signalement des membres Modifier l\'ordre - Mise à jour Les messages directs entre membres sont interdits dans cette discussion. Non Oui @@ -2310,9 +2235,7 @@ Navigation améliorée dans les discussions Lorsque plusieurs opérateurs sont activés, aucun d\'entre eux ne dispose de métadonnées permettant de savoir qui communique avec qui. Le message est trop volumineux ! - Mobiles à distance Modifier la liste - Vous pouvez définir les opérateurs dans les paramètres Réseau et serveurs. Transparence Vous pouvez définir un nom de connexion pour vous rappeler avec qui le lien a été partagé. Autoriser le signalement des messages aux modérateurs. @@ -2425,7 +2348,6 @@ Envoyer la demande Envoyer la demande sans message Envoyez vos commentaires privés aux groupes. - Envoyé à votre contact après la connexion. Le serveur requiert une autorisation pour se connecter au relais, vérifiez le mot de passe. Avertissement du serveur Partager l\'ancienne adresse @@ -2441,11 +2363,9 @@ L\'adresse sera courte et votre profil sera partagé via l\'adresse. Le lien sera court, et le profil de groupe sera partagé via le lien. L\'expéditeur n\'en sera PAS informé. - Ce réglage est pour votre profil actuel Propriétaires & contributeurs contributeur Description - Ajouter Ajouter une description Votre réseau vous @@ -2456,7 +2376,6 @@ Votre canal Lien de canal Lien de canal - Membres du canal Nom du canal Préférences du canal profil du canal mis à jour @@ -2504,7 +2423,6 @@ Vidéos Messages vocaux Liens - Filtre Depuis l\'historique Parler à quelqu\'un Permettez à quelqu\'un de se connecter à vous @@ -2561,11 +2479,6 @@ Délai d\'attente dépassé pour le routage privé L’empreinte de l’adresse du serveur de transfert ne correspond pas au certificat : %1$s. L’empreinte de l’adresse du serveur de destination ne correspond pas au certificat : %1$s. - Nom de canal non pris en charge - Nom de contact non pris en charge - La connexion via un nom de canal nécessite une version plus récente de l\'application. - La connexion via un nom de contact nécessite une version plus récente de l\'application. - Veuillez mettre à jour l\'application. Erreur de nom SimpleX Aucun de vos serveurs n\'est configuré pour résoudre les noms SimpleX. Configurez des serveurs ou utilisez un lien de connexion. Le serveur %1$s ne prend pas en charge la résolution de nom. Configurez les serveurs ou utilisez un lien de connexion. @@ -2605,11 +2518,9 @@ Le canal exigeait que ce message soit signé, mais la signature est manquante. Accepter la demande de contact Le membre a été supprimé : impossible d\'accepter la demande. - Mentions non lues Vérifier le nom Vérifier les noms SimpleX Nom SimpleX non vérifié - Nom SimpleX Votre nom SimpleX Définir un nom SimpleX Erreur lors de l\'enregistrement du nom @@ -2676,7 +2587,6 @@ L\'application a supprimé ce message après %1$d tentatives de réception. Supprimer le suivi des liens Demandes de contact dans les groupes - À propos Contact Soutenir le projet Aide et support @@ -2906,9 +2816,7 @@ Le canal commencera à fonctionner avec %1$d relais sur %2$d. Continuer ? Il s’agit d’une adresse de relais de messagerie, elle ne peut pas être utilisée pour se connecter. Ouvrir le canal - Ouvrir un nouveau canal %1$s !]]> - Erreur lors de l\'ouverture du canal Débloquer l\'abonné pour tout le monde ? Activer les aperçus de liens ? L’aperçu du lien sera demandé via le proxy SOCKS. La résolution DNS peut néanmoins toujours être effectuée localement via votre résolveur DNS. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/hi/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/hi/strings.xml index 3f17a6d8a7..ed301675fd 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hi/strings.xml @@ -34,7 +34,6 @@ संबंध अनुरोध स्वीकार करें\? स्वीकृत कॉल गुप्त स्वीकार करें - पूर्वनिर्धारित सर्वर जोड़ें प्रोफ़ाइल जोड़ें सर्वर जोड़े हमेशा बने रहें @@ -51,7 +50,6 @@ रंगीन जुड़े हुए जुडिये - जुडिये जुड़े हुए स्वामी जुड़े हुए @@ -63,7 +61,6 @@ आप चले गए अज्ञात त्रुटि आप आज्ञा दें - स्वागत! चालू करो अज्ञात संदेश प्रारूप स्वागत %1$s! @@ -166,7 +163,6 @@ संपर्क नाम सेट करें ईमेल जवाब मिला… - चैट का प्रयोग करें गलती: %s संदेशों को बाद में हटाएं छवि @@ -212,7 +208,6 @@ विवरण ऐप केवल तभी सूचनाएं प्राप्त कर सकता है जब वह चल रहा हो, कोई पृष्ठभूमि सेवा प्रारंभ नहीं की जाएगी संपादन करना - विकेन्द्रीकृत कॉल समाप्त कॉल चल रहा है छवियों को स्वत: स्वीकार करें @@ -227,7 +222,6 @@ चैट कंसोल आपके सभी संपर्क जुड़े रहेंगे। चैट प्रोफ़ाइल - बनाएं कॉल त्रुटि कॉल चल रहा है हमेशा रिले का प्रयोग करें @@ -278,7 +272,6 @@ चालू प्रणाली प्रमाणीकरण प्रणाली - आपका एसएमपी सर्वर संदेशों सदस्य को समूह से निकाल दिया जाएगा - इसे पूर्ववत नहीं किया जा सकता! सदस्य @@ -296,7 +289,6 @@ गुप्त प्रोफ़ाइल का उपयोग करें आपकी प्रोफ़ाइल उस संपर्क को भेजी जाएगी जिससे आपको यह लिंक प्राप्त हुआ है। आप सभी ग्रुप मेंबर्स से कनेक्ट होंगे। - इनकॉग्निटो कनेक्ट करें चैट खोलें नया ग्रुप खोलें यह लिंक मान्य नहीं है diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml index 3493f56711..2f010d8aa9 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml @@ -125,7 +125,6 @@ Razumeo Odstranjeno odstranjeno - Napraviti Poruke i datoteke Poruka Serveri @@ -137,7 +136,6 @@ Odstraniti %d poruke? Odstraniti Podešavanje - Ažuriraj Odstraniti sve datoteke Odstraniti profil razgovora? Poruke @@ -171,7 +169,6 @@ Pristup na servere pomoću SOCKS proxy na portu %d? Proxy mora da bude uključen pre omogućavanja ove opcije. Prihvaćeni uslovi Dodaj server - Dodaj unapred postavljene servere Odstraniti adresu? odstranjena grupa Dodaj nalog @@ -196,7 +193,6 @@ Dodaj prijatelja Dodaj člana tima Arhiva - poveži Kamera Više Otključaj @@ -224,9 +220,7 @@ blokirano administratorom Vi nepoznat status - Poveži se anonimno greška u pozivu - Poveži nepoznat format poruke Povezano %ds @@ -444,8 +438,6 @@ Video poziv Koristi server Adresa vašeg servera - Vaši SMP serveri - Vaši XFTP serveri Obaveštenja i baterija Prikazati e2e šifrovano @@ -462,7 +454,6 @@ Započeti novi razgovor Očistiti Očistiti razgovor - Tvoj server šifrovanje prihvaćeno za %s Glasovne poruke su zabranjene. veza %1$d @@ -502,7 +493,6 @@ Vaši kontakti Ne Započeti razgovor? - otvoriti Nastaviti Otvoriti Pozadina @@ -657,8 +647,6 @@ Sistem Poslati direktnu poruku za povezivanje Slika - Na čekanju - SMP serveri Upišite Vaše ime: Otvoriti razgovor pozvan %1$s @@ -686,7 +674,6 @@ Otvoriti pomoću %s Ukloniti arhivu? Izabrano %d - Dobrodošli! Previše video snimaka! Poslati Obnoviti @@ -694,7 +681,6 @@ Izabrati profil razgovora Skenirati QR kod servera Napredna mrežna podešavanja - SOCKS proxy Anonimni režim broj PING Obnoviti statistiku? @@ -762,7 +748,6 @@ vlasnik Kopirati Predati - Ostali XFTP serveri Napraviti SimpleX adresu Šifrovati Kreirano u @@ -784,7 +769,6 @@ Dovršiti migraciju Ništa nije izabrano Konverzacija odstranjena! - Ostali SMP serveri Verzija jezgra: v%s Šifrovati bazu podataka Odstraniti vezu? @@ -797,7 +781,6 @@ Za primanje drugo Napraviti profil - Vi odlučujete ko se može povezati. Odstraniti grupu? Obnoviti Prebaciti @@ -865,7 +848,6 @@ autor Izvesti temu Žive poruke - Website dani Prosleđivanje %1$s poruka Kada je dostupno @@ -880,7 +862,6 @@ Nalepiti link koji ste primili Oceniti aplikaciju Nema nepročitanih razgovora - Izveštaj će biti arhiviran za Vas. Sačuvati neiskorišćenu pozivnicu? Glasovna poruka… Koristiti trenutni profil @@ -909,7 +890,6 @@ Glasovna poruka (%1$s) Naučiti više Označiti da je verifikovano - Koristiti razgovor Označiti da nije pročitano Verifikovati vezu zahtev za povezivanje @@ -949,7 +929,6 @@ Odstraniti server blokirali ste %s Kvantno otporno šifrovanje - Ne praviti adresu Omogućiti svim grupama Ne prikazivati ponovo minuti @@ -1016,7 +995,6 @@ Operator servera blokira vezu:\n%1$s. Promeniti ulogu Ime ovog uređaja - Dodirnuti za početak novog razgovora pozvani ste u grupu Slika sačuvana u Galeriji vaša uloga je promenjena u %s @@ -1052,7 +1030,6 @@ Pozvani ste u grupu promenili ste vašu ulogu na %s Potvrda je onemogućena - Imunitet na spam duplikat poruke Da Greška u bazi podataka @@ -1083,7 +1060,6 @@ Proslediti poruku… Adresa ili jednokratna veza? Omogućiti pristup kameri - Nevažeći QR kod Jednokratna pozivnica Uneti poruku dobrodošlice… Čuvanje %1$s poruka @@ -1110,7 +1086,6 @@ Unos pin koda Prosleđena poruka Ništa za prosleđivanje - Pregledati kasnije kontakt ima e2e šifrovanje Zaključati nakon Skriven kontakt: @@ -1213,9 +1188,7 @@ Koristiti za poruke Greška pri kreiranju profila! Greška pri učitavanju detalja - Greška pri čuvanju XFTP servera Otpremljene datoteke - Otvoriti podešavanja servera Greška pri odstranjivanju privatnih beleški Greška pri prosleđivanju poruka Poruke su odstranjene nakon što ste ih odabrali. @@ -1230,7 +1203,6 @@ Onemogućiti potvrde? povezivanje (uvedeno) Uneti šifru u pretragu - Greška pri učitavanju XFTP servera Greška pri odstranjivanju grupe Nije moguće poslati poruku Poslati poruku koja nestaje @@ -1300,8 +1272,6 @@ Poslano putem proxy pozvan za povezivanje pomoću %1$s - Greška pri učitavanju SMP servera - Greška pri čuvanju SMP servera Greška pri promeni adrese Pristupnu frazu je potrebna Spomenuti članove 👋 @@ -1314,7 +1284,6 @@ Bez Informacija, pokušajte ponovo da učitate Komadi su odstranjeni SimpleX Chat pozivi - XFTP serveri Mreža i serveri Podesite ICE servere Koristiti direktnu internet vezu? @@ -1331,7 +1300,6 @@ Vremensko ograničenje protokola Greška pri slanju poruke Greška pri odstranjivanju kontakta - Napraviti jednokratnu pozivnicu Markdown pomoć Dozvoliti slanje datoteka i medijskog sadržaji. Pin kod promenjen! @@ -1386,7 +1354,6 @@ Zapis ažuriran u: %s Član će biti uklonjen iz grupe – ovo se ne može poništiti! Pozivi zabranjeni! - \nDostupno u v5.1 SimpleX veze Odstraniti za sve Dozvoliti slanje poruka koje nestaju. @@ -1402,13 +1369,11 @@ Dozvoliti vašim kontaktima da vas zovu. Prijem poruke Greška pri izvoženju baze podataka razgovora - Možete učiniti vidljivim vašim SimpleX kontaktima putem Podešavanja. Greška pri slanju pozivnice Preskočiti pozivanje članova Za privatno usmeravanje Dodatni sekundarni Pokretanje iz %s. - Vaši ICE serveri Koristiti nasumičnu pristupnu frazu Onemogućiti SimpleX Zaključavanje Ponovo povezati server? @@ -1421,15 +1386,12 @@ Neispravan hash poruke Ponovo povezati server kako biste prisilili dostavu poruke. To koristi dodatni saobraćaj. neispravan ID poruke - Konfigurisani XFTP serveri Upozorenje pri isporuci poruke - Povezati se pomoću linka / QR koda Već imate chat profil sa istim prikaznim imenom. Molimo vas da odaberete drugo ime. Greška u autentifikaciji Unapređena konfiguracija servera %1$d ostale greška datoteke(a). Autentifikacija nije dostupna - Konfigurisani SMP serveri Automatsko prihvatanje Sačuvati preference? Za pozive je potreban podrazumevani veb pretraživač. Molimo vas da konfigurišete podrazumevani pretraživač u sistemu i podelite više informacija sa programerima. @@ -1451,7 +1413,6 @@ Otvoriti fasciklu baze podataka Sačuvati i ažurirati grupni profil Uz smanjenu potrošnju baterije. - (skenirati ili nalepiti iz memorije) Greška u vezi Ažurirajte aplikaciju i kontaktirajte programere. Tokom uvoza došlo je do nekih nefatalnih grešaka: @@ -1464,11 +1425,9 @@ Uz smanjenu potrošnju baterije. Greška pri preuzimanju arhive Svi razgovori biće uklonjeni sa liste %s, a lista odstranjena - Ovaj QR kod nije link! Već se povezujete! Migriraj na drugi uređaj Otvoriti promene - Decentralizovano Nema informacija o prijem Povezati se pomoću linka Zadržati Vaše konekcije 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 504d68b1a0..8bc886865d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml @@ -36,7 +36,6 @@ Hitelesítés Egy üres csevegési profil lesz létrehozva a megadott névvel, és az alkalmazás a szokásos módon megnyílik. %s visszavonva - Előre beállított kiszolgálók hozzáadása A hívások kezdeményezése le van tiltva. Az összes partneréhez és csoporttaghoz külön TCP-kapcsolat (és SOCKS-hitelesítési adat) lesz használva.\nMegjegyzés: ha sok kapcsolata van, akkor az akkumulátor-használat és az adatforgalom jelentősen megnövekedhet, és néhány kapcsolódási kísérlet sikertelen lehet.]]> hivatkozáselőnézet visszavonása @@ -66,7 +65,6 @@ Alkalmazás verziója Üdvözlőüzenet hozzáadása titkosítás elfogadása %s számára… - \nElérhető az v5.1-es kiadásban Mindkét fél véglegesen törölheti az elküldött üzeneteket. (24 óra) Továbbfejlesztett csoportok Az összes üzenet törölve lesz – ez a művelet nem vonható vissza! Az üzenetek CSAK az Ön számára törlődnek. @@ -179,7 +177,6 @@ Zárolási mód módosítása Kapcsolódott kapcsolódott - Kapcsolódás kapcsolódott Társított hordozható eszköz kapcsolódott @@ -200,7 +197,6 @@ A partner és az összes üzenet törölve lesz – ez a művelet nem vonható vissza! A partnerei törlésre jelölhetnek üzeneteket; Ön majd meg tudja nézni azokat. Kapcsolódik az egyszer használható meghívón keresztül? - Kapcsolódás egy hivatkozáson vagy QR-kódon keresztül A kapcsolódási hivatkozás el lett távolítva Csak név Kapcsolódik a kapcsolattartási címen keresztül? @@ -219,7 +215,6 @@ Helyesbíti a nevet a következőre: %s? Időtúllépés kapcsolódáskor Kapcsolódik vele: %1$s? - Létrehozás Partnerbeállítások Kapcsolat Kapcsolat megszakítva @@ -240,7 +235,6 @@ törölt partner Törli a tag üzenetét? A csevegés fut - Egyszer használható meghívó létrehozása Törlés Új üzenetek ellenőrzése 10 percenként, legfeljebb 1 percen keresztül Adatbázis törlése @@ -312,7 +306,6 @@ Törli a hivatkozást? kapcsolódás Egyéni időköz - Kapcsolódás inkognitóban Csevegések Új profil létrehozása a számítógépes alkalmazásban. 💻 kapcsolódás (bejelentve) @@ -334,7 +327,6 @@ Elvetés Törli a partnert? Ürítés - Cím létrehozása, hogy az emberek kapcsolatba léphessenek Önnel. Biztonsági kódok összehasonlítása a partnerekével. Fájl-összehasonlítás Csevegések @@ -389,7 +381,6 @@ Csoportok felfedezése és csatlakozás %2$s %1$d üzenetet moderált Eltűnő üzenet - Ne hozzon létre címet Ne jelenjen meg újra SimpleX-zár kikapcsolása végpontok között titkosított @@ -437,7 +428,6 @@ Csoportprofil szerkesztése végpontok között titkosított hanghívás %d mp - Decentralizált Dekódolási hiba Kép szerkesztése Értesítések letiltása @@ -469,8 +459,6 @@ Hiba történt az ICE-kiszolgálók mentésekor Hiba Hiba - Hiba történt az XFTP-kiszolgálók betöltésekor - Hiba történt az SMP-kiszolgálók betöltésekor Hiba történt a hálózat konfigurációjának frissítésekor TCP életben tartása Kamera váltás @@ -497,7 +485,6 @@ Hiba történt a felhasználói adatvédelem frissítésekor Titkosítás Csoport nem található! - Hiba történt az SMP-kiszolgálók mentésekor Visszafejlesztés és a csevegés megnyitása A csoport inaktív Gyors és nem kell várni, amíg az üzenetküldő online lesz! @@ -563,7 +550,6 @@ Hiba történt a függőben lévő kapcsolat törlésekor Hiba történt a csevegési adatbázis importálásakor Hiba történt a kézbesítési jelentések engedélyezésekor! - Hiba történt az XFTP-kiszolgálók mentésekor A tagok küldhetnek egymásnak közvetlen üzeneteket. Hiba történt a tag eltávolításakor hívás vége @@ -660,10 +646,8 @@ Elrejtve: Hiba történt a partnerrel történő kapcsolat létrehozásában ICE-kiszolgálók (soronként egy) - beolvashatja a QR-kódot a videohívásban, vagy a partnere megoszthat egy meghívási hivatkozást.]]> Ha az alkalmazás megnyitásakor megadja ezt a jelkódot, az összes alkalmazásadat véglegesen el lesz távolítva! Ha nem tud személyesen találkozni, mutassa meg a QR-kódot egy videohívás közben, vagy ossza meg a hivatkozást. - mutassa meg a QR-kódot a videohívásban, vagy ossza meg a hivatkozást.]]> Megerősítés esetén az üzenetváltó-kiszolgálók látni fogják az IP-címét és a szolgáltatóját – azt, hogy mely kiszolgálókhoz kapcsolódik. A kép akkor érkezik meg, amikor a küldője befejezte annak feltöltését. QR-kód beolvasásával.]]> @@ -673,7 +657,6 @@ Számítógépek A markdown használata Csevegési profil létrehozása - Védett a kéretlen tartalmakkal szemben Hordozható eszközök leválasztása Különböző nevek, profilképek és átvitelelkülönítés. Elutasítás esetén a kérés küldője NEM kap értesítést. @@ -693,7 +676,6 @@ Nincsenek kézbesítési adatok moderált A tag el lesz távolítva a csoportból – ez a művelet nem vonható vissza! - Győződjön meg arról, hogy a megadott XFTP-kiszolgálók címei megfelelő formátumúak, soronként elkülönítettek, és nincsenek duplikálva. Nincs partner kiválasztva Nincsenek fogadott vagy küldött fájlok Megnyitás hordozható eszköz-alkalmazásban, majd koppintson a Kapcsolódás gombra az alkalmazásban.]]> @@ -732,7 +714,6 @@ k soha (új)]]> - Győződjön meg arról, hogy a megadott SMP-kiszolgálók címei megfelelő formátumúak, soronként elkülönítettek, és nincsenek duplikálva. Az onion kiszolgálók nem lesznek használva. perc Tudjon meg többet @@ -791,11 +772,9 @@ Hordozható eszköz társítása Értesítési szolgáltatás Csak a csoport tulajdonosai engedélyezhetik a hangüzenetek küldését. - A felhasználói profilok, partnerek, csoportok és üzenetek csak az eszközön vannak tárolva a kliensen belül. Érvénytelen átköltöztetési visszaigazolás Csak a csoport tulajdonosai módosíthatják a csoportbeállításokat. Nincsenek előzmények - Érvénytelen QR-kód Megjelölés olvasottként ÉLŐ Megjelölés olvasatlanként @@ -875,9 +854,7 @@ Szerepkör SimpleX kapcsolattartási cím Megállítás - Előre beállított kiszolgáló Új csevegés indítása - Bárki üzemeltethet kiszolgálókat. Megnyitás Protokoll időtúllépése titok @@ -892,7 +869,6 @@ Adatvédelem Profil SimpleX-címe Jelentse a fejlesztőknek. - Ön dönti el, hogy kivel beszélget. Az eltűnő üzenetek küldése le van tiltva. Csak Ön küldhet hangüzeneteket. Frissítés @@ -912,7 +888,6 @@ Csökkentett akkumulátor-használat Mentés és a partnerek értesítése Előnézet - SimpleX Chat használata Megosztás Fogadott üzenet Üdvözlőüzenet @@ -920,9 +895,7 @@ Csak a partnere kezdeményezhet hívásokat. Témák Túl sok videó! - Üdvözöljük! Önmegsemmisítő jelkód - (beolvasás vagy beillesztés a vágólapról) Várakozás a videóra Válasz Ez a saját egyszer használható meghívója! @@ -959,7 +932,6 @@ Kérje meg a partnerét, hogy engedélyezze a hangüzenetek küldését. Ön egy egyszer használható meghívót osztott meg A hivatkozás megnyitása a böngészőben gyengítheti az adatvédelmet és a biztonságot. A megbízhatatlan SimpleX-hivatkozások pirossal vannak kiemelve. - Saját ICE-kiszolgálók Ön elfogadta a kapcsolatot Elutasítás Partner nevének és az üzenet tartalmának megjelenítése @@ -976,7 +948,6 @@ Amikor elérhető Hangüzenet (%1$s) %s (jelenlegi) - Saját SMP-kiszolgáló Véletlen Megosztás a SimpleX partnerekkel Ön @@ -1075,15 +1046,12 @@ Kezelőfelület színei Adja meg a korábbi jelszót az adatbázis biztonsági mentésének visszaállítása után. Ez a művelet nem vonható vissza. Másodlagos szín - SOCKS proxy Mentés Újraindítás - SMP-kiszolgálók Videó SimpleX-címbeállítások mentése Újraegyeztetés Várakozás a videóra - Saját XFTP-kiszolgálók Videó kikapcsolva Privát fájlnevek Menti a beállításokat? @@ -1097,7 +1065,6 @@ Feloldja a tag letiltását? A kérés küldője törölte a kapcsolódási kérést. Érvénytelen adatbázis-jelmondat - Saját SMP-kiszolgálók A kézbesítési jelentések le vannak tiltva Adatbázismappa megnyitása egy egyszer használható meghívón keresztül @@ -1145,7 +1112,6 @@ Időszakos értesítések letiltva! A jelkód módosult! Akkor fut, amikor az alkalmazás meg van nyitva - Ez a QR-kód nem egy hivatkozás! Várakozás a fájlra simplexmq: v%s (%2s) Leválasztás @@ -1154,7 +1120,6 @@ A reakciók hozzáadása az üzenetekhez le van tiltva. Rendszer olvasatlan - Függőben Üdvözöljük %1$s! Eltávolítja a jelmondatot a Keystrore-ból? Feloldás @@ -1173,7 +1138,6 @@ Színek visszaállítása Mentés Váltás - A kapott hivatkozás beillesztése a partnerhez való kapcsolódáshoz… Beolvasás Port nyitása a tűzfalban hívás indítása… @@ -1181,7 +1145,6 @@ elküldve SOCKS proxy használata Élő üzenet küldése - Újraértelmezett adatvédelem Hangüzenet… Alkalmazás képernyőjének védelme QR-kód megjelenítése @@ -1200,7 +1163,6 @@ Felfedés Zárolási mód Fájl visszavonása - XFTP-kiszolgálók A fájlok és a médiatartalmak küldése le van tiltva. Fájl megosztása… Mentés @@ -1209,7 +1171,6 @@ Ön eltávolította őt: %1$s Jelmondat mentése és a csevegés megnyitása Menti a beállításokat? - Nincsenek felhasználói azonosítók. A közvetlen üzenetek küldése a tagok között le van tiltva. SOCKS proxy használata? Hangszóró kikapcsolva @@ -1274,9 +1235,7 @@ A jelenlegi csevegési adatbázis TÖRÖLVE és CSERÉLVE lesz az importáltra!\nEz a művelet nem vonható vissza – profiljai, partnerei, csevegési üzenetei és fájljai véglegesen törölve lesznek. Ötletek és javaslatok Figyelmeztetés: néhány adat elveszhet! - Koppintson ide az új csevegés indításához Várakozás a számítógép-alkalmazásra… - Az üzenetváltás jövője Módosítja a hálózati beállításokat? Várakozás a hordozható eszköz társítására: Biztonságos kapcsolat ellenőrzése @@ -1306,7 +1265,6 @@ Az alkalmazás elindításához vagy 30 másodpercnyi háttérben töltött idő után, az alkalmazáshoz való visszatéréshez hitelesítésre lesz szükség. Az üzenet az összes tag számára törölve lesz. A videó nem dekódolható. Próbálja ki egy másik videóval, vagy lépjen kapcsolatba a fejlesztőkkel. - Ez a szöveg a beállításokban érhető el A profilja el lesz küldve a partnere számára, akitől ezt a hivatkozást kapta. Az alkalmazás 1 perc után bezárható a háttérben. Ön meghívást kapott a csoportba @@ -1334,7 +1292,6 @@ különböző átköltöztetés az alkalmazásban/adatbázisban: %s / %s %1$s.]]> Profil felfedése - Ez nem egy érvényes kapcsolattartási hivatkozás! A végpontok közötti titkosítás ellenőrzéséhez hasonlítsa össze (vagy olvassa be a QR-kódot) a partnere eszközén lévő kóddal. A csevegési adatbázis legfrissebb verzióját CSAK egy eszközön kell használnia, ellenkező esetben előfordulhat, hogy az üzeneteket nem fogja megkapni valamennyi partnerétől. Ez a beállítás csak az Ön jelenlegi csevegési profiljában lévő üzenetekre vonatkozik @@ -1343,10 +1300,7 @@ A csatlakozás már folyamatban van a csoporthoz ezen a hivatkozáson keresztül. Ön meghívást kapott a csoportba A partnere a jelenleg támogatott legnagyobb (%1$s) fájlméretnél nagyobbat küldött. - A partnerei és az üzenetek (kézbesítés után) nem a SimpleX kiszolgálókon vannak tárolva. Üzenetek formázása a szövegbe szúrt speciális karakterekkel: - Megnyitás az alkalmazásban gombra.]]> - A csevegési profilja el lesz küldve\na partnere számára Egy olyan partnerét próbálja meghívni, akivel inkognitóprofilt osztott meg abban a csoportban, amelyben a fő profilja van használatban %1$s nevű csoporthoz.]]> Amikor az alkalmazás fut @@ -1362,15 +1316,10 @@ Akkor lesz kapcsolódva, amikor a partnerének az eszköze online lesz, várjon, vagy ellenőrizze később! Kéretlen üzenetek elrejtése. Onion kiszolgálók használata beállítást „Nemre”, ha a SOCKS proxy nem támogatja őket.]]> - Megoszthatja a címét egy hivatkozásként vagy egy QR-kódként – így bárki kapcsolódhat Önhöz. - Létrehozás később A profilja az eszközén van tárolva és csak a partnereivel van megosztva. A SimpleX kiszolgálók nem láthatják a profilját. Ön a következőre módosította %s szerepkörét: „%s” Csoportmeghívó elutasítva - Adatainak védelme érdekében a SimpleX külön azonosítókat használ minden egyes kapcsolatához. - (a megosztáshoz a partnerével) Csoportmeghívó elküldve - Frissíti az átvitelelkülönítési módot? Átvitelelkülönítés Nem fog több üzenetet kapni ebből a csoportból, de a csevegés előzményei megmaradnak. A csevegési adatbázis nem titkosított – állítson be egy jelmondatot annak védelméhez. @@ -1407,14 +1356,12 @@ Ön csatlakozott ehhez a csoporthoz %1$s nevű csoporthoz!]]> A hangüzenetek küldése le van tiltva ebben a csevegésben. - Ön irányítja csevegését! Kód ellenőrzése a számítógépen Az időzóna védelmének érdekében a kép-/hangfájlok UTC-t használnak. A csatlakozási kérése el lesz küldve ennek a csoporttagnak. Ha egy inkognitóprofilt oszt meg valamelyik partnerével, a rendszer ezt az inkognitóprofilt fogja használni azokban a csoportokban, ahová az adott partnere meghívja Önt. Már kért egy kapcsolódási kérést ezen a címen keresztül! Megoszthatja ezt a SimpleX-címet a partnereivel, hogy kapcsolatba léphessenek vele: %s. - Amikor az emberek kapcsolatot kérnek, Ön elfogadhatja vagy elutasíthatja azokat. Megjelenítendő üzenet beállítása az új tagok számára! Köszönet a felhasználóknak a Weblate-en való közreműködésért! A kézbesítési jelentések küldése az összes partnere számára engedélyezve lesz. @@ -1456,7 +1403,6 @@ A kézbesítési jelentések engedélyezve vannak %d csoportban A tag szerepköre a következőre fog módosulni: „%s”. A csoport összes tagja értesítést fog kapni. Profil és kiszolgálókapcsolatok - Egy üzenetváltó- és alkalmazásplatform, amely védi az adatait és biztonságát. Koppintson ide a profil aktiválásához. A kézbesítési jelentések le vannak tiltva %d partner számára Munkamenet kódja @@ -1484,10 +1430,8 @@ Az előző üzenet kivonata különbözik. Ezek a beállítások csak a jelenlegi csevegési profiljára vonatkoznak Várjon, amíg a fájl betöltődik a társított hordozható eszközről - GitHub-tárolónkban talál.]]> Hiba történt a tartalom megjelenítésekor Hiba történt az üzenet megjelenítésekor - Láthatóvá teheti a SimpleXbeli partnerei számára a beállításokban. Legfeljebb az utolsó 100 üzenet lesz elküldve az új tagok számára. A beolvasott QR-kód nem egy SimpleX-hivatkozás. A beillesztett szöveg nem egy SimpleX-hivatkozás. @@ -1749,11 +1693,9 @@ Privát útválasztás Privát útválasztás használata az ismeretlen kiszolgálókhoz. Privát útválasztás használata minden esetben. - Üzenet-útválasztási mód Közvetlen üzenetküldés, ha az IP-cím védett és a saját kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást. Közvetlen üzenetküldés, ha a saját kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást. Az IP-cím védelmének érdekében a privát útválasztás az SMP-kiszolgálókat használja az üzenetek kézbesítéséhez. - Üzenet-útválasztási tartalék Privát üzenet-útválasztás Privát útválasztás használata az ismeretlen kiszolgálókkal, ha az IP-cím nem védett. NE küldjön üzeneteket közvetlenül, még akkor sem, ha a saját kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást. @@ -1778,7 +1720,6 @@ Csevegés témája Kitöltés Profiltéma - Csevegési lista megjelenítése új ablakban Világos Világos mód Fogadott válaszüzenet-buborék színe @@ -1830,9 +1771,6 @@ Az üzenet később is kézbesíthető, ha a tag aktívvá válik. Még nincs közvetlen kapcsolat, az üzenetet az adminisztrátor továbbítja. Hivatkozás megadása vagy QR-kód beolvasása - Konfigurált SMP-kiszolgálók - Egyéb SMP-kiszolgálók - Egyéb XFTP-kiszolgálók letiltva inaktív Nagyítás @@ -1878,7 +1816,6 @@ Feltöltött fájlok Letöltött töredékek Letöltött fájlok - Kiszolgáló-beállítások megnyitása Kiszolgáló címe Feltöltési hibák Visszaigazolva @@ -1889,7 +1826,6 @@ Feltöltött töredékek Elkészült Kapcsolódott kiszolgálók - Konfigurált XFTP-kiszolgálók Kapcsolódott Jelenlegi profil További részletek @@ -1952,7 +1888,6 @@ hívás A partner törölve lesz – ez a művelet nem vonható vissza! Csak a beszélgetés törlése - megnyitás Beszélgetés törölve! Partner törölve! Archivált partnerek @@ -1969,7 +1904,6 @@ üzenet Beszélgetés megtartása Biztosan törli a partnert? - kapcsolódás Könnyen elérhető eszköztárak Törlés értesítés nélkül Beállítások @@ -2098,7 +2032,6 @@ A kapcsolat elérte a kézbesítetlen üzenetek számának határát, a partnere lehet, hogy offline állapotban van. Nincs üzenet Ez az üzenet törölve lett vagy még nem érkezett meg. - Koppintson a SimpleX-cím létrehozása menüpontra a későbbi létrehozáshoz. Cím nyilvános megosztása SimpleX-cím megosztása a közösségi médiában. Egyszer használható meghívó megosztása egy baráttal @@ -2115,12 +2048,7 @@ Hálózatüzemeltetők Az alkalmazás úgy védi az adatait, hogy minden egyes beszélgetéshez más-más üzemeltetőt használ. Például, ha a partnere egy SimpleX Chat kiszolgálón keresztül fogadja az üzeneteket, akkor az Ön alkalmazása egy Flux kiszolgálón keresztül fogja azokat kézbesíteni. - Jelölje ki a használni kívánt hálózatüzemeltetőket. - Felülvizsgálat később A kiszolgálókat a „Hálózat és kiszolgálók” menüben konfigurálhatja. - A feltételek 30 nap elteltével lesznek elfogadva az engedélyezett üzemeltetők számára. - Az üzemeltetőket a „Hálózat és kiszolgálók” menüben konfigurálhatja. - Frissítés Folytatás Feltételek felülvizsgálata Elfogadott feltételek @@ -2130,7 +2058,6 @@ %s.]]> %s kiszolgáló Hálózatüzemeltető - Weboldal Feltételek elfogadásának ideje: %s. A feltételek el lesznek fogadva a következő időpontban: %s. Kiszolgálók használata @@ -2166,18 +2093,15 @@ A második előre beállított üzemeltető az alkalmazásban! A Flux kiszolgálókat engedélyezheti a beállításokban, a „Hálózat és kiszolgálók” menüben, a metaadatok jobb védelme érdekében. Eszköztárak - a metaadatok jobb védelme érdekében. Továbbfejlesztett csevegési navigáció - Csevegés megnyitása az első olvasatlan üzenetnél.\n- Ugrás az idézett üzenetekre. Frissített feltételek megtekintése A jelenlegi csevegési profiljához tartozó új fájlok kiszolgálói Vagy archívumfájl importálása - Távoli hordozható eszközök Xiaomi eszközök: engedélyezze az automatikus indítást a rendszerbeállításokban, hogy az értesítések működjenek.]]> Másolhatja és csökkentheti az üzenet méretét a küldéshez. Adja hozzá a munkatársait a beszélgetésekhez. Üzleti cím - végpontok közötti titkosítással, a közvetlen üzenetek továbbá kvantumbiztos titkosítással is rendelkeznek.]]> Hogyan segíti az adatvédelmet Nincs háttérszolgáltatás Értesítések és akkumulátor @@ -2253,7 +2177,6 @@ Elrendezés módosítása Hiba történt a jelentés létrehozásakor Hiba történt a beállítások mentésekor - A jelentés archiválva lesz az Ön számára. Tartalom jelentése: csak a csoport moderátorai látják. Archívum Archiválja a jelentést? @@ -2302,7 +2225,6 @@ A következő TCP-port használata, amikor nincs port megadva: %1$s. TCP-port az üzenetváltáshoz Webport használata - Olvasatlan említések Összes némítása Legfeljebb %1$s tagot említhet meg egy üzenetben! Az üzenetek jelentése a moderátorok felé engedélyezve van. @@ -2431,7 +2353,6 @@ Partneri kapcsolatkérés elutasítása A kérés küldője NEM lesz értesítve. Saját profil - Elküldés a partnernek a kapcsolódást követően. Üdvözlőüzenet Frissíti a címet? A cím rövid lesz és a profil meg lesz osztva a címen keresztül. @@ -2486,7 +2407,6 @@ Partneri kapcsolatkérések a csoportok tagjaitól A tag törölve lett – nem lehet elfogadni a kérést a(z) %1$s nevű csoportból partneri kapcsolatot kért - Ez a beállítás a jelenlegi profiljára vonatkozik Megnyitás a bot használatához Koppintson a „Kapcsolódás” gombra a bot használatához Bot @@ -2516,7 +2436,6 @@ Eltávolítás és az üzeneteinek törlése Összes üzenet Fájlok - Szűrő Képek Hivatkozások Fájlok keresése @@ -2535,7 +2454,6 @@ Csatorna Csatorna Csatornahivatkozás - Csatornatagok Csatorna neve Kapcsolódás kapcsolódott @@ -2550,7 +2468,6 @@ új meghíva Csatorna megnyitása - Új csatorna megnyitása Tulajdonos Tulajdonosok és közreműködők Csatorna elhagyása @@ -2633,7 +2550,6 @@ Átjátszó címe Ez egy csevegési átjátszó címe, nem használható kapcsolódásra. %1$s nevű csatornához!]]> - Hiba történt a csatorna megnyitásakor Az összes feliratkozó számára feloldja a feliratkozó letiltását? Átjátszó tesztelése a nevének lekéréséhez.]]> Csatorna teljes neve: @@ -2773,7 +2689,6 @@ Felső sáv Az új %1$s nevű csatornája %3$d átjátszóból %2$d átjátszóhoz kapcsolódott.\nHa visszavonja, akkor a csatorna törlődni fog – de később újra létrehozhatja. Visszavonás és a csatorna törlése - Hozzáadás Átjátszó hozzáadása Átjátszók hozzáadása %d átjátszó kiválasztva @@ -2805,14 +2720,8 @@ az átjátszó üzemeltetője elutasította Az alkalmazás már fut Lehet, hogy egy másik alkalmazáspéldány fut, vagy nem zárult be megfelelően. Így is elindítja? - Nem támogatott csatornanév - Nem támogatott partnernév - A csatorna nevén keresztüli kapcsolódáshoz újabb alkalmazásverzió szükséges. - A partner nevén keresztüli kapcsolódáshoz újabb alkalmazásverzió szükséges. - Frissítse az alkalmazást. Alkalmazásfrissítés szükséges Ehhez a csoporthoz az alkalmazás újabb verziója szükséges. A csatlakozáshoz frissítse az alkalmazást. - Névjegy Kapcsolat A projekt támogatása Csevegési adatok @@ -2862,7 +2771,6 @@ Név ellenőrzése SimpleX-nevek ellenőrzése Nincs ellenőrizve a SimpleX-név - SimpleX-név Saját SimpleX-név SimpleX-név beállítása Hiba történt a név mentésekor diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/wefunder_logo@4x.png b/apps/multiplatform/common/src/commonMain/resources/MR/images/wefunder_logo@4x.png new file mode 100644 index 0000000000..ac90142f8d Binary files /dev/null and b/apps/multiplatform/common/src/commonMain/resources/MR/images/wefunder_logo@4x.png differ 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 b5c35bc7c6..392c935575 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml @@ -75,8 +75,6 @@ Gunakan profil penyamaran baru Profil Anda akan dikirim ke kontak yang menerima tautan ini. Anda akan terhubung ke semua anggota grup. - Hubungkan - Hubungkan penyamaran Membuka basis data… k Hubungkan melalui alamat kontak? @@ -137,7 +135,6 @@ Izinkan panggilan? Pesan baru Hanya kamu yang dapat mengirim pesan menghilang. - Tidak ada pengidentifikasi pengguna. Hanya pemilik grup yang dapat mengubah preferensi grup. dan %d peristiwa lainnya Peran baru anggota @@ -216,7 +213,6 @@ TIdak pernah Mati Hanya 10 video dapat dikirim pada saat bersamaan - Hanya perangkat klien yang menyimpan profil pengguna, kontak, grup, dan pesan. Hanya pemilik grup yang dapat mengaktifkan pesan suara. Seluruh pesan akan dihapus - ini tidak bisa dibatalkan! Izinkan turun versi @@ -322,7 +318,6 @@ Unduh diedit Obrolan - Selamat Datang! Chat dengan pengembang Setel nama kontak… Pengaturan @@ -373,15 +368,11 @@ Bersihkan verifikasi Konsol obrolan Server pesan - Server SMP Beri nilai aplikasi Bintang di GitHub Gunakan server SimpleX Chat? - Server SMP Anda - Server XFTP Kredensial Anda mungkin dikirim tidak terenkripsi. Username - Server XFTP Anda Gunakan server SimpleX Chat. Gunakan kredensial proxy yang berbeda untuk setiap koneksi. Kata sandi @@ -404,7 +395,6 @@ Tampilkan status pesan Terima otomatis Undang teman - Buat Nama tidak valid! Buat profil Masukkan nama Anda: @@ -417,8 +407,6 @@ berakhir Headphone Kesalahan saat menginisialisasi WebView. Perbarui sistem Anda ke versi baru. Mohon hubungi pengembang.\nKesalahan: %s - Anda pilih siapa yang dapat terhubung. - Kebal terhadap spam Buat profil Anda Buat koneksi pribadi Lewati @@ -534,9 +522,6 @@ Via peramban Gagal mengganti profil Buka tautan di peramban mengurangi privasi dan keamanan koneksi. Tautan SimpleX tidak tepercaya akan berwarna merah. - Gagal simpan server SMP - Pastikan alamat server SMP dalam format yang benar, pisahkan baris dan tidak terduplikasi. - Pastikan alamat server XFTP dalam format yang benar, pisahkan baris dan tidak terduplikasi. gagal terkirim belum dibaca Pesan dapat disampaikan kemudian jika anggota menjadi aktif. @@ -545,7 +530,6 @@ Terlalu banyak video! Pesan suara pesan - buka Sistem setel alamat kontak baru Tema gelap @@ -614,10 +598,7 @@ Statistik server akan direset - ini tidak dapat dibatalkan! Perbesar ukuran font. Sumber pesan tetap pribadi. - Siapa pun dapat menjadi pemegang server. - Terdesentralisasi Kesalahan saat menginisialisasi WebView. Pastikan Anda telah menginstal WebView dan arsitektur yang didukung adalah arm64.\nKesalahan: %s - Gunakan obrolan Bagaimana caranya Berkala Panggilan suara masuk @@ -659,7 +640,6 @@ Preferensi kontak diaktifkan diaktifkan untuk anda - \nTersedia di v5.1 Kirim pesan suara tidak diizinkan. Hingga 100 pesan terakhir dikirim ke anggota baru. Pesan suara @@ -697,7 +677,6 @@ Kunci SimpleX Bantuan Markdown Server media & berkas - Server Anda Alamat server ditetapkan Gunakan di koneksi baru Simpan server? @@ -713,7 +692,6 @@ Simpan kata sandi profil Kata sandi profil tersembunyi Mikrofon - Privasi didefinisikan ulang Hal yang mempengaruhi baterai Saat aplikasi sedang berjalan Notifikasi pribadi @@ -781,7 +759,6 @@ Tautan SimpleX Undangan 1-kali SimpleX via %1$s - Gagal simpan server XFTP Nama tampilan tidak valid! Gagal membuat profil! Kesalahan koneksi @@ -789,8 +766,6 @@ Gagal membuat pesan Waktu koneksi habis pengiriman tidak sah - Teks ini tersedia di pengaturan - Ketuk untuk memulai obrolan baru Anda diundang ke grup Gabung sebagai %s menghubungkan… @@ -821,12 +796,10 @@ Markdown dalam pesan Simpan server Tambah server - Tambah server prasetel Uji server gagal! Server uji Server uji Masukkan server manual - Server Prasetel Pindai kode QR server Beberapa server gagal dalam pengujian: Alamat server Anda @@ -871,7 +844,6 @@ Diaktifkan untuk %dh Alamat server tidak valid! - Server XFTP lainnya Gunakan kredensial proksi yang berbeda untuk setiap profil. Server penerusan %1$s gagal terhubung ke server tujuan %2$s. Coba lagi nanti. Cari atau tempel tautan SimpleX @@ -897,10 +869,7 @@ Pengaturan Anda Alamat SimpleX Anda Profil obrolan Anda - Server SMP dikonfigurasi - Server SMP lainnya Gunakan server - Server XFTP dikonfigurasi Periksa alamat server dan coba lagi. Tampilkan persentase Kontribusi @@ -969,8 +938,6 @@ Versi inti: v%s Ketika IP disembunyikan Warna antarmuka - Fallback perutean pesan - Mode routing pesan Routing pribadi JANGAN gunakan routing pribadi. Gunakan routing pribadi dengan server yang tak dikenal ketika alamat IP tidak dilindungi. @@ -982,7 +949,6 @@ Buat alamat ID Basis Data dan Opsi Isolasi Transport. panggilan tak terjawab - Anda dapat membuatnya terlihat oleh kontak SimpleX Anda melalui Pengaturan. Undang Hai!\nHubungi saya melalui SimpleX Chat: %s Konfirmasi kata sandi @@ -996,11 +962,9 @@ Panggilan pada layar terkunci: Speaker Izin dalam pengaturan - Perpesanan masa depan Temukan izin ini di pengaturan Android dan ubah secara manual. Earpiece Matikan - Server ICE Anda Server ICE WebRTC Jika Anda memasukkan kode sandi hapus otomatis saat membuka aplikasi: Ikon aplikasi @@ -1188,7 +1152,6 @@ Matikan (tetap ditimpa) Gagal memuat obrolan Gagal hapus kontak - Gagal memuat server SMP Gagal memuat obrolan Nama tampilan ini tidak valid. Silakan pilih nama lain. Pengirim mungkin telah hapus permintaan koneksi. @@ -1209,7 +1172,6 @@ Pengaturan ini untuk profil Anda saat ini Kirim tanda terima diaktifkan untuk %d kontak Kirim tanda terima dimatikan untuk %d kontak - Gagal memuat server XFTP Gagal memperbarui konfigurasi jaringan Mohon perbarui aplikasi dan hubungi pengembang. Anda sudah memiliki nama tampilan profil obrolan yang sama. Silakan pilih nama lain. @@ -1330,7 +1292,6 @@ Buat grup Nama lengkap grup: Simpan profil grup - Peramban Server Anda Gagal simpan profil grup %s server @@ -1389,7 +1350,6 @@ Operator server Operator jaringan Aplikasi ini melindungi privasi Anda dengan gunakan operator yang berbeda setiap percakapan. - Ketentuan akan diterima untuk operator yang diaktifkan setelah 30 hari. Alat pengembang Warna obrolan permintaan koneksi @@ -1416,7 +1376,6 @@ %1$s!]]> Batal pindah Potongan diunggah - hubungkan Percakapan dihapus! Pesan sementara Hapus catatan pribadi? @@ -1482,12 +1441,10 @@ Gagal hapus Perangkat Xiaomi: harap aktifkan Autostart di pengaturan sistem agar notifikasi berfungsi.]]> Hapus obrolan - Hubungkan via tautan / kode QR Profil acak baru akan dibagikan. Permintaan koneksi terkirim! Keamanan koneksi Nama telah benar untuk %s? - Anda dapat konfigurasi operator di pengaturan Jaringan dan server. Kesalahan basis data Frasa sandi basis data berbeda dengan yang disimpan di Keystore. Ubah peran grup? @@ -1547,7 +1504,6 @@ batal pratinjau tautan Tombol tutup Email - pindai kode QR dalam panggilan video, atau kontak Anda dapat bagikan tautan undangan.]]> Panduan Pengguna.]]> Aktifkan Ketika lebih dari satu operator diaktifkan, tidak satupun dari mereka memiliki metadata untuk mengetahui siapa yang berkomunikasi. @@ -1563,8 +1519,6 @@ Frasa sandi enkripsi basis data akan diperbarui dan disimpan di pengaturan. Frasa sandi enkripsi basis data akan diperbarui dan disimpan di Keystore. migrasi berbeda di aplikasi/basis data: %s / %s - tunjukkan kode QR dalam panggilan video, atau bagikan tautan.]]> - Jangan buat alamat Rincian Koneksi Terhubung ke ponsel @@ -1577,33 +1531,25 @@ Kapasitas terlampaui - penerima tidak menerima pesan yang dikirim sebelumnya. hanya dengan satu kontak - bagikan secara langsung atau melalui messenger apa pun.]]> Disalin ke papan klip - Buat tautan undangan satu-kali Saat ini maksimal ukuran berkas adalah %1$s. - Buka di ponsel.]]> Pengaturan alamat Tambahkan anggota tim Anda ke percakapan. Alamat bisnis - dengan enkripsi end-to-end, dengan keamanan post-quantum dalam pesan pribadi.]]> Buat tautan 1-kali Notifikasi dan baterai Tiada layanan latar belakang Aplikasi selalu berjalan di latar belakang Periksa pesan setiap 10 menit - Pilih operator jaringan yang akan digunakan. Misalnya, jika kontak Anda menerima pesan melalui server SimpleX Chat, aplikasi Anda akan mengirimkannya melalui server Flux. Bagaimana ini membantu privasi Lanjutkan Anda dapat konfigurasi server di pengaturan. - repositori GitHub kami.]]> - Nilai nanti - Perbarui Rincian tautan unduhan untuk setiap profil obrolan yang Anda miliki di aplikasi.]]> untuk setiap kontak dan anggota grup.\nHarap diperhatikan: jika Anda memiliki banyak koneksi, konsumsi baterai dan lalu lintas dapat jauh lebih tinggi dan beberapa koneksi mungkin gagal.]]> Mengunduh pembaruan aplikasi, jangan tutup aplikasi Mati Buat alamat SimpleX - Buat alamat agar orang dapat terhubung dengan Anda. Lanjutkan Hapus gambar Edit gambar @@ -1696,7 +1642,6 @@ negosiasi ulang enkripsi diperlukan negosiasi ulang enkripsi diizinkan untuk %s Aktifkan Flux di pengaturan Jaringan dan server untuk privasi metadata yang lebih baik. - untuk privasi metadata lebih baik. Navigasi obrolan ditingkatkan Galat Selesaikan migrasi pada perangkat lain. @@ -1743,7 +1688,6 @@ Gagal memulai obrolan Mode samaran Gagal hapus basis data - Kode QR tidak valid Gagal enkripsi basis data Galat Impor @@ -1894,20 +1838,16 @@ Silakan hubungi admin grup. Memuat berkas Harap tunggu sementara berkas sedang dimuat dari ponsel yang terhubung - Tertunda Negosiasi ulang enkripsi? Negosiasi ulang Kirim pesan sementara Kirim Pindai kode QR - (pindai atau tempel dari papan klip) Izin Ditolak! Tandai dibaca tautan pratinjau gambar - Anda dapat bagikan alamat sebagai tautan atau kode QR - siapa pun dapat terhubung dengan Anda. Simpan Jadikan profil pribadi! - Ponsel jarak jauh Jalankan obrolan Harap simpan frasa sandi dengan aman, Anda TIDAK akan dapat mengakses obrolan jika hilang. dihapus %1$s @@ -1917,14 +1857,12 @@ Hapus frasa sandi dari pengaturan? Simpan frasa sandi di pengaturan Diproxy - Tempel tautan yang Anda terima untuk terhubung dengan kontak… lainnya Kirim pesan langsung Pesan langsung! Kode QR Memindah Alamat server - Buka pengaturan server alamat kontak dihapus Putar dari daftar obrolan. Ponsel terhubung @@ -1951,7 +1889,6 @@ Mari bicara di SimpleX Chat Simpan dan beritahu kontak Simpan preferensi? - Anda dapat buat nanti Gabung ke grup? Bergabung dengan grup Minta kontak Anda untuk aktifkan panggilan. @@ -2020,18 +1957,12 @@ Untuk memulai obrolan baru Video Koneksi yang Anda terima akan dibatalkan! - Kode QR ini bukan tautan! Bunyikan Isolasi transport - Perbarui mode isolasi transport? Untuk melindungi alamat IP, routing pribadi menggunakan server SMP untuk mengirim pesan. Perlihat kesalahan internal Lihat panggilan API lambat - Kami tidak menyimpan kontak atau pesan Anda (setelah terkirim) di server. Profil, kontak, dan pesan terkirim Anda disimpan di perangkat Anda. - Platform perpesanan dan aplikasi yang melindungi privasi dan keamanan Anda. - Untuk melindungi privasi Anda, SimpleX gunakan ID terpisah untuk setiap kontak. - Proxy SOCKS Tingkatkan dan buka obrolan Ketuk untuk gabung ke samaran Anda memblokir %s @@ -2152,7 +2083,6 @@ Pesan suara (%1$s) Lihat kode keamanan Anda perlu mengizinkan kontak mengirim pesan suara agar dapat mengirimkannya. - (untuk dibagikan dengan kontak Anda) Ketuk untuk pindai Terima kasih telah memasang SimpleX Chat! Setel nama kontak @@ -2162,7 +2092,6 @@ Anda akan terhubung saat perangkat kontak Anda online, harap tunggu atau periksa nanti! Anda akan terhubung saat permintaan koneksi Anda diterima. Harap tunggu atau periksa nanti! Bagikan tautan 1-kali - Saat orang meminta untuk terhubung, Anda dapat terima atau menolaknya. Anda tidak akan kehilangan kontak jika menghapus alamat Anda nanti. Matikan? Hentikan obrolan? @@ -2177,7 +2106,6 @@ Perlihat profil Anda harus masukkan frasa sandi setiap aplikasi dibuka - frasa sandi tidak disimpan di perangkat. Server SMP - Profil obrolan Anda akan dikirim\nke kontak Anda Bagikan alamat Peran akan diubah menjadi %s. Anggota akan menerima undangan baru. Perbarui pengaturan jaringan? @@ -2197,23 +2125,19 @@ Gambar tidak dapat didekodekan. Silakan coba gambar lain atau hubungi pengembang. Kunci salah atau koneksi tidak dikenal - kemungkinan besar koneksi ini dihapus. Mulai obrolan baru - Ketuk Buat alamat SimpleX di menu untuk membuatnya nanti. Untuk terhubung, kontak Anda dapat pindai kode QR atau gunakan tautan di aplikasi. Profil Anda %1$s akan dibagikan. Untuk melindungi tautan Anda dari penggantian, Anda dapat membandingkan kode keamanan kontak. Pembaruan unduhan dibatalkan Profil Anda saat ini Anda menggunakan profil samaran untuk grup ini - untuk mencegah berbagi profil utama Anda, undang kontak tidak diizinkan - Lihat daftar obrolan di jendela baru Anda mengirim undangan grup - Tautan ini bukan tautan koneksi yang valid! Anda akan berhenti menerima pesan dari grup ini. Riwayat obrolan akan disimpan. Anda bergabung ke grup ini. Menghubungkan untuk undang anggota grup. Anda buka blokir %s Menunggu desktop… Ganti profil obrolan untuk undangan 1-kali. Untuk perlihat profil tersembunyi Anda, masukkan kata sandi lengkap di kolom pencarian di halaman Profil obrolan Anda. - Anda mengendalikan obrolan Anda! Untuk terima Perlihat Perbarui pengaturan akan menghubungkan ulang klien ke semua server. @@ -2277,7 +2201,6 @@ Laporkan profil anggota: hanya moderator grup yang akan melihat. Arsip laporan? Berkas diblokir oleh operator server:\n\n%1$s. - Laporan akan diarsipkan untuk Anda. moderator Laporkan konten: hanya moderator grup yang akan melihat. Ubah daftar @@ -2305,7 +2228,6 @@ 1 tahun bawaan (%s) Bisukan semua - Sebutan belum terbaca Port TCP untuk pesan Gunakan port TCP %1$s jika tidak ada port yang ditentukan. Gunakan port peramban @@ -2474,7 +2396,6 @@ Kirim permintaan Kirim permintaan tanpa pesan Kirimkan masukan pribadi Anda ke grup. - Dikirim ke kontak Anda setelah terhubung. Pasang bio profil dan pesan sambutan. Bagikan alamat lama Bagikan tautan lama @@ -2496,7 +2417,6 @@ Alamat akan singkat, dan profil Anda akan dibagikan melalui alamat tersebut. Tautan akan singkat, dan profil grup akan dibagikan melalui tautan tersebut. Pengirim TIDAK akan diberi tahu. - Pengaturan ini untuk profil Anda saat ini Waktu menghilang hanya diatur untuk kontak baru. Untuk mengirim perintah, Anda harus terhubung. Untuk menggunakan profil lain setelah mencoba hubungkan, hapus chat dan gunakan tautan lagi. @@ -2535,11 +2455,9 @@ %1$d pelanggan %1$d pelanggan %1$s mendukung SimpleX Chat. Lencana berakhir pada %2$s. - Tentang diterima daftar yang di akui aktif - Tambahkan Tambah relay Tambah relay Tambahkan relay untuk memulihkan pengiriman pesan. @@ -2572,7 +2490,6 @@ Gagal menyiarkan Saluran Tautan saluran - Anggota saluran Nama saluran Preferensi saluran Profil saluran disimpan di perangkat pelanggan dan di relai obrolan. @@ -2602,8 +2519,6 @@ Hubungkan terhubung menghubungkan - Menghubungkan melalui nama saluran memerlukan versi aplikasi yang lebih baru. - Menghubungkan melalui nama kontak memerlukan versi aplikasi yang lebih baru. Koneksi gagal Hubungkan melalui tautan atau kode QR Kontak @@ -2648,7 +2563,6 @@ Galat saat menambahkan relai Galat saat membuat saluran Gagal menghapus pesan - Galat saat membuka saluran galat: %s Galat saat menyimpan profil saluran Galat saat membagikan saluran @@ -2656,7 +2570,6 @@ gagal gagal Berkas - Filter Agar siapa pun dapat menghubungi Anda Dari riwayat (dari pemilik) @@ -2705,9 +2618,6 @@ Tidak ada relai Tidak ada relay yang dipilih Perekaman suara tidak didukung di platform Anda. - Nama saluran tidak didukung - Nama kontak tidak didukung - Silakan perbarui aplikasinya. Grup ini membutuhkan versi aplikasi yang lebih baru. Silakan perbarui aplikasi untuk bergabung. Cari gambar Cari video 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 16ba6db570..afaac45347 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml @@ -14,7 +14,6 @@ Connettere via link una tantum? Entrare nel gruppo? Il tuo profilo verrà inviato al contatto da cui hai ricevuto questo link. - Connetti connesso errore in connessione @@ -46,8 +45,6 @@ Link gruppo SimpleX Link completo Via browser - Errore di salvataggio server SMP - Assicurati che gli indirizzi dei server SMP siano nel formato giusto, uno per riga e non doppi. Errore di aggiornamento della configurazione di rete Caricamento conversazione fallito Caricamento delle chat fallito @@ -143,13 +140,10 @@ invio fallito non letto Benvenuto/a %1$s! - Benvenuto/a! - Questo testo è disponibile nelle impostazioni Сhat Sei stato/a invitato/a in un gruppo Entra come %s in connessione… - Tocca per iniziare una conversazione Scrivi agli sviluppatori Non hai chat Condividi multimediale… @@ -182,7 +176,6 @@ Connesso Disconnesso Errore - In attesa Cambiare l\'indirizzo di ricezione\? Vedi codice di sicurezza Verifica codice di sicurezza @@ -247,7 +240,6 @@ Accettare la richiesta di connessione\? Accetta in incognito Tutti i messaggi verranno eliminati, non è reversibile! I messaggi verranno eliminati SOLO per te. - Aggiungi server preimpostati Aggiungi server Impostazioni di rete avanzate Riguardo SimpleX @@ -263,7 +255,6 @@ 1 mese Errore nell\'importazione del database della chat Nome completo del gruppo: - scansionare il codice QR nella videochiamata, oppure il tuo contatto può condividere un link di invito.]]> Backup dei dati dell\'app L\'archivio chiavi di Android è usato per memorizzare in modo sicuro la password; permette il funzionamento del servizio di notifica. Permetti ai tuoi contatti di inviare messaggi vocali. @@ -294,9 +285,7 @@ Svuota chat Svuotare la chat\? Svuota - Connetti via link / codice QR Copiato negli appunti - Crea link di invito una tantum Crea gruppo segreto Scansiona un codice QR.]]> Dalla Galleria @@ -310,7 +299,6 @@ Eliminare la connessione in attesa\? Email aiuto - mostra il codice QR nella videochiamata, oppure condividi il link.]]> Console della chat Annulla la verifica Connetti @@ -338,7 +326,6 @@ connesso connessione… connessione chiamata… - Crea Crea profilo Elimina immagine Nome del profilo: @@ -352,7 +339,6 @@ Buono per la batteria. L\'app cerca messaggi ogni 10 minuti. Potresti perdere chiamate o messaggi urgenti.]]> Chiamata già terminata! Crea il tuo profilo - Decentralizzato Chiamata crittografata e2e Videochiamata crittografata e2e terminata @@ -522,18 +508,15 @@ (memorizzato solo dai membri del gruppo) Autorizzazione negata! Rifiuta - (scansiona o incolla dagli appunti) Scansiona un codice QR Inizia una nuova conversazione Tocca il pulsante Grazie per aver installato SimpleX Chat! Per connettersi via link - (da condividere con il tuo contatto) Per iniziare una nuova chat Fotocamera connetterti con gli sviluppatori di SimpleX Chat per porre domande e ricevere aggiornamenti.]]> Link non valido! - Codice QR non valido immagine di anteprima link Segna come già letto Segna come non letta @@ -547,8 +530,6 @@ Mostra codice QR La connessione che hai accettato verrà annullata! Il contatto con cui hai condiviso questo link NON sarà in grado di connettersi! - Questo non è un link di connessione valido! - Questo codice QR non è un link! Riattiva notifiche vuole connettersi con te! Logo di SimpleX @@ -556,8 +537,6 @@ Squadra di SimpleX Hai accettato la connessione Hai invitato il contatto - Il tuo profilo di chat verrà inviato -\nal tuo contatto Il tuo contatto deve essere in linea per completare la connessione. \nPuoi annullare questa connessione e rimuovere il contatto (e riprovare più tardi con un link nuovo). Verrai connesso/a quando la tua richiesta di connessione verrà accettata, attendi o controlla più tardi! @@ -569,7 +548,6 @@ Segna come verificato/a Link di invito una tantum Incolla - Server preimpostato Indirizzo server preimpostato Salva i server Scansiona codice @@ -583,7 +561,6 @@ SimpleX Lock %s non è verificato/a %s è verificato/a - Server SMP Alcuni server hanno fallito il test: Prova server Prova i server @@ -591,8 +568,6 @@ Per verificare la crittografia end-to-end con il tuo contatto, confrontate (o scansionate) il codice sui vostri dispositivi. Usa per connessioni nuove Usa il server - Apri nell\'app mobile.]]> - Il tuo server L\'indirizzo del tuo server Il tuo indirizzo SimpleX Se confermi, i server di messaggistica saranno in grado di vedere il tuo indirizzo IP e il tuo fornitore, a quali server ti stai connettendo. @@ -618,7 +593,6 @@ Stai usando i server di SimpleX Chat. Quando disponibili I tuoi server ICE - I tuoi server SMP corsivo chiamata persa risposta ricevuta… @@ -631,36 +605,23 @@ segreto avvio… barrato - La piattaforma di messaggistica che protegge la tua privacy e sicurezza. Il profilo è condiviso solo con i tuoi contatti. in attesa di risposta… in attesa di conferma… - Non memorizziamo nessuno dei tuoi contatti o messaggi (una volta recapitati) sui server. Puoi usare il markdown per formattare i messaggi: - Sei tu a controllare la tua chat! Il tuo profilo attuale Il tuo profilo, i contatti e i messaggi recapitati sono memorizzati sul tuo dispositivo. Il tuo profilo è memorizzato sul tuo dispositivo e condiviso solo con i tuoi contatti. I server di SimpleX non possono vedere il tuo profilo. Ignora - Immune allo spam Chiamata in arrivo Videochiamata in arrivo Istantaneo Come influisce sulla batteria Crea una connessione privata - Solo i dispositivi client memorizzano i profili utente, i contatti, i gruppi e i messaggi. - Chiunque può installare i server. Incolla il link che hai ricevuto - Sei tu a decidere chi può connettersi. Periodico - Privacy ridefinita Notifiche private - repository GitHub.]]> Rifiuta - Nessun identificatore utente. - Il futuro dei messaggi - Per proteggere la tua privacy, SimpleX usa ID separati per ciascuno dei tuoi contatti. - Usa la chat videochiamata videochiamata (non crittografata e2e) Quando l\'app è in esecuzione @@ -690,7 +651,6 @@ Il server relay viene usato solo se necessario. Un altro utente può osservare il tuo indirizzo IP. %1$d messaggio/i saltato/i Le tue chiamate - I tuoi server ICE La tua privacy Importa Importare il database della chat\? @@ -705,7 +665,6 @@ Invia le anteprime dei link Imposta la password per esportare Impostazioni - Proxy SOCKS Ferma Fermare la chat\? Ferma la chat per esportare, importare o eliminare il database della chat. Non potrai ricevere e inviare messaggi mentre la chat è ferma. @@ -897,7 +856,6 @@ Solo dati del profilo locale Messaggi File e multimediali - Aggiornare la modalità di isolamento del trasporto\? Tutte le chat e i messaggi verranno eliminati. Non è reversibile! Profilo di chat per ogni contatto e membro del gruppo .\n Nota: : se hai molte connessioni, il consumo di batteria e traffico può essere notevolmente superiore e alcune connessioni potrebbero fallire.]]> @@ -1018,15 +976,11 @@ Il video verrà ricevuto quando il tuo contatto sarà in linea, attendi o controlla più tardi! In attesa del video 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. Confronta file Crea file Scarica file Invia file - Server XFTP - I tuoi server XFTP Impostazioni proxy SOCKS Usa proxy SOCKS Host @@ -1034,8 +988,6 @@ porta %d Usa gli host .onion su No se il proxy SOCKS non li supporta.]]> Elimina file - Errore nel caricamento dei server SMP - Assicurati che gli indirizzi del server XFTP siano nel formato corretto, uno per riga e non doppi. Autenticazione fallita Inserimento del codice di accesso Modalità di SimpleX Lock @@ -1093,8 +1045,6 @@ Solo il tuo contatto può effettuare chiamate. Proibisci le chiamate audio/video. Chiamate audio/video - " -\nDisponibile nella v5.1" Consenti ai tuoi contatti di chiamarti. Codice di accesso dell\'app Veloce e senza aspettare che il mittente sia in linea! @@ -1106,12 +1056,10 @@ Cambia i profili di chat Maggiori informazioni Per connettervi, il tuo contatto può scansionare il codice QR o usare il link nell\'app. - Quando le persone chiedono di connettersi, puoi accettare o rifiutare. Indirizzo SimpleX Colori dell\'interfaccia I tuoi contatti resteranno connessi. Aggiungi l\'indirizzo al tuo profilo, in modo che i tuoi contatti di SimpleX possano condividerlo con altre persone. L\'aggiornamento del profilo verrà inviato ai tuoi contatti di SimpleX. - Crea un indirizzo per consentire alle persone di connettersi con te. Crea indirizzo SimpleX Condividi con i contatti di SimpleX Condividere l\'indirizzo con i contatti di SimpleX? @@ -1121,7 +1069,6 @@ \nConnettiti a me tramite SimpleX Chat: %s Invita amici Salva le impostazioni dell\'indirizzo SimpleX - Puoi crearlo più tardi Condividi indirizzo… Inserisci il messaggio di benvenuto… Anteprima @@ -1144,7 +1091,6 @@ Sfondo Continua Errore di impostazione dell\'indirizzo - Non creare un indirizzo Personalizza il tema Tema scuro Se non potete incontrarvi di persona, mostra il codice QR in una videochiamata o condividi il link. @@ -1155,7 +1101,6 @@ Smettere di condividere l\'indirizzo\? Salvare le impostazioni\? Non perderai i contatti se in seguito elimini il tuo indirizzo. - Puoi condividere il tuo indirizzo come link o codice QR: chiunque può connettersi a te. Apertura del database… Cambia modalità di autodistruzione Attiva il codice di autodistruzione @@ -1336,13 +1281,11 @@ Questo gruppo ha più di %1$d membri, le ricevute di consegna non vengono inviate. Connettersi direttamente\? La richiesta di connessione verrà inviata a questo membro del gruppo. - Connetti in incognito Usa il profilo attuale Usa nuovo profilo in incognito Consenti Apri impostazioni app L\'app potrebbe venire chiusa dopo 1 minuto in secondo piano. - Incolla il link che hai ricevuto per connetterti con il contatto… Verrà condiviso il tuo profilo %1$s. Verrà condiviso un nuovo profilo casuale. Disattiva le notifiche @@ -1514,7 +1457,6 @@ Apri porta nel firewall errore di visualizzazione del contenuto errore di visualizzazione del messaggio - Puoi renderlo visibile ai tuoi contatti SimpleX nelle impostazioni. La cronologia non viene inviata ai nuovi membri. Riprova Fotocamera non disponibile @@ -1772,12 +1714,10 @@ Mai Server sconosciuti Usa l\'instradamento privato con server sconosciuti. - Modalità instradamento messaggio Usa l\'instradamento privato con server sconosciuti quando l\'indirizzo IP non è protetto. Sì Invia messaggi direttamente quando il tuo server o quello di destinazione non supporta l\'instradamento privato. Quando l\'IP è nascosto - Ripiego instradamento messaggio Mostra stato del messaggio Consenti downgrade Sempre @@ -1819,7 +1759,6 @@ Adatta Risposta inviata Imposta tema predefinito - Mostra la lista di chat in una nuova finestra Sistema Tinta dello sfondo Buon pomeriggio! @@ -1906,7 +1845,6 @@ Eliminato Errori di eliminazione File scaricati - Apri impostazioni server Protetto Indirizzo server Dimensione @@ -1919,8 +1857,6 @@ La versione del server non è compatibile con la tua app: %1$s. Membro inattivo Il messaggio può essere consegnato più tardi se il membro diventa attivo. - Server SMP configurati - Altri server SMP Mostra percentuale inattivo Zoom @@ -1938,7 +1874,6 @@ Sessioni di trasporto Tutti i profili tentativi - Server XFTP configurati Completato Server connessi disattivato @@ -1955,7 +1890,6 @@ Partendo da %s. \nTutti i dati sono privati, nel tuo dispositivo. Ancora nessuna connessione diretta, il messaggio viene inoltrato dall\'amministratore. Non sei connesso/a a questi server. L\'instradamento privato è usato per consegnare loro i messaggi. - Altri server XFTP Server precedentemente connessi Riprova più tardi. Beta @@ -1989,7 +1923,6 @@ Forte chiama messaggio - apri cerca Impostazioni Elimina senza avvisare @@ -2016,7 +1949,6 @@ Invia un messaggio per attivare le chiamate. video Puoi ancora vedere la conversazione con %1$s nell\'elenco delle chat. - connetti Contatto eliminato! Confermare l\'eliminazione del contatto? Chatta @@ -2134,12 +2066,8 @@ O per condividere in modo privato Operatori di rete L\'app protegge la tua privacy usando diversi operatori per ogni conversazione. - Puoi configurare gli operatori nelle impostazioni di rete e server. Operatori del server - Seleziona gli operatori di rete da usare. Continua - Aggiornamento - Leggi più tardi Server preimpostati Condizioni accettate Le condizioni verranno accettate automaticamente per gli operatori attivati il: %s. @@ -2151,7 +2079,6 @@ Operatore di rete Server di %s Usa %s - Sito web %s.]]> Condizioni accettate il: %s. Operatore @@ -2189,18 +2116,15 @@ Indirizzo o link una tantum? Impostazioni dell\'indirizzo con un solo contatto - condividilo di persona o tramite un messenger.]]> - Le condizioni verranno accettate per gli operatori attivati dopo 30 giorni. Le condizioni verranno accettate il: %s. Errore di accettazione delle condizioni Errore di salvataggio dei server - per una migliore privacy dei metadati. Ad esempio, se il tuo contatto riceve i messaggi tramite un server di SimpleX Chat, la tua app li consegnerà tramite un server di Flux. Navigazione della chat migliorata Nuovo server Usa per i file Indirizzo SimpleX o link una tantum? Questo messaggio è stato eliminato o non ancora ricevuto. - Tocca Crea indirizzo SimpleX nel menu per crearlo più tardi. La connessione ha raggiunto il limite di messaggi non consegnati, il contatto potrebbe essere offline. Usa i server Puoi configurare i server nelle impostazioni. @@ -2212,12 +2136,10 @@ Condividi indirizzo pubblicamente Condividi l\'indirizzo SimpleX sui social media. O importa un file dell\'archivio - Telefoni remoti I messaggi diretti tra i membri sono vietati in questa chat. Dispositivi Xiaomi: attiva l\'avvio automatico nelle impostazioni di sistema per fare funzionare le notifiche.]]> Aggiungi i membri del tuo team alle conversazioni. Indirizzo di lavoro - cifrati end-to-end, con sicurezza quantistica nei messaggi diretti.]]> Controlla i messaggi ogni 10 minuti Come aiuta la privacy Invita in chat @@ -2303,7 +2225,6 @@ Spam Segnala Motivo della segnalazione? - La segnalazione verrà archiviata per te. Segnala profilo: solo i moderatori del gruppo lo vedranno. Segnala spam: solo i moderatori del gruppo lo vedranno. Segnala violazione: solo i moderatori del gruppo lo vedranno. @@ -2339,7 +2260,6 @@ Porta TCP per i messaggi Usa porta web Silenzia tutto - Menzioni non lette Puoi menzionare fino a %1$s membri per messaggio! I membri possono segnalare messaggi ai moderatori. Archiviare tutte le segnalazioni? @@ -2465,7 +2385,6 @@ Inviare una richiesta di contatto? Invia richiesta Invia richiesta senza messaggio - Inviato al tuo contatto dopo la connessione. Aggiornare il link del gruppo? Aggiorna Aggiornare l\'indirizzo? @@ -2521,7 +2440,6 @@ Richieste di contatto nei gruppi Il membro è eliminato - impossibile accettare la richiesta connessione richiesta dal gruppo %1$s - Questa impostazione è per il tuo profilo attuale Consenti file e contenuti multimediali solo se il tuo contatto li consente. Consenti ai tuoi contatti di inviare file e contenuti multimediali. Bot @@ -2560,7 +2478,6 @@ Cerca messaggi vocali Video Messaggi vocali - Filtro Connessione fallita fallito Se sei dentro canali o ne hai creati, essi smetteranno di funzionare definitivamente. @@ -2580,7 +2497,6 @@ Canale Canale Link del canale - Membri del canale Nome del canale Il canale verrà eliminato per tutti gli iscritti, non è reversibile! Il canale verrà eliminato per te, non è reversibile! @@ -2610,7 +2526,6 @@ Inserisci il nome del relay… Errore di aggiunta del relay Errore di creazione del canale - Errore di apertura del canale fallito fallito Ottieni link @@ -2624,7 +2539,6 @@ Nessun relay di chat attivato. Non tutti i relay sono connessi Apri canale - Apri il nuovo canale Proprietario Proprietari e collaboratori Indirizzo relay preimpostato @@ -2807,7 +2721,6 @@ L\'anteprima del link verrà richiesta via proxy SOCKS. La ricerca DNS può ancora accadere localmente tramite il tuo risolutore DNS. Barra superiore Il tuo nuovo canale %1$s è connesso a %2$d di %3$d relay.\nSe annulli, il canale verrà eliminato. Potrai crearlo di nuovo. - Aggiungi Aggiungi relay Aggiungi relay Annulla ed elimina il canale @@ -2844,7 +2757,6 @@ %1$d proprietari %1$d proprietari e collaboratori %1$s ha sostenuto SimpleX Chat. La targhetta è scaduta il %2$s. - Informazioni lista riconosciuta Aggiungi questo codice alla tua pagina web. Mostrerà l\'anteprima del tuo canale / gruppo. Opzioni avanzate @@ -2855,8 +2767,6 @@ La targhetta non può essere verificata Pagina web del canale Dati della chat - La connessione tramite nome del canale richiede una versione dell\'app più recente. - La connessione tramite nome del contatto richiede una versione dell\'app più recente. Contatto collaboratore Copia codice @@ -2868,7 +2778,6 @@ Verrà mostrato agli iscritti e usato per permettere il caricamento dell\'anteprima. Più privacy Solo la tua pagina soprastante può mostrare l\'anteprima. - Aggiorna l\'app. %s ha investito nella raccolta fondi di SimpleX Chat. %s sostiene SimpleX Chat. iscritto @@ -2877,8 +2786,6 @@ Il ruolo verrà cambiato in "%s". Verrà avvisato chiunque nel canale. Non è stato possibile verificare questa targhetta e potrebbe non essere autentica. Questo gruppo richiede una versione dell\'app più recente. Aggiorna l\'app per entrare. - Nome del canale non supportato - Nome del contatto non supportato Targhetta non verificata I relay di chat usati non supportano le pagine web. Codice pagina web @@ -2893,7 +2800,6 @@ Errore del risolutore: %1$s Il server %1$s non supporta la risoluzione dei nomi. Configura i server o usa un link di connessione. Imposta nome SimpleX - Nome SimpleX Errore del nome SimpleX Nome SimpleX non verificato Il nome SimpleX %1$s è registrato, ma non ha alcun link valido. 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 8487614c9a..e42232f93a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml @@ -17,7 +17,6 @@ אפשר כל ההודעות יימחקו – לא ניתן לבטל זאת! ההודעות יימחקו רק עבורך. אשר זהות נסתרת - הוסף שרתים מוגדרים מראש הוסף שרת לגשת לשרתים דרך פרוקסי SOCKS בפורט %d\? הפרוקסי חייב לפעול לפני הפעלת אפשרות זו. הגדרות רשת מתקדמות @@ -85,8 +84,6 @@ מזהה הודעה שגוי קבל אוטומטית תמונות שימו לב: לא ניתן יהיה לשחזר או לשנות את הסיסמה אם תאבדו אותה.]]> - " -\nזמין מ־v5.1" גם אתם וגם איש הקשר יכולים לשלוח הודעות קוליות. גם אתם וגם איש הקשר יכולים לבצע שיחות. אשר אוטומטית בקשות ליצירת קשר. @@ -117,7 +114,6 @@ השיחה הסתיימה %1$s בטל תצוגה מקדימה של קבצים להתחבר באמצעות קישור ליצירת קשר? - התחבר להצטרף לקבוצה? מחובר מתחבר @@ -197,7 +193,6 @@ להתחבר דרך קישור חד-פעמי? איש הקשר כבר קיים איש הקשר וכל ההודעות יימחקו – לא ניתן לבטל זאת! - התחברות באמצעות קישור / קוד QR התחברות באמצעות קישור איש הקשר מאפשר איש קשר מוסתר: @@ -211,12 +206,10 @@ העתק סמל מידע נוסף הועתק ללוח - צור קישור הזמנה חד־פעמי צור קבוצה סודית תרומה גרסת ליבה: v%s צור כתובת - צור צור פרופיל יצירת הפרופיל שלך צור קישור קבוצה @@ -246,8 +239,6 @@ מחק מחק מחק שרת - צרו כתובת כדי לאפשר לאנשים להתחבר אליכם. - מבוזר מסד הנתונים מוצפן באמצעות סיסמה אקראית. אנא שנו אותה לפני הייצוא. מחק פרופיל צ׳אט ברירת מחדל (%s) @@ -317,7 +308,6 @@ נעילת המכשיר אינה מופעלת. ניתן להפעיל את נעילת SimpleX, לאחר שתפעילו את נעילת המכשיר. %d קבצים בגודל כולל של %s שגיאה - שגיאה בטעינת שרתי SMP שגיאה ביצירת פרופיל! שם תצוגה כבר קיים! שגיאה ביצירת כתובת @@ -353,7 +343,6 @@ שגיאה בשינוי כתובת שגיאה במחיקת פרופיל משתמש השבתת נעילת SimpleX - לא ליצור כתובת מושבת הודעה כפולה שגיאה במחיקת מסד הנתונים של הצ׳אט @@ -399,7 +388,6 @@ הזינו הודעת פתיחה… שגיאה במחיקת איש קשר שגיאה במחיקת חיבור איש קשר ממתין - שגיאה בטעינת שרתי XFTP שגיאה בשליחת הודעה שגיאה בקבלת קובץ שגיאה בשמירת שרתי ICE @@ -410,8 +398,6 @@ שגיאה בשמירת קובץ יציאה ללא שמירה שגיאה בעצירת צ׳אט - שגיאה בשמירת שרתי SMP - שגיאה בשמירת שרתי XFTP שגיאה בהחלפת פרופיל! שגיאה בעדכון תצורת הרשת טעינת הצ׳אט נכשלה @@ -453,7 +439,6 @@ הקובץ יימחק מהשרתים. הפוך מצלמה עזרה - הציגו את קוד ה־QR בשיחת וידאו, או שתפו את הקישור.]]> איך איך להשתמש בשרתים שלך שרתי ICE (אחד בכל שורה) @@ -473,7 +458,6 @@ מוסתר הסתר איש קשר והודעה הסתר - לסרוק את קוד ה־QR בשיחת וידאו, או שאיש הקשר שלך ישתף קישור הזמנה.]]> אם לא ניתן להיפגש פנים אל פנים, הציגו את קוד ה־QR בשיחת וידאו, או שתפו את הקישור. איך להשתמש בזה היי! @@ -504,7 +488,6 @@ התעלם מיד ייבא מסד נתונים - חסין מפני ספאם לייבא מסד נתונים של צ׳אט\? תמונה נשלחה התמונה תתקבל כאשר איש הקשר יסיים להעלות אותה. @@ -541,7 +524,6 @@ התראות מיידיות התראות מיידיות! קישור לא תקין! - קוד QR לא תקין קוד אבטחה שגוי! מיידית שיחת שמע נכנסת @@ -576,8 +558,6 @@ עזוב הפוך את הפרופיל לפרטי! הודעה חיה - ודאו שכתובות שרתי ה־SMP הן בפורמט הנכון, מופרדות בשורה ולא משוכפלות. - ודאו שכתובות שרתי ה־XFTP הן בפורמט הנכון, מופרדות בשורה ולא משוכפלות. התחבר באמצעות האישור שלך למדו עוד עזרה במרקדאון @@ -662,7 +642,6 @@ \nשימו לב: לא תוכלו להתחבר לשרתים ללא כתובת .onion. לא ייעשה שימוש במארחי Onion. שיחה שלא נענתה - הצפנה מקצה־לקצה דו־שכבתית.]]> ללא הצפנה מקצה־לקצה שיחה שלא נענתה קוד גישה חדש @@ -730,10 +709,6 @@ אנא צרו קשר עם מנהל הקבוצה. אנא בקשו מאיש הקשר שלכם לאפשר שליחת הודעות קוליות. שומר מקום לתמונת פרופיל - שרת מוגדר מראש - פרטיות מוגדרת מחדש - אנשים יכולים להתחבר אליכם רק דרך הקישורים שאתם משתפים. - כל אחד יכול לארח שרתים. תקופתי נא להזין את הסיסמה הקודמת לאחר שחזור גיבוי מסד הנתונים, לא ניתן לבטל פעולה זו. לאסור מחיקה בלתי הפיכה של הודעות. @@ -743,7 +718,6 @@ ייתכן שטביעת האצבע של התעודה בכתובת השרת שגויה פתיחת מסוף צ׳אט שנה פרופילי צ׳אט - ממתין כתובת שרת מוגדר מראש סיסמה להצגה התראות פרטיות @@ -786,7 +760,6 @@ דחיה מדריך למשתמש.]]> דרגו את האפליקציה - GitHub repository שלנו.]]> יבוצע שימוש בשרת ממסר רק במידת הצורך. גורם אחר יכול לצפות בכתובת ה־IP שלך. שרת ממסר מגן על כתובת ה־IP שלך, אך הוא יכול לראות את משך השיחה. שיחה נדחתה @@ -853,7 +826,6 @@ הצג בטל קובץ שליחה נכשלה - (סירקו או הדביקו מהלוח) שלח סירקו קוד אבטחה מהאפליקציה של איש הקשר שלך. השמדה עצמית @@ -900,7 +872,6 @@ נשלח שלחו שאלות ורעיונות בדיקת השרתים נכשלה! - פרוקסי SOCKS נשלח ב: %s הגדרת שם איש קשר… שלח הודעה @@ -967,7 +938,6 @@ לעצור קבלת קובץ\? לשנות כתובת קבלה\? כתובת SimpleX - שרתי SMP שתף עם אנשי קשר הצג רמקול כבוי @@ -989,7 +959,6 @@ בזכות המשתמשים – תרמו באמצעות Weblate! בזכות המשתמשים – תרמו באמצעות Weblate! בזכות המשתמשים – תרמו באמצעות Weblate! - הקישו כדי להתחיל צ׳אט חדש בדוק שרת בדוק שרתים הקישו על הכפתור @@ -1003,20 +972,15 @@ האפליקציה בודקת הודעות חדשות מעת לעת - היא משתמשת בכמה אחוזים מהסוללה ביום. האפליקציה לא משתמשת בהתראות דחיפה - נתונים מהמכשיר שלך לא נשלחים לשרתים. התפקיד ישתנה ל־"%s". כל חברי הקבוצה יקבלו הודעה על כך. כדי להתחבר באמצעות קישור - פלטפורמת ההודעות והיישומים המגנה על הפרטיות והאבטחה שלך. המזהה של ההודעה הבאה שגוי (קטן או שווה להודעה הקודמת). \nזה יכול לקרות בגלל באג כלשהו או כאשר החיבור נפגע. ערכת נושא מסד הנתונים אינו פועל כראוי. הקישו למידע נוסף - טקסט זה זמין בהגדרות יותר מדי תמונות! תודה שהתקנתם את SimpleX Chat! - קישור זה אינו קישור חיבור תקין! צבעי ממשק התפקיד ישתנה ל־"%s". החבר יקבל הזמנה חדשה. השרתים לחיבורים חדשים של פרופיל הצ׳אט הנוכחי שלך - הפלטפורמה הראשונה ללא כל מזהי משתמש - פרטית בעיצובה. - הדור הבא של תקשורת פרטית הגיבוב של ההודעה הקודמת שונה. לא ניתן לבטל פעולה זו - ההודעות שנשלחו והתקבלו לפני הזמן שנבחר יימחקו. זה עשוי להימשך מספר דקות. הקבוצה הזו כבר לא קיימת. @@ -1025,7 +989,6 @@ כתובת הקבלה תשתנה לשרת אחר. שינוי הכתובת יושלם לאחר שהשולח יתחבר לאינטרנט. החיבור שאישרת יבוטל! איש הקשר שאיתו שיתפת את הקישור הזה לא יוכל להתחבר! - קוד QR זה אינו קישור! כדי להתחבר, איש הקשר שלך יכול לסרוק קוד QR או להשתמש בקישור באפליקציה. מחרוזת טקסט זו אינה קישור חיבור! הפרופיל משותף רק עם אנשי הקשר שלך. @@ -1036,7 +999,6 @@ הניסיון לשנות את סיסמת מסד הנתונים לא הושלם. כותרת הקבוצה מבוזרת לחלוטין - היא גלויה רק לחברי הקבוצה. - כדי לשמור על הפרטיות, במקום מזהי משתמש הקיימים בכל הפלטפורמות האחרות, ל־SimpleX יש מזהים לתורי הודעות, נפרדים עבור כל אחד מאנשי הקשר שלך. משתמש בשרתי SimpleX Chatז מנסה להתחבר לשרת המשמש לקבלת הודעות מאיש קשר זה. אלא אם איש הקשר שלכם מחק את החיבור או שהקישור הזה כבר היה בשימוש, זה עשוי להיות באג - אנא דווחו על כך. \nכדי להתחבר, אנא בקשו מאיש הקשר שלכם ליצור קישור חיבור נוסף ובידקו שיש לכם חיבור יציב לרשת. @@ -1055,12 +1017,10 @@ שליחה לא מורשית לא נקרא אימות קוד אבטחה - (כדי לשתף עם איש הקשר שלך) כדי להתחיל צ׳אט חדש השתמש עבור חיבורים חדשים כדי לאמת הצפנה מקצה־לקצה עם איש הקשר שלכם, יש להשוות (או לסרוק) את הקוד במכשירים שלכם. פרופילי צ׳אט - לעדכן מצב בידוד תעבורה\? מנסה להתחבר לשרת המשמש לקבלת הודעות מאיש קשר זה (שגיאה: %1$s). פורמט הודעה לא ידוע דרך הדפדפן @@ -1069,7 +1029,6 @@ להשתמש בשרתי SimpleX Chat\? להשתמש בחיבור ישיר לאינטרנט\? כדי לחשוף את הפרופיל המוסתר שלכם, הזינו סיסמה מלאה בשדה חיפוש בדף פרופילי צ׳אט. - שימוש בצ׳אט עדכן שגיאת מסד נתונים לא ידועה: %s עידכן את פרופיל הקבוצה @@ -1083,7 +1042,6 @@ בטל השתקה ממתין לאישור… ממתין למענה… - איננו מאחסנים את אנשי הקשר או ההודעות שלך (לאחר המסירה) בשרתים. SimpleX אתם באמצעות קישור לכתובת איש קשר @@ -1110,7 +1068,6 @@ אתם כבר מחוברים ל־%1$s. שירות SimpleX Chat לא ניתן היה לאמת אתכם; אנא נסו שוב. - ברוכים הבאים! ברוכ/ה הבא/ה %1$s! הוזמנת לקבוצה ממתין לתמונה @@ -1137,7 +1094,6 @@ הודעה קולית אישרת את החיבור סמל SimpleX - שרתי XFTP דילג על %1$d הודעות. סיסמת מסד נתונים שגויה הוזמנת לקבוצה @@ -1153,12 +1109,7 @@ ממתין לקובץ הודעות קוליות אסורות! הזמנת את איש הקשר שלך - באפשרותכם לשתף את הכתובת שלכם כקישור או כקוד QR – כל אחד יכול להתחבר אליכם. - כאשר אנשים מבקשים להתחבר, באפשרותך לקבל או לדחות זאת. - פתח באפליקציה.]]> כשזמין - תוכלו ליצור אותה מאוחר יותר - אתם שולטים בצ׳אט שלכם! באפשרותך להשתמש במרקדאון כדי לעצב הודעות: כאשר האפליקציה פועלת %1$s רוצה שתתחברו דרך @@ -1189,15 +1140,12 @@ הצ׳אטים איש הקשר שלך שלח קובץ גדול יותר מהגודל המרבי הנתמך כעת (%1$s). איש הקשר שלך צריך להיות מקוון כדי שהחיבור יושלם.\nניתן לבטל חיבור זה ולהסיר את איש הקשר (ולנסות מאוחר יותר עם קישור חדש). - פרופיל הצ׳אט שלך יישלח -\nלאיש הקשר שלך מסד הנתונים שלך דחית את ההזמנה לקבוצה מסד הנתונים הנוכחי שלך יימחק ויוחלף במסד הנתונים המיובא. \nלא ניתן לבטל פעולה זו – הפרופיל, אנשי הקשר, ההודעות והקבצים שלך ייאבדו באופן בלתי הפיך. הפרופיל הנוכחי שלך אנשי הקשר שלך יישארו מחוברים. - שרתי ה־ICE שלך אתם תהיו מחוברים כאשר בקשת החיבור תאושר, אנא חכו או בידקו מאוחר יותר! הפרופיל שלך יישלח לאיש הקשר ממנו קיבלת קישור זה. תתחבר לכל חברי הקבוצה. @@ -1210,16 +1158,13 @@ הפרטיות שלך תידרשו לבצע אימות כאשר תפעילו או תחזרו לאפליקציה לאחר 30 שניות ברקע. ההגדרות שלך - השרת שלך כתובת השרת שלך - שרתי XFTP שלך שלחת הזמנה לקבוצה שיתפת קישור חד־פעמי אתם תהיו מחוברים לקבוצה כאשר המכשיר של מארח הקבוצה יהיה מקוון, אנא חכו או בידקו מאוחר יותר! אתם תהיו מחוברים כאשר המכשיר של איש הקשר שלך יהיה מקוון, אנא חכו או בידקו מאוחר יותר! לא תאבדו את אנשי הקשר שלכם אם תמחקו מאוחר יותר את הכתובת שלכם. כתובת SimpleX שלך - שרתי SMP שלך הפרופיל שלך מאוחסן במכשירך ומשותף רק עם אנשי הקשר שלך.\nשרתי SimpleX אינם יכולים לראות את הפרופיל שלך. הפרופיל, אנשי הקשר וההודעות שנמסרו מאוחסנים במכשיר שלך. אתם תפסיקו לקבל הודעות מקבוצה זו. היסטוריית הצ׳אט תישמר. @@ -1309,14 +1254,12 @@ גם אם הוא מושבת בשיחה. שימרו על הקשרים שלכם בחר קובץ - התחבר בזהות נסתרת השתמש בפרופיל הנוכחי השתמש בפרופיל זהות נסתרת חדש השבת התראות פתח את הגדרות האפליקציה ללא שיחות ברקע ישותף פרופיל אקראי חדש. - הדבק את הקישור שקיבלת כדי להתחבר לאיש הקשר שלך… הפרופיל שלך %1$s ישותף. קבלות מושבתות %s :%s @@ -1396,7 +1339,6 @@ פתח מסך העברה הרחב הקוד שסרקת אינו קוד QR של קישור SimpleX. - תוכל להפוך אותו לגלוי לאנשי הקשר שלך ב-SimpleX דרך ההגדרות. שם לא חוקי! הגדר סיסמא לא ידוע @@ -1738,7 +1680,6 @@ אוזניות ערכת נושא לפרופיל צבעי הצא\'ט - הצג רשימת שיחות בחלון חדש מצב הקובץ סטטוס הודעה מצב הקובץ:%s @@ -1823,7 +1764,6 @@ שגיאה בהתחברות לשרת %1$s, אנא נסה מאוחר יותר אין עדיין חיבור ישיר, ההודעה תעובר ע"י מנהל. חבר לא פעיל - שרתי XFTP אחרים הצג אחוזים מושבת יציבה @@ -1873,7 +1813,6 @@ נשלח דרך פרוקסי קבצים שהורדו שגיאות בהורדה - פתח הגדרות שרת כתובת שרת חריגה מהקיבולת - הנמען לא קיבל הודעות שנשלחו בעבר. בדוק עבור עדכונים @@ -1890,7 +1829,6 @@ קישורי SimpleX לא מאופשרים בקבוצה הזו. סרוק/ הדבק קישור אנא נסה מאוחר יותר - שרתי SMP אחרים אפס הועלה סטטיסטיקה מפורטת @@ -1958,7 +1896,6 @@ %s.]]> מכשירי שיואמי: אנא תאפשר הפעלה אוטומטית בהגדרות הטלפון שלך כדי שההתראות על הודעות חדשות יפעלו.]]> %1$s ההודעות לא הועברו. - מוצפנים מקצה לקצה, עם אבטחה פוסט-קוונטית בהודעות ישירות.]]> טישטוש עסקי %s, קבל את תנאי השימוש.]]> @@ -2000,7 +1937,6 @@ אל תחמיץ הודעות חשובות. הורד גרסאות חדשות מ GitHub. פרופיל לא הולם - לפרטיות מטא דאטא טובה יותר. אפשר Flux בהגדרות רשת ושרתים בשביל לשפר את הפרטיות של המטא דאטא מעביר %1$s הודעות שגיאה בקריאת משפט-סיסמה של מסד נתונים @@ -2123,7 +2059,6 @@ צ\'אטים פרטיים, קבוצות ואנשי הקשר שלך אינם נגישים למפעילי השרת. לדוגמה, אם איש הקשר שלך מקבל הודעות דרך שרת SimpleX Chat, האפליקציה שלך תעביר אותן דרך שרת Flux. מדיניות פרטיות ותנאי שימוש. - ניתן להגדיר מפעילים בהגדרות רשת ושרתים. לא זנב לבועה סרגל כלים נגיש לצ\'אט 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 1e0686d2de..e5c425e7d7 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml @@ -3,7 +3,6 @@ 1日 1週間 受けた通話 - 既存サーバを追加 管理者 管理者はグループの参加リンクを発行できます。 ネットワーク詳細設定 @@ -98,7 +97,6 @@ %d分 グループのリンク k - 接続 接続中 エラー 接続待ち @@ -121,7 +119,6 @@ 削除する ファイルを保存しました 連絡先を削除しますか? - リンク・QRコード経由で繋がる 検証済みにマーク 使い方 必須 @@ -137,7 +134,6 @@ グループリンク発行にエラー発生 役割変更にエラー発生 チャットデータベースの書き出しでエラー発生 - SMPサーバ保存でエラー発生 ICEサーバ保存でエラー発生 ネットワーク設定の更新にエラー発生 ファイル @@ -151,14 +147,12 @@ グループのプロフィールが更新されました。 連絡先とメッセージ内容をプライベートにする。 通知の常時受信 - SMPサーバのアドレスを正しく1行ずつに分けて、重複しないように、形式もご確認ください。 WebRTC ICEサーバのアドレスを正しく1行ずつに分けて、重複しないように、形式もご確認ください。 通話中 電池消費がより高い!非アクティブ時でもバックグラウンドのサービスが常に稼働します(着信してすぐに通知が出ます)。]]> 発信中 通話終了 %1$s 通話が終了しました。 - ビデオ通話中にQRコードを見せてもらうか、招待リンクを送ってもらえば相手に繋がります。]]> メンバーをグループから除名する (※元に戻せません※)! 通話をロック画面に表示 リンクのプレビューを中止 @@ -167,7 +161,6 @@ ファイル受信ができません。 連絡先を招待できません! 変更 - ビデオ通話にてQRコードを見せるか、招待リンクを送って、相手に繋がります。]]> あなたのアドレスを変えました。 アドレスを変更しています… チャットのデータベース @@ -217,7 +210,6 @@ オニオンのホストが利用可能時に使われます。 オニオンのホストが使われません。 画像を1回で最大10枚を送信できます。 - 2層エンドツーエンド暗号化で送信されたプロフィール、連絡先、グループ、メッセージは、クライント端末にしか保存されません。]]> グループ設定を変えられるのはグループのオーナーだけです。 音声メッセージを利用可能に設定できるのはグループのオーナーだけです。 プロフィールを作成する @@ -256,13 +248,11 @@ TCPキープアライブを有効にする メール 設定メニューでロック画面からの通話を有効にできます。 - あなたと繋がることができるのは、あなたからリンクを頂いた方のみです。 仮のプロフィール画像 QRコード リンクが正しいかどうかご確認ください。または、連絡相手にもう一度リンクをお求めください。 %1$s との接続を確認し、もう一度お試しください。 現在の正しいパスフレーズを入力してください。 - プレセットサーバ プレセットサーバのアドレス 接続待ち… 連絡先リンク経由でシークレットモード @@ -290,7 +280,6 @@ 全員用 ファイル保存でエラー発生 接続中 - 確認待ち キャンセル 使い捨ての招待リンク 音声メッセージを録音 @@ -298,7 +287,6 @@ 削除 ヘルプ 無効なリンク! - 無効なQRコード つづき 誤ったセキュリティコード! 貼り付け @@ -319,12 +307,9 @@ 接続中 発信中… 終了 - 誰でもサーバーをホストできます。 - プライバシーを再定義 技術の説明 プライベートな接続をする プライベートな通知 - GitHubリポジトリで詳細をご確認ください。]]> エンドツーエンド暗号化済みビデオ通話 無効にする エンドツーエンド暗号化がありません @@ -497,7 +482,6 @@ 詳細がありません 戻す クリップボードにコピー完了 - 使い捨てリンクを発行する シークレットグループを作成する (グループのメンバーのみに保存されてます) 「QRコードを読み取る」経由でアプリに表示されるQRコードを読み取る]]> @@ -522,12 +506,9 @@ アドレスを削除? プロフィール名: フルネーム: - 作成 あなたの名前を入力: 色付き 応答 - 分散型 - スパム耐性 常時受信 定期的に受信 通話は既に終了してます! @@ -651,9 +632,7 @@ リンク経由で接続 セキュリティコード %s は認証済み - SMPサーバ 保存 - トランスポート隔離モードを更新しますか? この設定でよろしいですか? ビデオオフ リレー経由 @@ -678,9 +657,7 @@ 起動時、または非アクティブ状態で30秒が経った後に戻ると、認証する必要となります。 共有する 非認証の送信 - このテキストは設定にあります。 画像数の上限を超えてます! - ようこそ! グループ招待が届きました チャット 連絡先を設定… @@ -688,8 +665,6 @@ 受信アドレスを変えますか? 送信する ライブメッセージを送信 (入力しながら宛先の画面で更新される) - (連絡先に共有) - (クリップボードから読み込むか、貼り付ける) SimpleX Chatをご利用いただきありがとうございます! カメラ 接続を承認しました @@ -698,12 +673,8 @@ あなたからリンクを受けた連絡先が接続できなくなります! SimpleXアドレス QRコードを表示 - このリンクは有効な接続リンクではありません! - このQRコードはリンクではありません! グループのホスト端末がオンラインになったら、接続されます。後でチェックするか、しばらくお待ちください。 連絡先がオンラインになったら、接続されます。後でチェックするか、しばらくお待ちください。 - あなたのチャットプロフィールが -\n連絡先に公開されます。 ワンタイムリンクを送る コードを読み込む 連絡相手のアプリからセキュリティコードを読み込む @@ -714,7 +685,6 @@ サーバを保存 サーバテスト失敗! サーバを使う - あなたのサーバ あなたのサーバアドレス 現在のチャットプロフィールの新しい接続のサーバ 新しい接続に使う @@ -728,20 +698,15 @@ 保存して連絡先に公開 現在のプロフィール 保存して連絡先に公開 - あなたのプライバシーとセキュリティを守るメッセージとアプリのプラットフォーム - 連絡先情報と届けたメッセージをサーバに保存することは一切ありません。 - あなたのチャットはあなたが決めます! あなたのプロフィール、連絡先、送信したメッセージがご自分の端末に保存されます。 プロフィールは連絡先にしか共有されません。 メッセージの書式をマークダウンで編集できます。 シークレット 取り消し線 接続中… - 次世代のプライベートメッセンジャー ビデオ通話 アプリがアクティブ時のみ WebRTC ICEサーバ - あなたのICEサーバ 設定 飛ばしたメッセージ あなた @@ -834,7 +799,6 @@ ロック解除 保存 開示する - タップして新しいチャットを始める 画像が解読できません。別のイメージで試すか、開発者に伝えてください。 画像を待機中 画像を待機中 @@ -847,7 +811,6 @@ ミュート解除 連絡先を設定 あなたと接続を希望しています! - Open in mobile app (アプリで開く)ボタンをクリックしてください。]]> チャットプロフィール サーバのQRコードを読み込む テストに失敗したサーバがあります: @@ -857,8 +820,6 @@ simplexmq: バージョン%s (%2s) 応答を待機中… 確認を待機中… - 世界初のユーザーIDのないプラットフォーム - プライバシーに配慮した設計 - チャット %1$sは次の方法であなたと繋がりたいです: ビデオ通話 (非エンドツーエンド暗号化) 表示 @@ -883,7 +844,6 @@ SimpleX Chatを支援 テストサーバ 受信アドレスは別のサーバーに変更されます。アドレス変更は送信者がオンラインになった後に完了します。 - あなたのプライバシーを守るために、他のアプリと違って、ユーザーIDの変わりに SimpleX メッセージ束毎にIDを配布し、各連絡先が別々と扱います。 あなたのチャットプロフィールが他のグループメンバーに公開されます。 エンドツーエンド暗号化を確認するには、ご自分の端末と連絡先の端末のコードを比べます (スキャンします)。 このコンタクトから受信するメッセージのサーバに接続しようとしてます。(エラー: %1$s)。 @@ -899,13 +859,11 @@ シークレットモードのプロフィールでこのグループに参加しています。メインのプロフィールを守るために、招待することができません。 使い捨てリンクを送りました リンクを送ってくれた連絡先にあなたのプロフィールを送ります。 - あなたのSMPサーバ あなたのSimpleXアドレス 連絡先が繋がりリクエストを承認したら、接続されます。後でチェックするか、しばらくお待ちください。 受信アドレスを変える あなたのランダム・プロフィール 音声メッセージ - SOCKSプロキシ データベースのエクスポート、読み込み、削除するにはチャット機能を停止する必要があります。チャット機能を停止すると送受信ができなくなります。 あなたのプロフィール、連絡先、メッセージ、ファイルが完全削除されます (※元に戻せません※)。 データベースパスフレーズを更新 @@ -915,7 +873,6 @@ 連絡先を選択 今はメンバーを招待しません %1$sメンバー - XFTPサーバ ウェルカムメッセージを保存しますか? 次から表示しない パスコードを変更 @@ -963,7 +920,6 @@ 音声/ビデオ通話 音声/ビデオ通話は禁止されています。 受信拒絶 - XFTPサーバーのアドレスが正しい形式で、行で区切られており、重複していないことを確認してください。 ファイルの削除 ビデオ待機中 ビデオを待機中 @@ -975,7 +931,6 @@ ビデオ ← から: SOCKSプロキシー設定 - あなたのXFTPサーバ プロフィールにアドレスを追加し、連絡先があなたのアドレスを他の人と共有できるようにします。プロフィールの更新は連絡先に送信されます。 自動承認 ロックモード @@ -1006,12 +961,9 @@ 音声/ビデオ通話を禁止する 。 連絡先からの通話を許可する。 連絡先が通話を許可している場合のみ通話を許可する。 - " -\nv5.1 で利用可能" 連絡先からのみ通話ができます。 あなたからも連絡先からも通話ができます。 システム - XFTP サーバーの保存中にエラーが発生しました ファイルを作成 メッセージはすべてのメンバーに対して削除されます。 確認できませんでした。 もう一度お試しください。 @@ -1025,7 +977,6 @@ インターフェースカラー 共有を停止 友人を招待する - 後からでも作成できます ここにパスワードを入力してください。 認証がキャンセルされました アドレスを共有する @@ -1047,9 +998,7 @@ 1GBまでのビデオとファイル %1$d メッセージの復号化に失敗しました。 %1$d メッセージをスキップしました - SMPサーバーのロード中にエラーが発生しました ユーザーパスワードの保存中にエラー発生 - XFTPサーバーのロード中にエラーが発生しました テーマのエクスポート ファイルはサーバーから削除されます。 直接会えない場合は、ビデオ通話で QR コードを表示するか、リンクを共有してください。 @@ -1078,7 +1027,6 @@ 次のメッセージの ID が正しくありません (前のメッセージより小さいか等しい)。 \n何らかのバグが原因で、または接続に問題があった場合に発生する可能性があります。 ユーザーに感謝します – Weblate 経由で貢献してください! - 接続が要求されたら、それを受け入れるか拒否するかを選択できます。 ビデオが送信されました 管理者は次のことができます。 \n- メンバーのメッセージを削除します。 @@ -1139,16 +1087,13 @@ 表示にする 非表示プロフィールのパスワード 表示する: - 人々があなたとつながるためのアドレスを作成します。 アドレス設定エラー - アドレスをリンクまたは QR コードとして共有すると、誰でもあなたに接続できます。 後でアドレスを削除しても、連絡先が失われることはありません。 連絡先との接続は維持されます。 連絡先との接続は維持されます。 プロフィールの更新が連絡先に共有されます。 SimpleX のアドレスを作成 連絡先に公開する プロフィールの更新は連絡先に送信されます。 - アドレスを作成しない SimpleXチャットで会話しよう 自動受け入れ設定を保存する プロフィールのパスワードを保存する @@ -1300,7 +1245,6 @@ ファイルやメディアを有効にできるのは、グループオーナーだけです。 再接続 再起動 - シークレットモードで接続 許可 直接接続しますか\? 新しいランダムなプロファイルが共有されます。 @@ -1321,7 +1265,6 @@ 配信通知の送信が%d件のグループで無効になっています 無効 バックグラウンド通話なし - 受信したリンクを貼り付け、連絡先に接続する。 %s と %s は接続中 SimpleXはバックグラウンドでは動作できません。アプリが起動している時のみ通知を受け取ることができます。 送信情報なし @@ -1454,7 +1397,6 @@ %sと%s 新規モバイルデバイス 同時に動作するのはひとつのデバイスだけです。 - 設定でSimpleXの連絡先に表示させることができます。 %1$sに参加しています。]]> これはあなた自身のワンタイムリンクです! チャットが停止しています。このデータベースを他のデバイスで既に使用している場合は、チャットを開始する前に転送し直してください。 @@ -1821,15 +1763,12 @@ \n*ネットワークとサーバー*設定から有効にして下さい。 いいえ はい - メッセージルーティングモード フォントサイズ ベータ アップデートを確認 アップデートを確認 完了 - SMPサーバーの構成 接続中 - XFTPサーバーの構成 連絡先 メッセージサーバ メディア&ファイルサーバ @@ -1847,7 +1786,6 @@ 全てのプロフィール 通話 連絡先の削除を確認しますか? - 接続 連絡先が削除されます - この操作は取り消せません! プロフィールの切り替えエラー メディアのぼかし @@ -1965,7 +1903,6 @@ 情報がありません、リロードしてください SMPサーバ メッセージ - ウェブサイト ビデオ 設定画面からサーバを構成できます。 改定履歴を開く @@ -1995,7 +1932,6 @@ バックグラウンドサービスを使用しません 利用条件の承諾 %s の利用条件に承諾しています。]]> - 後で作成する場合はメニューから「SimpleXのアドレスを作成」を選択してください。 運営者について 1年 1 件のレポート @@ -2053,7 +1989,6 @@ リンク すべて 音声メッセージ - フィルター メンバーとして承認する オブザーバーとして承認する スパム @@ -2097,11 +2032,6 @@ Tor または VPN を使用しない場合、あなたのIPアドレスはこれらのXFTPリレーに表示されます:\n%1$s。 サポートされていない接続リンク このリンクには新しいバージョンのアプリが必要です。アプリをアップグレードするか、互換性のあるリンクを送るよう連絡先に依頼してください。 - サポートされていないチャンネル名 - サポートされていない連絡先名 - チャンネル名による接続には新しいバージョンのアプリが必要です。 - 連絡先名による接続には新しいバージョンのアプリが必要です。 - アプリをアップグレードしてください。 チャンネルは一時的に利用できません チャンネルにアクティブなリレーがありません。後でもう一度参加をお試しください。 アプリの更新が必要です @@ -2126,7 +2056,6 @@ 報告をアーカイブしますか? %d件の報告をアーカイブしますか? すべての報告をアーカイブしますか? - 報告はあなたのためにアーカイブされます。 すべての報告があなたのためにアーカイブされます。 自分のみ すべてのモデレーター向け @@ -2265,7 +2194,6 @@ この操作は元に戻せません - このチャットで選択した時点より前に送受信したメッセージは削除されます。 メッセージ削除を無効にする デバイスからチャットメッセージを削除します。 - 開く 検索 会話を保持 会話のみ削除 @@ -2281,7 +2209,6 @@ 送信者には通知されません。 メンバーが削除されています - リクエストを承諾できません すべてミュート - 未読のメンション リストを変更 リスト名と絵文字はすべてのリストで異なる必要があります。 順序を変更 @@ -2303,9 +2230,7 @@ あなたのプロフィール プロフィールを変更できません 接続試行後に別のプロフィールを使用するには、チャットを削除してリンクをもう一度使用してください。 - その他のSMPサーバ 新しいサーバ - その他のXFTPサーバ プロキシ認証 ランダムな認証情報を使用 プロフィールごとに異なるプロキシ認証情報を使用します。 @@ -2321,7 +2246,6 @@ IPが隠されている場合 あなたまたは宛先のサーバがプライベートルーティングをサポートしていない場合、メッセージを直接送信します。 IPアドレスが保護されており、かつあなたまたは宛先のサーバがプライベートルーティングをサポートしていない場合、メッセージを直接送信します。 - メッセージルーティングのフォールバック IPアドレスを保護するため、プライベートルーティングはあなたのSMPサーバを使用してメッセージを配信します。 メッセージング用のTCPポート Webポートを使用 @@ -2342,7 +2266,6 @@ 新しいリリースの通知を受け取るには、安定版またはベータ版の定期チェックをオンにしてください。 非推奨のオプション 設定の保存エラー - 接続後に連絡先に送信されます。 ウェルカムメッセージ またはプライベートに共有する場合 SimpleXアドレスまたはワンタイムリンク? @@ -2383,18 +2306,12 @@ 監視されることなく他者と話すという、人類最古の自由を — それを裏切ることのできないインフラの上に築く。 私たちは、あなたが誰であるかを知る力を破壊したからだ。あなたの力が決して奪われないように。 あなたのネットワークで自由に。 - エンドツーエンドで暗号化されて送信され、ダイレクトメッセージではポスト量子暗号で保護されます。]]> 通知とバッテリー ネットワーク運営者 アプリは会話ごとに異なる運営者を使用することで、あなたのプライバシーを保護します。 複数の運営者が有効な場合、どの運営者も誰が誰と通信しているかを知るためのメタデータを持ちません。 例えば、あなたの連絡先がSimpleX Chatのサーバ経由でメッセージを受信する場合、あなたのアプリはFluxのサーバ経由でそれらを配信します。 - 使用するネットワーク運営者を選択してください。 プライバシーにどう役立つか - 有効な運営者の条件は30日後に承諾されます。 - 運営者は「ネットワークとサーバ」設定で構成できます。 - 後で確認 - 更新 あなたのネットワーク ネットワークのルーターは\n誰が誰と話しているかを知ることができません ルーターを設定 @@ -2411,7 +2328,6 @@ アプリは不明なファイルサーバからのダウンロードの確認を求めます(.onion の場合、またはSOCKSプロキシが有効な場合を除く)。 Tor または VPN を使用しない場合、あなたのIPアドレスはファイルサーバに表示されます。 リンクのトラッキングを削除 - この設定は現在のプロフィールに適用されます チャット一覧からリンクを開く はい いいえ @@ -2421,7 +2337,6 @@ 完全なリンクを開く クリーンなリンクを開く グループからの連絡先リクエスト - リモートのモバイル端末 デフォルト(%s) エクスポートしたアーカイブを保存できます。 エクスポートしたデータベースを移行できます。 @@ -2433,7 +2348,6 @@ 手の届くチャットツールバー 下部のバー 上部のバー - 新しいウィンドウでチャット一覧を表示 チャンネルに参加または作成した場合、それらは恒久的に機能しなくなります。 チャンネルから退出しますか? チャットから退出しますか? @@ -2475,7 +2389,6 @@ 設定を変更できるのはチャットのオーナーだけです。 リンク 管理者とのチャット - チャンネルのメンバー チャットリレー 購読者を削除しますか? メンバーを削除しますか? @@ -2616,7 +2529,6 @@ ネットワークの分散化 アプリに2番目のプリセット運営者が登場! メタデータのプライバシー向上のため、「ネットワークとサーバ」設定でFluxを有効にしてください。 - メタデータのプライバシー向上のため。 チャットナビゲーションの改善 - 最初の未読メッセージでチャットを開きます。\n- 引用されたメッセージにジャンプします。 ビジネスチャット @@ -2702,7 +2614,6 @@ サイズ アップロードエラー サーバアドレス - サーバ設定を開く 1つのメッセージにつき最大 %1$s 人のメンバーをメンションできます! 購読者 オーナーと貢献者 @@ -2801,7 +2712,6 @@ あなたのプロフィール %1$s はチャンネルのリレーと購読者に共有されます。\nリレーはチャンネルのメッセージにアクセスできます。 リレーを構成 失敗 - 追加 リレーを追加 リレーを追加 利用可能なリレーがありません @@ -2817,10 +2727,8 @@ リレーアドレス これはチャットリレーのアドレスであり、接続には使用できません。 チャンネルを開く - 新しいチャンネルを開く あなたのチャンネル %1$s 用のあなたのリンクです!]]> - チャンネルを開く際のエラー 全員に対して購読者のブロックを解除しますか? リンクプレビューを有効にしますか? リンクプレビューを送信すると、あなたのIPアドレスがWebサイトに知られる可能性があります。これは後でプライバシー設定で変更できます。 diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml index a987a0c92e..880d328df6 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml @@ -31,7 +31,6 @@ 라이브 메시지 취소 파일 확인 - 링크 / QR 코드로 연결 클립보드로 복사됨 비밀 그룹 생성 수락 @@ -42,7 +41,6 @@ 채팅을 지우겠습니까\? 연결 요청이 전송되었습니다! 링크로 연결 - 프리셋 서버 추가 서버 추가 채팅 콘솔 서버 주소를 확인 후 다시 시도해 주세요. @@ -62,7 +60,6 @@ 색깔 암호 확인 전화 연결 중… - 생성 프로필 생성 통화 오류 연결됨 @@ -128,7 +125,6 @@ 중국어 및 스페인어 인터페이스 SimpleX에 대하여 수락 - 일회용 초대 링크 생성 주소 생성 1일 SimpleX Chat에 대하여 @@ -184,7 +180,6 @@ 채팅이 멈춤 채팅 설정 개발자와 채팅 - 연결 연결 중… 닫기 버튼 연결 @@ -221,7 +216,6 @@ 주소 삭제 주소를 삭제할까요\? 이미지 삭제 - 탈중앙화 개발자 도구 기기 데이터베이스 암호 @@ -357,7 +351,6 @@ 부재 중 전화 사용 안 함 질문이나 아이디어 보내기 - (스캔하거나 클립보드에서 붙여넣기) 대화 상대가 현재 지원되는 최대 크기(%1$s)보다 큰 파일을 보냈습니다. 초대를 받았어요. 프로필 생성 오류! @@ -369,8 +362,6 @@ 상대의 연락처 링크로 익명 연결 상대의 연락처 링크로 연결 일회용 링크로 익명 연결 - SMP 서버 주소가 올바른 형식이고 줄로 구분되어 있고 중복이 없는지 확인해 주세요. - SMP 서버 저장 오류 네트워크 설정 업데이트 오류 프로필 변경 오류! 동일한 표시 이름을 가진 채팅 프로필이 있어요. 다른 이름을 선택해 주세요. @@ -516,7 +507,6 @@ 보내기 실패 비인증 전송 도움말 - 개인을 식별할 수 있는 어떠한 정보(임의의 숫자 포함)도 없는 첫 번째 플랫폼. 단순히 약속이 아니라 프로그램 설계상 완전한 익명성을 제공해요. 거절된 전화 대기 중인 전화 부재중 전화 @@ -526,7 +516,6 @@ 오류 사용법 이메일 - 대기 중 설정 이미지 기다리는 중 이미지를 디코딩할 수 없어요. 다른 이미지를 시도하거나 개발자에게 문의해 주세요. @@ -535,7 +524,6 @@ 동시에 최대 10개까지만 이미지를 보낼 수 있어요. 이미지 수가 너무 많아요! 거절해도 상대에게 알림이 전송되지 않아요. - 영상 통화에서 QR 코드를 보여주거나 링크를 공유해 주세요.]]> 영상 통화 영상 끄기 스피커 켜기 @@ -547,13 +535,11 @@ 대화 상대가 업로드를 완료하면 이미지가 수신될 거예요. 프로필 이미지 하나의 프로필로 여러 사람과 연락할 필요 없이 무수히 많은 익명 프로필로 연락할 수 있어요. - 스팸 방지 무시하기 SimpleX Chat 초대 링크를 받으면 브라우저에서 참여할 수 있어요 : 링크 미리보기 이미지 QR 코드 SimpleX 팀 - 영상 통화에서 QR 코드를 스캔하거나 상대에게 초대 링크를 공유할 수 있어요.]]> 동영상 보내짐 동영상 수신 요청됨 동영상 기다리는 중 @@ -588,7 +574,6 @@ 영상 통화 옴 잘못된 이전 확인 익명 모드로 참여 - 잘못된 QR 코드 잘못된 보안 코드! 잘못된 링크! 삭제됨으로 표시됨 @@ -657,7 +642,6 @@ 사용 가능한 경우 Onion 호스트가 사용될 거예요. Onion 호스트가 사용되지 않을 거예요. 전송 격리 - 차세대 사생활 보호 메시징 새 암호… TCP 연결 유지 활성화 %s의 새로운 기능 @@ -683,7 +667,6 @@ OK 거절 비밀번호 표시 - 2계층 종단 간 암호화 로 전송된 사용자 프로필, 연락처, 그룹 및 메시지를 저장되어요.]]> 개인 정보 보호 및 보안 알림은 앱이 중지되기 전까지만 전달될 거예요! 당신만 사라지는 메시지를 보낼 수 있습니다. @@ -708,10 +691,7 @@ 일회성 초대 링크 붙여넣기 프로필은 대화 상대들하고만 공유됩니다. - 프라이버시의 재정의 - 누구나 서버를 호스팅할 수 있습니다. 앱이 실행 중일 때 - GitHub 에서 확인해 주세요.]]> 릴레이 서버는 IP 주소를 숨겨주지만, 통화 시간을 관찰 할 수 있어요. 그룹 링크로 초대 설정을 통해 나중에 변경할 수 있어요. @@ -728,7 +708,6 @@ 받은 링크 붙여넣기 릴레이 서버는 필요한 경우에만 사용되어요. 릴레이 서버가 사용되지 않으면 제3자가 내 IP 주소를 관찰할 수 있어요. 채팅 열기 - 공유한 링크를 통해서만 나에게 연결할 수 있어요. 그룹 프로필 업데이트됨 프로필 비밀번호 보이기 @@ -824,7 +803,6 @@ 도움말 설정 링크 미리보기 보내기 - SOCKS 프록시 SimpleX Chat 도와주기 나 실험적 기능 @@ -832,7 +810,6 @@ 연락처 이름 설정 개발자에게 이메일 보내기 서버를 수동으로 입력 - 미리 설정된 서버 서버 저장하기 서버 테스트하기 현재 채팅 프로필의 새로운 연결을 위한 서버 @@ -843,9 +820,7 @@ 익명 모드 실험적 내보낼 암호 설정 - SMP 서버 미리 설정된 서버 주소 - 내 서버 내 서버 주소 %1$s을(를) 강퇴했어요. 채팅 데이터베이스를 내보내기, 가져오기 또는 삭제 하려면 채팅 기능을 중지해 주세요. 채팅 기능이 중지된 동안에는 메시지를 주고받을 수 없어요. @@ -862,8 +837,6 @@ 제공된 이름을 가진 빈 채팅 프로필이 생성되고, 앱이 정상적으로 열립니다. 데이터베이스 ID: %d 음성/영상 통화 - " -\nv5.1에서 사용 가능" 대화 상대가 허용하는 경우에만 통화를 허용합니다. 입력하면 모든 데이터가 삭제됩니다. 더 나은 메시지 @@ -881,7 +854,6 @@ 사용자 설정 기간 사라지는 메시지 사라지는 메시지 전송 - 다른 사용자와 연결할 수 있도록 주소를 만듭니다. 커스텀 테마 자동 수락 자체 소멸 모드 변경 @@ -1012,7 +984,6 @@ - 디렉터리 서비스(베타)에 연결하세요!\n- 전송 알림(최대 20명).\n- 더 빠르고 안정적입니다. 이전 취소 이전하려는 데이터베이스의 암호를 기억하고 있는지 확인합니다. - 익명 모드로 연결 베타 앱 패스코드 계속 @@ -1050,12 +1021,10 @@ 그룹 생성: 새로운 그룹을 생성합니다.]]> 연락처 추가 : 새 초대 링크를 만들거나 받은 링크를 통해 연결합니다.]]> 개인 메모를 지우시겠습니까? - XFTP 서버 구성 나중에 채팅할 수 있도록 연락처를 보관합니다. 연결된 데스크톱 일괄 업로드됨 보관된 연락처 - SMP 서버 구성 채팅 프로필 생성 그룹 멤버에 전화를 걸 수 없음 자동 연결 @@ -1080,13 +1049,11 @@ 업로드 확인 모바일에 연결 연결된 모바일 - 연결 그룹 멤버에게 메시지를 보낼 수 없음 전송 알림을 활성화하시겠습니까? 암호화 OK 데스크톱 기능 처리 시간이: %1$d 초 이상: %2$s - 주소를 만들지 않음 기기 파일 및 미디어 금지됨! 링크 생성 중… @@ -1192,7 +1159,6 @@ 상대가 온라인 상태가 될 때까지 기다릴 필요가 없습니다! 그룹 멤버 차단 현재 프로필 - SMP 서버를 로드하는 중 오류 개인 메모를 삭제하는 동안 오류 데이터베이스를 삭제하는 동안 오류 다음과 같은 이유로 끊어졌습니다: %s @@ -1200,7 +1166,6 @@ 알림을 표시하는 동안 오류가 발생하였으니, 개발자에게 문의하십시오. %d 분 %d 개의 메시지가 삭제됨 - XFTP 서버를 저장하는 중 오류 알림 비활성화 패스코드 입력 주소를 설정하는 중 오류 @@ -1228,7 +1193,6 @@ 자체 소멸 환영 메시지 입력…(선택사항) WebView를 초기화하는 중 오류가 발생했습니다. WebView가 설치되어 있고 지원되는 아키텍처가 arm64인지 확인합니다.\n오류: %s - XFTP 서버를 로드하는 중 오류가 발생했습니다. 세부 정보를 로드하는 중 오류 통계를 재설정하는 중 오류 대화에서 비활성화 된 경우에도 마찬가지입니다. @@ -1379,7 +1343,6 @@ 성공적으로 설치됨 업데이트 설치 인터페이스 색상 - 메시지 라우팅 대체 그룹 멤버가 메시지 반응을 추가할 수 있습니다. 메시지 모양 메시지 대기열 정보 @@ -1395,7 +1358,6 @@ 향상된 서버 구성 메시지 메시지 서버 - 메시지 라우팅 모드 안녕하세요!\nSimpleX Chat 초대장이 도착했습니다: %s 내부 오류 잘못된 이름입니다! @@ -1426,7 +1388,6 @@ %1$s!]]> 사용해서는 안 됩니다.]]> 사용자 가이드에서 확인하세요.]]> - 모바일 앱에서 열기 버튼을 클릭합니다.]]> 운영자 %s.]]> 약관 수락 날짜: %s. @@ -1469,9 +1430,7 @@ 현재 약관 텍스트를 로드할 수 없습니다, 다음 링크를 통해 약관을 검토할 수 있습니다: 사용 약관 Flux 활성화 - 종단 간 암호화로 전송됩니다.]]> 앱이 항상 백그라운드에서 실행 - 활성화된 운영자에 대한 약관은 30일 후에 수락됩니다. 팀 멤버 추가하기 친구 추가 모든 멤버에게서 채팅이 삭제됩니다 - 이 결정은 되돌릴 수 없습니다! diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ku/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ku/strings.xml index 09c428e48b..f942077e4c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ku/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ku/strings.xml @@ -50,8 +50,6 @@ şandin bi ser neket nexwendî Bi xêr hatî %1$s! - Bi xêr hatî! - Ev nivîs di eyaran de heye Eyar Bi navê %s bikeviyê redkirî @@ -108,7 +106,6 @@ Bêdengkirinê betal bike Bike favorît Ji favorîtan derxe - Behsên nexwendî Lîste çêke Li lîstê zêde bike Lîstê biguhere @@ -319,7 +316,6 @@ Funksiyona hêdî Komên piçûk (herî zêde 20) Servera SMPyê - Serverên SMPyê Nerm Bêdeng Spam @@ -345,7 +341,6 @@ Sîstem Moda sîstemê Terî/Dûvik - Pêl Adresa SimpleXê çêke di meniwê de ji bo ko tu dûvre çêkî. Pêl Bikeve komê bike Bikeviyê Bikeve komê @@ -439,8 +434,6 @@ Şert di %s de hatin qebûlkirin. Şertên şuxulandinê Şert wê di %s de bên qebûlkirin. - Serverên SMPyê ên eyarkirî - Serverên XFTPyê ên eyarkirî Serverên ICEyê eyar bike Dosyayên ji serverên nenas qebûl bike. Eyarên torê tesdîq bike. @@ -448,7 +441,6 @@ Bi xwe re bikeve danûstandinê? Bi riya lînkê bikeve danûstandinê Bi riya lînkê bikeve danûstandinê? - Bi riya lînkê / koda QRyê bikeve danûstandinê Bi riya lînka yek carê bikeve danûstandinê? Bi %1$s re bikeve danûstandinê? Muhtewa ne li gora şertên şuxulandinê ye @@ -457,10 +449,8 @@ Beşdar bibe Tora xwe kontrol bike Xeletiyê kopî bike - Çêke Çêke Adres çêke - Adresekê çêke ji bo ko xelk karibin bi te re bikevin danûstandinê. Hat çêkirin Wextê çêkirinê Wextê çêkirinê: %s @@ -468,7 +458,6 @@ Kom çêke Lînka komê çêke Lînk çêke - Lînkeke dewetiyê ya yek carî çêke Profîl çêke Profîl çêke Dor çêke @@ -528,7 +517,6 @@ %d heyv %d heyv %d heyv - Adres çêneke Dîsa nîşan nede Daxe Daxistî @@ -566,21 +554,16 @@ Xeletî di guhertina profîlê de Xeletî di guhertina rolê de Xeletî di çêkirina adresê de - Serverên te yên XFTPyê Te dewetîke komê şand - Serverên te yên SMPyê Serverên te Adresa servera te - Servera te Profîla te yî %1$s wê bê parvekirin. Tercihên te - Serverên te yên ICEyê Serverên te yên ICEyê Koma te te %1$s derxist Te dewetiya komê red kir Profîla te yî niha - Tu karî dûvre wê çêkî Tu dikarî wê di Eyarên xuyakirinê de biguherî. te %s blok kir Tu hatiye dewetkirinî komê @@ -592,7 +575,6 @@ tu Erê erê - Serverên XFTPyê Servera XFTPyê Şîfra xelet! Şîfra xelet ya databasê @@ -603,7 +585,6 @@ Etherneta bi qeblo WiFi Çi yî nû heye - Websîte Serverên WebRTC ICEyê Hişyarî: hinek dataya te kare winda bibe! dixwaze bi te re bikeve danûstandinê! @@ -637,7 +618,6 @@ Aplîkasyon Dosya Ji nû ve veke - Proksiya SOCKSê Sûretên profîlan Girêdana torê Ji kompîterê bişuxulîne @@ -783,8 +763,6 @@ Profîla siḧbetê çêke Ber serverên SimpleX Chatê werin şuxulandin? Profîla siḧbetê - Tu siḧbeta xwe qontrol dikî! - Siḧbetê bişuxulîne Siḧbet Rengên siḧbetê Siḧbet sekinandî ye 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 aa1da521bb..87144b710d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml @@ -45,7 +45,6 @@ Turėkite omenyje: jeigu prarasite slaptafrazę, NEBEGALĖSITE jos atkurti ar pakeisti.]]> Atsisakyti jungiamasi… - Prisijungti prisijungta jungiamasi ryšys užmegztas @@ -86,7 +85,6 @@ Grupių nuorodos klaida Aprašas - Klaida įrašant SMP serverius Klaida atnaujinant tinklo konfigūraciją Klaida kuriant profilį! Nepavyko įkelti pokalbio @@ -155,7 +153,6 @@ Sukurti adresą Ištrinti adresą Klaida įrašant naudotojo slaptažodį - Sukurti Kaip tai veikia Gaunamas garso skambutis Gaunamas vaizdo skambutis @@ -227,7 +224,6 @@ Patobulinta serverio konfigūracija Ištrinti pokalbio profilį Ištrinti profilį - Sukurti vienkartinio pakvietimo nuorodą Ištrinti Įrašyti ir pranešti kontaktui Įjungti automatinį žinučių ištrynimą\? @@ -237,7 +233,6 @@ Importuoti Įrašyti ir pranešti kontaktams pradedama… - Naudoti pokalbį Išjungti garsiakalbį Įjungti garsiakalbį Praleistos žinutės @@ -251,12 +246,10 @@ Rodyti peržiūrą Atrakinti Bendrinti - Bakstelėkite, norėdami pradėti naują pokalbį Bendrinti failą… Nustatymai Rodyti QR kodą Bendrinti pakvietimo nuorodą - SMP serveriai Naudoti SimpleX Chat serverius\? Naudoti tiesioginį interneto ryšį\? Atnaujinti @@ -306,16 +299,13 @@ Balso žinutė… Rodyti saugumo kodą SimpleX adresas - Jūsų SMP serveriai Jūsų ICE serveriai Pridėti profilį Visi pokalbiai ir žinutės bus ištrinti – to neįmanoma bus atšaukti! - Sveiki! Pridėti Jūs negalite siųsti žinučių! SimpleX logotipas Jūsų nustatymai - Jūsų serveris Jūsų serverio adresas Pridėti į kitą įrenginį Naudojami SimpleX Chat serveriai. @@ -325,7 +315,6 @@ vaizdo skambutis Jūsų skambučiai WebRTC ICE serveriai - Jūsų ICE serveriai išėjote administratorius Jūsų atsitiktinis profilis @@ -372,9 +361,6 @@ Didelis failas! Balso žinutės uždraustos! Siųsti - Klaida įkeliant SMP serverius - Klaida įrašant XFTP serverius - Klaida įkeliant XFTP serverius Nepavyksta gauti failo Sukurti eilę Gali būti, kad liudijimo kontrolinis kodas serverio adrese yra neteisingas @@ -382,8 +368,6 @@ Dekodavimo klaida Išvalyti pokalbį Įjungti pranešimus - Neteisingas QR kodas - Šis QR kodas nėra nuoroda! Prisijungti per nuorodą Saugumo kodas Žymėti kaip patvirtintą @@ -419,8 +403,6 @@ Neteisingas serverio adresas! Pokalbio profilis Profilis yra bendrinamas tik su jūsų kontaktais. - „GitHub“ saugykloje.]]> - SOCKS įgaliotasis serveris Įrašyti slaptafrazę ir atverti pokalbį Atkurti atsarginę duomenų bazės kopiją Atkurti atsarginę duomenų bazės kopiją\? @@ -463,7 +445,6 @@ Išvalyti Nebeslėpti profilio Per daug vaizdo įrašų! - Prisijungti per nuorodą / QR kodą Įrašyti profilio slaptažodį Iššifravimo klaida inkognito per grupės nuorodą @@ -494,7 +475,6 @@ Stabdyti failą Stabdyti failo gavimą\? Stabdyti failo siuntimą\? - Šis tekstas yra prieinamas nustatymuose Garso/vaizdo skambučiai yra uždrausti. Tiek jūs, tiek ir jūsų kontaktas gali skambinti. Žinutės juodraštis @@ -508,8 +488,6 @@ Profilis ir ryšiai su serveriu Tiesioginės žinutės tarp narių šioje grupėje yra uždraustos. Garso/vaizdo skambučiai - " -\nPrieinama versijoje v5.1" Vaizdo įrašas Vaizdo įrašai ir failai iki 1GB Ieškoti @@ -533,7 +511,6 @@ Programėlė galės gauti sistemos pranešimus tik tada, kai veiks. Jokia foninė tarnyba nebus paleidžiama Priimti Priimti inkognito režimu - Pridėti iš anksto parinktus serverius Administratoriai gali kurti prisijungimo prie grupių nuorodas. Foninė tarnyba visada veikia – pranešimai bus rodomi iš karto, kai tik bus prieinamos žinutės. Patvirtinti tapatybę @@ -765,7 +742,6 @@ Atkreipkite dėmesį: žinučių ir failų perdavimas yra prijungtas per SOCKS tarpinį serverį. Skambučiams ir nuorodų peržiūrų siuntimui naudojamas tiesioginis ryšys.]]> Netinkamas vardas! Sukurti savo profilį - Asmenys gali prisijungti prie jūsų tik per nuorodas kuriomis dalinatės. Tai gali būti pakeista vėliau nustatymuose. Tai gali įvykti kai: \n1. Žinutės galiojimo laikas baigėsi siuntėjo programoje po 2 dienų arba serveryje po 30 dienų. @@ -816,12 +792,10 @@ Prisijungti prie savęs? Kontaktas dar nėra susijungęs! Pataisyti pavadinimą į %s? - Laukiama Duomenų bazės šifravimo slaptafrazė bus atnaujinta ir saugoma raktų saugykloje. %s yra blogos būsenos]]> užblokuota administratoriaus Pilna nuoroda - Atsparu šlamštui ir piktnaudžiavimui Prašome pranešti tai kūrėjams. Prašome saugoti slaptafrazę saugiai, jūs NEGALĖSITE pasiekti pokalbių, jei ją prarasite. Duomenų bazės slaptafrazė yra reikalinga pokalbių atidarymui. @@ -852,7 +826,6 @@ Įrenginio autentifikacija išjungta. Išjungiamas SimpleX užraktas. %d failas (-ai), kurių bendras dydis yra %s %d valandos - Prisijungti inkognito režimu Reikalinga slaptafrazė Uždrausti siųsti balso žinutes. Eksperimentinis @@ -865,14 +838,12 @@ Paprašyta gauti vaizdo įrašą Labas! \nPrisijunk prie manęs per SimpleX Chat: %s - Privatumas apibrėžtas iš naujo Sukurta: %s jungiamasi (supažindinimo pakvietimas) Kaip naudoti markdown Savaiminio susinaikinimo prieigos kodas pakeistas! Pasilikti nepanaudotą pakvietimą? Pasilikti - Atviro kodo protokolas ir kodas - bet kas gali paleisti savo serverius. - pasirinktinai praneškite ištrintiems kontaktams. \n- profilių vardai su tarpais. \n- ir daugiau! @@ -890,7 +861,6 @@ Nuotrauka Įvertinti programėlę jungiamas skambutis… - Sukurkite adresą, kad leistumėte žmonėms prisijungti prie jūsų. Duomenų bazės ID ir transporto izoliacijos parinktis. kursyvinis Atsitiktinė slaptafrazė yra saugoma nustatymuose kaip paprastas tekstas. @@ -930,8 +900,6 @@ Nuotrauka išsaugota į galeriją Failas bus gautas kai jūsų kontaktas yra prisijungęs, palaukite arba patikrinkite vėliau! Iš anksto nustatyto serverio adresas - Iš anksto nustatytas serveris - Decentralizuota Pateikti Prisijungti inkognito režimu Pradinė rolė @@ -948,7 +916,6 @@ Nuotrauka išsiųsta Nuotrauka bus gauta kai jūsų kontaktas užbaigs jos įkėlimą. Pakeisti gavimo adresą? - nuskanuoti QR kodą vaizdo skambutyje, arba jūsų kontaktas gali pasidalinti pakvietimo nuoroda.]]> Arba parodykite šį kodą ICE serveriai (vienas per liniją) Įdiegti SimpleX Chat terminalui @@ -986,10 +953,8 @@ Momentiniai pranešimai! Momentiniai pranešimai yra išjungti! profilio nuotraukos vietos ženklas - parodykite QR kodą vaizdo skambutyje, arba pasidalinkite nuoroda.]]> naudotojo vadove.]]> Jei negalite susitikti asmeniškai, parodykite QR kodą vaizdo skambutyje, arba pasidalinkite nuoroda. - Įklijuokite nuorodą, kurią gavote prisijungimui prie savo kontakto… gautas atsakas… Jungiamas skambutis Tai gali įvykti kai jūs ar jūsų prisijungimas naudojo seną duomenų bazės atsarginę kopiją. @@ -1109,8 +1074,6 @@ Temos spalvos Rodyti lėtus API iškvietimus Nustoti bendrinti adresą? - Žinučių siuntimo ir programų platforma, apsauganti jūsų privatumą ir saugumą. - Pirma platforma neturinti jokių naudotojų identifikatorių - privati pagal sumanymą. Nustatyti duomenų bazės slaptafrazę Sistema Nustatyti slaptafrazę eksportui @@ -1128,7 +1091,6 @@ SimpleX Chat tarnyba Nustoti bendrinti Bendrinti su kontaktais - Naujos kartos privatus susirašinėjimas %s sekundė(s) Kodas kurį nuskanavote, nėra SimpleX nuorodos QR kodas. Kontaktas su kurio pasidalinote šia nuoroda NEGALĖS prisijungti! @@ -1197,7 +1159,6 @@ Ši galimybė dar nėra palaikoma. Išbandykite sekantį leidimą. %1$s!]]> Kad prisijungti su nuoroda - Ši nuoroda nėra tinkama prisijungimo nuoroda! Šie nustatymai yra jūsų dabartiniam profiliui Slaptafrazė saugoma nustatymuose kaip paprastas tekstas. Bakstelėkite, kad prisijungti kaip inkognito @@ -1233,8 +1194,6 @@ Sustabdomi pokalbiai %s įkeltas Jūsų pokalbių profiliai - Jūsų pokalbių profilis bus nusiųstas -\njūsų kontaktui Jūsų duomenų bazė Jūsų duomenų bazė nėra užšifruota - nustatykite slaptafrazę, kad ją apsaugoti. Jūsų pokalbių profilis bus nusiųstas grupės nariams @@ -1266,7 +1225,6 @@ Jūsų profilis, kontaktai ir gautos žinutės yra saugomi jūsų įrenginyje. Išnyks Rodomas vardas negali turėti tarpų. - Nekurti adreso Atsiuntimas nepavyko Atsiunčiamas archyvas visapusiškai užšifruotas balso skambutis @@ -1359,13 +1317,10 @@ neteisėtas siuntimas Ieškoti ar įklijuoti SimpleX nuorodą Jūs turite leisti savo kontaktui siųsti balso žinutes, kad galėtumėte siųsti jas. - (nuskanuokite ar įklijuokite iš iškarpinės) Priėmėte prisijungimą Jūs pakvietėte kontaktą Onion serveriai bus naudojami, kai tik bus. Išeiti neišsaugant - Jūs kontroliuojate savo pokalbį! - Mes nesaugome jokių jūsų kontaktu ar žinučių (po pristatymo) serveriuose. Nėra gautų ar išsiųstų failų Išsaugoti slaptafrazę raktų saugykloje Atnaujinti duomenų bazės slaptafrazę @@ -1428,8 +1383,6 @@ Nuskanuoti QR kodą iš darbastalio %s buvo atjungtas]]> %1$s.]]> - Galite bendrinti savo adresą kaip nuorodą ar QR kodą - bet kas galės prisijungti prie jūsų. - Atnaujinti transporto izoliacijos režimą? Išsaugoti automatinio priėmimo nustatymus Antrinis Žinučių reakcijos yra draudžiamos šiame pokalbyje. @@ -1439,7 +1392,6 @@ siuntimas nepavyko Per daug nuotraukų! Siųsti išnykstančią žinutę - (kad bendrinti su savo kontaktu) Kad pradėti naują pokalbį Pakartoti Naudoti .onion serverius @@ -1451,8 +1403,6 @@ Gavimo adresas bus pakeistas į kitą serverį. Adreso pakeitimas bus užbaigtas kai siuntėjas prisijungs. Įvyko klaida įkeliant archyvą Prisijunkite su savo kredencialais - Įsitikinkite, kad SMP serverių adresai yra tinkamu formatu, atskirose eilutėse ir nesikartojantys. - Įsitikinkite, kad XFTP serverių adresai yra tinkamu formatu, atskirose eilutėse ir nesikartojantys. Markdown pagalba Maks. 40 sekundžių, gaunama iš karto. Žinučių reakcijos @@ -1464,7 +1414,6 @@ Jūsų kontaktas turi būti prisijungęs, kad užbaigti prisijungimą. \nJūs galite atšaukti šį prisijungimą ir pašalinti kontaktą (ir vėliau bandyti su nauja nuoroda). Jūsų kontaktai išliks prisijungę. - Jūsų XFTP serveriai Prisijungsite kai jūsų prisijungimo užklausa bus patvirtinta, palaukite arba patikrinkite vėliau! %s yra užsiėmęs]]> %s yra neaktyvus]]> @@ -1484,7 +1433,6 @@ Su sumažintu akumuliatoriaus naudojimu. Esate kviečiami į grupę prisijungti prie SimpleX Chat kūrėjų, kad klausti klausimus ir gauti atnaujinimus.]]> - Galite padaryti tai matomą savo SimpleX kontaktams per nustatymus. Susieto darbastalio parinktys %1$s.]]> Pašalinti nutildymą @@ -1511,7 +1459,6 @@ Prisijungsite kai jūsų konktakto įrenginys bus prisijungęs, palaukite arba patikrinkite vėliau! Atidaryti mobilioje programėlėje, tada bakstelėkite Prisijungti programėlėje.]]> Naudoti naujiems prisijungimams - Atidaryti mobilioje programėlėje mygtuko.]]> Įvyko klaida atveriant naršyklę paslaptis Pranešimai nustos veikti iki tol kol paleisite programėlę iš naujo @@ -1581,11 +1528,9 @@ Laukiama nuotraukos Siųsti tiesioginę žinutę, kad prisijungti Laukiama nuotraukos - Kai asmenys išsiunčia užklausą prisijungti, galite ją priimti arba atmesti. Galite peržiūrėti pakvietimo nuorodą vėl, prisijungimo detalėse. Markdown žinutėse Norėdami patvirtinti visapusį šifravimą su savo kontaktu, palyginkite (ar nuskanuokite) kodą ant savo įrenginių. - XFTP serveriai Onion serveriai bus reikalingi ryšiui. \nAtkreipkite dėmesį: negalėsite prisijungti prie serverių be .onion adreso. Išsaugoti nustatymus? @@ -1598,8 +1543,6 @@ pakeitėte adresą %s Išplėsti rolių pasirinkimą %1$s.]]> - dviejų sluoksnių visapusiu šifravimu.]]> - Kad apsaugoti privatumą, vietoj naudotojų ID naudojamų visose kitose platformose, SimpleX turi identifikatorius žinučių eilėms, skirtingus kiekvienam jūsų kontaktui. Žinutės juodraštis Paleisti iš naujo narys @@ -1641,7 +1584,6 @@ %s ir %s Atnaujinti Įsitikinkite, kad WebRTC ICE serverių adresai yra tinkamu formatu, atskirose eilutėse ir nesikartojantys. - Galite sukurti tai vėliau Nėra pasirinkto pokalbio nežinoma būsena pašalintas diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/lv/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/lv/strings.xml index a6882871c7..b045044925 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/lv/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/lv/strings.xml @@ -10,7 +10,6 @@ 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 @@ -71,7 +70,6 @@ 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 @@ -105,12 +103,6 @@ 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 @@ -351,11 +343,7 @@ Č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 @@ -365,8 +353,6 @@ 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 @@ -377,9 +363,6 @@ 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 @@ -387,8 +370,6 @@ 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 @@ -451,7 +432,6 @@ 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 @@ -539,7 +519,6 @@ 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 @@ -734,7 +713,6 @@ SimpleX Saites Nesenā Vēsture Audio Video Zvani - \nPieejams V51 versijā Funkcija Ieslēgta Funkcija Ieslēgta Jums Funkcija Ieslēgta Kontaktam @@ -1022,7 +1000,6 @@ 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 @@ -1107,8 +1084,6 @@ 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 @@ -1118,7 +1093,6 @@ 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… @@ -1134,7 +1108,6 @@ 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 @@ -1167,8 +1140,6 @@ 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 @@ -1191,7 +1162,6 @@ 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 @@ -1229,12 +1199,8 @@ 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 @@ -1273,7 +1239,6 @@ Atjaunot sarunu Atzīmēt kā iecienītu Noņemt no iecienītajiem - Neizlasītie minējumi Izveidot sarakstu Pievienot sarakstam Mainīt sarakstu @@ -1307,19 +1272,12 @@ 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ē - - Jūsu čata profils tiks nosūtīts jūsu kontaktam - 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 @@ -1328,7 +1286,6 @@ 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 @@ -1336,14 +1293,12 @@ 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 Adrese vai vienreizēja saite Savienoties caur saiti Savienoties Ielīmēt Šī virkne nav savienojuma saite - Jauna saruna Jauns Pievienot kontaktu cilni @@ -1378,7 +1333,6 @@ Tīkla sesijas režīms sesija. Tīkla sesijas režīms serveris. - Atjaunināt tīkla sesijas režīmu? Tīkla smp proxy režīms privātā maršrutēšana @@ -1390,7 +1344,6 @@ 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 @@ -1398,7 +1351,6 @@ 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 @@ -1453,7 +1405,6 @@ 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? @@ -1461,7 +1412,6 @@ 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? @@ -1487,9 +1437,6 @@ 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 @@ -1512,16 +1459,12 @@ 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 @@ -1556,22 +1499,10 @@ 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 - Lai aizsargātu privātumu, SimpleX izmanto ID rindām - Tikai klientu ierīces glabā kontaktu grupas un e2e šifrētas ziņas - - - Izmantot čatu Ievada paziņojumu režīms Ievada paziņojumu režīma apakšvirsraksts Ievada paziņojumu režīms izslēgts @@ -1598,13 +1529,8 @@ 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 @@ -1656,7 +1582,6 @@ 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 @@ -1675,7 +1600,6 @@ Izmantot no darbvirsmas Jūsu sarunu datubāze Palaist sarunu - Attālie hosti Saruna notiek Saruna ir apstājusies Sarunu datubāze @@ -1799,7 +1723,6 @@ Sarunu apakšējā josla 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 @@ -1994,7 +1917,6 @@ Operators Operatora serveri Operators - Operatora mājaslapa Operatora nosacījumi pieņemti Operatora nosacījumi tiks pieņemti Operatora izmantošanas slēdzis @@ -2317,7 +2239,6 @@ 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 @@ -2496,6 +2417,5 @@ 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/ml/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ml/strings.xml index 008ad2bb81..02e93696b3 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ml/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ml/strings.xml @@ -59,14 +59,11 @@ ഉപയോക്താക്കൾക്ക് നന്ദി - Weblate വഴി സംഭാവന ചെയ്യുക! സ്വകാര്യതയും സുരക്ഷയും ബന്ധിപ്പിച്ചിരിക്കുന്നു - സ്വകാര്യത പുനർ നിർവചിച്ചു ബന്ധിപ്പിക്കുന്നു (പ്രഖ്യാപിച്ചു) വിലാസം സൃഷ്ടിക്കുക അജ്ഞാത പിശക് - നിങ്ങളുമായി ബന്ധപ്പെടാൻ ആളുകളെ അനുവദിക്കുന്നതിന് ഒരു വിലാസം സൃഷ്‌ടിക്കുക. ബന്ധിപ്പിക്കുന്നു പുനഃസ്ഥാപിക്കുക - ബന്ധിപ്പിക്കുക ബന്ധിപ്പിക്കുക ബന്ധിപ്പിച്ചിരിക്കുന്നു ബന്ധിപ്പിക്കുക @@ -130,8 +127,6 @@ പശ്ചാത്തലം ഇല്ല നേരിട്ടുള്ള സന്ദേശങ്ങൾ - " -\nപ5.1-ൽ ലഭ്യമാണ്" എപ്പോഴും നിങ്ങൾക്ക് മാത്രമേ ശബ്ദ സന്ദേശങ്ങൾ അയയ്ക്കാൻ കഴിയൂ. അയച്ച സന്ദേശങ്ങൾ മാറ്റാനാവാത്തവിധം ഇല്ലാതാക്കാൻ അനുവദിക്കുക. @@ -154,7 +149,6 @@ സന്ദേശം പങ്കിടുക… മറയ്ക്കുക: കാണിക്കുക: - വികേന്ദ്രീകൃതം ശബ്ദ സന്ദേശം ശബ്ദ സന്ദേശങ്ങൾ ശബ്ദ സന്ദേശം… @@ -214,12 +208,10 @@ കൂടുതലറിയുക ഉപയോക്തൃ മാര്‍ഗ്ഗദര്‍ശിയിൽ കൂടുതൽ വായിക്കുക.]]> സെർവർ വിലാസം പരിശോധിച്ച് വീണ്ടും ശ്രമിക്കുക. - നിങ്ങളുടെ SMP സെർവറുകൾ ബന്ധം വിലാസം പങ്കിടുന്നത് നിർത്തണോ\? തുടരുക SimpleX വിലാസം സൃഷ്ടിക്കുക - സൃഷ്ടിക്കുക നിറമുള്ള അവസാനിച്ചു നിങ്ങളുടെ വിളികൾ @@ -261,18 +253,15 @@ %1$s നീക്കം ചെയ്തു അപ്രത്യക്ഷമാകുന്ന സന്ദേശം അയയ്ക്കുക ഓരോ 10 നിമിഷവും ഒരു നിമിഷം വരെ പുതിയ സന്ദേശങ്ങൾ പരിശോധിക്കുന്നു - നിങ്ങൾക്ക് ഇത് പിന്നീട് സൃഷ്ടിക്കാൻ കഴിയും നിങ്ങൾ സ്വയം %s എന്ന കര്‍ത്തവ്യം മാറ്റി വീഡിയോ സ്വീകരിക്കാൻ ആവശ്യപ്പെട്ടു തത്സമയ സന്ദേശം റദ്ദാക്കുക വിളിക്കുന്നു… തത്സമയ സന്ദേശം! ഞങ്ങൾക്ക് ഇമെയിൽ അയയ്ക്കുക - നിങ്ങളുടെ സെർവർ അയച്ചു നിങ്ങളുടെ സെർവർ വിലാസം എന്ന വിലാസത്തിൽ അയച്ചു - നിങ്ങളുടെ XFTP സെർവറുകൾ കുറിച്ച് SimpleX Chat എല്ലാ സംഭാഷണങ്ങളും സന്ദേശങ്ങളും ഇല്ലാതാക്കപ്പെടും - ഇത് പഴയപടിയാക്കാനാകില്ല! സംഘത്തിലെ എല്ലാ അംഗങ്ങളുടെ ബന്ധം നിലനിർത്തും. @@ -292,13 +281,10 @@ എല്ലാ അംഗങ്ങൾക്കും സന്ദേശം ഇല്ലാതാക്കപ്പെടും. വെളിപ്പെടുത്തുക നിർത്തുക - സ്വാഗതം! - ഈ വാചകം ക്രമീകരണങ്ങളിൽ ലഭ്യമാണ് നിങ്ങൾ നിരീക്ഷകനാണ് നിങ്ങൾ നിരീക്ഷകനാണ് നിങ്ങൾ അംഗമാണ് നിങ്ങൾ ഉടമയാണ് - തീർപ്പാക്കാത്തത് സന്ദേശം അയയ്ക്കുക തത്സമയ സന്ദേശം അയയ്ക്കുക %s സ്ഥിരീകരിച്ചിട്ടില്ല @@ -311,7 +297,6 @@ മുൻഗണനകൾ സംരക്ഷിക്കണോ\? ആരംഭിക്കുന്നു ഉത്തരം ലഭിച്ചു… - സ്വകാര്യ സന്ദേശമയയ്ക്കലിന്റെ അടുത്ത തലമുറ ഒഴിവാക്കിയ സന്ദേശങ്ങൾ സംവിധാനം സ്വയം നശിപ്പിക്കുക @@ -380,7 +365,6 @@ %d മണിക്കൂർ %d മാസങ്ങള്‍ മണിക്കൂറുകൾ - വിലാസം സൃഷ്ടിക്കരുത് ചിത്രം തിരുത്തുക പരീക്ഷണാത്മക സവിശേഷതകൾ വിലാസം മാറ്റുന്നത് നിർത്തുക 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 index eeddfd1b38..65614f25aa 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/nb-rNO/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/nb-rNO/strings.xml @@ -47,7 +47,6 @@ 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. @@ -124,7 +123,6 @@ 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 @@ -179,7 +177,6 @@ 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. 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 2ec65534a4..988d0e73cc 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml @@ -36,7 +36,6 @@ Verbindingsverzoek accepteren\? geaccepteerde oproep Accepteer incognito - Vooraf ingestelde servers toevoegen Profiel toevoegen Server toevoegen Toevoegen aan een ander apparaat @@ -135,7 +134,6 @@ verbinding gemaakt Verbindingsverzoek verzonden! Timeout verbinding - Maak een eenmalige uitnodiging link Adres aanmaken Groep link maken Maak een geheime groep aan @@ -147,7 +145,6 @@ %dd Verwijderen Verwijderen na - Verbind verbonden Verbinden Verbinden via contact link? @@ -160,7 +157,6 @@ Contact en alle berichten worden verwijderd, dit kan niet ongedaan worden gemaakt! Verbonden Bevestigen - Maak verbinding via link / QR-code Gekopieerd naar het klembord Bijdragen ICE servers configureren @@ -168,7 +164,6 @@ Core versie: v%s verbonden Verbinden… - Gedecentraliseerd Maak je profiel aan %d dag %d dagen @@ -200,7 +195,6 @@ Maak verbinding via link gekleurd Oproep verbinden… - Maak Maak een profiel aan Adres verwijderen Altijd relay gebruiken @@ -383,7 +377,6 @@ Fout bij aanmaken van adres help Draai camera - Fout bij opslaan van SMP servers Fout bij updaten van netwerk configuratie Kan het gesprek niet laden Kan de chats niet laden @@ -410,9 +403,6 @@ Markeer gelezen Markeer als ongelezen Dempen - de QR-code scannen in het video gesprek , of uw contact kan een uitnodiging link delen.]]> - toon je de QR-code in het video gesprek of deel je de link.]]> - Ongeldige QR-code Meer Onjuiste beveiligingscode! Markeer geverifieerd @@ -505,7 +495,6 @@ Zorg ervoor dat WebRTC ICE server adressen de juiste indeling hebben, regel gescheiden zijn en niet gedupliceerd zijn. Als u bevestigt, kunnen de berichten servers en uw provider uw IP-adres zien en met welke servers u verbinding maakt. Nee - Immuun voor spam Maak een privéverbinding Het definitief verwijderen van berichten is niet toegestaan in dit gesprek. Nieuw in %s @@ -523,11 +512,9 @@ ongeldig berichtformaat uitgenodigd om te verbinden LIVE - Zorg ervoor dat SMP server adressen de juiste indeling hebben, regel gescheiden zijn en niet gedupliceerd zijn. gemarkeerd als verwijderd Controleer of u de juiste link heeft gebruikt of vraag uw contact om u een andere te sturen. profielfoto - Privacy opnieuw gedefinieerd Privacy en beveiliging Controleer uw netwerkverbinding met %1$s en probeer het opnieuw. Mogelijk is de certificaat vingerafdruk in het server adres onjuist @@ -566,17 +553,13 @@ Eenmalige uitnodiging link Alleen groep eigenaren kunnen groep voorkeuren wijzigen. (alleen opgeslagen door groepsleden) - Vooraf ingestelde server Periodieke meldingen zijn uitgeschakeld! - In behandeling Alleen groep eigenaren kunnen spraak berichten inschakelen. Vraag uw contact om het verzenden van spraak berichten in te schakelen. OK Onion hosts zijn vereist voor verbinding. Onion hosts worden gebruikt indien beschikbaar. Onion hosts worden niet gebruikt. - Iedereen kan servers hosten. - Jij bepaalt wie er verbinding mag maken. Alleen uw contact kan berichten onherroepelijk verwijderen (u kunt ze markeren voor verwijdering). (24 uur) Alleen jij kunt spraak berichten verzenden. Alleen uw contact kan spraak berichten verzenden. @@ -589,7 +572,6 @@ Oproep in behandeling Het openen van de link in de browser kan de privacy en beveiliging van de verbinding verminderen. Niet vertrouwde SimpleX links worden rood weergegeven. Werk de app bij en neem contact op met de ontwikkelaars. - Alleen clientapparaten slaan gebruikersprofielen, contacten, groepen en berichten op. De afzender heeft mogelijk het verbindingsverzoek verwijderd. Schakel SimpleX Vergrendelen in om uw informatie te beschermen. \nU wordt gevraagd de authenticatie te voltooien voordat deze functie wordt ingeschakeld. @@ -600,13 +582,10 @@ Om een nieuw gesprek te starten Dempen opheffen SimpleX-Team - Deze QR-code is geen link! - Deze link is geen geldige link! Eenmalige link delen Code scannen Uw instellingen Deel link - Jij beheert je gesprek! Uw profiel, contacten en afgeleverde berichten worden op uw apparaat opgeslagen. beginnen… Video aan @@ -636,9 +615,6 @@ verstuurd ongeoorloofd verzenden Ongelezen - Tik hier om een nieuw gesprek te starten - Deze tekst is beschikbaar in instellingen - Welkom! je bent uitgenodigd voor de groep Je hebt geen chats Chats @@ -654,7 +630,6 @@ Controleer de beveiligingscode Beveiligingscode bekijken U moet uw contact toestemming geven om spraak berichten te verzenden om ze te kunnen verzenden. - (scannen of plakken vanaf klembord) Om verbinding te maken via een link Afwijzen Naam contact instellen @@ -670,7 +645,6 @@ SimpleX Adres Toon QR-code SimpleX-Logo - Je chatprofiel wordt verzonden naar uw contact Je wordt verbonden met de groep wanneer het apparaat van de groep host online is, even geduld a.u.b. of controleer het later! U wordt verbonden wanneer uw verbindingsverzoek wordt geaccepteerd, even geduld a.u.b. of controleer later! Je wordt verbonden wanneer het apparaat van je contact online is, even geduld a.u.b. of controleer het later! @@ -679,13 +653,11 @@ %s is niet geverifieerd %s is geverifieerd Vergelijk (of scan) de code op uw apparaten om end-to-end codering met uw contact te verifiëren. - Openen in mobiele app .]]> Uw chat profielen Uw SimpleX adres Stuur vragen en ideeën Stuur ons een e-mail SimpleX Vergrendelen - SMP servers Servers opslaan Servertest mislukt! Sommige servers hebben de test niet doorstaan: @@ -695,7 +667,6 @@ Gebruik server Gebruik SimpleX Chat servers. Uw server adres - Uw server Transport isolation Voorkeuren opslaan\? Opslaan en Contact melden @@ -703,16 +674,11 @@ Opslaan en Contacten melden Opslaan en groepsleden melden staking - Het berichten- en applicatieplatform dat uw privacy en veiligheid beschermt. Het profiel wordt alleen gedeeld met uw contacten. - We slaan geen van uw contacten of berichten (eenmaal afgeleverd) op de servers op. U kunt markdown gebruiken voor opmaak in berichten: geweigerde oproep geheim - De toekomst van berichtenuitwisseling wachten op antwoord… - Om uw privacy te beschermen, gebruikt SimpleX voor elk van uw contacten afzonderlijke ID\'s. - Gebruik chat Wanneer de app actief is video gesprek (niet e2e versleuteld) %1$s wil met je in contact komen via @@ -723,7 +689,6 @@ WebRTC ICE servers Relay server beschermt uw IP-adres, maar kan de duur van het gesprek observeren. Relay server wordt alleen gebruikt als dat nodig is. Een andere partij kan uw IP-adres zien. - Uw ICE servers Overgeslagen berichten via relay Video uit @@ -825,13 +790,11 @@ Resetten Verstuur Stuur een live bericht, het wordt bijgewerkt voor de ontvanger(s) terwijl u het typt - (om te delen met uw contact) Bedankt voor het installeren van SimpleX Chat! Camera Gebruik voor nieuwe verbindingen Star on GitHub De servers voor nieuwe verbindingen van je huidige chatprofiel - Uw SMP servers Opgeslagen WebRTC ICE servers worden verwijderd. Uw ICE servers Opslaan @@ -841,10 +804,8 @@ Wanneer beschikbaar Vereist simplexmq: v%s (%2s) - Transportisolatiemodus updaten\? bevestiging ontvangen… Wachten op bevestiging… - Geen gebruikers-ID\'s. Verbied het sturen van directe berichten naar leden. Verbieden het verzenden van spraak berichten. De beveiliging van SimpleX Chat is gecontroleerd door Trail of Bits. @@ -874,7 +835,6 @@ Bericht delen… SimpleX Vergrendelen Sla het wachtwoord op in Keychain - SOCKS proxy Dank aan de gebruikers – draag bij via Weblate! De app haalt regelmatig nieuwe berichten op - het gebruikt een paar procent van de batterij per dag. De app maakt geen gebruik van push meldingen, gegevens van uw apparaat worden niet naar de servers verzonden. De afbeelding kan niet worden gedecodeerd. Probeer een andere afbeelding of neem contact op met de ontwikkelaars. @@ -922,7 +882,6 @@ je hebt een eenmalige link gedeeld je hebt een eenmalige link incognito gedeeld Tik op de knop - GitHub repository.]]> %1$d bericht(en) overgeslagen gemodereerd gemodereerd door %s @@ -1013,23 +972,17 @@ Wachten op video Video De video wordt gedownload wanneer uw contact het uploaden heeft voltooid. - Fout bij opslaan van XFTP-servers - Fout bij het laden van XFTP servers Server vereist autorisatie om te uploaden, wachtwoord controleren Bestand vergelijken Bestand verwijderen Bestand downloaden Upload bestand - XFTP servers - Uw XFTP servers Host Poort SOCKS proxy instellingen Gebruik SOCKS proxy Use .onion hosts in op Nee als de SOCKS-proxy deze niet ondersteunt.]]> Bestand maken - Fout bij het laden van SMP servers - Zorg ervoor dat XFTP server adressen de juiste indeling hebben, regel gescheiden zijn en niet gedupliceerd zijn. poort %d Vergrendelen na Verificatie mislukt @@ -1087,8 +1040,6 @@ Sta oproepen alleen toe als uw contact dit toestaat. Sta toe dat uw contacten u bellen. Audio/video oproepen - " -\nBeschikbaar in v5.1" Audio/video gesprekken verbieden. Audio/video gesprekken zijn niet toegestaan. Snel en niet wachten tot de afzender online is! @@ -1106,10 +1057,8 @@ U kunt dit adres delen met uw contacten om ze verbinding te laten maken met %s. Exporteer thema Titel - Wanneer mensen vragen om verbinding te maken, kunt u dit accepteren of weigeren. U raakt uw contacten niet kwijt als u later uw adres verwijdert. SimpleX adres - Maak een adres aan zodat mensen contact met je kunnen opnemen. Maak een SimpleX adres aan Delen met contacten Uw contacten blijven verbonden. @@ -1122,10 +1071,8 @@ Sla instellingen voor automatisch accepteren op Instellingen opslaan\? Voer welkom bericht in... (optioneel) - Maak geen adres aan Hoi! \nMaak verbinding met mij via SimpleX Chat: %s - U kan het later maken Adres delen Welkom bericht invoeren… Thema importeren @@ -1151,7 +1098,6 @@ Database openen… Gebruikershandleiding.]]> Interface kleuren - U kunt uw adres delen als een link of QR-code - iedereen kan verbinding met u maken. Alle app-gegevens worden verwijderd. Er wordt een leeg chatprofiel met de opgegeven naam gemaakt en de app wordt zoals gewoonlijk geopend. Zelfvernietigings wachtwoord inschakelen @@ -1331,7 +1277,6 @@ Ontvangstbevestigingen zijn uitgeschakeld Verbind direct\? Toestaan - Verbind incognito Meldingen uitzetten Open app instellingen Een nieuw willekeurig profiel zal gedeeld worden. @@ -1342,7 +1287,6 @@ app -batterijgebruik / onbeperkt in de app -instellingen om met gesloten app te bellen.]]> Uw profiel %1$s wordt gedeeld. Verzoek voor het verbinden wordt naar dit groepslid verzonden. - Plak de link die je hebt ontvangen om verbinding te maken met je contact… App-batterijgebruik / Onbeperkt in de app-instellingen.]]> Gebruik een nieuw incognito profiel Concept bericht @@ -1509,7 +1453,6 @@ Open poort in firewall Fout bij het tonen van inhoud fout bij weergeven bericht - Je kunt het via Instellingen zichtbaar maken voor je SimpleX contacten. Geschiedenis wordt niet naar nieuwe leden gestuurd. Opnieuw proberen Camera niet beschikbaar @@ -1772,7 +1715,6 @@ Gebruik altijd privéroutering. Gebruik privéroutering met onbekende servers. Gebruik GEEN privéroutering. - Berichtrouteringsmodus Gebruik privéroutering met onbekende servers wanneer het IP-adres niet beveiligd is. Downgraden toestaan Wanneer IP verborgen is @@ -1782,7 +1724,6 @@ Toon berichtstatus Om uw IP-adres te beschermen, gebruikt privéroutering uw SMP-servers om berichten te bezorgen. Stuur GEEN berichten rechtstreeks, zelfs als uw of de bestemmingsserver geen privéroutering ondersteunt. - Terugval op berichtroutering Privébericht routing Stuur berichten rechtstreeks als het IP-adres beschermd is en uw of bestemmingsserver geen privéroutering ondersteunt. Onbekende servers! @@ -1835,7 +1776,6 @@ Systeem Wallpaper achtergrond Stel het standaard thema in - Toon chat-lijst in nieuw venster geen Foutopsporing bezorging Informatie over berichtenwachtrij @@ -1861,7 +1801,6 @@ Serverversie is niet compatibel met uw app: %1$s. Bericht doorgestuurd Nog geen directe verbinding, bericht wordt doorgestuurd door beheerder. - Overige XFTP servers Link scannen/plakken Zoom Huidig profiel @@ -1922,7 +1861,6 @@ Geüploade bestanden Upload fouten Downloadfouten - Server instellingen openen Server adres Alle profielen pogingen @@ -1941,15 +1879,12 @@ Het bericht kan later worden bezorgd als het lid actief wordt. Berichten verzonden Geen info, probeer opnieuw te laden - Overige SMP servers Totaal ontvangen Probeer het later. Maak opnieuw verbinding met alle verbonden servers om de bezorging van berichten te forceren. Er wordt gebruik gemaakt van extra data. Het serveradres is niet compatibel met de netwerkinstellingen: %1$s. Serverstatistieken worden gereset - dit kan niet ongedaan worden gemaakt! Beginnend vanaf %s. - Geconfigureerde SMP-servers - Geconfigureerde XFTP-servers Percentage weergeven Uitgeschakeld App update downloaden. Sluit de app niet @@ -1999,13 +1934,11 @@ Bericht Berichten worden gemarkeerd voor verwijdering. De ontvanger(s) kunnen deze berichten onthullen. Niets geselecteerd - verbinden Contact verwijderd! Het contact wordt verwijderd. Dit kan niet ongedaan worden gemaakt! Behoud het gesprek bericht Alleen conversatie verwijderen - open Plak de link Uitnodiging Bereikbare app-toolbars @@ -2129,7 +2062,6 @@ Niet afgeleverde berichten De verbinding heeft de limiet van niet-afgeleverde berichten bereikt. Uw contactpersoon is mogelijk offline. Dit bericht is verwijderd of nog niet ontvangen. - Tik op SimpleX-adres maken in het menu om het later te maken. Adres openbaar delen Deel eenmalig een link met een vriend Deel het SimpleX-adres op sociale media. @@ -2143,13 +2075,8 @@ Adres instellingen Eenmalige link maken Serverbeheerders - Voor ingeschakelde operators worden de voorwaarden na 30 dagen geaccepteerd. Netwerkbeheerders - Later beoordelen - Selecteer welke netwerkoperators u wilt gebruiken. - Update De app beschermt uw privacy door in elk gesprek verschillende operators te gebruiken. - U kunt operators configureren in Netwerk- en serverinstellingen. Doorgaan Voorwaarden bekijken Uw servers @@ -2164,7 +2091,6 @@ %s servers Gebruik %s Gebruik servers - Website %s.]]> %s.]]> %s te gebruiken, moet u de gebruiksvoorwaarden accepteren.]]> @@ -2180,7 +2106,6 @@ Operatorserver Server toegevoegd aan operator %s. Transparantie - voor betere privacy van metagegevens. Verbeterde chatnavigatie Netwerk decentralisatie De tweede vooraf ingestelde operator in de app! @@ -2208,14 +2133,12 @@ U kunt servers configureren via instellingen. Of importeer archiefbestand Directe berichten tussen leden zijn in deze chat niet toegestaan. - Externe mobiele telefoons Xiaomi-apparaten: schakel Automatisch starten in de systeeminstellingen in om meldingen te laten werken.]]> Bericht is te groot! Verklein het bericht en verstuur het opnieuw. U kunt het bericht kopiëren en verkleinen om het te verzenden. Voeg uw teamleden toe aan de gesprekken. Zakelijk adres - end-to-end-versleuteld verzonden, met post-kwantumbeveiliging in directe berichten.]]> App draait altijd op de achtergrond Controleer berichten elke 10 minuten Meldingen en batterij @@ -2299,7 +2222,6 @@ Rapport archiveren? rapporteren Reden melding? - Het rapport wordt voor u gearchiveerd. Anders melden: alleen groepsmoderators kunnen het zien. Spam melden: alleen groepsmoderators kunnen het zien. Rapporteer overtreding: alleen groepsmoderators kunnen dit zien. @@ -2334,7 +2256,6 @@ Gebruik een webpoort Gebruik TCP-poort %1$s wanneer er geen poort is opgegeven. Alles dempen - Ongelezen vermeldingen Je kunt maximaal %1$s leden per bericht vermelden! Hiermee kunt u berichten rapporteren aan moderators. Alle rapporten worden voor u gearchiveerd. 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 bf47cebdb5..b725bfab32 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml @@ -3,7 +3,6 @@ Uwierzytelnianie urządzenia jest wyłączone. Wyłączanie blokady SimpleX. Uwierzytelnianie urządzenia nie jest włączone. Możesz włączyć blokadę SimpleX w Ustawieniach po włączeniu uwierzytelniania urządzenia. Bezpośrednie wiadomości - Połącz połączony łączenie łączenie… @@ -146,13 +145,10 @@ wyślij Udostępnij plik… Udostępnij media… - Dotknij, aby rozpocząć nowy czat Wiadomość zostanie usunięta dla wszystkich członków. - Ten tekst jest dostępny w ustawieniach Zbyt wiele obrazów! nieautoryzowane wysyłanie oznacz jako nieprzeczytane - Witaj! Witaj %1$s! Jesteś zaproszony do grupy Nie masz czatów @@ -198,7 +194,6 @@ Plik zostanie odebrany, gdy Twój kontakt będzie online, proszę czekać lub sprawdzić później! Duży plik! Powiadomienia - Oczekujące Nagraj wiadomość głosową Wyślij wiadomość Ustaw nazwę kontaktu… @@ -221,13 +216,11 @@ Wysyłaj wiadomości na żywo - będą one aktualizowane dla odbiorcy(ów) w trakcie ich wpisywania Wyślij wiadomość na żywo Skopiowano do schowka - Utwórz jednorazowy link do zaproszenia brak szczegółów Jednorazowy link zaproszenia (przechowywane tylko przez członków grupy) Zeskanuj kod QR Rozpocznij nowy czat - (aby udostępnić Twojemu kontaktowi) Plik Odmowa Uprawnienia! Aparatu @@ -269,10 +262,8 @@ Połącz się przez link Email pomoc - pokaż kod QR w rozmowie wideo, lub udostępnij link.]]> Nieprawidłowy kod bezpieczeństwa! Nieprawidłowy link! - Nieprawidłowy kod QR Oznacz jako zweryfikowane Więcej Jednorazowy link zaproszenia @@ -285,16 +276,11 @@ Udostępnij link jednorazowy Pokaż kod QR %s jest zweryfikowany - Ten kod QR nie jest linkiem! Ten ciąg nie jest linkiem połączenia! Adres SimpleX Logo SimpleX - Otwórz w aplikacji mobilnej.]]> - Twój profil czatu zostanie wysłany -\ndo Twojego kontaktu Zostaniesz połączony do grupy, gdy urządzenie gospodarza grupy będzie online, proszę czekać lub sprawdzić później! Zostaniesz połączony, gdy urządzenie Twojego kontaktu będzie online, proszę czekać lub sprawdzić później! - Dodaj gotowe serwery Dodaj serwer Dodaj do innego urządzenia Konsola czatu @@ -308,7 +294,6 @@ Nieprawidłowy adres serwera! Pomoc markdown Markdown w wiadomościach - Predefiniowany serwer Adres predefiniowanego serwera Oceń aplikację Zapisane serwery WebRTC ICE zostaną usunięte. @@ -320,7 +305,6 @@ Test serwera nie powiódł się! Blokada SimpleX %s nie jest zweryfikowany - Serwery SMP Daj gwiazdkę na GitHub Przetestuj serwer Przetestuj serwery @@ -329,7 +313,6 @@ Używanie serwerów SimpleX Chat. Twoje profile czatu Twoje serwery ICE - Twój serwer Twój adres serwera Twój adres SimpleX Przyczyń się @@ -357,7 +340,6 @@ Wymagane Zapisz Izolacja transportu - Zaktualizować tryb izolacji transportu\? Użyć bezpośredniego połączenia z Internetem\? Użyj hostów .onion Użyć proxy SOCKS\? @@ -390,11 +372,8 @@ Hasło ukrytego profilu Hasło do wyświetlenia Zapisz hasło profilu - Platforma komunikacyjna i aplikacyjna chroniąca Twoją prywatność i bezpieczeństwo. Profil jest udostępniany tylko Twoim kontaktom. Aby ujawnić Twój ukryty profil, wprowadź pełne hasło w pole wyszukiwania na stronie Twoich profili czatu. - Nie przechowujemy na serwerach żadnych Twoich kontaktów ani wiadomości (po ich dostarczeniu). - Ty kontrolujesz swój czat! Twój profil, kontakty i dostarczone wiadomości są przechowywane na Twoim urządzeniu. a + b O SimpleX @@ -408,24 +387,16 @@ połączony łączenie… łączenie połączenia… - Utwórz - Zdecentralizowane zakończona Jak korzystać z markdown - Odporność na spam kursywa nieodebrane połączenie - Każdy może hostować serwery. - Ty decydujesz, kto może się połączyć. - Redefinicja prywatności otrzymano odpowiedź… otrzymano potwierdzenie… odrzucone połączenie sekret uruchamianie… przekreślenie - Brak identyfikatorów użytkownika. - Następna generacja \nprywatnych wiadomości oczekiwanie na odpowiedź… oczekiwanie na potwierdzenie… Możesz używać markdown do formatowania wiadomości: @@ -434,11 +405,8 @@ Natychmiastowy Jak wpływa na baterię Nawiąż prywatne połączenie - Tylko urządzenia klienckie przechowują profile użytkowników, kontakty, grupy i wiadomości. Okresowo Prywatne powiadomienia - repozytorium GitHub.]]> - Użyj czatu Gdy aplikacja jest uruchomiona Zużywa więcej baterii! Aplikacja zawsze działa w tle - powiadomienia są wyświetlane natychmiastowo.]]> Przychodzące połączenie audio @@ -462,7 +430,6 @@ Serwer przekaźnikowy chroni Twój adres IP, ale może obserwować czas trwania połączenia Pokaż Serwery WebRTC ICE - Twoje serwery ICE Odbierz połączenie Dźwięk wyłączony Dźwięk włączony @@ -525,7 +492,6 @@ Uruchom czat Wyślij podgląd linku Ustawienia - Proxy SOCKS Zatrzymać czat\? Wspieraj SimpleX Chat Motywy @@ -918,7 +884,6 @@ łączenie… Kontakt i wszystkie wiadomości zostaną usunięte - nie można tego cofnąć! Błąd połączenia (UWIERZYTELNIANIE) - Połącz się przez link / kod QR Utwórz tajną grupę Utwórz tajną grupę Baza danych jest zaszyfrowana przy użyciu losowego hasła. Proszę zmienić je przed eksportem. @@ -928,7 +893,6 @@ Znikające wiadomości są zabronione. Błąd usuwania prośby o kontakt Nie znaleziono pliku - Błąd zapisu serwerów SMP Błąd aktualizacji linku grupy Błąd importu bazy danych czatu Błąd dołączenia do grupy @@ -948,7 +912,6 @@ Członkowie mogą wysyłać wiadomości głosowe. Grupa zostanie usunięta dla wszystkich członków - nie można tego cofnąć! Jak korzystać z Twoich serwerów - zeskanować kod QR w rozmowie wideo, lub Twój rozmówca może udostępnić link z zaproszeniem.]]> Jeśli odrzucisz nadawca NIE zostanie powiadomiony. incognito poprzez link adresu kontaktowego Jeśli otrzymałeś link do zaproszenia SimpleX Chat, możesz go otworzyć w swojej przeglądarce: @@ -961,7 +924,6 @@ zaproszenie do grupy %1$s Nieodwracalne usuwanie wiadomości Upewnij się, że adresy serwerów WebRTC ICE są w poprawnym formacie, rozdzielone liniami i nie są zduplikowane. - Upewnij się, że adresy serwerów SMP są w poprawnym formacie, rozdzielone liniami i nie są zduplikowane. Poproś Twój kontakt o włączenie wysyłania wiadomości głosowych. Sprawdź, czy użyłeś prawidłowego linku lub poproś Twój kontakt o przesłanie innego. Wiadomość zostanie usunięta - nie można tego cofnąć! @@ -976,7 +938,6 @@ Odbiorcy widzą aktualizacje podczas ich wpisywania. Uruchom ponownie aplikację, aby użyć zaimportowanej bazy danych czatu. Zapisz profil grupy - (zeskanuj lub wklej ze schowka) Ustaw preferencje grupy Udostępnij wiadomość… Bezpieczeństwo SimpleX Chat zostało zaudytowane przez Trail of Bits. @@ -988,9 +949,7 @@ Rola zostanie zmieniona na "%s". Wszyscy w grupie zostaną powiadomieni. Tego działania nie można cofnąć - wszystkie odebrane i wysłane pliki oraz media zostaną usunięte. Obrazy o niskiej rozdzielczości pozostaną. Adres odbiorczy zostanie zmieniony na inny serwer. Zmiana adresu zostanie zakończona gdy nadawca będzie online. - Ten link nie jest prawidłowym linkiem połączenia! SimpleX działa w tle zamiast korzystać z powiadomień push.]]> - Aby chronić Twoją prywatność, SimpleX używa oddzielnych identyfikatorów dla każdego z Twoich kontaktów. Aby zweryfikować szyfrowanie end-to-end z Twoim kontaktem porównaj (lub zeskanuj) kod na waszych urządzeniach. Użyj dla nowych połączeń O ile Twój kontakt nie usunął połączenia lub ten link był już użyty, może to być błąd - zgłoś go. \nAby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połączenia i sprawdź, czy masz stabilne połączenie z siecią. @@ -1014,26 +973,19 @@ Twój profil jest przechowywany na Twoim urządzeniu i udostępniany tylko Twoim kontaktom. Serwery SimpleX nie widzą Twojego profilu. udostępniłeś jednorazowy link incognito Zostaniesz połączony ze wszystkimi członkami grupy. - Twoje serwery SMP Zostaniesz połączony, gdy Twoje żądanie połączenia zostanie zaakceptowane, proszę czekać lub sprawdzić później! - Błąd ładowania serwerów SMP - Błąd ładowania serwerów XFTP - Błąd zapisu serwerów XFTP Utwórz plik Pobierz plik Serwer wymaga autoryzacji do przesłania, sprawdź hasło Prześlij plik Porównaj plik Usuń plik - Serwery XFTP - Twoje serwery XFTP Host Port port %d Użyj hostów .onion na Nie jeśli proxy SOCKS ich nie obsługuje.]]> Ustawienia PROXY SOCKS Użyj proxy SOCKS - Upewnij się, że adresy serwerów XFTP są w poprawnym formacie, rozdzielone liniami i nie są zduplikowane. Uwierzytelnianie nie powiodło się Wpis pinu Tryb blokady SimpleX @@ -1081,8 +1033,6 @@ Zezwalaj na połączenia tylko wtedy, gdy Twój kontakt na to pozwala. Zezwól swoim kontaktom na połączenia do Ciebie. Połączenia audio/wideo - " -\nDostępny w v5.1" Zarówno Ty, jak i Twój kontakt możecie nawiązywać połączenia. Zabroń połączeń audio/wideo. Tylko Twój kontakt może wykonywać połączenia. @@ -1124,7 +1074,6 @@ Zapisać ustawienia\? Przestań udostępniać Kontynuuj - Nie twórz adresu Cześć! \nPołącz się ze mną za pomocą SimpleX Chat: %s Zaproś znajomych @@ -1137,17 +1086,14 @@ link jednorazowy Podręczniku Użytkownika.]]> Adres SimpleX - Kiedy ludzie proszą o połączenie, możesz je zaakceptować lub odrzucić. Nie stracisz kontaktów, jeśli później usuniesz swój adres. Dostosuj motyw Kolory interfejsu Twoje kontakty pozostaną połączone. Dodaj adres do swojego profilu, aby Twoje kontakty mogły go udostępnić innym osobom. Aktualizacja profilu zostanie wysłana do Twoich kontaktów. - Utwórz adres, aby ludzie mogli się z Tobą połączyć. Utwórz adres SimpleX Zapisz ustawienia adresów SimpleX Udostępnij kontaktom - Możesz go utworzyć później Adres SimpleX Drugorzędny @@ -1166,7 +1112,6 @@ Wszystkie Twoje kontakty pozostaną połączone. Aktualizacja profilu zostanie wysłana do Twoich kontaktów. Wpisz wiadomość powitalną… Jeśli nie możesz spotkać się osobiście, pokaż kod QR w rozmowie wideo lub udostępnij link. - Możesz udostępnić swój adres jako link lub kod QR - każdy może się z Tobą połączyć. Pin samozniszczenia Pin samozniszczenia włączony! Aby się połączyć, Twój kontakt może zeskanować kod QR lub skorzystać z linku w aplikacji. @@ -1341,9 +1286,7 @@ Brak rozmów w tle SimpleX nie może działać w tle. Otrzymasz powiadomienia gdy aplikacja jest uruchomiona. Nowy losowy profil zostanie udostępniony. - Wklej otrzymany link w pole poniżej, aby połączyć się z kontaktem… Twój profil %1$s zostanie udostępniony. - Połącz incognito Aplikacja może zostać zamknięta po 1 minucie w tle. Optymalizacja baterii / Nieograniczony w ustawieniach aplikacji.]]> Optymalizacja baterii / Nieograniczony w ustawieniach aplikacji.]]> @@ -1501,7 +1444,6 @@ Proszę poczekać na załadowanie pliku z połączonego telefonu Zweryfikuj połączenie Odśwież - Możesz ustawić go jako widoczny dla swoich kontaktów SimpleX w Ustawieniach. Losowy błąd wyświetlania zawartości błąd wyświetlania wiadomości @@ -1771,7 +1713,6 @@ Nigdy Niezabezpieczony NIE używaj prywatnego trasowania. - Tryb trasowania wiadomości Tak Nie Gdy IP ukryty @@ -1781,7 +1722,6 @@ Aby chronić Twój adres IP, prywatne trasowanie używa Twoich serwerów SMP, aby dostarczyć wiadomości. Nieznane serwery Używaj prywatnego trasowania z nieznanymi serwerami. - Rezerwowe trasowania wiadomości Prywatne trasowanie Wysyłaj wiadomości bezpośrednio, gdy adres IP jest chroniony i Twój lub docelowy serwer nie obsługuje prywatnego trasowania. Wysyłaj wiadomości bezpośrednio, gdy Twój lub docelowy serwer nie obsługuje prywatnego trasowania. @@ -1794,7 +1734,6 @@ Bez Tor lub VPN, Twój adres IP będzie widoczny do serwerów plików. Pliki Motyw profilu - Pokaż listę czatów w nowym oknie Kolory ciemnego trybu Jasny Otrzymano odpowiedź @@ -1891,13 +1830,11 @@ Fragmenty pobrane Fragmenty przesłane Pobrane pliki - Skonfigurowane serwery SMP Potwierdzono Błędy potwierdzenia Usunięto Aktywne połączenia Wszystkie profile - Skonfigurowane serwery XFTP błąd odszyfrowywania Szczegółowe statystyki Szczegóły @@ -1915,8 +1852,6 @@ wygasły Fragmenty usunięte Brak bezpośredniego połączenia, wiadomość została przekazana przez administratora. - Inne serwery SMP - Inne serwery XFTP Pokaż procent Stabilny Aktualizacja dostępna: %s @@ -1949,7 +1884,6 @@ Zabezpieczone Zasubskrybowano Przesłane pliki - Otwórz ustawienia serwera Adres serwera Wysłane wiadomości Ponownie połącz ze wszystkimi połączonymi serwerami w celu wymuszenia dostarczenia wiadomości. Wykorzystuje to dodatkowy ruch. @@ -1981,7 +1915,6 @@ Zwiększ rozmiar czcionki. wiadomość Wiadomość - otwórz szukaj Zaznaczono %d wideo @@ -2002,7 +1935,6 @@ Odtwórz z listy czatów. Zarchiwizowane kontakty zadzwoń - połącz Stan połączenia i serwerów. Utwórz Nieprawidłowy link @@ -2143,7 +2075,6 @@ Dodaj listę Dodaj do listy "Wszystkie konwersacje zostaną usunięte z list %s, oraz listy." - zaszyfrowanej, z post-kwantowym bezpieczeństwem w bezpośrednich wiadomościach.]]> Dodaj znajomych Dodane serwery plików i mediów Paski narzędziowe aplikacji @@ -2183,7 +2114,6 @@ Czat zostanie usunięty dla wszystkich członków - nie można tego cofnąć! Warunki będą akceptowane w dniu: %s. Bezpieczeństwo połączenia - Warunki będą akceptowane dla operatorów włączonych po 30 dniach. Zmień kolejność Połączenie wymaga renegocjacji szyfrowania. przyjął %1$s @@ -2288,14 +2218,12 @@ Plik jest zablokowany przez operatora serwera:\n%1$s. Pliki Pliki i media są zabronione na tym czacie. - Filtr Odcisk palca w docelowym serwerze nie pasuje do certyfikatu: %1$s. Odcisk palca w adresie serwera nie pasuje do certyfikatu: %1$s. Odcisk palca w adresie serwera nie pasuje do certyfikatu: %1$s. Napraw Naprawić połączenie? Dla wszystkich moderatorów - Lepsza prywatność metadanych. Dla profilu czatu %s: Na przykład, jeśli kontakt otrzyma wiadomości za pośrednictwem serwera czatu SimpleX, aplikacja dostarczy je za pośrednictwem serwera Flux. Dla mnie @@ -2417,7 +2345,6 @@ odrzucono odrzucono Odrzucić członka? - Zdalne telefony komórkowe Usuń i skasuj wiadomości usunięty z grupy Usuń śledzenie linków @@ -2442,7 +2369,6 @@ Przejrzyj warunki sprawdzone przez administratorów Przejrzyj członków grupy - Przejrzyj później Przejrzyj członków Przejrzyj członków przed przyjęciem (pukanie). Zapisać ustawienia wstępu? @@ -2452,13 +2378,11 @@ Szukaj linków Szukaj wideo Szukaj wiadomości głosowych - Wybierz operatora sieci Wysłać prośbę o kontakt? Wyślij prywatne zgłoszenia Wyślij prośbę Wyślij prośbę bez wiadomości Wyślij swoją prywatną opinię do grup. - Wysłano do Twojego kontaktu po połączeniu. Serwer dodany do operatora %s. Operator serwera został zmieniony. Operatorzy serwera @@ -2484,7 +2408,6 @@ Dotknij Połącz aby rozpocząć czat Dotknij Połącz, aby wysłać prośbę Dotknij Połącz aby użyć bota - Dotknij Stwórz adres SimpleX w menu aby utworzyć go później. Dotknij Dołącz do grupy Przekroczono limit czasu połączenia TCP Port TCP dla wiadomości @@ -2492,7 +2415,6 @@ Aplikacja chroni Twoją prywatność, korzystając z różnych operatorów w każdej rozmowie. Połączenie osiągnęło limit niedostarczonych wiadomości, Twój kontakt może być offline. Link będzie krótki, a profil grupowy zostanie udostępniony poprzez link. - Raport zostanie dla Ciebie zarchiwizowany. Rola zostanie zmieniona na %s. Wszyscy uczestnicy czatu zostaną powiadomieni. Drugi predefiniowany operator w aplikacji! Nadawca NIE zostanie poinformowany. @@ -2500,7 +2422,6 @@ Tej akcji nie można cofnąć - wiadomości wysłane i otrzymane na tym czacie wcześniej niż wybrane zostaną usunięte. Ten link wymaga nowszej wersji aplikacji. Zaktualizuj aplikację lub poproś osobę kontaktową o przesłanie kompatybilnego łącza. Ta wiadomość została usunięta lub jeszcze nie otrzymana. - To ustawienie jest dla Twojego obecnego profilu. Czas zniknięcia jest ustawiony tylko dla nowych kontaktów. Aby zabezpieczyć się przed wymianą łącza, możesz porównać kody bezpieczeństwa kontaktu. Żeby odebrać @@ -2510,9 +2431,7 @@ Przeźroczystość Odblokować członków dla wszystkich? Niedostarczone wiadomości - Nieprzeczytane wzmianki Nieobsługiwane łącze połączenia - Aktualizacja Warunki aktualizacji Aktualizuj swój adres Upgrade @@ -2532,14 +2451,12 @@ Zobacz warunki Zobacz zaktualizowane warunki Wiadomości głosowe - Strona Internetowa Wiadomość powitalna Powitaj swoje kontakty Gdy włączony jest więcej niż jeden operator, żaden z nich nie ma metadanych pozwalających dowiedzieć się, kto się z kim komunikuje. Tak zaakceptowałeś tego członka Nie masz połączenia z serwerem używanym do odbierania wiadomości z tego połączenia (brak subskrypcji). - Możesz skonfigurować operatorów w ustawieniach sieci i serwerów. Serwery można skonfigurować w ustawieniach. Możesz skopiować i zmniejszyć rozmiar wiadomości, aby ją wysłać. Możesz wzmiankować do %1$s członków na wiadomość! 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 7e807ff9b3..01239d6049 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 @@ -104,9 +104,7 @@ Permitir Permitir enviar mensagens temporárias. Permitir o envio de mensagens diretas para os membros. - Conectar via link/QR Code Todas as mensagens serão apagadas. Isso não pode ser desfeito! As mensagens serão apagadas APENAS para você. - Adicionar servidores predefinidos Adicionar servidor Crie seu perfil Ícone de contexto @@ -146,7 +144,6 @@ Contato verificado Contato ainda não está conectado! Contribuir - Criar Acessar os servidores por meio do proxy SOCKS na porta %d? O proxy deve ser iniciado antes de ativar esta opção. Permitir que seus contatos apaguem permanentemente as mensagens enviadas em até 24h. Adicionar a outro dispositivo @@ -156,7 +153,6 @@ Conexão Excluir perfil de chat\? Apagar para todos - Conectar conectado conectando apagado @@ -171,7 +167,6 @@ Apagar contato Configurar servidores ICE Excluir endereço\? - Descentralizado Excluir banco de dados O banco de dados é criptografado usando uma senha aleatória. Altere-a antes de exportar. Confirmar nova senha… @@ -281,7 +276,6 @@ Editar Ativar exclusão automática de mensagens\? %d seg - Erro ao salvar servidores SMP Erro ao aceitar solicitação de contato Erro ao excluir solicitação de contato Erro ao trocar de perfil! @@ -382,8 +376,6 @@ Ocultar contato e mensagem Como usar Como usar markdown - exiba o QR Code na videochamada ou compartilhe o link.]]> - escanear o QR Code em uma videochamada, ou pedir que o contato compartilhe um link de convite.]]> Se você confirmar, os servidores de mensagens poderão ver seu endereço IP e seu provedor - e quais servidores você está se conectando. Imagem Se você recebeu o link de convite SimpleX Chat, você pode abri-lo em seu navegador: @@ -391,13 +383,11 @@ anonimamente pelo link do grupo Chamada de vídeo recebida Permita na próxima caixa de diálogo para receber notificações instantaneamente.]]> - Gerar link de uso único Arquivo não encontrado Se você optar por rejeitar o remetente NÃO será notificado. Código de segurança incorreto! Instale o SimpleX para terminal Como funciona - Imune a spam Vire a câmera Desligar Modo anônimo @@ -439,7 +429,6 @@ Atualize o aplicativo e entre em contato com os desenvolvedores. O servidor exige autorização para criar filas. Verifique a senha. Revelar - Pendente Proibir o envio de mensagens diretas para os membros. Escanear QR Code Recusar @@ -449,7 +438,6 @@ Novo em %s Envie-nos um email Escanear QR Code do servidor - Seus servidores SMP nunca Restaurar backup do banco de dados? moderado @@ -459,7 +447,6 @@ Arquivo grande! Marcar como lida Você convidou seu contato - QR Code inválido Mais Você será conectado ao grupo quando o dispositivo que hospeda o grupo estiver online. Aguarde ou verifique mais tarde. Essa string não é um link de conexão! @@ -480,7 +467,6 @@ Notificações periódicas Câmera Seus servidores ICE - Seus servidores ICE Privacidade Entrar no grupo? Sair @@ -546,7 +532,6 @@ conversa inválida Abrir o link no navegador pode reduzir a privacidade e a segurança da conexão. Links SimpleX não confiáveis ficarão vermelhos. Link de conexão inválido - Certifique-se de que os endereços do servidor SMP estejam no formato correto, separados por linhas e não estejam duplicados. O remetente excluiu a solicitação de conexão. Notificações periódicas estão desativadas! As notificações instantâneas estão desativadas! @@ -582,7 +567,6 @@ observador Por favor, entre em contato com o administrador do grupo. Markdown em mensagens - Servidores SMP Endereço do servidor predefinido Rejeitar %1$d mensagem(ens) ignorada(s) @@ -621,22 +605,17 @@ Redefinir Mensagem ao vivo! Envie uma mensagem ao vivo: ela será atualizada para os destinatários enquanto você digita - (para compartilhar com seu contato) - (escanear ou colar da área de transferência) Você aceitou a conexão Marcar como não lida Silenciar Definir nome do contato Link inválido! - Esse QR Code não é um link! - Seu perfil de chat será enviado\nao seu contato Você será conectado quando o dispositivo do seu contato estiver online. Aguarde ou verifique mais tarde. Como Teste do servidor falhou! Os hosts .onion não serão usados. Os hosts .onion serão obrigatórios para a conexão.\nAtenção: você não conseguirá se conectar aos servidores sem um endereço .onion. Versão principal: v%s - repositório do GitHub.]]> Como isso afeta a bateria %1$s quer se conectar com você via sem criptografia de ponta a ponta @@ -745,14 +724,11 @@ Novidades Chamadas de áudio e vídeo Proteja seus perfis de chat com uma senha! - Este texto está disponível nas configurações Escanear código Os hosts .onion serão usados quando estiverem disponíveis. Seu perfil atual - Privacidade redefinida Notificações privadas Fazer uma conexão privada - Você decide quem pode se conectar. Isso pode acontecer quando:\n1. As mensagens expiraram no remetente após 2 dias ou no servidor após 30 dias.\n2. A descriptografia da mensagem falhou porque você ou seu contato usou um backup antigo do banco de dados.\n3. A conexão foi comprometida. Você deverá inserir a senha sempre que o aplicativo for iniciado – ela não fica armazenada no dispositivo. Salvar mensagem de boas-vindas\? @@ -781,7 +757,6 @@ Usar servidor Você precisa permitir que seu contato envie mensagens de voz para poder enviá-las também. Seus perfis de chat - Servidor predefinido Escaneie o código de segurança do aplicativo do seu contato. Enviar mensagem Requerido @@ -833,17 +808,12 @@ O banco de dados não está funcionando corretamente. Toque para saber mais Aguardando a imagem Mostrar QR Code - Abrir no aplicativo móvel.]]> Testar servidores - Atualizar o modo de isolamento de transporte\? Vídeo ativado Aguardando o arquivo Toque no botão Para começar uma nova conversa Ligar - Bem-vindo(a)! - O futuro da transmissão de mensagens - Proxy SOCKS Não foi possível concluir a alteração da senha do banco de dados. Pare o chat para exportar, importar ou excluir o banco de dados. Não será possível enviar ou receber mensagens enquanto o chat estiver parado. %s segundo(s) @@ -865,7 +835,6 @@ O teste falhou na etapa %s. Iniciar periodicamente envio não autorizado - Toque para iniciar uma nova conversa Você não tem conversas aguardando resposta… Seu banco de dados atual será EXCLUÍDO e SUBSTITUÍDO pelo importado.\nEsta ação não pode ser desfeita - seu perfil, contatos, mensagens e arquivos serão completamente destruídos. @@ -883,7 +852,6 @@ Você é um colaborador Mensagem de voz (%1$s) Compartilhar link - Para proteger sua privacidade, o SimpleX usa IDs separados para cada um dos seus contatos. chamada de vídeo Mostrar Servidores ICE WebRTC @@ -909,12 +877,9 @@ Alterar endereço de recebimento\? Ativar som Logo SimpleX - Esse link não é válido! Servidor de teste simplexmq: v%s (%2s) Isolar transporte - Você controla seu chat! - Sem identificadores de usuário. Alto-falante ligado Vídeo desativado Alto-falante desligado @@ -948,7 +913,6 @@ Compartilhar mensagem… Bem-vindo(a) %1$s! Você está convidado para o grupo - Usar chat Mensagens de voz são proibidas neste chat. Vídeo Vídeo enviado @@ -964,7 +928,6 @@ Mostrar: iniciando… aguardando confirmação… - Não armazenamos nenhum dos seus contatos nem suas mensagens (uma vez entregues) nos servidores. Mensagens ignoradas Toque para ativar o perfil. Mostrar perfil de chat @@ -972,7 +935,6 @@ Não foi possível conectar ao servidor que recebe mensagens desta conexão: %1$s. Tentando se conectar ao servidor utilizado para receber mensagens desta conexão. Você está conectado ao servidor usado para receber mensagens desta conexão. - Seu servidor Bloqueio SimpleX A impressão digital do endereço do servidor não corresponde à do certificado. Você enviou um convite de grupo @@ -983,7 +945,6 @@ imagem de pré-visualização do link Para proteger suas informações, ative o bloqueio SimpleX. \nVocê será solicitado a completar a autenticação antes que este recurso seja ativado. - Qualquer um pode hospedar os servidores. Claro O contato permite ativado @@ -1004,23 +965,16 @@ Apoie SimpleX Chat Esta ação não pode ser desfeita - as mensagens enviadas e recebidas antes do período selecionado serão excluídas. Isso pode levar vários minutos. Confirme as atualizações do banco de dados - Somente os dispositivos do usuário armazenam perfis, contatos, grupos e mensagens. Obrigado por instalar o SimpleX Chat! - A plataforma de mensagens que protege sua privacidade e segurança. Você está tentando convidar um contato com quem compartilhou um perfil anônimo para o grupo no qual está usando seu perfil principal. Fila segura imagem de perfil temporária - Erro ao carregar servidores SMP - Erro ao carregar servidores XFTP - Erro ao salvar servidores XFTP - Certifique-se de que os endereços dos servidores XFTP estejam no formato correto, separados por linha e não estão duplicados. Criar arquivo Baixar arquivo O servidor requer autorização para fazer upload, verifique a senha. Enviar arquivo Comparar arquivo Excluir arquivo - Seus servidores XFTP Autenticação falhou Mudar senha Senha atual @@ -1036,7 +990,6 @@ Você não pôde ser verificado; por favor, tente novamente. Autenticar Bloqueio SimpleX não ativado! - Servidores XFTP porta %d Configurações do proxy SOCKS Usar proxy SOCKS @@ -1077,7 +1030,6 @@ %1$d mensagens ignoradas. Chamadas de áudio/vídeo Chamadas de áudio/vídeo são proibidas. - \nDisponível em v5.1 Você e seu contato podem fazer chamadas. Somente você pode fazer chamadas. Somente seu contato pode fazer chamadas. @@ -1098,10 +1050,8 @@ Destaque adicional link de uso único Adicione o endereço ao seu perfil para que seus contatos possam compartilhá-lo com outras pessoas. A atualização do perfil será enviada aos seus contatos. - Crie um endereço para permitir que as pessoas se conectem com você. Criar endereço SimpleX Continuar - Não criar endereço Endereço Tema escuro Fundo @@ -1121,10 +1071,8 @@ Título Se não puderem se encontrar pessoalmente, exiba o QR Code na videochamada ou compartilhe o link. Para se conectar, seu contato pode ler o QR Code ou usar o link no aplicativo. - Você pode compartilhar seu endereço como link ou QR Code para que qualquer pessoa possa se conectar com você. Você não perderá seus contatos se, posteriormente, excluir seu endereço. Endereço SimpleX - Quando alguém solicitar conexão, você pode aceitar ou recusar. Cores da interface Compartilhar com contatos do SimpleX A atualização do perfil será enviada aos seus contatos. @@ -1135,7 +1083,6 @@ \nConecte-se comigo via SimpleX Chat: %s Convide amigos Vamos conversar no SimpleX - Você pode criá-lo mais tarde Compartilhar endereço… Você pode compartilhar este endereço com seus contatos para que eles se conectem com %s. Prévia @@ -1299,13 +1246,11 @@ As confirmações de entrega serão ativadas para todos os contatos. Ocorreu um erro ao ativar as confirmações de entrega! Escolher arquivo - Conexão anônima Permitir Desativar notificações Sem chamadas de fundo Abrir configurações do aplicativo Um novo perfil aleatório será compartilhado. - Cole o link que você recebeu para se conectar com seu contato… Desativar confirmações para grupos? Ativar confirmações para grupos? O envio de confirmações está desativado para %d grupos @@ -1401,7 +1346,6 @@ Você pode visualizar o código de convite novamente nos detalhes de conexão. Opções de desenvolvedor Mostrar chamadas de API lentas - Você pode torná-lo visível para seus contatos nas Configurações. Criptografar arquivos locais %s conectou Erro ao bloquear membro para todos @@ -1633,8 +1577,6 @@ Configurações avançadas Verificar atualizações Completas - Servidores SMP configurados - Servidores XFTP configurados Verifique sua conexão de internet e tente novamente Verificar atualizações Modo de cor @@ -1648,7 +1590,6 @@ Desfocar mídia Borrar para melhor privacidade. Confirmar exclusão do contato? - conectar Apagar sem notificar Destaque adicional 2 Confirmar configurações de rede @@ -1726,12 +1667,10 @@ \nErro: %2$s Destinatário(s) não podem ver de onde essa mensagem veio. A mensagem poderá ser entregue mais tarde se o membro se tornar ativo. - Outros servidores SMP Para proteger seu endereço IP, o roteamento privado usa seus servidores SMP para entregar mensagens. Pular essa versão Baixar %s (%s) Download da atualização cancelado - Mostrar lista de chats em nova janela Tamanho da fonte Fundo do papel de parede Tonalidade do papel de parede @@ -1846,7 +1785,6 @@ Servidores desconhecidos Desprotegido Nunca - Modo de roteamento de mensagens Conceder permissões Alto falante Headphones @@ -1912,10 +1850,8 @@ Proteja seu endereço IP dos relays de mensagens escolhidos pelos seus contatos.\nAtive nas configurações de Rede e servidores. Iniciar o chat Configurações - abrir Manter conversa Apenas apagar conversa - Outros servidores XFTP Use roteamento privado em servidores desconhecidos quando o endereço de IP não está protegido. Sim Instalar atualização @@ -2005,7 +1941,6 @@ Endereço do servidor Tamanho Erros de envio - Abrir configurações de servidor Selecione As mensagens serão apagadas para todos os membros. As mensagens serão marcadas como moderadas para todos os membros. @@ -2019,7 +1954,6 @@ Seus contatos NÃO envie mensagens diretamente, mesmo que o seu servidor ou o servidor de destino não suporte roteamento privado. Enviar mensagens diretamente quando o seu servidor ou o servidor de destino não for compatível com roteamento privado. - Retorno de roteamento de mensagens Mostrar status da mensagem Migrar de outro dispositivo Status da mensagem: %s @@ -2128,7 +2062,6 @@ %s.]]> Mensagens diretas entre membros são proibidas neste chat. Melhor desempenho de grupos - com criptografia de ponta a ponta, com segurança pós-quântica em mensagens diretas.]]> A conversa será apagada para você. Isso não pode ser desfeito! Condições aceitas em: %s. Mensagens diretas entre membros são proibidas. @@ -2170,7 +2103,6 @@ Condições de uso Erro ao adicionar servidor Chats de empresas - para melhoria da privacidade de metadados. Exclusão mais rápida de grupos. Erro ao criar lista de chat Erro ao carregar lista de chats @@ -2194,7 +2126,6 @@ Renegociação de criptografia em andamento. Apagar Clique no botão de informação perto do campo de endereço para permitir usar o microfone. - As condições serão aceitas para operadores habilitados após 30 dias. Por exemplo, se o seu contato receber mensagens por meio de um servidor SimpleX Chat, seu aplicativo as entregará por meio de um servidor Flux. A conexão não está pronta. Erros nas configurações de servidores. @@ -2284,7 +2215,6 @@ Proibir a denúncia de mensagens aos moderadores. Denunciar conteúdo: somente os moderadores do grupo poderão ver. Denunciar perfil de membro: somente moderadores do grupo poderão ver. - Dispositivos móveis remotos Remover membros? Denunciar outro: somente os moderadores do grupo poderão ver. Qual é a razão da denúncia? @@ -2308,7 +2238,6 @@ Endereço SimpleX ou link único? Som silenciado Alterne entre áudio e vídeo durante a chamada. - Selecione os operadores de rede que deseja utilizar. Revisar condições Endereços SimpleX e links únicos são seguros para compartilhar por meio de qualquer mensageiro. Operadores do servidor @@ -2317,7 +2246,6 @@ Denunciar spam: somente os moderadores do grupo poderão ver. Protocolo do servidor alterado. Compartilhe o endereço do SimpleX nas redes sociais. - Revisar depois Servidor O SimpleX Chat e o Flux fizeram um acordo para incluir servidores operados pelo Flux no aplicativo. conexão solicitada @@ -2330,13 +2258,10 @@ O segundo operador predefinido no aplicativo! Esta mensagem foi apagada ou ainda não foi recebida. Ver condições atualizadas - Toque em Criar endereço SimpleX no menu para criá-lo mais tarde. Usar porta TCP %1$s quando nenhuma porta for especificada. - A denúncia será arquivada para você. Para receber Esta ação não pode ser desfeita - as mensagens enviadas e recebidas nesta conversa antes do período selecionado serão apagadas. Para se proteger contra a substituição do seu link, você pode comparar os códigos de segurança com os dos seus contatos. - Menções não lidas Porta TCP para mensagens Usar porta web O aplicativo protege sua privacidade usando diferentes operadores em cada conversa. @@ -2349,12 +2274,9 @@ É possível ajustar os servidores através das configurações. Usar %s Usar servidores - Website Você pode mencionar até %1$s membros por mensagem! Final Você pode copiar e reduzir o tamanho da mensagem para enviá-la. - Atualização - Você pode configurar operadores em Configurações de rede & servidores. Usar para arquivos Você deixará de receber mensagens deste chat. O histórico do chat será preservado. Para realizar chamadas, permita o uso do microfone. Encerre a chamada atual e tente novamente. @@ -2395,13 +2317,11 @@ %1$d inscritos %1$s apoiou o SimpleX Chat. O selo expirou em %2$s. Você pode apoiar o SimpleX a partir da versão 7 do aplicativo. - Sobre Aceitar solicitação de contato Aceitar solicitação de contato aceito lista reconhecida ativo - Adicionar Adicionar colaboradores. Adicionar descrição Adicionar mensagem @@ -2483,7 +2403,6 @@ Nome completo do canal: O canal não possui relays ativos. Tente entrar novamente mais tarde. Link do canal - Membros do canal Nome do canal Preferências do canal perfil do canal atualizado @@ -2517,8 +2436,6 @@ conectado Conecte-se mais rápido! 🚀 conectando - Para se conectar usando o nome do canal, é necessário estar na versão mais recente do aplicativo. - Para se conectar usando o nome do contato, é necessário estar na versão mais recente do aplicativo. A conexão falhou Conectar-se a %s Contato @@ -2582,7 +2499,6 @@ Erro ao apagar conversa Erro ao apagar mensagem Erro ao marcar como lida - Erro ao abrir canal Erro ao abrir conversa Erro ao abrir grupo Erro ao recusar solicitação de contato @@ -2598,7 +2514,6 @@ Arquivos e mídias não são permitidos neste chat. Servidores de arquivos Servidores de arquivos: %s - Filtro A impressão digital no endereço do servidor de destino não corresponde ao certificado: %1$s. A impressão digital do endereço do servidor de encaminhamento não corresponde à do certificado: %1$s. A impressão digital do endereço do servidor não corresponde à do certificado: %1$s. @@ -2696,7 +2611,6 @@ Abrir link limpo Abrir link externo? Abrir link completo - Abrir novo canal Abrir nova conversa Abrir novo grupo Abrir para aceitar @@ -2708,7 +2622,6 @@ Proprietários e colaboradores Autonomia: você pode executar seus próprios relays. aguardando revisão - Atualize o aplicativo. Aguarde os moderadores do grupo analisarem seu pedido para participar. Endereço do relay predefinido Nome do relay predefinido @@ -2780,7 +2693,6 @@ Enviar solicitação Enviar solicitação sem mensagem Envie o link por qualquer aplicativo de mensagens - é seguro. Peça para colar no SimpleX. - Será enviado ao seu contato após a conexão. O servidor %1$s não suporta resolução de nomes. Configure os servidores ou use um link de conexão. O servidor requer autorização para se conectar ao relay; verifique a senha. Aviso do servidor @@ -2805,7 +2717,6 @@ Assinar mensagens SimpleX Link do canal SimpleX - Nome SimpleX Erro no nome SimpleX Nome SimpleX não verificado Nomes públicos SimpleX (BETA) @@ -2843,8 +2754,6 @@ Vídeos Seu dispositivo não tem suporte para gravação de áudio Link de conexão não suportado - Nome de canal não suportado - Nome de contato não suportado Nome não confirmado Seu endereço público O nome SimpleX %1$s está registrado, mas não está associado ao perfil. Se você for o proprietário, adicione-o ao perfil do endereço ou canal. @@ -2883,7 +2792,6 @@ Você nasceu sem uma conta. Sua rede O aplicativo removeu esta mensagem após %1$d tentativas de recebê-la. - Esta configuração se aplica ao seu perfil atual perfil do canal atualizado você aceitou este membro Código da página da web diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml index 6ab12f76f6..8b43d5295d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml @@ -43,8 +43,6 @@ não Definir preferências de grupo Mensagens de voz - " -\nDisponível na v5.1" Aceitar Permitir que seus contatos enviem mensagens que desaparecem. Definir 1 dia @@ -71,10 +69,8 @@ As mensagens enviadas serão eliminadas após o tempo definido. Mensagem de rascunho Defina-o em vez de autenticação do sistema. - Conectar Você está conectado ao servidor usado para receber mensagens deste contacto. você - Certifique-se de que os endereços do servidor XFTP estão no formato correto, separados por linhas e não estão duplicados. Desconectar Possivelmente, a impressão digital do certificado no endereço do servidor está incorreta A senha é necessária @@ -128,7 +124,6 @@ Aceitar Aceitar pedido de ligação\? Aceitar modo anónimo - Adicionar servidores pré-definidos Aceder aos servidores via proxy SOCKS no porto %d\? O proxy tem de iniciar antes de ativar esta opção. Adicionar a outro dispositivo administrador @@ -157,14 +152,12 @@ Nome para Exibição: Mostrar código QR Mensagens temporárias são proibidas neste grupo. - Conectar via link / código QR Mensagens temporárias são proibidas nesta conversa. Enviar AO VIVO Enviar uma mensagem ao vivo - ela será atualizada para o(s) destinatário(s) à medida que você a digita Nome local Modo de bloqueio - Certifique-se de que os endereços do servidor SMP estão no formato correto, separados por linhas e não estão duplicados. Fazer uma conexão privada Tornar o perfil privado! Certifique-se de que os endereços do servidor WebRTC ICE estão no formato correto, separados por linhas e não estão duplicados. @@ -204,7 +197,6 @@ Você e o seu contato podem eliminar irreversivelmente as mensagens enviadas. Você já tem um perfil de chat com o mesmo nome para exibição. Por favor, escolha outro nome. Você está convidado para o grupo - Abrir na aplicação móvel.]]> Você está convidado para o grupo. Junte-se para se conectar com os membros do grupo. Grupo Áudio ligado @@ -325,7 +317,6 @@ Definir senha para exportar Pedido de conexão enviado! Criar endereço - Criar colorido conectando… Conectando chamada @@ -374,7 +365,6 @@ Se você recebeu convite de ligação do SimpleX Chat, você pode abri-lo no seu navegador: Toque para entrar no modo anônimo Salvar senha na Keystore - mostre o código QR na chamada de vídeo ou partilhe a ligação.]]> Salvar definições de aceitação automática Aceitar automaticamente Salvar e atualizar o perfil do grupo @@ -400,7 +390,6 @@ Ligação inválida! Guia de Utilizador.]]> Conexão - Crie um endereço para permitir que as pessoas se conectem consigo. Salvar e notificar contato Salvar e notificar contatos Salvar e notificar membros do grupo @@ -411,7 +400,6 @@ Todos os seus contatos permanecerão conectados. A atualização do perfil será enviada aos seus contatos. Adicione endereço ao seu perfil, para que os seus contatos possam partilhá-lo com outras pessoas. A atualização do perfil será enviada aos seus contatos. Os contactos podem marcar mensagens para eliminar; você será capaz de as ver. - Criar convite de ligação de utilização única anónimo via ligação de utilização única anónimo via ligação de grupo via ligação de utilização única @@ -419,15 +407,11 @@ Ligação para 1 utilização Criar ligação Apagar ligação\? - ler o código QR na chamada de vídeo , ou o seu contato pode partilhar um convite de ligação.]]> Se não se poderem encontrar pessoalmente, mostre o código QR numa chamada de vídeo ou partilhe a ligação. - As pessoas podem se conectar a si apenas por meio das ligações que você partilhe. Verifique se você usou a ligação correta ou peça ao seu contato para enviar outra. Partilhar ligação Enviar pré-visualizações de ligações - (ler ou colar da área de transferência) Partilhar ligação de utilização única - GitHub.]]> Criar perfil Criar o seu perfil %d contacto(s) selecionado(s) @@ -467,7 +451,6 @@ Eliminar servidor Personalizar tema Eliminar imagem - Não criar endereço Desativar Palavra-passe da base de dados Criar endereço SimpleX @@ -476,7 +459,6 @@ Eliminar contacto? Dispositivo Mensagens diretas - Descentralizado mensagem duplicada Ficheiro: %s você: %1$s @@ -498,7 +480,6 @@ Você não irá perder os seus contatos se eliminar o seu endereço mais tarde. Você pode esconder ou silenciar um perfil de utilizador - pressione-o para o menu. Vídeo ligado - Servidores XFTP %1$s quer conectar-se consigo via Junte-se Sair @@ -535,8 +516,6 @@ Endereço SimpleX Equipa SimpleX Saiba mais - Quando as pessoas solicitam conexão, você pode aceitá-la ou rejeitá-la. - Você pode criá-lo mais tarde Vamos conversar no SimpleX Chat Vídeo desligado %1$d mensagem(s) ignoradas @@ -573,7 +552,6 @@ Mais melhorias chegam brevemente! Pré-visualização de notificação Muito provavelmente este contato eliminou a conexão consigo. - Este texto está disponível nas definições Pode ser alterado mais tarde através das definições. Ajuda Suporte SimpleX Chat @@ -637,7 +615,6 @@ Para verificar a encriptação de ponta a ponta com o seu contato, compare (ou leia) o código nos seus dispositivos. Ler o código de segurança a partir da aplicação do seu contacto. Ler o código QR do servidor - Apenas dispositivos pessoais armazenam perfis de utilizador, contatos, grupos e mensagens. o contacto tem encriptação ponta a ponta sem encriptação ponta a ponta criador @@ -725,7 +702,6 @@ Pesquisar Desativado O teste falhou na etapa %s. - Servidor SMP Servidor de teste %s segundo(s) Parar conversa? @@ -741,7 +717,6 @@ %s, %s e %d outros membros conectados Iniciar conversa? Começa periodicamente - Toque para iniciar uma nova conversa Sistema de autenticação Parar de receber o arquivo? Servidores de teste @@ -798,9 +773,6 @@ A transferir detalhes de ligação A criar ligação de arquivo duplicados(as) - Abrir definições do servidor - O seu perfil de conversa será enviado -\npara o seu contacto Conectar com %1$s? Auricular Dispositivos @@ -818,7 +790,6 @@ Ativar Computador O seu contacto enviou um ficheiro que é maior que o tamanho máximo suportado atualmente (%1$s). - O seu servidor O endereço do seu servidor As tuas chamadas Não enviar histórico a novos membros. @@ -836,7 +807,6 @@ Os seus contactos podem permitir eliminação total de mensagens. Desconectado com a razão: %s Você já solicitou ligação através deste endereço! - Código e protocolo open-source - qualquer indivíduo pode ser anfitrião dos servidores. %d mensagens bloqueadas %d mensagens marcadas como eliminadas Você não pode ser verificado; por favor tente novamente. @@ -885,7 +855,6 @@ Transferir %s (%s) Os seus servidores ICE O seu perfil, contactos e mensagens entregues são armazenados no seu dispositivo. - Os seus servidores ICE Desativar (manter as sobreposições de grupo) Desativar (manter sobreposições) Desativar recibos? @@ -926,10 +895,7 @@ Você será conectado quando o dispositivo do seu contacto estiver online, por favor aguarde ou volte mais tarde! Desconectar Desativado - Os seus servidores XFTP - Você controla a conversa! Você poderá ver a conversa com %1$s na lista de conversas. - Os seus servidores SMP Você pode usar markdown para formatar mensagens: Você poderá enviar mensagens para %1$s através das conversas Eliminadas. O seu contacto precisa de estar online para a ligação ser completada. @@ -943,7 +909,6 @@ A tua base de dados de conversas Detalhes Você pode partilhar o seu endereço com os seus contactos para permitir que se conectem com %s. - Você pode partilhar o seu endereço como uma ligação ou código QR - qualquer pessoa pode conectar-se a si. Permitir Todas as novas mensagens de %s serão ocultadas! Somente você pode fazer ligações. 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 f2437af22b..8c7651fecd 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml @@ -33,7 +33,6 @@ Accepți cererea de conectare? Adaugă contact apel acceptat - Adaugă servere presetate Adaugă profil Adresă Adaugă servere prin scanarea codurilor QR. @@ -104,7 +103,6 @@ Arhivează și încarcă Bază de date de arhivare Se creează un link de arhivare - Creează link de invitație de unică folosință Acceptă automat imagini Apel audio apel audio @@ -128,7 +126,6 @@ Autentificare anulată Cod de acces aplicație este înlocuit cu cod de acces de autodistrugere. Apeluri audio/video - \nDisponibil în v5.1 Cod de acces aplicație Migrare date aplicație Eroare critică @@ -250,7 +247,6 @@ Răspuns trimis Partajează media… Partajează mesaj… - Afișează lista de conversații într-o fereastră nouă Afișează consola în fereastră nouă Configurează parola bazei de date Setează parola bazei de date @@ -286,7 +282,6 @@ Setează numele contactului… Trimite mesaj Trimiteți un mesaj care dispare - (scanează sau lipește din clipboard) Afișează codul QR Testul serverului a eșuat! Afișează: @@ -342,7 +337,6 @@ Pătrat, cerc, sau orice între. %s nu este verificat %s este verificat - Servere SMP %s, %s și %d alți membri s-au conectat %s, %s și %d membri Difuzor @@ -387,7 +381,6 @@ Migrează de pe alt dispozitiv pe noul dispozitiv și scanați codul QR.]]> Te conectezi prin adresa de contact? Vrei să te conectezi printr-un link unic? - Conectare incognito Contactul deja există Schimbă codul de acces Puteți activa SimpleX Lock din Setări. @@ -424,7 +417,6 @@ Profilurile tale de conversație Creează un profil de conversație apel încheiat %1$s - Creează Bluetooth Camera Apeluri pe ecranul blocat: @@ -458,13 +450,11 @@ Nu ați putut fi verificat; vă rugăm să încercați din nou. Copiază Anulează mesajul live - Conectare prin link / cod QR Contactele tale vor rămâne conectate. Parola pentru criptarea bazei de date va fi actualizată. Contactele tale pot permite ștergerea totală a mesajelor. Trebuie să îi permiți contactului tău să trimită mesaje vocale pentru a le putea trimite. Versiunea de bază: v%s - Creează o adresă pentru a permite oamenilor să se conecteze cu tine. %s blocat ați schimbat adresa ați schimbat adresa pentru %s @@ -479,7 +469,6 @@ Copiat Camera Ați invitat un contact - Profilul tău de conversație va fi trimis contactului tău. Contribuie Profil conversație aldin @@ -512,7 +501,6 @@ Contactul tău a trimis un fișier care este mai mare decât dimensiunea maximă acceptată în prezent (%1$s). anulează previzualizarea linkului Verifică adresa serverului și încearcă din nou. - Tu îți controlezi chatul! Apelul s-a încheiat deja! Apel încheiat Blochează pentru toți @@ -531,7 +519,6 @@ Confirmați fișiere de la servere necunoscute. am schimbat adresa pentru tine Confirmă noua parolă… - Conectare conexiune %1$d conexiune stabilită Eroare de conexiune @@ -618,7 +605,6 @@ Ștergeți mesajul membrului? Șterge grupul Șterge și notifică contactele - Descentralizat grup șters %d contact(e) selectat(e) Șters la: %s @@ -662,7 +648,6 @@ Șterge baza de date de pe acest dispozitiv Șterge profilul de conversație Șterge fișierele pentru toate profilurile de conversație - Oricine poate găzdui servere. Contacte arhivate Beta Apeluri interzise! @@ -674,10 +659,8 @@ Controlează-ți rețeaua Parola pentru criptarea bazei de date va fi actualizată și stocată în Keystore. Parola bazei de date - Servere XFTP configurate Baza de date este criptată folosind o parolă aleatorie. Trebuie schimbată înainte de exportare. apel - conectare Contact șters! Conversație ștearsă! Actualizarea aplicației este descărcată @@ -704,7 +687,6 @@ Profil actual Confirmi ștergerea contactului? Contactul va fi șters - acest lucru nu poate fi anulat! - Servere SMP configurate Conectat Corectează numele la %s? Continuă @@ -749,9 +731,6 @@ Încălcarea liniilor directoare ale comunității Profil inadecvat Alt motiv - Eroare la salvarea serverelor SMP - Asigurați-vă că adresele serverelor XFTP sunt în format corect, separate pe linii și nu sunt duplicate. - Eroare la încărcarea serverlor XFTP Eroare la actualizarea configurației de rețea Nu s-au putut încărca conversațiile Vă rugăm să actualizați aplicația și contactați dezvoltatorii. @@ -773,12 +752,9 @@ incognito printr-un link de unică folosință a + b prin %1$s - Eroare la încărcarea serverlor SMP %1$d fișier(e) au fost șterse. raport arhivat de %s Invitație acceptată - Asigurați-vă că adresele serverelor SMP sunt în format corect, separate pe linii și nu sunt duplicate. - Eroare la salvarea serverelor XFTP %1$d fișier(e) sunt încă în curs de descărcare. %1$d fișier(e) nu au putut fi descărcate. %1$d fișier(e) nu au fost descărcate. @@ -852,7 +828,6 @@ Sau scanați codul QR Gazdă Cum se folosește marcarea - Imun la spam Baza de date va fi criptată. Grup inactiv Bună dimineaţa! @@ -914,7 +889,6 @@ Nicio conversație necitită Nicio conversație în lista %s. Nimic selectat - deschis Ajutor Doar contactul tău poate trimite mesaje care dispar. Se importă arhiva @@ -987,7 +961,6 @@ Nume nevalid! Eroare la deschiderea browserului Cum funcționează - Doar dispozitivele client stochează profiluri de utilizator, contacte, grupuri și mesaje. Instalat cu succes Dezactivează Opțiuni dezvoltator @@ -1019,7 +992,6 @@ Erori la ștergere Dezactivați ștergerea mesajelor Păstrează conversația - Cod QR nevalid Cod de securitate incorect! Dispare la: %s Conectează-te cu datele tale de autentificare @@ -1035,7 +1007,6 @@ Nicio conversație filtrată Server nou Asigurați -vă că adresele Serverului WebRTC ICE sunt în format corect, separate pe linii și nu sunt duplicate. - Rutarea mesajelor Editează imaginea Deschide setările Acordare în setări @@ -1217,7 +1188,6 @@ Interfață de utilizator maghiară și turcă Redirecționează și salvează mesajele Activează Flux în setările Rețea și servere pentru o mai bună confidențialitate a metadatelor. - pentru o mai bună confidențialitate a metadatelor. Redirecționați până la 20 de mesaje simultan. Nu rata mesajele importante. Organizează conversațiile în liste @@ -1307,7 +1277,6 @@ Încă nu există conexiune directă, mesajul a fost redirecționat de administrator. - Deschide conversația la primul mesaj necitit.\n- Mergi la mesajele citate. Oprit - Deschide setările serverului criptare ok pentru %s profilul grupului a fost actualizat proprietar @@ -1378,7 +1347,6 @@ Eroare la schimbarea profilului Nicio persoană de contact filtrată Servere media și fișiere - Alte servere XFTP NU utilizați rutare privată. Activează apelurile de pe ecranul de blocare prin Setări. Dezactivează (păstrează suprascrierile) @@ -1416,7 +1384,6 @@ expirat Descărcați %s (%s) Instalează actualizarea - Nu crea adresă De exemplu, dacă un contact al tău primește mesaje printr-un server SimpleX Chat, aplicația ta le va trimite printr-un server Flux. Deschide Setările Safari / Site-uri web / Microfon, apoi selectează Permite pentru localhost. Apel ratat @@ -1493,7 +1460,6 @@ Conversație nouă Link de invitație unic Servere mesaje - Alte servere SMP Introduceți serverul manual Servere ICE (unul pe linie) Rețea și servere @@ -1504,7 +1470,6 @@ Ieșire fără salvare Parolă profil ascuns italic - Fără identificatori de utilizator. Acest lucru se poate întâmpla atunci când:\n1. Mesajele au expirat în aplicația de trimitere după 2 zile sau pe server după 30 de zile.\n2. Decriptarea mesajului a eșuat, deoarece tu sau contactul tău ați folosit un backup vechi al bazei de date.\n3. Conexiunea a fost compromisă. Eroare la exportarea bazei de date a conversației Eroare la oprirea conversației @@ -1587,7 +1552,6 @@ Erori în configurarea serverelor. Adresa serverului de redirecționare este incompatibilă cu setările rețelei: %1$s. Nu există servere pentru a trimite fișiere. - Mod de rutare a mesajelor Niciodată Întoarce camera Link nevalid @@ -1599,7 +1563,6 @@ Lipește Vă rugăm să rugați persoana de contact să activeze apelurile. Rutare privată - Confidențialitatea redefinită Vă rugăm să verificați conexiunea la rețea cu %1$s și să încercați din nou. Rutare mesaje private 🚀 Bara de instrumente pentru conversație, accesibilă @@ -1611,14 +1574,12 @@ Se pregătește încărcarea Acces refuzat! Vă rugăm să reduceți dimensiunea mesajului și să îl trimiteți din nou. - În așteptare peer-to-peer Vă rugăm să reporniți aplicația. Mesaje primite Lipește linkul pentru conectare! Interziceți reacțiile la mesaje. Interzicerea trimiterii de linkuri SimpleX - Lipește linkul primit pentru a te conecta cu contactul tău… Te rugăm să-i ceri persoanei tale de contact să activeze trimiterea de mesaje vocale. Cod QR portul %d @@ -1704,7 +1665,6 @@ substituent pentru imaginea de profil Lipește linkul Adresa prestabilită a serverului - Server presetat Evaluează aplicația Port Conversațiile private, grupurile și contactele tale nu sunt accesibile operatorilor serverului. @@ -1751,7 +1711,6 @@ Raport Adresa de primire va fi schimbată la un alt server. Schimbarea adresei se va finaliza după ce expeditorul se conectează online. Salvezi setările de acces? - Revizuiți mai târziu Trimiterea confirmărilor este dezactivată pentru %d grupuri recenzie Operatorul serverului s-a schimbat. @@ -1803,8 +1762,6 @@ Trimite confirmări de livrare la Parola de autodistrugere a fost schimbată! Înregistrare actualizată la - Selectează operatorii de rețea de utilizat. - Telefoane mobile la distanță Trimite confirmări de citire Setați 1 zi Parolă de autodistrugere @@ -1897,10 +1854,8 @@ Opriți fișierul trimitere neautorizată 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 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 Utilizare pentru fișiere @@ -1938,7 +1893,6 @@ Mod Blocare SimpleX Mesajele vor fi marcate ca moderate pentru toți membrii. necitit - Bun venit! Atingeți pentru conectare Mesajele vocale sunt interzise! Ați acceptat conexiunea @@ -1965,8 +1919,6 @@ Atinge pentru a lipi linkul Textul pe care l-ai lipit nu este un link SimpleX. Stabil - Puteți configura operatorii în setările Rețea și servere. - Actualizare apel video (necriptat e2e) ID-ul următorului mesaj este incorect (mai mic sau egal cu precedentul).\nSe poate întâmpla din cauza unei erori sau când conexiunea este compromisă. Fără Tor sau VPN, adresa ta IP va fi vizibilă pentru serverele de fișiere. @@ -1974,7 +1926,6 @@ Sistem Acestea pot fi ignorate în setările de contact și de grup. Suport SimpleX Chat - Proxy SOCKS Teme În timpul importului au apărut câteva erori non-fatale: Atingeți pentru a vă alătura @@ -1995,7 +1946,6 @@ Actualizează parola bazei de date Opriți trimiterea fișierului? tăiere - Viitorul mesageriei Pentru a efectua apeluri, permiteți utilizarea microfonului. Încheiați apelul și încercați să sunați din nou. Când sunt activați mai mulți operatori, niciunul dintre ei nu are metadate pentru a afla cine comunică cu cine. Video pornit @@ -2016,7 +1966,6 @@ Rolul va fi schimbat în %s. Toți participanții la chat vor fi notificați. Rolul va fi schimbat în %s. Toți membrii grupului vor fi notificați. Folosește %s - Site web %s Servere Actualizați setările de rețea? Atingeți pentru a activa profilul. @@ -2084,7 +2033,6 @@ Când partajezi un profil incognito cu cineva, acest profil va fi folosit pentru grupurile la care te invită. Mesaj vocal La conectarea apelurilor audio și video. - Când oamenii solicită conectarea, poți accepta sau respinge solicitarea. Acest grup are peste %1$d membri, confirmările de livrare nu sunt trimise. Comutați între audio și video în timpul apelului. Deconectați desktopul? @@ -2093,19 +2041,13 @@ Încarcă fișier Atinge butonul Atingeți pentru a scana - (pentru a partaja cu persoana de contact) Pentru a începe o nouă conversație Video Partajează link de unică folosință - Acest link nu este un link de conectare valid! - Acest cod QR nu este un link! Link scurt - Server XFTP - Actualizați modul de izolare a transportului? Folosește rutare privată cu servere necunoscute. Folosește rutare privată cu servere necunoscute atunci când adresa IP nu este protejată. Folosiți portul TCP %1$s atunci când nu este specificat niciun port. - Nu stocăm niciunul dintre contactele sau mesajele tale (odată livrate) pe servere. prin releu Soft Puternic @@ -2128,9 +2070,6 @@ Încărcat Folosiți portul TCP 443 doar pentru serverele presetate. Omiteți această versiune - O poți crea mai târziu - O poți face vizibilă contactelor tale SimpleX din Setări. - Platforma de mesagerie și aplicații care vă protejează confidențialitatea și securitatea. Profilul este partajat doar cu contactele tale. Pentru a-ți dezvălui profilul ascuns, introdu parola completă în câmpul de căutare de pe pagina Profilurile tale de conversație. Puteți configura serverele prin intermediul setărilor. @@ -2155,16 +2094,12 @@ Blocarea SimpleX nu este activă! Autentificare sistem Baza de date nu funcționează corect. Atingeți pentru a afla mai multe. - Raportul va fi arhivat. - Atingeți Creează o adresă SimpleX în meniu pentru a o crea mai târziu. - Acest text este disponibil în setări Ești invitat în grup Se așteaptă imaginea Se așteaptă imaginea Se așteaptă videoclipul Eroare temporară de fișier Elimină din favorite - Mențiuni necitite Conexiunea pe care ați acceptat-o va fi anulată! Contactul cu care ați partajat acest link NU se va putea conecta! vrea să se conecteze cu tine! @@ -2177,7 +2112,6 @@ Servere de testare Folosește credențiale aleatorii Actualizare disponibilă: %s - Folosește chatul Hash-ul mesajului anterior este diferit. Această acțiune nu poate fi anulată - mesajele trimise și primite anterior celei selectate vor fi șterse. Poate dura câteva minute. Parolă greșită! @@ -2246,13 +2180,11 @@ %1$d erori de fișier:\n%2$s Conexiune blocată Conexiunea este blocată de operatorul serverului:\n%1$s. - criptate end-to-end, cu securitate post-cuantică în mesajele directe.]]> Bările de instrumente ale aplicației Securitate îmbunătățită ✅ Toate conversațiile vor fi eliminate din lista %s, iar lista va fi ștearsă Conexiunea ta a fost mutată la %s, dar a apărut o eroare la schimbarea profilului. Puteți migra baza de date exportată. - Serverele tale XFTP Utilizați gazdele .onion la Nu dacă proxy-ul SOCKS nu le acceptă.]]> Profilul tău de chat va fi trimis membrilor chatului Vei primi în continuare apeluri și notificări de la profilurile dezactivate atunci când acestea sunt active. @@ -2286,7 +2218,6 @@ Ai partajat o cale de fișier nevalidă. Raportează problema dezvoltatorilor aplicației. Deschide în aplicația mobilă, apoi atinge Conectare în aplicație.]]> Actualizează adresa - Condițiile vor fi acceptate pentru operatorii activați după 30 de zile. Apasă pe butonul de informații de lângă bara de adrese pentru a permite accesul la microfon. Colţ Puteți salva arhiva exportată. @@ -2321,7 +2252,6 @@ Adaugă prieteni Adaugă membri echipei Conversația există deja! - Deschide în aplicația mobilă.]]> Continuă %s a fost deconectat]]> Despre operatori @@ -2333,11 +2263,9 @@ contact dezactivat contactul nu este gata Profilul, contactele și mesajele livrate sunt stocate pe dispozitiv. - Serverele tale ICE Adaugă la listă Modificați lista invitație acceptată - Îți poți partaja adresa sub formă de link sau cod QR - oricine se poate conecta cu tine. Acceptă Confidențialitatea ta Acceptă condițiile @@ -2354,11 +2282,9 @@ Poți vizualiza rapoartele tale în conversația cu administratorii. Schimbă ordinea Te vei conecta atunci când dispozitivul contactului tău va fi online, te rugăm să aștepți sau să verifici mai târziu! - scana codul QR în timpul apelului video sau contactul tău poate partaja un link de invitație.]]> Profilul tău %1$s va fi distribuit. Nu îți vei pierde contactele dacă ulterior îți ștergi adresa. Securitatea conexiunii - Serverele tale SMP Toate serverele Profilul tău este stocat pe dispozitiv și este partajat doar cu contactele tale. Serverele SimpleX nu pot vedea profilul tău. Baza ta de date actuală de chat va fi ȘTERSĂ și ÎNLOCUITĂ cu cea importată.\nAceastă acțiune nu poate fi anulată - profilul, contactele, mesajele și fișierele tale vor fi pierdute iremediabil. @@ -2370,18 +2296,15 @@ %s cu motivul: %s]]> %s a fost deconectat]]> (nou)]]> - Serverul tău Trebuie să îi permiți contactului tău să te sune pentru a-l putea suna. Adresa serverului tău Serverele tale Vei fi conectat la grup atunci când dispozitivul gazdei grupului va fi online, te rugăm să aștepți sau să verifici mai târziu! - Tu decizi cine se poate conecta. Vei fi conectat când cererea ta de conectare va fi acceptată, te rugăm să aștepți sau să verifici mai târziu! Acceptați ca observator Experiență de utilizare îmbunătățită 1 chat cu un membru Toate rapoartele vor fi arhivate pentru tine. - depozitul nostru GitHub.]]> Permiteți în următoarea fereastră de dialog să primească notificări instantaneu.]]> Setări adresă Arhivare rapoarte @@ -2391,7 +2314,6 @@ 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 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.]]> SimpleX rulează în fundal în loc să utilizeze notificări push.]]> Utilizarea bateriei aplicației / Nerestricționat în setările aplicației.]]> @@ -2432,11 +2354,9 @@ Te poți conecta la conversație și poți trimite mesaje imediat ce apeși pe Conectare. Rol nou în grup: Moderator Descriere scurtă: - Trimis contactului tău după conectare. Actualizezi la o adresă permanentă? Mesaj de bun venit Solicitări de contact de la grupuri - Această setare este pentru profilul tău actual Partajează adresa veche Partajează linkul vechi Se încarcă profilul… @@ -2516,7 +2436,6 @@ %1$d abonat %1$d abonați %1$s a susținut SimpleX Chat. Insigna a expirat pe %2$s. - Despre acceptată Aplicația necesita actualizare Eticheta nu poate fi verificată 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 44f3d3858e..ba571e4d0a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml @@ -8,7 +8,6 @@ Вступить в группу? Ваш профиль будет отправлен контакту, от которого Вы получили эту ссылку. Вы соединитесь со всеми членами группы. - Соединиться соединен(а) ошибка @@ -48,8 +47,6 @@ В браузере Использование ссылки в браузере может уменьшить конфиденциальность и безопасность соединения. Ссылки на неизвестные сайты будут красными. - Ошибка при сохранении SMP-серверов - Пожалуйста, проверьте, что адреса SMP-серверов имеют правильный формат, каждый адрес на отдельной строке и не повторяется. Ошибка при сохранении настроек сети Превышено время соединения @@ -169,14 +166,11 @@ не прочитано Здравствуйте %1$s! - Здравствуйте! - Этот текст можно найти в Настройках Чаты соединяется… Вы приглашены в группу Вступить как %s соединяется… - Нажмите, чтобы начать чат Соединиться с разработчиками У Вас нет чатов @@ -224,7 +218,6 @@ Соединено Соединение с сервером не установлено Ошибка - Ожидает Поменять адрес получения? Адрес получения сообщений будет перемещён на другой сервер. Изменение адреса завершится после того как отправитель будет онлайн. @@ -246,12 +239,8 @@ Скопировано в буфер обмена Начать новый разговор - Создать ссылку-приглашение - Соединиться через ссылку или QR-код Сканировать QR-код Создать секретную группу - (чтобы отправить Вашему контакту) - (сканировать или вставить из буфера) (хранится только у членов группы) Разрешение не получено! @@ -320,18 +309,11 @@ Показать QR-код - Ошибка QR-кода - Этот QR-код не является ссылкой! Неверная ссылка! - Эта ссылка не является ссылкой-приглашением! Запрос на соединение отправлен! Соединение с группой будет установлено, когда хост группы будет онлайн. Пожалуйста, подождите или проверьте позже! Соединение будет установлено, когда Ваш запрос будет принят. Пожалуйста, подождите или проверьте позже! Соединение будет установлено, когда Ваш контакт будет онлайн. Пожалуйста, подождите или проверьте позже! - показать QR-код во время видеозвонка или поделиться ссылкой.]]> - Ваш профиль будет отправлен -\nВашему контакту - сосканировать QR-код во время видеозвонка, или Ваш контакт может отправить Вам ссылку.]]> Поделиться одноразовой ссылкой Соединиться @@ -350,9 +332,7 @@ Написать нам письмо Блокировка SimpleX Консоль - SMP-серверы Адрес сервера по умолчанию - Добавить серверы по умолчанию Добавить сервер Тестировать сервер Тестировать серверы @@ -361,8 +341,6 @@ Серверы не прошли тест: Сканировать QR-код сервера Ввести сервер вручную - Сервер по умолчанию - Ваш сервер Адрес Вашего сервера Использовать сервер Использовать для новых соединений @@ -375,7 +353,6 @@ Внести свой вклад Оценить приложение Использовать серверы, предоставленные SimpleX Chat? - Ваши SMP-серверы Используются серверы, предоставленные SimpleX Chat. Инфо Как использовать серверы @@ -421,15 +398,11 @@ Сохранить и уведомить членов группы Выйти без сохранения - Вы контролируете Ваш чат! - Платформа для сообщений и приложений, которая защищает Вашу личную информацию и безопасность. - Мы не храним Ваши контакты и сообщения (после доставки) на серверах. Создать профиль Ваш профиль, контакты и доставленные сообщения хранятся на Вашем устройстве. Профиль отправляется только Вашим контактам. Имя профиля не может содержать пробелы. Имя: - Создать О SimpleX Как форматировать @@ -442,7 +415,6 @@ секрет Соединиться через ссылку Эта строка не является ссылкой-приглашением! - Открыть в приложении.]]> входящий звонок… пропущенный звонок @@ -462,22 +434,11 @@ соединен(а) завершён - Будущее коммуникаций - Более конфиденциальный - Без идентификаторов пользователей. - Защищён от спама - Вы определяете, кто может соединиться. - Децентрализованный - Кто угодно может запустить сервер. Создать профиль Добавьте контакт Как это работает - Чтобы защитить Вашу конфиденциальность, SimpleX использует разные ID для каждого Вашего контакта. - Только пользовательские устройства хранят контакты, группы и сообщения. - GitHub репозитория.]]> - Использовать чат Вставьте полученную ссылку @@ -502,7 +463,6 @@ Принять Показывать Выключить - Ваши ICE-серверы WebRTC ICE-серверы Релей-сервер защищает Ваш IP-адрес, но может отслеживать продолжительность звонка. Релей-сервер используется только при необходимости. Другая сторона может видеть Ваш IP-адрес. @@ -556,7 +516,6 @@ Чаты Инструменты разработчика Экспериментальные функции - SOCKS-прокси Значок Темы Сообщения и файлы @@ -988,7 +947,6 @@ Удалить профиль чата для Эта настройка применяется к сообщениям в Вашем текущем профиле чата Отдельные транспортные сессии - Обновить режим отдельных сессий\? Имя профиля уже используется! Ошибка создания профиля! У Вас уже есть профиль с таким именем. Пожалуйста, выберите другое имя. @@ -1089,17 +1047,11 @@ Раскрыть профиль Видео будет получено, когда Ваш контакт будет онлайн, пожалуйста, подождите или проверьте позже! Раскрыть профиль чата - Ошибка при загрузке SMP-серверов - Ошибка при загрузке XFTP-серверов - Ошибка при сохранении XFTP-серверов - Проверьте, что адреса XFTP-серверов указаны в правильном формате и не дублируются. Сервер требует авторизации для загрузки, проверьте пароль. Сравнение файла Удалить файл Загрузка файла Загрузка файла - XFTP-серверы - Ваши XFTP-серверы Использовать .onion хосты в Нет если SOCKS-прокси их не поддерживает.]]> Ошибка аутентификации Нет кода доступа @@ -1154,8 +1106,6 @@ Разрешить Вашим контактам звонить Вам. Аудио/видео звонки Аудио/видео звонки запрещены. - " -\nДоступно в v5.1" Вы и Ваш контакт можете совершать звонки. Только Вы можете совершать звонки. Только Ваш контакт может совершать звонки. @@ -1177,7 +1127,6 @@ И Вы, и Ваш контакт можете добавлять реакции на сообщения. Одноразовая ссылка Адрес SimpleX - Когда Вы получите запрос на соединение, Вы можете принять или отклонить его. Включить код самоуничтожения Все данные приложения будут удалены. Если Вы введёте код самоуничтожения при открытии приложения: @@ -1234,11 +1183,9 @@ Если Вы не можете встретиться лично, покажите QR-код во время видеозвонка или поделитесь ссылкой. Чтобы соединиться с Вами, Ваш контакт может отсканировать QR-код или использовать ссылку в приложении. Вы не потеряете контакты, если позже удалите Ваш адрес. - Вы можете поделиться своим адресом в виде ссылки или QR-кода - любой может соединиться с Вами. Руководстве пользователя.]]> Ваши контакты сохранятся. Настроить тему - Создайте адрес, чтобы можно было соединиться с Вами. Все Ваши контакты сохранятся. Обновлённый профиль будет отправлен Вашим контактам. Добавьте адрес в свой профиль, чтобы Ваши SimpleX контакты могли поделиться им. Профиль будет отправлен Вашим SimpleX контактам. Создать адрес SimpleX @@ -1249,9 +1196,6 @@ Сохранить настройки\? Прекратить делиться Продолжить - Не создавать адрес - Вы можете создать его позже - Вы можете сделать его видимым для ваших контактов в SimpleX через Настройки. Адрес Введите приветственное сообщение… Просмотр @@ -1328,7 +1272,6 @@ Нет истории Отправка отчётов о доставке включена для %d контактов Отправка отчётов о доставке будет включена для всех контактов во всех видимых профилях чата. - Установка для Вашего активного профиля Установки для Вашего активного профиля Отправка отчётов о доставке выключена для %d контактов Шифрование работает, и новое соглашение не требуется. Это может привести к ошибкам соединения! @@ -1389,7 +1332,6 @@ Пересогласовать Пересогласовать шифрование\? Запретить отправлять файлы и медиа. - Соединиться Инкогнито Разрешить Открыть настройки приложения Выключить уведомления @@ -1397,7 +1339,6 @@ Звонки в фоне невозможны Приложение может быть выключено после 1 минуты в фоне. Нет информации от доставке - Вставьте полученную ссылку, чтобы соединиться с Вашим контактом… Будет отправлен новый случайный профиль. Ваш профиль %1$s будет отправлен. Выключить отчёты о доставке для групп\? @@ -1853,7 +1794,6 @@ информация сервера об очереди: %1$s \n \nпоследнее полученное сообщение: %2$s - Показать список чатов в новом окне Приложение будет запрашивать подтверждение загрузки с неизвестных серверов (за исключением .onion адресов или когда SOCKS-прокси включен). Незащищённый Без Tor или VPN, Ваш IP-адрес будет доступен серверам файлов. @@ -1895,8 +1835,6 @@ Неизвестные серверы Нет Показать статус сообщения - Прямая доставка сообщений - Режим доставки сообщений Конфиденциальная доставка сообщений Цвета чата Тема чата @@ -1934,7 +1872,6 @@ Не удаётся позвонить контакту Не удаётся написать члену группы Проверять обновления - соединиться Адрес сервера назначения %1$s несовместим с настройками пересылающего сервера %2$s. Ошибка подключения к пересылающему серверу %1$s. Попробуйте позже. Пересылающий сервер %1$s не смог подключиться к серверу назначения %2$s. Попробуйте позже. @@ -1944,8 +1881,6 @@ Выбранные настройки чата запрещают это сообщение. Ошибка файла Вставить ссылку / Сканировать - Другие XFTP-серверы - Настроенные XFTP-серверы Загрузка %s (%s) Доступно обновление: %s Выключить @@ -2006,7 +1941,6 @@ Нет отфильтрованных контактов Ваши контакты Архивированные контакты - Настроенные SMP-серверы Показать процент Слабое Среднее @@ -2045,12 +1979,10 @@ Член группы неактивен Прямого соединения пока нет, сообщение переслано или будет переслано админом. Ничего не выбрано - открыть поиск Выбрано %d Настройки Вы по-прежнему можете просмотреть разговор с %1$s в списке чатов. - Другие SMP-серверы Выключено Установлено успешно Установить обновление @@ -2094,7 +2026,6 @@ Соединяйтесь с друзьями быстрее. Управляйте своей сетью Защищает ваш IP-адрес и соединения. - Открыть настройки серверов Полученные сообщения Ошибки приёма Архивируйте контакты чтобы продолжить переписку. @@ -2197,7 +2128,6 @@ Нет серверов для приёма файлов. Нет серверов для отправки файлов. Недоставленные сообщения - Нажмите Создать адрес SimpleX в меню, чтобы создать его позже. Адрес или одноразовая ссылка? Безопасность соединения Операторы серверов @@ -2207,7 +2137,6 @@ %s.]]> Условия будут автоматически приняты для включенных операторов: %s. Условия приняты: %s - Вебсайт %s.]]> %s.]]> %s, примите условия использования.]]> @@ -2223,7 +2152,6 @@ Децентрализация сети Второй оператор серверов в приложении! Включите Flux в настройках Сеть и серверы для лучшей конфиденциальности метаданных. - для лучшей конфиденциальности метаданных. Улучшенная навигация в разговоре Посмотреть измененные условия Устройства Xiaomi: пожалуйста, включите опцию Autostart в системных настройках для работы нотификаций.]]> @@ -2246,19 +2174,13 @@ Настройки адреса Добавьте сотрудников в разговор. Бизнес-адрес - сквозным шифрованием, с пост-квантовой безопасностью в прямых разговорах.]]> Приложение всегда выполняется в фоне Проверять сообщения каждые 10 минут Без фонового сервиса Нотификации и батарейка Как это улучшает конфиденциальность Операторы серверов - Выберите операторов сети. - Вы можете настроить операторов в настройках Сети и серверов. Продолжить - Посмотреть позже - Обновить - Связанные мобильные устройства Покинуть разговор? Вы прекратите получать сообщения в этом разговоре. История будет сохранена. Добавить друзей @@ -2313,7 +2235,6 @@ Приложение улучшает конфиденциальность, используя разных операторов в каждом разговоре. Когда больше чем один оператор включен, ни один из них не видит метаданные, чтобы определить, кто соединен с кем. Ошибка сохранения серверов - Условия будут приняты для включенных операторов через 30 дней. Ошибка приёма условий Соединение достигло предела недоставленных сообщений. Возможно, Ваш контакт не в сети. Чтобы защитить Вашу ссылку от замены, Вы можете сравнить код безопасности. @@ -2368,7 +2289,6 @@ Конфиденциальные названия медиафайлов. Спам Сообщения о нарушениях - Непрочитанные упоминания Да Упоминайте членов группы 👋 Улучшенная конфиденциальность и безопасность @@ -2380,7 +2300,6 @@ Установите срок хранения сообщений в чатах. Вы можете упомянуть до %1$s пользователей в одном сообщении! Причина сообщения? - Эта жалоба будет архивирована для вас. Разрешить отправлять сообщения о нарушениях модераторам. Содержание нарушает условия использования Ошибка чтения пароля базы данных @@ -2549,7 +2468,6 @@ Отправить запрос Отправить запрос без сообщения Отправляйте Ваши конфиденциальные предложения группе. - Отправляется Вашему контакту после соединения. Обновить ссылку группы? Обновить Обновить адрес? @@ -2617,7 +2535,6 @@ Удалить вместе с сообщениями Все сообщения Файлы - Фильтр Изображения Ссылки Поиск файлов @@ -2636,7 +2553,6 @@ Канал Полное имя канала: Ссылка канала - Участники канала Имя канала профиль канала обновлён Канал будет удалён для всех подписчиков - это нельзя отменить! @@ -2656,7 +2572,6 @@ Введите имя релея… Ошибка добавления релея Ошибка при создании канала - Ошибка при открытии канала ошибка: %s Ошибка при сохранении профиля канала Неверный адрес релея! @@ -2715,7 +2630,6 @@ неактивен Подпись ссылки проверена. Открыть канал - Открыть новый канал Владельцы Владелец релей @@ -2878,7 +2792,6 @@ Нижнее меню Картинка ссылки будет загружена через SOCKS-прокси. DNS-запрос может быть локальным через Ваш резолвер. Верхнее меню - Добавить Добавить релей Добавить релеи Отменить и удалить канал @@ -2887,7 +2800,6 @@ %1$d владельцев %1$d владельцев и участников канала %1$s поддерживал SimpleX Chat. Срок действия бейджа истек %2$s. - О приложении Продвинутые настройки Приложение уже запущено Необходимо обновление приложения @@ -2909,8 +2821,6 @@ Веб-страница канала Выбрано %d релеев Данные чата - Для подключения по имени канала требуется более новая версия приложения. - Для подключения по имени контакта требуется более новая версия приложения. Контакт соавтор подписчик @@ -2949,9 +2859,6 @@ Если Вы выберете Закрыть, сообщения не будут доставляться.\nВы можете изменить это позже в настройках Интерфейса. Адрес будет показан подписчикам и разрешит загрузку предпросмотра. Вступить в канал %s - Неподдерживаемое имя канала - Неподдерживаемое имя контакта - Пожалуйста, обновите приложение. Ошибка SimpleX имени Ни один из Ваших серверов не настроен для разрешения SimpleX имён. Настройте серверы или используйте ссылку для соединения. Сервер %1$s не поддерживает разрешение имён. Настройте серверы или используйте ссылку для соединения. @@ -2980,7 +2887,6 @@ Проверить имя Проверять SimpleX имена SimpleX имя не проверено - SimpleX имя Ваше SimpleX имя Установить SimpleX имя SimpleX имя %1$s зарегистрировано без ссылки канала. Добавьте ссылку канала к имени на странице регистрации. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/sk/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/sk/strings.xml index 74db414936..61b75fcf90 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/sk/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/sk/strings.xml @@ -9,8 +9,6 @@ Použiť inkognito profil Váš profil bude odoslaný kontaktu, od ktorého ste obdržali tento odkaz. Pripojíte sa ku všetkým členom skupiny. - Pripojiť sa - Spojiť sa inkognito Otvoriť chat Pripojiť sa na kanál %s Otvoriť nový chat @@ -72,7 +70,6 @@ Prerušiť Prerušiť zmenu adresy Prerušiť zmenu adresy? - O aplikácii O operátoroch O SimpleX O adrese SimpleX @@ -82,7 +79,6 @@ Pristupovať k serverom cez SOCKS proxy na porte %d? Pred povolením tejto možnosti musí byť spustená proxy. aktívne aktívne spojenia - Pridať Pridať kontakt Pridať prispievateľov. Pridať opis @@ -94,7 +90,6 @@ Ďalšie sekundárne Pridať zoznam Pridať správu - Pridať prednastavené servery Pridať profil Pridať relé Pridať relé @@ -130,7 +125,6 @@ Všetky farebné režimy Všetky dáta sa pri zadaní vymažú. Všetky správy - end-to-end šifrovaním, s post-quantovým zabezpečením v priamych správach.]]> Všetky nové správy od %s budú skryté! Všetky nové správy od týchto členov budú skryté! Povoliť @@ -187,7 +181,6 @@ Verzia aplikácie: v%s Archivovať Archivované kontakty - \nDostupné vo verzii v5.1 Späť Pozadie Služba na pozadí je vždy spustená - notifikácie budú zobrazené hneď ako budú správy k dispozícii. @@ -222,7 +215,6 @@ Zrušiť náhľad súboru zrušené %s Zrušiť migráciu - Vytvoriť Vytvoriť Vytvoriť jednorazový odkaz Vytvoriť adresu @@ -232,7 +224,6 @@ Vytvoriť odkaz na skupinu Vytvoriť odkaz Vytvoriť zoznam - Vytvoriť jednorazovú pozvánku Vytvoriť profil Vytvoriť profil Vytvoriť tajnú skupinu @@ -258,7 +249,6 @@ %dd %d deň %d dní - Decentralizovaná Vymazať Vymazať Prijať @@ -298,7 +288,6 @@ Povoliť vašim kontaktom odosielať súbory a média. Povoliť vašim kontaktom posielať hlasové správy. Všetky hlásenia budú pre vás archivované. - Servery môže prevádzkovať ktokoľvek. arabčina, bulharčina, fínčina, hebrejčina, thajčina a ukrajinčina - vďaka používateľom a Weblate. Archivovať všetky hlásenia? Archivovať a nahrať @@ -367,7 +356,6 @@ Kanál nemá žiadne aktívne relé. Prosím, skúste sa pripojiť neskôr. Odkaz kanálu Odkaz kanálu - Členovia kanálu Názov kanálu Vlastnosti kanálu Kanály @@ -469,8 +457,6 @@ Podmienky prijaté dňa: %s. %s.]]> Podmienky používania - Nastavené SMP servery - Nastavené XFTP servery Nastaviť relé Potvrdiť Potvrdiť vymazanie kontaktu? @@ -523,7 +509,6 @@ Verzia jadra: v%s Roh Opraviť meno na %s? - Vytvoriť adresu, aby sa s vami ľudia mohli spojiť. Vytvoriť chatový profil Vytvorený Vytvoriť nový profil v počítačovej aplikácii. 💻 @@ -753,7 +738,6 @@ Tlačidlo zavrieť Porovnajte bezpečnostné kódy s vašimi kontaktmi. %s.]]> - Podmienky budú prijaté pre povolených operátorov po 30 dňoch. %s.]]> %s.]]> Podmienky budú prijaté dňa: %s. @@ -914,7 +898,6 @@ %dmes Neposielať históriu novým členom. Neposielať históriu novým odberateľom. - Nevytvárať adresu Nepovoliť Nenechajte si ujsť dôležité správy. Znova nezobrazovať @@ -1124,7 +1107,6 @@ Súbory a médiá Status súboru: %s Súbor bude vymazaný zo serverov. - Filter Opraviť Opraviť Opraviť pripojenie @@ -1299,11 +1281,8 @@ Chyba pri preposielaní správ Chyba inicializácie WebView. Ujistite sa, že máte nainštalovaní WebView ktorý podporuje architektúru arm64.\nChyba: %s Chyba pri načítavaní zoznamov chatov - Chyba pri načítavaní SMP serverov - Chyba pri načítavaní XFTP serverov Chyba pri označovaní ako prečítané Chyba pri otváraní prehliadača - Chyba pri otváraní kanálu Chyba pri otváraní chatu Chyba pri otváraní skupiny Chyba pri odmietaní žiadosti o kontakt @@ -1337,8 +1316,6 @@ Chyba pri ukladaní serverov Chyba pri ukladaní nastavení Chyba pri ukladaní nastavení - Chyba pri ukladaní SMP serverov - Chyba pri ukladaní XFTP serverov Chyba pri aktualizovaní zoznamu chatov Chyba pri aktualizovaní odkazu skupiny Chyba pri aktualizovaní konfigurácie siete @@ -1357,7 +1334,6 @@ Neplatný odkaz! neplatný formát správy Neplatné meno! - Neplatný QR kód Neplatný QR kód Neplatná adresa relé! Neplatná meno relé! @@ -1388,7 +1364,6 @@ Jednorazový odkaz Naraz sa dá poslať iba 10 obrázkov Naraz sa dá poslať iba 10 videí - otvoriť Otvoriť Otvoriť Otvoriť nastavenia aplikácie @@ -1400,8 +1375,6 @@ Otvoriť priečinok databázy Otvoriť externý odkaz? Otvoriť celý odkaz - Otvoriť nový kanál - Otvoriť nastavenia serveru Otvoriť nastavenia Otvorte SimpleX Chat na prijatie hovoru Otvoriť webový odkaz? @@ -1409,8 +1382,6 @@ Alebo naskenovať QR kód Alebo vložiť odkaz archívu Chyba súboru - naskenovať QR kód vo video hovore, alebo váš kontakt môže zdieľať pozvánku.]]> - ukážte QR kód vo video hovore, alebo zdieľajte odkaz.]]> Ak sa nemôžte stretnúť osobne, ukážte QR kód vo video hovore, alebo zdieľajte odkaz. Ak ste dostali SimpleX Chat pozvánku, môžete ju otvoriť v prehliadači: Nesprávny bezpečnostný kód @@ -1453,8 +1424,6 @@ Uložiť nastavenia SimpleX adresy Uložiť SimpleX meno? Uložiť uvítaciu správu? - Vaše SMP servery - Vaše XFTP servery Vaše ICE servery ICE servery (jeden na riadok) Ujistite sa, že adresy WebRTC ICE serverov sú v správnom formáte, oddelené na riadkoch a nie sú duplikované. 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 da2aea2868..9e782ee24f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml @@ -75,7 +75,6 @@ ข้อมูลแอปทั้งหมดถูกลบแล้ว. รองเพิ่มเติม เพิ่มโปรไฟล์ - เพิ่มเซิร์ฟเวอร์ที่ตั้งไว้ล่วงหน้า แชทและข้อความทั้งหมดจะถูกลบ - การดำเนินการนี้ไม่สามารถยกเลิกได้! ที่อยู่ เพิ่มเซิร์ฟเวอร์โดยการสแกนรหัส QR @@ -92,8 +91,6 @@ อนุญาตข้อความเสียงเฉพาะเมื่อผู้ติดต่อของคุณอนุญาตเท่านั้น ผู้ติดต่อทั้งหมดของคุณจะยังคงเชื่อมต่ออยู่. การอัปเดตโปรไฟล์จะถูกส่งไปยังผู้ติดต่อของคุณ. เสมอ - " -\nพร้อมใช้งานใน v5.1" กลับ แฮชข้อความไม่ดี บริการพื้นหลังทำงานตลอดเวลา - การแจ้งเตือนจะแสดงทันทีที่มีข้อความ @@ -164,7 +161,6 @@ ธีมที่กำหนดเอง ID ฐานข้อมูลและตัวเลือกการแยกการส่งผ่าน ID ฐานข้อมูล - เชื่อมต่อ กำลังเชื่อมต่อ เชื่อมต่อผ่านลิงค์ติดต่อ\? เชื่อมต่อผ่านลิงค์เชิญ\? @@ -191,8 +187,6 @@ เวลาที่กําหนดเอง ยืนยัน คัดลอกไปที่คลิปบอร์ดแล้ว - เชื่อมต่อผ่านลิงค์ / คิวอาร์โค้ด - สร้างลิงก์เชิญแบบใช้ครั้งเดียว สร้างกลุ่มลับ ล้าง ล้างแชท\? @@ -211,10 +205,8 @@ เวอร์ชันหลัก: v%s ปรับแต่งธีม สร้างที่อยู่ - สร้างที่อยู่เพื่อให้ผู้อื่นเชื่อมต่อกับคุณ สร้างที่อยู่ SimpleX ดำเนินการต่อ - สร้าง สร้างโปรไฟล์ มีสี กําลังเชื่อมต่อสาย… @@ -277,14 +269,10 @@ ลบกลุ่ม\? การตรวจสอบอุปกรณ์ไม่ได้ถูกเปิดใช้งาน คุณสามารถเปิด SimpleX Lock ผ่านการตั้งค่าได้ เมื่อคุณเปิดใช้งานการตรวจสอบอุปกรณ์แล้ว ใส่ข้อความต้อนรับ… (ไม่บังคับ) - เกิดข้อผิดพลาดในการโหลดเซิร์ฟเวอร์ SMP - เกิดข้อผิดพลาดในการโหลดเซิร์ฟเวอร์ XFTP ข้อผิดพลาด ลบแล้ว ลิงค์เต็ม คำอธิบาย - เกิดข้อผิดพลาดในการบันทึกเซิร์ฟเวอร์ SMP - เกิดข้อผิดพลาดในการบันทึกเซิร์ฟเวอร์ XFTP เกิดข้อผิดพลาดในการอัปเดตการกำหนดค่าเครือข่าย ชื่อที่แสดงซ้ำ! เกิดข้อผิดพลาดในการสร้างโปรไฟล์! @@ -362,7 +350,6 @@ สวัสดี! \nเชื่อมต่อกับฉันผ่าน SimpleX Chat: %s ลบที่อยู่ - อย่าสร้างที่อยู่ ชื่อเต็ม: ชื่อที่แสดง: ออกโดยไม่บันทึก @@ -376,7 +363,6 @@ วิธีใช้มาร์กดาวน์ สิ้นสุดลงแล้ว มันทำงานอย่างไร - กระจายอำนาจแล้ว การโทรเสียงแบบ encrypted จากต้นจนจบ การโทรวิดีแบบ encrypted จากต้นจนจบ ปิดการใช้งาน @@ -517,8 +503,6 @@ เกิดข้อผิดพลาดในการรับไฟล์ หากคุณใส่รหัสผ่านนี้เมื่อเปิดแอป ข้อมูลแอปทั้งหมดจะถูกลบอย่างถาวร! หากคุณเลือกที่จะปฏิเสธ ผู้ส่งจะไม่ได้รับแจ้ง - หากคุณไม่สามารถพบกันในชีวิตจริงได้ คุณสามารถสแกนคิวอาร์โค้ดในวิดีโอคอล หรือผู้ติดต่อของคุณสามารถแชร์ลิงก์เชิญได้ - ให้แสดงคิวอาร์โค้ดในวิดีโอคอล หรือแชร์ลิงก์]]> หากคุณไม่สามารถพบกันในชีวิตจริงได้ ให้แสดงคิวอาร์โค้ดในวิดีโอคอล หรือแชร์ลิงก์ หากคุณยืนยัน เซิร์ฟเวอร์การส่งข้อความจะสามารถเห็นที่อยู่ IP ของคุณและผู้ให้บริการของคุณ - ซึ่งคือเซิร์ฟเวอร์ใดที่คุณกำลังเชื่อมต่ออยู่ หากคุณใส่รหัสผ่านทำลายตัวเองขณะเปิดแอป: @@ -558,7 +542,6 @@ ภาพ หากคุณได้รับลิงก์เชิญ SimpleX Chat คุณสามารถเปิดได้ในเบราว์เซอร์ของคุณ: ภาพตัวอย่างลิงค์ - รหัส QR ไม่ถูกต้อง ลิงค์ไม่ถูกต้อง! ศึกษาเพิ่มเติม รหัสความปลอดภัยไม่ถูกต้อง! @@ -566,7 +549,6 @@ เชิญเพื่อน ๆ มาคุยกันใน SimpleX Chat ตัวเอียง - มีภูมิคุ้มกันต่อสแปมและการละเมิด สร้างการเชื่อมต่อแบบส่วนตัว สามารถเปลี่ยนแปลงได้ในภายหลังผ่านการตั้งค่า สายวิดีโอเข้ามา @@ -617,7 +599,6 @@ กำลังเปิดฐานข้อมูล… ทำเครื่องหมายว่าลบแล้ว กลั่นกรองแล้ว - ตรวจสอบให้แน่ใจว่าที่อยู่เซิร์ฟเวอร์ XFTP อยู่ในรูปแบบที่ถูกต้อง แยกบรรทัดและไม่ซ้ำกัน การแจ้งเตือนเป็นระยะ จำเป็นต้องใช้รหัสผ่าน การแจ้งเตือนเป็นระยะปิดอยู่! @@ -637,7 +618,6 @@ สามารถส่งวิดีโอได้ครั้งละ 10 วิดีโอเท่านั้น โปรดติดต่อผู้ดูแลกลุ่ม การแจ้งเตือน - รอดำเนินการ โปรดขอให้ผู้ติดต่อของคุณเปิดใช้งานการส่งข้อความเสียง เฉพาะเจ้าของกลุ่มเท่านั้นที่สามารถเปิดใช้งานข้อความเสียงได้ ไม่มีรายละเอียด @@ -662,9 +642,6 @@ โฮสต์หัวหอมจะไม่ถูกใช้ รหัสผ่านที่จะแสดง สายที่ไม่ได้รับ - ผู้คนสามารถเชื่อมต่อกับคุณผ่านลิงก์ที่คุณแบ่งปันเท่านั้น - โปรโตคอลและโค้ดโอเพ่นซอร์ส – ใคร ๆ ก็สามารถเปิดใช้เซิร์ฟเวอร์ได้ - การเข้ารหัสแบบ encrypted จากต้นจนจบ 2 ชั้น]]> เป็นระยะ แปะลิงก์ที่ได้รับ ไม่มีการ encrypt จากต้นจนจบ @@ -744,7 +721,6 @@ กลั่นกรองโดย %s เป็นไปได้มากว่าผู้ติดต่อนี้ได้ลบการเชื่อมต่อกับคุณ ข้อผิดพลาดในการส่งข้อความ - ตรวจสอบให้แน่ใจว่าที่อยู่เซิร์ฟเวอร์ SMP อยู่ในรูปแบบที่ถูกต้อง แยกบรรทัดและไม่ซ้ำกัน โฮสต์หัวหอมจะถูกใช้เมื่อมี ผู้ติดต่อของคุณเท่านั้นที่สามารถส่งข้อความเสียงได้ การเปิดลิงก์ในเบราว์เซอร์อาจลดความเป็นส่วนตัวและความปลอดภัยของการเชื่อมต่อ ลิงก์ SimpleX ที่ไม่น่าเชื่อถือจะเป็นสีแดง @@ -758,14 +734,11 @@ คิวอาร์โค้ด คู่มือผู้ใช้ ]]> ที่อยู่เซิร์ฟเวอร์ที่ตั้งไว้ล่วงหน้า - เซิร์ฟเวอร์ที่ตั้งไว้ล่วงหน้า ให้คะแนนแอป พอร์ต พอร์ต %d ได้รับคำตอบ… ได้รับการยืนยัน… - นิยามความเป็นส่วนตัวใหม่ - GitHub repository ของเรา]]> การแจ้งเตือนส่วนตัว โปรดรายงานไปยังผู้พัฒนาแอป ความเป็นส่วนตัวและความปลอดภัย @@ -890,7 +863,6 @@ ส่งข้อความ ส่งข้อความสด ส่ง - (สแกนหรือวางจากคลิปบอร์ด) สแกนคิวอาร์โค้ด ตั้งชื่อผู้ติดต่อ การตั้งค่า @@ -972,7 +944,6 @@ หยุดส่งไฟล์ไหม\? หยุด หยุดรับไฟล์\? - แตะเพื่อเริ่มแชทใหม่ เปลี่ยนที่อยู่ผู้รับ\? เริ่มแชทใหม่ ขอบคุณสำหรับการติดตั้ง SimpleX Chat! @@ -984,20 +955,17 @@ %s ไม่ได้รับการยืนยัน %s ได้รับการยืนยันแล้ว ล็อค SimpleX - เซิร์ฟเวอร์ SMP เซิร์ฟเวอร์ทดสอบ เซิร์ฟเวอร์ทดสอบ บางเซิร์ฟเวอร์ผ่านการทดสอบล้มเหลว: การตั้งค่าพร็อกซี SOCKS Simplexmq: v%s (%2s) - แพลตฟอร์มแรกที่ไม่มีตัวระบุผู้ใช้ - ถูกออกแบบให้เป็นส่วนตัว ปิดลำโพง เปิดลำโพง ข้อความที่ข้ามไป ส่ง ระบบ สนับสนุน SimpleX Chat - พร็อกซี SOCKS หยุด หยุดแชท\? %s วินาที @@ -1008,8 +976,6 @@ ระบบ ขอบคุณผู้ใช้ – มีส่วนร่วมผ่าน Weblate! ขอบคุณผู้ใช้ – มีส่วนร่วมผ่าน Weblate! - แพลตฟอร์มการส่งข้อความและแอปพลิเคชันที่ปกป้องความเป็นส่วนตัวและความปลอดภัยของคุณ - การส่งข้อความส่วนตัวรุ่นต่อไป บทบาทจะถูกเปลี่ยนเป็น "%s" ทุกคนในกลุ่มจะได้รับแจ้ง การดำเนินการนี้ไม่สามารถยกเลิกได้ ไฟล์และสื่อที่ได้รับและส่งทั้งหมดจะถูกลบ รูปภาพความละเอียดต่ำจะยังคงอยู่ หากต้องการเปิดเผยโปรไฟล์ที่ซ่อนอยู่ของคุณ ให้ป้อนรหัสผ่านแบบเต็มในช่องค้นหาในหน้าโปรไฟล์แชทของคุณ @@ -1031,7 +997,6 @@ การ decrypt %1$d ข้อความล้มเหลว %1$s สมาชิก เชื่อมต่อกับ SimpleX Chat นักพัฒนาแอปเพื่อถามคำถามและรับการอัปเดต]]> - คุณสามารถแชร์ที่อยู่ของคุณเป็นลิงก์หรือรหัสคิวอาร์ - ใคร ๆ ก็สามารถเชื่อมต่อกับคุณได้ คุณเปลี่ยนที่อยู่ คุณเปลี่ยนบทบาทของตัวเองเป็น %s โปรไฟล์ปัจจุบันของคุณ @@ -1072,7 +1037,6 @@ ข้อความจะถูกทำเครื่องหมายว่ากลั่นกรองสำหรับสมาชิกทุกคน ยังไม่ได้อ่าน ส่งโดยไม่ได้รับอนุญาต - ยินดีต้อนรับ! ยินดีต้อนรับ %1$s! คุณได้รับเชิญให้เข้าร่วมกลุ่ม คุณไม่มีการแชท @@ -1101,7 +1065,6 @@ ดูรหัสความปลอดภัย ห้ามข้อความเสียง! คุณต้องอนุญาตให้ผู้ติดต่อของคุณส่งข้อความเสียงจึงจะสามารถส่งได้ - (เพื่อแบ่งปันกับผู้ติดต่อของคุณ) เพื่อเริ่มแชทใหม่ วิดีโอ เปิดเสียง @@ -1115,44 +1078,30 @@ SimpleX ทีม คุณจะเชื่อมต่อกับกลุ่มเมื่ออุปกรณ์โฮสต์ของกลุ่มออนไลน์อยู่ โปรดรอหรือตรวจสอบภายหลัง! คุณจะเชื่อมต่อเมื่อคำขอเชื่อมต่อของคุณได้รับการยอมรับ โปรดรอหรือตรวจสอบในภายหลัง! - โปรไฟล์แชทของคุณจะถูกส่ง -\nให้กับผู้ติดต่อของคุณ คุณจะเชื่อมต่อเมื่ออุปกรณ์ของผู้ติดต่อของคุณออนไลน์อยู่ โปรดรอหรือตรวจสอบภายหลัง! - เมื่อมีคนขอเชื่อมต่อ คุณสามารถยอมรับหรือปฏิเสธได้ - เปิดในแอปมือถือ]]> ในการตรวจสอบการเข้ารหัสแบบ encrypt จากต้นจนจบ กับผู้ติดต่อของคุณ ให้เปรียบเทียบ (หรือสแกน) รหัสบนอุปกรณ์ของคุณ การตั้งค่าของคุณ ที่อยู่ SimpleX ของคุณ โปรไฟล์แชทของคุณ ใช้เซิร์ฟเวอร์ - เซิร์ฟเวอร์ของคุณ ที่อยู่เซิร์ฟเวอร์ของคุณ เซิร์ฟเวอร์สำหรับการเชื่อมต่อใหม่ของโปรไฟล์การแชทปัจจุบันของคุณ ใช้สำหรับการเชื่อมต่อใหม่ - เซิร์ฟเวอร์ XFTP - เซิร์ฟเวอร์ SMP ของคุณ - เซิร์ฟเวอร์ XFTP ของคุณ เซิร์ฟเวอร์ ICE ของคุณ ใช้พร็อกซี SOCKS ใช้การเชื่อมต่ออินเทอร์เน็ตโดยตรงหรือไม่\? ใช้พร็อกซี SOCKS หรือไม่\? ใช้โฮสต์ .onion เมื่อพร้อมใช้งาน - อัปเดตโหมดการแยกการขนส่งไหม\? สีของธีม ผู้ติดต่อของคุณจะยังคงเชื่อมต่ออยู่ - คุณสามารถสร้างได้ในภายหลัง - คุณเป็นผู้ควบคุมการแชทของคุณ! โปรไฟล์นี้แชร์กับผู้ติดต่อของคุณเท่านั้น - เราไม่เก็บผู้ติดต่อหรือข้อความของคุณ (เมื่อส่งแล้ว) ไว้บนเซิร์ฟเวอร์ โปรไฟล์ รายชื่อผู้ติดต่อ และข้อความที่ส่งของคุณจะถูกจัดเก็บไว้ในอุปกรณ์ของคุณ คุณสามารถใช้มาร์กดาวน์เพื่อจัดรูปแบบข้อความ: รอคำตอบ… - ใช้แชท การสนทนาทางวิดีโอ การโทรของคุณ เซิร์ฟเวอร์ WebRTC ICE - เซิร์ฟเวอร์ ICE ของคุณ ผ่านรีเลย์ ปิดวิดีโอ เปิดวิดีโอ @@ -1216,16 +1165,12 @@ การดำเนินการนี้ไม่สามารถยกเลิกได้ - โปรไฟล์ ผู้ติดต่อ ข้อความ และไฟล์ของคุณจะสูญหายไปอย่างถาวร SimpleX – ใช้แบตเตอรี่เพียงไม่กี่เปอร์เซ็นต์ต่อวัน]]> คุณ: %1$s - เพื่อปกป้องความเป็นส่วนตัว แทนที่จะใช้ ID ผู้ใช้เหมือนที่แพลตฟอร์มอื่นๆใช้ SimpleX มีตัวระบุสำหรับคิวข้อความ โดยแยกจากกันสำหรับผู้ติดต่อแต่ละราย คุณจะต้องตรวจสอบสิทธิ์เมื่อคุณเริ่มหรือกลับมาใช้แอปพลิเคชันอีกครั้งหลังจากผ่านไป 30 วินาทีในพื้นหลัง คุณจะเข้าร่วมกลุ่มที่ลิงก์นี้อ้างถึงและเชื่อมต่อกับสมาชิกในกลุ่ม - ข้อความนี้มีอยู่ในการตั้งค่า รูปเยอะเกิน! วิดีโอเยอะเกิน! ที่อยู่ผู้รับจะถูกเปลี่ยนเป็นเซิร์ฟเวอร์อื่น การเปลี่ยนแปลงที่อยู่จะเสร็จสมบูรณ์หลังจากที่ผู้ส่งออนไลน์ เพื่อการเชื่อมต่อผ่านลิงค์ - ลิงค์นี้ไม่ใช่ลิงค์เชื่อมต่อที่ถูกต้อง! - รหัสคิวอาร์นี้ไม่ใช่ลิงก์! เพื่อการเชื่อมต่อ ผู้ติดต่อของคุณสามารถสแกนคิวอาร์โค้ดหรือใช้ลิงก์ในแอป คุณจะไม่สูญเสียรายชื่อผู้ติดต่อของคุณหากคุณลบที่อยู่ในภายหลัง สตริงนี้ไม่ใช่ลิงค์เชื่อมต่อ! 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 bacc84b0f7..88148f022f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml @@ -1,7 +1,6 @@ Bildirimler - Bağlantı ya da karekod ile bağlan Gizli grup oluştur SimpleX adresin cevapsız çağrı @@ -77,7 +76,6 @@ Ara Göster İptal - (kodu okut ya da panodan yapıştır) Karekodu okut SimpleX adresi Sunucuları kaydet @@ -93,7 +91,6 @@ Tercihleri kaydet\? Profil parolasını kaydet Profil sadece konuştuğun kişilerle paylaşılır. - Mesajlaşmanın geleceği Ses kapalı Doğrulama iptal edildi Yeniden başlat @@ -141,7 +138,6 @@ Kimlik doğrulama başarısız SimpleX Adresi Mesaj, tüm üyeler için silinecek. - Gizliliğinizi ve güvenliğinizi koruyan mesajlaşma ve uygulama platformu. Gizlilik kipi Yetki, "%s" olarak değiştirilecek. Üye, yeni bir davet alacak. Yetki @@ -179,7 +175,6 @@ Profiliniz, bu bağlantıyı aldığınız kişiye gönderilecek. Adres bağlantısı üzerinden bağlan? hata - Bağlan bağlanıldı silindi dosya alma henüz desteklenmiyor @@ -225,7 +220,6 @@ renklendirilmiş Parolayı onayla geri çevrilmiş çağrı - Oluştur arama hatası bağlanıldı bağlanılıyor… @@ -313,10 +307,6 @@ kişi adres bağlantısı ile SimpleX bağlantı adresi Bağlantıyı tarayıcıda açmak, bağlantı gizliliğini ve güvenliğini azaltabilir. Güvenilmeyen SimpleX bağlantıları kırmızı olacaktır. - SMP sunucuları kaydedilirken hata oluştu - XFTP sunucuları kaydedilirken hata oluştu - SMP sunucuları yüklenirken hata oluştu - XFTP sunucuları yüklenirken hata oluştu Bağlantı yapılandırması güncellenirken hata Konuşma yüklenemedi Konuşmalar yüklenemedi @@ -381,7 +371,6 @@ Hata Kişiselleştirilmiş süre Panoya kopyalandı - Tek seferlik davet bağlantısı oluştur Karekodu okut ile karekodu okut]]> Bekleyen bağlantıları sil\? Sil @@ -394,14 +383,11 @@ Renk temalarını kişiselleştir Çekirdek sürümü: v%s Adresi sil\? - Kişilerin sana bağlanması için bir adres oluştur. SimpleX adresi oluştur Adresi sil Devam et Görseli sil - Konuşman, senin elinde! Profil oluştur - Merkezi olmayan Erişim kodunu onayla Yanlış erişim kodu Yeni erişim kodu @@ -507,7 +493,6 @@ Profil adı: %day %dsn - Adres oluşturma Dosyayı indir Düzelt TCP keep-alive özelliğini etkinleştir @@ -587,7 +572,6 @@ Gizli profil parolası Markdown nasıl kullanılır Nasıl çalışıyor - Spamdan etkilenmez Çağırıyı bitir. Kameranın karşı yüzüne geç Konuşma veri tabanı içe aktarılsın mı? @@ -611,8 +595,6 @@ Gizle Konuşulan kişileri ve mesajları gizle Uygulamayı, son kullanılanlar kısmından gizle. - bir görüntülü aramada karşıdakine karekodunu gösterebilir ya da konuştuğun kişiye bir katılım bağlantısı paylaşabilirsin.]]> - bir görüntülü aramada karşıdakinin karekodunu okutabilirsin ya da konuştuğun kişi seninle bir katılım bağlantısı paylaşabilir.]]> Eğer yüz yüze görüşemiyorsanız bir görüntülü aramada karşıdakine karekodunu gösterebilir ya da konuştuğun kişiye bir katılım bağlantısı paylaşabilirsin. Eğer geri çevirmeyi seçersen göndericiye bildirilmeyecek. Eğer SimplexX Chat katılım bağlantısı alırsan bu bağlantıyı tarayıcında açabilirsin: @@ -663,7 +645,6 @@ saat Sadece uygulama çalışırken bildirim alabileceksiniz, hiçbir arka plan hizmeti başlatılmayacaktır yeni mesaj - Hoşgeldin! Hoşgeldin %1$s! Görsel bekleniyor Görsel bekleniyor @@ -672,10 +653,8 @@ Dosya bekleniyor Sesli mesajlar yasaktır! sana bağlanmak istiyor! - İnsanlar bağlantı talebinde bulunduğunda, kabul edebilir veya reddedebilirsiniz. Mevcut olduğunda Otomatik kabul etme - Kişilerinizin veya mesajlarınızın hiçbirini (teslim edildikten sonra) sunucularda saklamıyoruz. yanıt bekleniyor… onay bekleniyor… Uygulama çalışıyorken @@ -710,7 +689,6 @@ Şuna cevap olarak %s olarak katılın Geçersiz link! - Geçersiz QR kodu Geçersiz sunucu adresi! Terminal için SimpleX Chat\'i yükleyin Aramaya bağlanılıyor @@ -780,10 +758,7 @@ Kişinizin cihazı çevrimiçi olduğunda bağlanacaksınız, lütfen bekleyin veya daha sonra kontrol edin! Daha fazla bilgi edinin Eğer sonradan bağlantınızı silseniz bile kişilerinizi kaybetmeyeceksiniz. - Sunucunuz Sunucu adresiniz - SMP sunucularınız - XFTP sunucularınız ICE sunucularını yapılandır Hadi SimpleX Chat\'te konuşalım Profiliniz cihazınızda saklanır ve sadece kişilerinizle paylaşılır. SimpleX sunucuları profilinizi göremez. @@ -793,8 +768,6 @@ Bu gruptan artık mesaj almayacaksınız. Sohbet geçmişi korunacaktır. Gruptan ayrıl\? ayrıldı - " -\nv5.1\'de mevcut" Sohbet profili ile (varsayılan) veya bağlantı ile (BETA). bağlantı önizleme resmi Profiliniz, kişileriniz ve gönderilmiş mesajlar cihazınızda saklanır. @@ -852,12 +825,10 @@ sen gözlemcisin Güvenlik kodunu görüntüle Sesli mesaj gönderebilmeniz için kişinizin de sesli mesaj göndermesine izin vermeniz gerekir. - XFTP sunucuları ICE sunucularınız Nasıl Mevcut profiliniz video arama (uçtan uca şifreli değil) - ICE sunucularınız Video kapalı Video açık Uygulama her başladığında parola girmeniz gerekir - parola cihazınızda saklanmaz. @@ -866,7 +837,6 @@ Biriyle gizli bir profil paylaştığınızda, bu profil sizi davet ettikleri gruplar için kullanılacaktır. İsteğe bağlı karşılama mesajı ile. Kişileriniz bağlı kalacaktır. - Daha sonra oluşturabilirsiniz Mesajları biçimlendirmek için markdown kullanabilirsiniz: Sohbet veritabanınız Mevcut sohbet veritabanınız SİLİNECEK ve içe aktarılan veritabanıyla DEĞİŞTİRİLECEKTİR. @@ -884,7 +854,6 @@ Sesli mesaj Sesli mesaj (%1$s) Sesli mesaj… - Adresinizi bir bağlantı veya QR kodu olarak paylaşabilirsiniz - herkes size bağlanabilir. bağlantı değiştirdiniz Daha sonra Ayarlardan etkinleştirebilirsin Bunları daha sonra uygulamanın Gizlilik ayarlarından etkinleştirebilirsiniz. @@ -892,7 +861,6 @@ \nBu bağlantıyı iptal edebilir ve kişiyi kaldırabilirsiniz (ve daha sonra yeni bir bağlantıyla deneyebilirsiniz). Kişiniz desteklenen maksimum boyuttan (%1$s) daha büyük bir dosya gönderdi. Kişiniz yüklemeyi tamamladığında video alınacaktır. - mobil uygulamada aç seçeneğine tıklayın.]]> SimpleX Chat geliştiricilerine bağlanabilirsiniz.]]> Bir kullanıcının profilini gizleyebilir veya sessize alabilirsiniz - menü için basılı tutun. Sohbet veritabanınızın en son sürümünü SADECE bir cihazda kullanmalısınız, aksi takdirde bazı kişilerden daha fazla mesaj alamayabilirsiniz. @@ -937,7 +905,6 @@ - daha stabil mesaj iletimi. \n- biraz daha iyi gruplar. \n- ve daha fazlası! - Sadece istemci cihazlar kullanıcı profillerini, kişileri, grupları ve gönderilen mesajları depolar. Grup tercihlerini sadece grup sahipleri değiştirebilir. metin yok Ağ durumu @@ -956,17 +923,13 @@ Hatırlayın veya güvenli bir şekilde saklayın - kaybolan bir parolayı kurtarmanın bir yolu yoktur! Sohbet konsolunu aç Sohbet profillerini değiştir. - Bu metin ayarlarda mevcut Filtrelenmiş sohbet yok Çok fazla görsel! Çok fazla video! Lütfen grup yöneticisiyle iletişime geçin - Bekleniyor Lütfen irtibat kişinizden sesli mesaj göndermeyi etkinleştirmesini isteyin. Canlı mesaj! Link ile bağlanmak için - Bu geçerli bir bağlantı linki değil - Bu QR kodu bir bağlantı değil! Kullanıcı Kılavuzu.]]> Yapıştır Bu dize bir bağlantı linki değil! @@ -987,8 +950,6 @@ Zaman dilimi, görsel/ses korumak için UTC kullan. Özel dosya adları Yeni bir sohbet başlatmak için - Kimin bağlanabileceğine siz karar verirsiniz. - Gizlilik yeniden tanımlanıyor Periyodik Gizli bildirimler Aldığın bağlantıyı yapıştır @@ -1010,14 +971,12 @@ Favorilerden çıkar Sohbeti gizli yap! Profil güncellemesi SimpleX kişilerinize gönderilecektir. - GitHub repomuzda daha fazlasını okuyun.]]> Lütfen geliştiricilere bildirin. Profil ve sunucu bağlantıları gizlemeyi kaldır Maksimum 40 saniye, anında alınır. Sesli mesaj kaydet İzin Reddedildi! - (irtibat kişinizle paylaşmak için) profil resmi profil fotoğrafı yer tutucusu Kişinizle uçtan uca şifrelemeyi doğrulamak için cihazlarınızdaki kodu karşılaştırın (veya tarayın). @@ -1028,7 +987,6 @@ Mesaj taslağı 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). Alıcılar güncellemeleri siz yazdıkça görürler. Bilgilerinizi kullanarak giriş yapın @@ -1062,7 +1020,6 @@ Bağlantı isteğini tekrarla? detay yok Veri tabanı düzgün çalışmıyor. Daha fazla bilgi için dokunun - Açık kaynaklı protokol ve kod - sunucuları herkes çalıştırabilir. Aç Önizlemeyi göster Depolanan dosyaları ve medyayı şifrele @@ -1076,7 +1033,6 @@ Seç Sadece sen arama yapabilirsin. Yeni mobil cihaz - Sohbeti kullan Otomatik olarak bağlan Paylaş %s, %s ve %d diğer üye bağlandı @@ -1141,9 +1097,7 @@ Bağlantı paylaş SimpleX Ekibi %s, %s ve %s bağlandı - SOCKS vekili Masaüstür cihazlar - SMP sunucuları Uyumlu değil! Bağlantı güvenliğini onayla %1$s ile bağlan? @@ -1171,7 +1125,6 @@ Kodu mobilde onayla Medya paylaş… Parola, siz onu değiştirdikten veya uygulamayı yeniden başlattıktan sonra ayarlarda düz metin olarak depolanacak. - Kişinizle bağlantı kurmak için aldığınız bağlantıyı yapıştırın… Kapat gönderildi Canlı mesaj gönder @@ -1184,7 +1137,6 @@ SimpleX Kilit modu Dosya paylaş… Uygulama yeni mesajları periyodik olarak alır - günde pilin yüzde birkaçını kullanır. Uygulama anlık bildirimleri kullanmaz - cihazınızdan gelen veriler sunuculara gönderilmez. - Herhangi bir kullanıcı tanımlayıcısı yok. Hoparlör kapalı Şifreleme çalışıyor ve yeni bir şifreleme anlaşması gerekli değil. Yoksa bağlantı hataları ortaya çıkabilir! Göster @@ -1218,8 +1170,6 @@ Profiliniz %1$s paylaşılacaktır. Rastgele bir profil kullanarak grup oluştur. Gruba zaten bu bağlantı üzerinden katılıyorsunuz. - Sohbet profiliniz kişinize -\ngönderilecek Gizli bir profil paylaştığınız kişiyi ana profilinizi kullandığınız gruba davet etmeye çalışıyorsunuz güvenlik kodu değiştirildi Bluetooth desteği ve diğer iyileştirmeler. @@ -1270,13 +1220,11 @@ alınmış, yasaklanmış Sunucu yükleme için yetki gerektiriyor, şifreyi kontrol edin. Şifreleme yeniden aşma hatası - Ön ayarlı sunucu %s iptal edildi silinmiş kişi TCP bağlantısı zaman aşımına uğradı uygulama/veritabanı içinde farklı değişim: %s / %s SimpleX Chat serverları kullanılıyor. - Ön ayarlı sunucular ekle gizli Bütün gruplar için devre dışı bırak Yeni üyeler için gösterilecek mesajı seç! @@ -1284,7 +1232,6 @@ yönetildi Üye bağlantısı oluşturulurken hata Tüm kişiler için iletim bilgisi gönderme özelliği etkinleştirilecek - XFTP sunucu adreslerinin doğru formatta olduğundan, satırın ayrılmış ve kopyalanmamış olduğundan emin olun. Yenile Lütfen dikkat: Mesaj ve dosya aktarımları SOCKS proxy üzerinden bağlanır. Aramalar doğrudan bağlantı kullanır.]]> Telefon uygulamasında aç seçeneğine tıkla, sonra uygulama içinden Bağlan seçeneğine tıkla.]]> @@ -1293,7 +1240,6 @@ %s ve %s Bağlantılarını koru Alınmış mesaj - Ayarlardan SimpleX kişilerinize görünür yapabilirsiniz. Grubuna bağlanılsın mı? Telefon veya bilgisayar uygulamalarını bağla! 🔗 Yeni bir rasgele profil paylaşılacaktır. @@ -1325,7 +1271,6 @@ .onion ana bilgisayarlarını kullan seçeneğini eğer SOCKS vekili destek vermiyorsa devre dışı bırak]]> Rasgele şifreler düz metin olarak ayarlarda saklanacaktır. \nBunu sonra değiştirebilirsin. - SMP sunucu adreslerinin doğru formatta olduğundan, satırın ayrılmış ve kopyalanmamış olduğundan emin olun. Sahte İsimle katılmak için tıkla Onion ana bilgisayarları kullanılmayacaktır. Dışarıya çıkarmak için parola belirle @@ -1338,7 +1283,6 @@ Bağlanmış bilgisayar ayarları Veritabanı kimlikleri ve Taşıma izolasyonu seçeneği. PING sayısı - Ulaşım izolasyonu modu güncellensin mi? Rasgele Bağlanmış bilgisayarlar Yerel ağ aracılığıyla keşfet @@ -1354,7 +1298,6 @@ %1$d mesaj %2$s tarafından yönetildi Ayarlardaki parola silinsin mi? Alıcılar etkinleştirilsin mi? - Yeni bir sohbet başlatmak için tıkla Kişinin engelini kaldır Yönlendirici sunucusu sadece lazım ise kullanılacak. Diğer taraf IP adresini görebilir. %s ın bağlantısı kesildi]]> @@ -1454,7 +1397,6 @@ Bir canlı mesaj gönder - bu yazdıklarını anlık olarak alıcıya(lara) güncelleyen bir mesajdır Şifre Yöneticisindeki parola silinsin mi? Lere gönder - Takma adla bağlan Her zaman yönlendirici kullan. Kilidini aç %s ten gelen mesajlar gösterilecek! @@ -1774,12 +1716,10 @@ Bilinmeyen sunucular Her zaman gizli yönlendirmeyi kullan. Gizli yönlendirmeyi KULLANMA. - Mesaj yönlendirme modu Hiçbir zaman Bilinmeyen sunucularla gizli yönlendirme kullan. Sürüm düşürmeye izin ver Hayır - Mesaj yönlendirme yedeklemesi Her zaman Sizin veya hedef sunucunun özel yönlendirmeyi desteklememesi durumunda bile mesajları doğrudan GÖNDERMEYİN. IP adresi korumalı olduğunda ve sizin veya hedef sunucunun özel yönlendirmeyi desteklemediği durumlarda mesajları doğrudan gönderin. @@ -1819,7 +1759,6 @@ Resmi kaldır Tekrarla Rengi sıfırla - Sohbet listesini yeni pencerede göster Bilinmeyen sunucular! Tor veya VPN olmadan, IP adresiniz bu XFTP yönlendiricileri tarafından görülebilir: \n%1$s. @@ -1871,15 +1810,12 @@ Mesajları ilet.. %1$s mesaj iletiliyor %1$s Mesaj kayıt ediliyor. - bağlan Ara mesaj Sadece sohbeti sil - açık ara Profil değiştirme sırasında hata oluştu. Sohbet profili seç - XFTP sunucuları yapılandırıldı Medya ve dosya sunucuları Kimlik bilgilerini proxy ile kullanmayın. Proxy kayıt edilirken hata oluştu. @@ -1958,10 +1894,8 @@ Tamamlandı Silindi Silme hatası - Sunucu ayarlarını aç Dosya durumu: %s Yeni mesaj - Diğer SMP sunucuları Yeniden bağlan Direkt gönderildi. Tüm Profiller @@ -1990,8 +1924,6 @@ Üye inaktif Henüz direkt bağlantı yok mesaj admin tarafından yönlendirildi. Mesaj sunucuları - SMP sunucları yapılandırıldı - Diğer XFTP sunucuları Medyayı bulanıklaştır. Kişiler silinecek - bu geri alınamaz ! Sohbeti sakla. @@ -2161,15 +2093,12 @@ %d raporu arşivleyelim mi? Arşiv Raporu arşivleyelim mi? - uçtan uca şifreli olarak gönderilir ve doğrudan mesajlarda kuantum sonrası güvenlik sağlanır.]]> Veritabanı şifresini okurken hata oluştu Sohbeti sil Sohbet silinsin mi? Bağlantı hazır değil. Kullanım şartları - daha iyi üstveri gizliliği için. Sunucuları kaydederken hata oluştu - Şartlar 30 gün sonra etkin operatörler için kabul edilecektir. Raporu sil Listeyi değiştir %s için de geçerli olacaktır.]]> @@ -2315,7 +2244,6 @@ Güncellenen koşullar Sadece siz ve moderatörler görebilir. Sadece gönderen ve moderatörler görebilir. - Daha sonra oluşturmak için menüden SimpleX adresi oluştur seçeneğine dokunun. Operatör Dosyalar için kullanın Sohbetlerde mesajların geçerlilik süresini ayarlayın. @@ -2339,7 +2267,6 @@ İstenmeyen Uygulama, her konuşmada farklı operatörler kullanarak gizliliğinizi korur. Rol %s olarak değiştirilecek. Sohbetteki herkes bilgilendirilecek. - Okunmamış bahsetmeler Koşulları gözden geçirin Sohbet profiliniz sohbet üyelerine gönderilecektir. Sunucularınız @@ -2347,7 +2274,6 @@ Bu işlem geri alınamaz - seçilen tarihten önce bu sohbette gönderilen ve alınan mesajlar silinecektir. Bağlantının kimlerle paylaşıldığını hatırlamak için bağlantı adını ayarlayabilirsiniz. SimpleX adresini sosyal medyada paylaşın. - Web sitesi Desteklenmeyen bağlantı bağlantısı Rapor SimpleX adresi ve tek kullanımlık bağlantılar herhangi bir mesajlaşma uygulaması üzerinden güvenle paylaşılabilir. @@ -2355,9 +2281,6 @@ Giriş ayarlarını kaydetmek ister misiniz? Operatörler şunları taahhüt eder:\n- Bağımsız olun\n- Meta veri kullanımını en aza indirin\n- Doğrulanmış açık kaynak kodunu çalıştırın Birden fazla operatör etkinleştirildiğinde, hiçbirinin kimin kiminle iletişim kurduğunu öğrenmek için meta verisi yoktur. - Kullanılacak ağ operatörlerini seçin. - Güncelleme - Ağ ve sunucular ayarlarında operatörleri yapılandırabilirsiniz. Bağlantıyı aç Anahtar deposundaki parola okunamıyor, lütfen elle girin. Bu, uygulamayla uyumsuz bir sistem güncellemesinden sonra meydana gelmiş olabilir. Eğer durum bu değilse, lütfen geliştiricilerle iletişime geçin. Bu üyeyi kabul ettiniz. @@ -2368,7 +2291,6 @@ Uygulamadaki ikinci önceden ayarlanmış operatör! Adresi güncelle Gizlilik politikası ve kullanım koşulları. - Daha sonra incele Sunucu operatörleri SimpleX Chat ve Flux, Flux tarafından işletilen sunucuların uygulamaya dahil edilmesi konusunda bir anlaşma yaptı. Sohbet listesinden bağlantıları aç @@ -2409,7 +2331,6 @@ Moderatörlere gönderilen rapor Raporlarınızı yöneticilerle sohbet bölümünde görüntüleyebilirsiniz. Ayarlar aracılığıyla sunucuları yapılandırabilirsiniz. - Uzaktan kumandalı cep telefonları kapalı İnceleme üyeleri Kabul etmeden önce üyeleri inceleyin (kapıyı çalın). @@ -2428,7 +2349,6 @@ Bağlantı, teslim edilemeyen mesajların sınırına ulaştı, bağlantınız çevrimdışı olabilir. Bu bağlantı için daha yeni bir uygulama sürümü gereklidir. Lütfen uygulamayı güncelleyin veya irtibat kişinizden uyumlu bir bağlantı göndermesini isteyin. Rapor nedeni? - Rapor sizin için arşivlenecektir. Okunmamış sohbet yok Rapor: %s Raporlar @@ -2480,7 +2400,6 @@ Talep gönder Mesaj olmadan istek gönder Özel geri bildirimlerinizi gruplara gönderin. - Bağlantı kurulduktan sonra kişinize gönderilir. Grup linki güncellensin mi? Güncelle Adres güncellensin mi? @@ -2533,11 +2452,9 @@ %1$d aboneler %1$s SimpleX Chat’i destekledi. Rozet %2$s tarihinde geçerliliğini yitirdi. 4 yeni arayüz dili - Hakkında kabul edildi onaylanan kadro aktif - Ekle Röle ekle Röleler ekle Mesaj iletimini geri yüklemek için röle ekleyin. @@ -2579,7 +2496,6 @@ Kanalda aktif röle bulunmuyor. Lütfen daha sonra katılmayı deneyin. Kanal bağlantısı Kanal bağlantısı - Kanal üyeleri Kanal adı Kanal tercihleri Kanal profili abonelerin cihazlarında ve sohbet rölelerinde saklanır. @@ -2609,8 +2525,6 @@ Bağlan bağlı bağlanılıyor - Kanal adı üzerinden bağlanmak için daha yeni bir uygulama sürümü gerekir. - Kişi adı ile bağlanmak için daha yeni bir uygulama sürümü gerekir. Bağlantı başarısız Bağlantı veya kare kod ile bağlanın İletişim @@ -2658,7 +2572,6 @@ Kanal oluşturulurken hata oluştu Mesaj silinirken hata oluştu Okundu olarak işaretlenirken hata - Kanal açılırken hata oluştu hata: %s Kanal profili kaydedilirken hata oluştu İsim kaydedilirken hata oluştu @@ -2668,7 +2581,6 @@ başarısız oldu Dosyalar Bu sohbette dosya ve medya yasaktır. - Filtre Hedef sunucu adresindeki parmak izi sertifika ile eşleşmiyor: %1$s. Yönlendirme sunucusu adresindeki parmak izi sertifika ile eşleşmiyor: %1$s. Sunucu adresindeki parmak izi sertifika ile eşleşmiyor: %1$s. @@ -2741,14 +2653,12 @@ Temiz bağlantıyı aç Harici bağlantı açılsın mı? Tam bağlantıyı aç - Yeni kanal açın - bağlantı önizlemelerini göndermeyi tercih edin.\n- etkinleştirilmişse SOCKS proxy kullanın.\n- köprü kimlik avını önleyin.\n- bağlantı izlemeyi kaldırın. Ya da kare kodu şahsen veya görüntülü arama yoluyla gösterin. Ya da bu kare kodu kullanın - yazdırın veya çevrimiçi gösterin. Sahip Sahipler ve katkıda bulunanlar Sahiplik: kendi aktarıcılarınızı çalıştırabilirsiniz. - Lütfen uygulamayı güncelleyin. Önceden ayarlanmış aktarıcı adresi Önceden ayarlanmış aktarıcı adı Gizlilik: sahipler ve aboneler için. @@ -2813,7 +2723,6 @@ ⚠️ İmza doğrulama başarısız oldu: %s. SimpleX SimpleX - %d okunmamış - SimpleX adı SimpleX ad hatası SimpleX adı doğrulanmadı SimpleX Chat kitlesel fonlamasına yatırım yapan %s\'ler. @@ -2858,15 +2767,12 @@ Bu bir sohbet aktarım adresidir, bağlanmak için kullanılamaz. Bu son aktif aktarıcıdır. Kaldırılması abonelere mesaj iletimini engelleyecektir. %1$s kanalı için bağlantınız!]]> - Bu ayar mevcut profiliniz içindir Bu SimpleX adı kayıtlı değil. Lütfen ismi kontrol edin. SimpleX Ağını kalıcı kılmak için. Üst çubuk İsimleri çözümlemek için Herkes için abone engeli kaldırılsın mı? Onaylanmamış isim - Desteklenmeyen kanal adı - Desteklenmeyen kişi adı Doğrulanmamış rozet güncellenen kanal profili Adresinizi güncelleyin 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 457fd4abe1..4f49834c7f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml @@ -23,7 +23,6 @@ Дозволити безповоротне видалення повідомлень, тільки якщо ваш контакт дозволяє вам. (24 години) Дозволити голосові повідомлення\? Пароль застосунку замінено паролем самознищення. - Додати попередньо встановлені сервери Резервне копіювання даних застосунку Додати на інший пристрій Сховище ключів Android використовується для безпечного збереження ключової фрази - це дозволяє службі сповіщень працювати. @@ -108,8 +107,6 @@ Запит на отримання зображення Прикріпити Аудіо/відео дзвінки - " -\nДоступно в версії 5.1" Аудіо/відео дзвінки заборонені. SimpleX Як ви, так і ваш контакт можуть здійснювати дзвінки. @@ -119,7 +116,6 @@ Приєднатися до групи? Ваш профіль буде відправлено контакту, з якого ви отримали це посилання. Ви приєднаєтеся до всіх учасників групи. - Підключити підключено помилка підключення @@ -159,8 +155,6 @@ Через браузер Відкриття посилання в браузері може зменшити конфіденційність та безпеку з\'єднання. Ненадійні посилання SimpleX будуть виділені червоним кольором. Не вдалося завантажити чати - Помилка збереження серверів SMP - Переконайтеся, що адреси серверів SMP вірного формату, розділені переносами рядків і не дублюються. Помилка оновлення конфігурації мережі Не вдалося завантажити чат Будь ласка, оновіть додаток і зверніться до розробників. @@ -211,7 +205,6 @@ Голосове повідомлення Голосове повідомлення… Контакт і всі повідомлення будуть видалені - цього не можна скасувати! - Очікує Змінити адресу для отримання? Переглянути код безпеки Ви повинні дозволити вашому контакту надсилати голосові повідомлення, щоб мати змогу надсилати їх. @@ -227,8 +220,6 @@ аватар не встановлено QR-код довідка - покажіть QR-код у відеовиклику, або поділіться посиланням.]]> - Ваш профіль буде відправлено \nвашому контакту Одноразове запрошення Невірний код безпеки! Для перевірки end-to-end шифрування порівняйте (або скануйте) код на своїх пристроях. @@ -237,7 +228,6 @@ Допомога з Markdown Блокування SimpleX Консоль чату - Сервери SMP Сканувати QR-код сервера Використовувати для нових підключень Видалити сервер @@ -253,25 +243,18 @@ Якщо доступно Ні .Onion-хости будуть використовуватися, якщо доступні. - Оновити режим ізоляції транспорту? simplexmq: v%s (%2s) Створити адресу Поділитися посиланням Видалити адресу Повне ім\'я: - Ви керуєте своїм чатом! - Платформа обміну повідомленнями і застосунок, які захищають вашу конфіденційність та безпеку. Введіть своє ім\'я: дзвінок в процесі запуск… - Ніяких ідентифікаторів користувачів. - Децентралізована - Використовувати чат Як це впливає на батарею Миттєво Виклик вже завершено! Виклики - Ваші сервери ICE Відкрити через реле Динамік увімкнено @@ -279,7 +262,6 @@ Відхилений виклик %1$d пропущено повідомлень Чати - SOCKS-проксі Помилка при запуску чату Зупинити Імпортувати @@ -307,9 +289,6 @@ Заборонити надсилання повідомлень, які зникають. Забороняйте надсилання голосових повідомлень. Французький інтерфейс - Помилка збереження серверів XFTP - Переконайтеся, що адреси серверів XFTP вірного формату, розділені переносами рядків і не дублюються. - Помилка завантаження серверів XFTP Помилка додавання учасників Помилка приєднання до групи Неможливо отримати файл @@ -326,7 +305,6 @@ Чати підключення… підключення… - Торкніться, щоб розпочати новий чат Чат із розробниками У вас немає чатів ви спостерігач @@ -347,19 +325,14 @@ Якщо ви виберете відхилити, відправник НЕ буде повідомлений. Очистити чат Неправильне посилання! - Це посилання не є дійсним з\'єднувальним посиланням! Запит на з\'єднання відправлено! - Відкрити у мобільному додатку.]]> Сканувати код Скануйте код безпеки з додатка вашого контакту. Невірна адреса сервера! Перевірте адресу сервера і спробуйте ще раз. - Сервери XFTP Встановити SimpleX Chat для терміналу Внести вклад Оцініть додаток - Ваші сервери SMP - Ваші сервери XFTP Використання серверів SimpleX Chat. Як користуватися дзвінок… @@ -405,8 +378,6 @@ Видалити адресу\? Ім\'я профілю: очікування підтвердження… - Приватність перевизначена - Ви вирішуєте, хто може під\'єднатися. зашифрований e2e аудіовиклик Відкрийте SimpleX Chat для прийняття виклику e2e зашифровано @@ -455,7 +426,6 @@ Вийти без збереження Сховати профіль Пароль для відображення - Створити без зашифрування e2e контакт має зашифрування e2e Хеш попереднього повідомлення інший. @@ -517,7 +487,6 @@ аватар Більше Створити профіль - GitHub.]]> Відео увімкнено Це може трапитися, якщо ви або ваше з\'єднання використовували застарілу резервну копію бази даних. Відновити резервну копію бази даних @@ -531,7 +500,6 @@ хвилини Китайський та іспанський інтерфейс підключення %1$d - Помилка завантаження серверів SMP Дубль імені відображення! Помилка відправлення повідомлення Відправник скасував передачу файлу. @@ -568,8 +536,6 @@ Відкликати несанкціонована відправка Вітаємо, %1$s! - Вітаємо! - Цей текст доступний у налаштуваннях Запрошуємо вас до групи Поділитися повідомленням… Поділитися медіа… @@ -606,14 +572,12 @@ \nВи можете скасувати це з\'єднання і видалити контакт (і спробувати пізніше за допомогою нового посилання). Логотип SimpleX Електронна пошта - Цей QR-код не є посиланням! Ви будете підключені до групи, коли пристрій власник групи буде в мережі, зачекайте або перевірте пізніше! Підключення відбудеться, коли ваш запит на підключення буде прийнято. Будь ласка, зачекайте або спробуйте пізніше! Поділитися 1-разовим посиланням Дізнатися більше Щоб підключитися, ваш контакт може сканувати QR-код або використовувати посилання у додатку. Якщо ви не можете зустрітися особисто, покажіть QR-код у відеовиклику або поділіться посиланням. - Ви можете поділитися своєю адресою в якості посилання або QR-коду - кожен може підключитися до вас. Вставити Цей рядок не є з\'єднувальним посиланням! Код безпеки @@ -623,7 +587,6 @@ Markdown у повідомленнях Надсилайте питання та ідеї Ввести сервер вручну - Попередньо встановлений сервер Адреса вашого сервера Сервери для нових підключень до вашого поточного профілю Використовувати сервери SimpleX Chat? @@ -634,7 +597,6 @@ Показати параметри розробника Ідентифікатори бази даних та опція ізоляції транспорту. Сповіщення перестануть працювати, поки ви не перезапустите додаток - Ви можете створити його пізніше Ваш поточний профіль Видалити зображення Зберегти налаштування? @@ -647,7 +609,6 @@ Ви можете використовувати markdown для форматування повідомлень: Створіть свій профіль Створіть приватне підключення - Тільки клієнтські пристрої зберігають профілі, контакти, групи та повідомлення. Приватні сповіщення Споживає більше акумулятора! Додаток завжди працює у фоновому режимі – сповіщення відображаються миттєво.]]> Вставте отримане посилання @@ -822,7 +783,6 @@ Надіслати Підтвердити Інший час - Створити одноразове запрошення Сканувати QR-код Фото Відео @@ -830,7 +790,6 @@ Контакт ще не підключений! Тестувати сервери Зберегти сервери - Ваш сервер Тест сервера не вдався! Деякі сервери не пройшли тест: Використовувати сервер @@ -841,7 +800,6 @@ Налаштування теми Поділитися адресою з контактами SimpleX? підключення дзвінка… - Ми не зберігаємо жодні з ваших контактів чи повідомлень (після доставки) на серверах. очікування відповіді… Як це працює відеовиклик @@ -889,7 +847,6 @@ Дякуємо користувачам – приєднуйтеся через Weblate! Режим блокування SimpleX Системна аутентифікація - Для захисту вашої конфіденційності SimpleX використовує окремі ID для кожного вашого контакту. Коли додаток запущено Періодично контакт не має зашифрування e2e @@ -998,12 +955,9 @@ Скасувати живе повідомлення Скинути без деталей - Підключитися за посиланням / QR-кодом Очистити - Неправильний QR-код Підключення відбудеться, коли пристрій вашого контакту буде онлайн. Будь ласка, зачекайте або спробуйте пізніше! Ви не втратите свої контакти, якщо ви пізніше видалите свою адресу. - Коли люди просять про з\'єднання, ви можете його прийняти чи відхилити. Посібнику користувача.]]> SimpleX-адреса Скинути підтвердження @@ -1021,7 +975,6 @@ Порт Обов\'язково Кольори інтерфейсу - Створіть адресу, щоб дозволити людям підключатися до вас. Контакти залишатимуться підключеними. Створити SimpleX-адресу Оновлення профілю буде відправлено вашим SimpleX контактам. @@ -1035,13 +988,11 @@ Запросити друзів Давайте говорити в SimpleX Chat Продовжити - Не створювати адресу отримано відповідь… отримано підтвердження… підключення… підключено завершено - Стійкий до спаму %1$s хоче підключитися до вас через зашифрований e2e відеовиклик Ігнорувати @@ -1164,8 +1115,6 @@ кольоровий дзвінок завершено %1$s помилка дзвінка - Майбутнє обміну повідомленнями - Кожен може хостити сервери. Інструменти розробника Експериментальні функції Дзвінки @@ -1178,8 +1127,6 @@ Зупинити відправлення файлу Зупинити відправлення файлу? Створити секретну групу - (щоб поділитися з вашим контактом) - (сканувати або вставити з буферу обміну) підключитися до розробників SimpleX Chat, щоб задати будь-які питання і отримувати оновлення.]]> Сканувати QR-код.]]> Адреса SimpleX @@ -1218,7 +1165,6 @@ вас видалили власник видалено - сканувати QR-код у відеовиклику, або ваш контакт може поділитися посиланням на запрошення.]]> Як користуватися Підключення Використовувати .onion-хости на Ні, якщо SOCKS-проксі їх не підтримує.]]> @@ -1251,7 +1197,6 @@ Перервати зміну адреси Дозволити надсилання файлів та медіафайлів. Файли та медіа заборонені. - Підключити інкогніто Використовувати поточний профіль Дозволити Вимкнути сповіщення @@ -1333,7 +1278,6 @@ Навіть коли вимкнено в розмові. Чорновик повідомлення Тільки власники групи можуть включити файли та медіа. - Вставте посилання, яке ви отримали, щоб підключитися до свого контакту… Надсилання повідомлень про доставку вимкнено для %d груп %s, %s і %s підключилися Їх можна замінити в налаштуваннях контактів і груп. @@ -1539,7 +1483,6 @@ Створення посилання… Налаштування для розробників Показувати повільні виклики API - Ви можете зробити це видимим для ваших контактів у SimpleX через Налаштування. %s заблокований %s розблокований ви заблокували %s @@ -1710,7 +1653,6 @@ Джерело повідомлення залишається приватним. Завжди Завжди використовуйте приватну маршрутизацію. - Режим маршрутизації повідомлень НЕ використовуйте приватну маршрутизацію. Ні НЕ надсилайте повідомлення напряму, навіть якщо ваш сервер або сервер призначення не підтримує приватну маршрутизацію. @@ -1738,7 +1680,6 @@ Учасники можуть надсилати посилання SimpleX. Звуки вхідного дзвінка Світлий режим - Запасний варіант маршрутизації повідомлень Маршрутизація приватних повідомлень переслано Інше @@ -1791,7 +1732,6 @@ Надсилайте повідомлення напряму, якщо ваш сервер або сервер призначення не підтримує приватну маршрутизацію. Встановлення теми за замовчуванням Надіслано відповідь - Показати список чату в новому вікні Використовуйте приватну маршрутизацію з невідомими серверами. Використовуйте приватну маршрутизацію з невідомими серверами, якщо IP-адреса не захищена. Фон шпалер @@ -1844,7 +1784,6 @@ Адреса сервера призначення %1$s несумісна з налаштуваннями сервера переадресації %2$s. Файл не знайдено — ймовірно, файл був видалений або скасований. Вставити / Сканувати посилання - Налаштовані XFTP сервери Бета Статус файлу Надіслано повідомлень @@ -1876,11 +1815,9 @@ Новий досвід чату 🎉 Адреса сервера Підтвердити видалення контакту? - підключитися Сервер переадресації %1$s не зміг з\'єднатися з цільовим сервером %2$s. Будь ласка, спробуйте пізніше. Повідомлення може бути доставлено пізніше, якщо учасник стане активним. Нічого не вибрано - Відкрити налаштування сервера Версія сервера несумісна з вашим додатком: %1$s. Повідомлення будуть позначені як модеровані для всіх учасників. Оновлювати додаток автоматично @@ -1991,18 +1928,14 @@ повідомлення дзвінок Зберегти розмову - відкрити пошук Ви все ще можете переглядати розмову з %1$s у списку чатів. Контакт видалено! Розмову видалено! Видалити без сповіщення Ви можете надсилати повідомлення %1$s з архівованих контактів. - Налаштовані SMP сервери Ніяких відфільтрованих контактів - Інші SMP сервери Ваші контакти - Інші XFTP сервери Показати відсоток Перевірити оновлення Вимкнено @@ -2130,17 +2063,12 @@ Або поділитися приватно Оператори серверів Мережеві оператори - Умови будуть прийняті для ввімкнених операторів через 30 днів. Наприклад, якщо ваш контакт отримує повідомлення через сервер SimpleX Chat, ваш додаток доставлятиме їх через сервер Flux. - Виберіть мережевих операторів для використання. Ви можете налаштувати сервери за допомогою налаштувань. - Перегляньте пізніше - Оновлення Прийняті умови Умови будуть автоматично прийняті для увімкнених операторів: %s. Оператор мережі %s сервери - Вебсайт Ваші сервери Використовувати %s Використовувати сервери @@ -2187,7 +2115,6 @@ Продовжити Створити одноразове посилання Помилка оновлення сервера - для кращої конфіденційності метаданих. Децентралізація мережі Оператор Немає серверів для отримання повідомлень. @@ -2201,12 +2128,9 @@ Поділіться адресою SimpleX у соціальних мережах. SimpleX-адреси та одноразові посилання можна безпечно ділитися через будь-який месенджер. З\'єднання досягло ліміту недоставлених повідомлень, ваш контакт може бути офлайн. - Натисніть Створити адресу SimpleX у меню, щоб створити її пізніше. Додаток захищає вашу конфіденційність, використовуючи різних операторів у кожній розмові. Використовувати для повідомлень - Ви можете налаштувати операторів у налаштуваннях Мережі та серверів. Або імпортуйте архівний файл - Віддалені мобільні Пристрої Xiaomi: будь ласка, увімкніть Автозапуск у налаштуваннях системи, щоб сповіщення працювали.]]> Повідомлення занадто велике! Будь ласка, зменшіть розмір повідомлення або видаліть медіа та надішліть знову. @@ -2216,7 +2140,6 @@ Без фонової служби Сповіщення та батарея Додаток завжди працює у фоні. - зашифрованими end-to-end, з пост-квантовою безпекою в особистих повідомленнях.]]> Покинути чат? Учасник буде видалений з чату — це неможливо скасувати! Бізнес чати @@ -2293,7 +2216,6 @@ Тільки відправник і модератори бачать це Повідомити Повідомити про порушення: тільки модератори групи побачать це. - Повідомлення буде архівоване для вас. Інша причина архівование повідомлення Архів @@ -2335,7 +2257,6 @@ Використовувати TCP-порт %1$s, якщо порт не вказано. Використовувати веб-порт Вимкнути звук - Непрочитані згадки Ви можете згадувати до %1$s учасників у кожному повідомленні! Усі звіти будуть архівовані для вас. Архівувати всі звіти? @@ -2485,7 +2406,6 @@ Надіслати запит Надіслати запит без повідомлення Надсилайте свої приватні відгуки до груп. - Відправлено вашому контакту після з\'єднання. Налаштуйте біографію профілю та вітальне повідомлення. Поділіться старою адресою Поділіться старим посиланням @@ -2550,11 +2470,9 @@ %1$d підписник %1$d підписники %1$s підтримував SimpleX Chat. Завершення дії значка %2$s. - Про підтверджено затверджений список активний - Додати Додати перемикач Додати перемикачі Додайте перемикачі для відновлення доставки повідомлень. @@ -2592,7 +2510,6 @@ Канал не має активних перемикачів. Будь ласка, спробуйте підключитися пізніше. Посилання на канал Посилання на канал - Учасники каналу Імʼя каналу Налаштування каналів Профіль каналу зберігається на пристроях підписників та на ретрансляторах чату. @@ -2622,8 +2539,6 @@ Підключитися підключено підключення - Для підключення за назвою каналу потрібна новіша версія додатка. - Для підключення за іменем контакту потрібна новіша версія додатка. Підключення не вдалося Підключитися через посилання або QR код Контакт @@ -2648,7 +2563,6 @@ Введіть URL веб-сторінки Помилка Помилка видалення повідомлення - Помилка відкриття каналу помилка: %s Помилка збереження профілю каналу Видалити реле @@ -2674,7 +2588,6 @@ помилка помилка Файли - Фільтр Відбиток в адресі цільового сервера не збігається із сертифікатом: %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 aac2109d11..9c63cff9ee 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml @@ -30,7 +30,6 @@ 30 giây Chấp nhận yêu cầu kết nối? Chấp nhận bằng hồ sơ ẩn danh - Thêm các máy chủ được cài sẵn Thêm vào một thiết bị khác cuộc gọi được chấp nhận đồng ý mã hóa… @@ -138,8 +137,6 @@ Địa chỉ máy tính xấu Thêm liên hệ: để tạo đường dẫn mời mới, hoặc kết nối qua đường dẫn bạn nhận được.]]> hàm băm tin nhắn xấu - " -\nCó sẵn ở v5.1" Tự động chấp nhận Quay về Tự động chấp nhận hình ảnh @@ -277,7 +274,6 @@ Đã kết nối đã kết nối Kết nối trực tiếp? - Kết nối đã kết nối Tự động kết nối đã kết nối @@ -293,7 +289,6 @@ đang kết nối đang kết nối… đang kết nối… - Kết nối ẩn danh đang kết nối Đã kết nối tới máy tính đang kết nối (đã được thông báo) @@ -302,7 +297,6 @@ đang kết nối (lời mời giới thiệu) Kết nối tới máy tính đang ở trong tình trạng không tốt Kết nối đã bị ngắt - Kết nối qua đường dẫn / mã QR Kết nối tới chính bạn? Yêu cầu kết nối đã được gửi! Kết nối qua đường dẫn @@ -340,7 +334,6 @@ liên hệ không có bảo mật đầu cuối Tùy chọn liên hệ Sao chép - Tạo Liên hệ có thể đánh dấu tin nhắn để xóa; bạn vẫn sẽ có thể xem được chúng. Biểu tượng ngữ cảnh Tiếp tục @@ -353,7 +346,6 @@ Tạo hồ sơ mới trong ứng dụng trên máy tính. 💻 Không thể gửi tin nhắn Tạo tệp - Tạo một địa chỉ để cho mọi người kết nối với bạn. Tạo đường dẫn nhóm Tạo đường dẫn Được tạo ra tại @@ -370,7 +362,6 @@ Các màu chế độ tối Tối Lỗi nghiêm trọng - Tạo đường dẫn lời mời dùng một lần Đang tạo đường dẫn… Tạo hồ sơ Tùy chỉnh và chia sẻ các chủ đề màu sắc. @@ -419,7 +410,6 @@ Xóa hồ sơ trò chuyện? Xóa %d tin nhắn? Xóa địa chỉ - Phi tập trung Xóa Xóa cơ sở dữ liệu khỏi thiết bị này Xóa sau @@ -482,8 +472,6 @@ Beta Kiểm tra cập nhật Đang kết nối - Các máy chủ SMP đã được cấu hình - Các máy chủ XFTP đã được cấu hình Bản cập nhật ứng dụng đã được tải xuống Kiểm tra cập nhật Đã kết nối @@ -537,7 +525,6 @@ đã bị tắt Tin nhắn tự xóa bị cấm. gọi - kết nối Liên hệ sẽ bị xóa - điều này không thể hoàn tác! Cuộc trò chuyện đã bị xóa! Làm mờ đa phương tiện @@ -556,7 +543,6 @@ Kiểm soát mạng của bạn Kết nối và trạng thái máy chủ. Cài đặt nâng cao - Bất kỳ ai cũng có thể tạo máy chủ. Tiếp tục Kết nối nhanh hơn với bạn bè. Cơ sở dữ liệu trò chuyện đã được xuất @@ -588,7 +574,6 @@ Tên hiển thị không thể chứa khoảng trắng. Khám phá qua mạng cục bộ Tải về không thành công - Không tạo địa chỉ Không hiển thị lại Tải về tệp tin Đang tải về kho lưu trữ @@ -712,8 +697,6 @@ Lỗi nhận tệp Lỗi lưu hồ sơ nhóm Lỗi lưu tệp - Lỗi tải máy chủ SMP - Lỗi tải máy chủ XFTP Lỗi kết nối lại máy chủ Lỗi kết nối lại máy chủ Lỗi @@ -722,8 +705,6 @@ lỗi hiển thị tin nhắn lỗi hiển thị nội dung Lỗi lưu máy chủ ICE - Lỗi lưu máy chủ XFTP - Lỗi lưu máy chủ SMP Lỗi gửi tin nhắn Lỗi khởi động kết nối trò chuyện Lỗi dừng kết nối trò chuyện @@ -896,7 +877,6 @@ Chuyển tiếp tối đa 20 tin nhắn cùng một lúc. Cách sử dụng máy chủ của bạn Giao diện Hungary và Thổ Nhĩ Kỳ - Miễn nhiễm với tin nhắn rác Nhập cơ sở dữ liệu trò chuyện? Nếu bạn nhập mã tự hủy của mình khi mở ứng dụng: Máy chủ ICE (một dòng mỗi máy) @@ -910,10 +890,8 @@ Hình ảnh Hình ảnh đã được lưu vào Thư viện Nếu bạn nhận được đường dẫn mời SimpleX Chat, bạn có thể mở nó trong trình duyệt của mình: - quét mã QR trong cuộc gọi video, hoặc liên hệ của bạn có thể chia sẻ một đường dẫn mời.]]> Nếu bạn xác nhận, các máy chủ truyền tin nhắn sẽ có thể biết địa chỉ IP, và nhà cung cấp của bạn - máy chủ nào mà bạn đang kết nối. Nếu bạn chọn từ chối người gửi sẽ KHÔNG được thông báo. - cho liên hệ của bạn xem mã QR trong cuộc gọi video, hoặc chia sẻ đường dẫn.]]> Nhập Bỏ qua không hoạt động @@ -964,7 +942,6 @@ Tên hiển thị không hợp lệ! Đường dẫn kết nối không hợp lệ Thông báo tức thời! - Mã QR không hợp lệ Thông báo tức thời Thông báo tức thời đã bị tắt! Đường dẫn tệp không hợp lệ @@ -978,8 +955,6 @@ Mời thành viên Tên cục bộ Cảnh báo chuyển gửi tin nhắn - Đảm bảo địa chỉ máy chủ SMP ở đúng định dạng, dòng được phân tách và không bị trùng lặp. - Đảm bảo địa chỉ máy chủ XFTP ở đúng định dạng, dòng được phân tách và không bị trùng lặp. Liên kết với điện thoại Thành viên không hoạt động Tin nhắn đã được chuyển tiếp @@ -1007,7 +982,6 @@ Làm cho một tin nhắn biến mất Tin nhắn động! Giữ lại lời mời chưa sử dụng? - Dự phòng định tuyến tin nhắn Mời bạn bè Đánh dấu đã xác thực Điều này có thể xảy ra khi:\n1. Tin nhắn hết hạn sau 2 ngày trên máy gửi hoặc sau 30 ngày trên máy chủ.\n2. Quá trình giải mã tin nhắn thất bại do bạn hoặc liên hệ của bạn sử dụng bản sao lưu cơ sở dữ liệu cũ.\n3. Kết nối bị xâm phạm. @@ -1052,7 +1026,6 @@ Tin nhắn và tệp Việc xóa tin nhắn mà không thể phục hồi là bị cấm. Tham gia vào các cuộc trò chuyện nhóm - Chế độ định tuyến tin nhắn Hãy trò chuyện trên SimpleX Chat in nghiêng Nó ảnh hưởng đến pin như thế nào @@ -1175,7 +1148,6 @@ bật Không có gì để chuyển tiếp! Thông báo sẽ dừng hoạt động cho đến khi bạn khởi động lại ứng dụng - Không có thông tin định danh người dùng. không có văn bản tắt tắt` @@ -1198,7 +1170,6 @@ OK Chỉ xóa cuộc trò chuyện Chỉ bạn mới có thể gửi tin nhắn thoại. - Chỉ thiết bị cuối mới lưu trữ các hồ sơ người dùng, liên hệ, nhóm, và tin nhắn Chỉ chủ nhóm mới có thể bật tính năng tin nhắn thoại. Chỉ bạn mới có thể gửi tin nhắn tự xóa. Đường dẫn lời mời dùng một lần @@ -1225,7 +1196,6 @@ Dịch vụ onion sẽ được sử dụng khi có sẵn. Chỉ liên hệ của bạn mới có thể gửi tin nhắn thoại. chủ sở hữu - Mở cài đặt máy chủ Mở đường dẫn trong trình duyệt có thể làm giảm sự riêng tư và bảo mật của kết nối. Đường dẫn SimpleX không đáng tin cậy sẽ được đánh dấu màu đỏ. Chỉ liên hệ của bạn mới có thể xóa tin nhắn mà không thể phục hồi (bạn có thể đánh dấu chúng để xóa). (24 giờ) Mở cài đặt ứng dụng @@ -1235,7 +1205,6 @@ Đang mở cơ sở dữ liệu… Hoặc hiển thị mã này Chỉ liên hệ của bạn mới có thể thực hiện cuộc gọi. - mở - tùy chọn thông báo khi xóa liên hệ.\n- tên hồ sơ với dấu cách.\n- và hơn thế nữa! Mở cài đặt Hoặc chia sẻ đường dẫn tệp này một cách an toàn. @@ -1252,14 +1221,10 @@ Mở nhóm khác các lỗi khác - Các máy chủ SMP khác - Các máy chủ XFTP khác - Dán đường dẫn mà bạn nhận được để kết nối với liên hệ của bạn… Dán đường dẫn Mật khẩu Định kỳ Đang chờ xử lý - Đang chờ xử lý Không tìm thấy mật khẩu trong Keystore, vui lòng nhập thủ công. Điều này có thể xảy ra nếu bạn khôi phục dữ liệu ứng dụng bằng một công cụ sao lưu. Nếu không phải như vậy, xin vui lòng liên hệ với nhà phát triển. Thành viên trước đây %1$s Dán đường dẫn để kết nối! @@ -1305,14 +1270,12 @@ Lưu lại bản nháp tin nhắn cuối cùng, với các tệp đính kèm. Đang chuẩn bị tải xuống Xin vui lòng cập nhật ứng dụng và liên lạc với các nhà phát triển. - Máy chủ cài sẵn Xin vui lòng thử lại sau. Xem trước Xin vui lòng lưu trữ mật khẩu một cách an toàn, bạn sẽ KHÔNG thể thay đổi nếu bạn làm mất nó. Giao diện tiếng Ba Lan Xin vui lòng khởi động lại ứng dụng. Các máy chủ đã kết nối trước đó - Định hình lại sự riêng tư Quyền riêng tư & bảo mật Bản cập nhật hồ sơ sẽ được gửi đến các liên hệ của bạn. Cấm thả cảm xúc tin nhắn. @@ -1363,7 +1326,6 @@ Cài đặt địa chỉ %s.]]> %s.]]> - Kho lưu trữ GitHub của chúng tôi.]]> %s.]]> %s.]]> %s.]]> @@ -1378,7 +1340,6 @@ %1$s rồi.]]> %1$s!]]> %1$s rồi.]]> - Mở trong ứng dụng di động.]]> Các bên vận hành máy chủ kết nối với các nhà phát triển SimpleX Chat để hỏi bất kỳ câu hỏi nào và nhận thông tin cập nhật.]]> không được sử dụng cùng một cơ sở dữ liệu trên hai thiết .]]> @@ -1387,7 +1348,6 @@ Lỗi trong cấu hình máy chủ. Bảo mật kết nối Tạo đường dẫn dùng một lần - Các điều kiện sẽ được chấp nhận với các nhà cung cấp được cho phép sau 30 ngày. Tiếp tục Không thể tải văn bản về các điều kiện hiện tại, bạn có thể xem xét các điều kiện thông qua đường dẫn này: Các điều kiện sử dụng @@ -1408,7 +1368,6 @@ Cuộc trò chuyện đã tồn tại! Thêm bạn bè đã chấp nhận lời mời - mã hóa đầu cuối, với bảo mật sau ượng tử trong các tin nhắn trực tiếp.]]> Giới thiệu về các nhà cung cấp Thêm các thành viên nhóm của bạn vào các cuộc trò chuyện. Địa chỉ doanh nghiệp @@ -1421,7 +1380,6 @@ Mời để trò chuyện Rời cuộc trò chuyện Cho định tuyến riêng tư - để bảo mật siêu dữ liệu tốt hơn. Đã cải thiện điều hướng trò chuyện Xóa cuộc trò chuyện Cho mạng xã hội @@ -1532,7 +1490,6 @@ Làm mới Đã giảm mức sử dụng pin Từ chối - Các thiết bị di động từ xa Xóa Xóa Xóa thành viên @@ -1616,8 +1573,6 @@ Đã chọn %d Gửi tin nhắn trực tiếp để kết nối Đang lưu %1$s tin nhắn - (quét hoặc dán từ bảng nháp) - Chọn các bên vận hành mạng lưới để sử dụng. Gửi tin nhắn trực tiếp Lưu lời chào? gửi thất bại @@ -1628,7 +1583,6 @@ Lưu danh sách Thử lại Lưu và thông báo tới liên hệ - Xem xét lại sau mã bảo mật đã thay đổi Lưu hồ sơ nhóm Lưu và kết nối lại @@ -1750,7 +1704,6 @@ Hiển thị bảng điều khiển trong cửa sổ mới Lỗi tạo báo cáo Chia sẻ tin nhắn… - Hiển thị danh sách trò chuyện trong cửa sổ mới Chỉ hiển thị liên hệ Chia sẻ đường dẫn dùng 1 lần này Chia sẻ với các liên hệ @@ -1827,7 +1780,6 @@ Loa ngoài bật Âm thanh đã bị tắt Ổn định - Proxy SOCKS Các nhóm nhỏ (tối đa 20 thành viên) Một vài lỗi không nghiêm trọng đã xảy ra trong lúc nhập: Loa ngoài tắt @@ -1853,7 +1805,6 @@ Một số tệp đã không được xuất Loa ngoài Bắt đầu kết nối trò chuyện? - Các máy chủ SMP Bắt đầu kết nối trò chuyện Tin nhắn rác Mạnh @@ -1902,7 +1853,6 @@ Hệ thống Xin gửi lời cảm ơn tới các người dùng đã góp công qua Weblate! Nhấn để Kết nối - Nhấn Tạo địa chỉ SimpleX ở menu để tạo sau. Nhấn để tham gia một cách ẩn danh Hệ thống Chuyển đổi âm thanh và video trong lúc gọi. @@ -1913,12 +1863,10 @@ Cảm ơn bạn đã cài đặt SimpleX Chat! Kiểm tra máy chủ Kiểm tra các máy chủ - Nhấn để bắt đầu một cuộc trò chuyện mới Nhấn để dán đường dẫn Đuôi Chuyển đổi Kết nối đã chạm giới hạn của các tin nhắn chưa được gửi đi, liên hệ của bạn có thể đang ngoại tuyến. - Tương lai của nhắn tin Hình ảnh không thể được giải mã. Xin vui lòng thử lại với một hình ảnh khác hoặc liên lạc với các nhà phát triển. Các tin nhắn sẽ bị xóa cho tất cả các thành viên. Mã hóa đang hoạt động và thỏa thuận mã hóa mới là không bắt buộc. Nó có thể dẫn đến các lỗi kết nối! @@ -1966,7 +1914,6 @@ Tính năng này chưa được hỗ trợ. Hãy thử bản phát hành tiếp theo. Dấu tick thứ hai mà chúng ta từng thiếu! ✅ Những máy chủ cho tệp mới của hồ sơ trò chuyện hiện tại của bạn - Nền tảng ứng dụng và nhắn tin bảo vệ sự riêng tư và bảo mật của bạn. Bên vận hành được cài sẵn thứ hai trong ứng dụng! Chức vụ sẽ được đổi thành %s. Tất cả mọi người trong cuộc trò chuyện sẽ được thông báo. Văn bản bạn vừa dán không phải là một đường dẫn SimpleX. @@ -1977,13 +1924,9 @@ Bản lưu trữ cơ sở dữ liệu đã được tải lên sẽ bị xóa vĩnh viễn khỏi các máy chủ. Việc này không thể được hoàn tác - hồ sơ, các liên hệ, tin nhắn và tệp của bạn sẽ biến mất mà không thể khôi phục. Những máy chủ cho các kết nối mới của hồ sơ trò chuyện hiện tại của bạn - Báo cáo sẽ được lưu trữ cho bạn. Tin nhắn này đã bị xóa hoặc vẫn chưa được nhận. - Mã QR này không phải là một đường dẫn! - Đường dẫn này không phải là một đường dẫn kết nối hợp lệ! Thời gian chờ đã hết trong khi kết nối tới máy tính Để cho phép một ứng dụng di động kết nối tới máy tính, mở cổng này trong tường lửa của bạn, nếu bạn có bật nó lên - Để bảo vệ sự riêng tư của bạn, SimpleX sử dụng các ID riêng biệt cho mỗi liên hệ bạn có. Để nhận thông báo, xin vui lòng nhập mật khẩu cơ sở dữ liệu Quá nhiều ảnh! Quá nhiều video! @@ -1997,7 +1940,6 @@ Tiêu đề Chuyển đổi ẩn danh khi kết nối. Để bảo vệ múi giờ, các tệp hình ảnh/âm thanh sử dụng UTC. - Văn bản này có sẵn trong cài đặt Để được thông báo về các bản phát hành mới, bật kiểm tra định kỳ cho các phiên bản Ổn định hoặc Beta. Để ẩn các tin nhắn không mong muốn. Để tiết lộ hồ sơ ẩn của bạn, nhập đầy đủ mật khẩu vào trường tìm kiếm trong trang Các hồ sơ trò chuyện của bạn. @@ -2026,7 +1968,6 @@ Các phiên truyền tải Các máy chủ không xác định! gửi mà không được cho phép - (để chia sẻ với liên hệ của bạn) Bỏ ẩn Bật Tổng @@ -2040,7 +1981,6 @@ Tối đa tới 100 tin nhắn cuối cùng là được gửi tới các thành viên mới. Sử dụng các thông tin đăng nhập proxy khác cho mỗi kết nối. Sử dụng các thông tin đăng nhập proxy khác nhau cho mỗi hồ sơ. - Cập nhật chế độ cách ly truyền tải? Hủy liên kết máy tính? Tải lên tệp Có bản cập nhật: %s @@ -2067,7 +2007,6 @@ Nâng cấp ứng dụng một cách tự động Đã tải lên Cập nhật - Cập nhật Mở khóa Xác minh bảo mật kết nối Sử dụng cho các tệp @@ -2089,7 +2028,6 @@ Sử dụng hồ sơ ẩn danh mới qua %1$s Sử dụng proxy SOCKS - Sử dụng SimpleX Chat Sử dụng cho các kết nối mới Sử dụng máy chủ Sử dụng proxy SOCKS? @@ -2130,8 +2068,6 @@ cuộc gọi video (không được mã hóa đầu cuối) qua đường dẫn địa chỉ liên lạc Video - Chúng tôi không lưu bất kỳ liên hệ hay tin nhắn nào của bạn (một khi đã được gửi) trên các máy chủ. - Website Xin chào %1$s! Khi có sẵn Các tin nhắn thoại bị cấm! @@ -2146,7 +2082,6 @@ Màu nền hình nền Đang chờ máy tính… Đang chờ di động để kết nối: - Xin chào! Đang chờ video muốn kết nối với bạn! Đang chờ tệp @@ -2179,19 +2114,15 @@ Tắt tính năng xóa tin nhắn Đặt tên cuộc trò chuyện… Không có Tor hoặc VPN, địa chỉ IP của bạn sẽ bị lộ ra cho các relay XFTP sau đây:\n%1$s. - Các máy chủ XFTP Khi có nhiều hơn một bên vận hành được kích hoạt, không ai trong số họ có siêu dữ liệu để biết được ai trò chuyện với ai. Với các tệp và đa phương tiện được mã hóa. Mức sử dụng pin đã được giảm xuống. Mức sử dụng pin đã được giảm xuống. Với lời chào tùy chọn. - Khi mọi người gửi yêu cầu kết nối, bạn có thể chấp nhận hoặc từ chối nó. Khóa sai hoặc địa chỉ khối tệp không xác định - khả năng cao tệp đã bị xóa. 1 năm Việc này không thể được hoàn tác - các tin nhắn đã được gửi và nhận trong cuộc trò chuyện này sớm hơn thời gian được chọn sẽ bị xóa. có - Bạn có thể hiển thị nó cho các liên hệ SimpleX của mình thông qua Cài đặt. - Bạn có thể tạo nó sau Bạn có thể thay đổi nói trong cài đặt Giao diện. Bạn đang tham gia nhóm thông qua đường dẫn này. Bạn có thể bật vào lúc sau thông qua Cài đặt @@ -2204,7 +2135,6 @@ Bạn đã kết nối với %1$s rồi. Bạn cho phép Bạn không được kết nối với các máy chủ này. Định tuyến riêng tư được sử dụng để gửi tin nhắn tới chúng. - Bạn có thể định cấu hình các bên vận hành trong cài đặt Mạng & máy chủ. Bạn đang kết nối thông qua đường dẫn dùng một lần này! Bạn có thể thử một lần nữa. Bạn được mời vào nhóm. Tham gia để kết nối với các thành viên nhóm. @@ -2243,9 +2173,7 @@ bạn đã thay đổi địa chỉ Bạn đã yêu cầu kết nối thông qua địa chỉ này rồi! Bạn đã mời một liên hệ - Bạn có thể chia sẻ địa chỉ của mình dưới dạng một đường dẫn hoặc mã QR - bất kỳ ai cũng có thể kết nối với bạn. Bạn có thể xem đường dẫn mời lần nữa trong chi tiết kết nối. - Bạn kiểm soát cuộc trò chuyện của mình! bạn là quan sát viên Bạn có thể chia sẻ địa chỉ này với các liên hệ của mình để họ kết nối với %s. Bạn cần cho phép liên hệ của mình gửi tin nhắn thoại để có thể gửi cho họ. @@ -2254,7 +2182,6 @@ bạn đã thay đổi địa chỉ cho %s Bạn có thể sử dụng markdown để định dạng tin nhắn: Bạn có thể bắt đầu kết nối trò chuyện thông qua phần Cài đặt / Cơ sở dữ liệu ở trên ứng dụng hoặc bằng cách khởi động lại ứng dụng. - Bạn quyết định ai có thể kết nối tới. Cơ sở dữ liệu trò chuyện của bạn Sự riêng tư của bạn Cơ sở dữ liệu trò chuyện hiện tại của bạn sẽ bị XÓA và THAY THẾ bằng cái được nhập vào.\nViệc này không thể được hoàn tác - hồ sơ, các liên hệ, tin nhắn và tệp của bạn sẽ biến mất mà không thể khôi phục. @@ -2263,7 +2190,6 @@ Hồ sơ, các liên hệ và những tin nhắn đã được gửi của bạn được lưu trữ trên thiết bị bạn dùng. Các liên hệ của bạn có thể cho phép xóa tin nhắn hoàn toàn. Thu phóng - Các máy chủ SMP của bạn Bạn đã chia sẻ đường dẫn dùng một lần Hồ sơ trò chuyện của bạn sẽ được gửi tới các thành viên nhóm Hồ sơ hiện tại của bạn @@ -2273,7 +2199,6 @@ Bạn đã gửi lời mời nhóm Bạn đã từ chối lời mời nhóm bạn đã bỏ chặn %s - Hồ sơ trò chuyện của bạn sẽ được gửi\ntới liên hệ của bạn bạn đã chia sẻ đường dẫn ẩn danh dùng một lần Địa chỉ SimpleX của bạn Liên hệ của bạn cần phải trực tuyến để cho kết nối hoàn thành.\nBạn có thể hủy kết nối này và xóa liên hệ (và thử lại sau với một đường dẫn mới). @@ -2282,7 +2207,6 @@ Hồ sơ trò chuyện của bạn sẽ được gửi tới các thành viên có liên lạc Bạn sẽ được kết nối khi yêu cầu kết nối của bạn được chấp nhận, xin vui lòng đợi hoặc kiểm tra sau! Hồ sơ %1$s sẽ được chia sẻ. - Các máy chủ XFTP của bạn Hồ sơ của bạn sẽ được gửi tới liên hệ mà bạn đã nhận từ người đó đường dẫn này. Bạn sẽ kết nối với tất cả các thành viên nhóm. Bạn đã chia sẻ một đường dẫn tệp không hợp lệ. Báo cáo vấn đề tới các nhà phát triển ứng dụng. @@ -2296,9 +2220,7 @@ Các liên hệ của bạn Các hồ sơ trò chuyện của bạn Cài đặt của bạn - Máy chủ của bạn Địa chỉ máy chủ của bạn - Các máy chủ ICE của bạn Thông tin định danh của bạn có thể bị gửi mà không được mã hóa. Hồ sơ của bạn được lưu trên thiết bị bạn dùng và chỉ được chia sẻ với các liên hệ bạn có. Các máy chủ SimpleX không thể xem hồ sơ của bạn. Kết nối của bạn đã bị chuyển tới %s nhưng một lỗi không mong muốn đã xảy ra trong khi chuyển hướng bạn đến hồ sơ. @@ -2310,7 +2232,6 @@ Sử dụng cổng web Bạn có thể nhắc đến tối đa là %1$s thành viên trong một tin nhắn! Tắt thông báo tất cả - Những lời nhắc chưa đọc Các thành viên có thể báo cáo tin nhắn tới các kiểm duyệt viên. Tất cả các báo cáo sẽ được lưu trữ cho bạn. Lưu trữ tất cả báo cáo? @@ -2442,8 +2363,6 @@ đã kết nối Kết nối nhanh hơn 🚀 đang kết nối - Kết nối bằng tên kênh yêu cầu phiên bản ứng dụng mới hơn - Kết nối bằng tên liên hệ yêu cầu phiên bản ứng dụng mới hơn. Kết nối thất bại Kết nối bằng đường dẫn hoặc mã QR Liên hệ @@ -2465,7 +2384,6 @@ Lỗi tạo kênh Lỗi khi xoá tin nhắn Lỗi khi đánh dấu \"đã xem\" - Lỗi mở kênh Lỗi khi mở đoạn chat Lỗi khi mở nhóm Lỗi khi từ chối lời mời kết nối @@ -2481,13 +2399,11 @@ Để ai đó kết nối với bạn Đường dẫn Đang tải hồ sơ… - Giới thiệu Đồng ý yêu cầu kết nối Đồng ý yêu cầu kết nối đã chấp nhận danh sách đã xác nhận đang hoạt động - Thêm mới Thêm tin nhắn Thêm thiết bị Thêm mới thiết bị 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 831da9ca8c..804316d18c 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 @@ -23,7 +23,6 @@ 接受连接请求? 接受隐身聊天 管理员可以创建链接以加入群。 - 添加预设服务器 通过链接连接 已建立连接 连接 %1$d @@ -64,7 +63,6 @@ 通过一次性链接进行连接? 通过联系人地址进行连接? 加入群? - 通过群链接/二维码连接 总是通过中继连接 允许你的联系人不不可逆地删除已发送消息。(24小时) 联系人允许 @@ -169,13 +167,11 @@ 只有群主可以改变群偏好设置。 保存偏好设置? 设置群偏好设置 - 重新定义隐私 改进的隐私和安全 隐身聊天 加入群中 加入隐身聊天 隐身模式 - 点击开始一个新聊天 你的随机资料 通过联系地址链接隐身 通过群链接隐身 @@ -223,7 +219,6 @@ 彩色 已连接 连接 - 连接 文件 聊天资料 按聊天资料(默认)或按连接(BETA)。 @@ -269,10 +264,7 @@ 扫描二维码 从应用程序扫描显示的二维码。]]> 删除待定连接? 如何使用 markdown - 创建 显示名不能包含空格。 - 分散式 - 不受垃圾和骚扰消息影响 端到端加密视频通话 忽视 视频通话来电 @@ -326,7 +318,6 @@ 创建群链接 创建私密群 创建链接 - 创建一次性邀请链接 创建队列 创建私密群 不同的名字、头像和传输隔离。 @@ -398,7 +389,6 @@ 更新网络配置错误 删除联系人请求错误 保存群资料错误 - 保存 SMP 服务器错误 保存 ICE 服务器错误 发送消息错误 完整链接 @@ -433,7 +423,6 @@ 邀请已过期! 实时消息! 链接预览图片 - 无效的二维码 它如何影响电量 它可能在以下情况发生: \n1. 消息在发送客户端 2 天后或在服务器上 30 天后过期。 \n2. 消息解密失败,因为你或你的联系人使用了旧的数据库备份。 \n3.连接被破坏。 离开群? @@ -484,7 +473,6 @@ 设置联系人姓名…… 已收到回复…… 已受到确认…… - 仅客户端设备存储用户个人资料、联系人、群和消息。 视频通话(非端到端加密) 定期 私密通知 @@ -556,7 +544,6 @@ 拒接来电 点对点 错误:%s - 扫描视频通话中的二维码,或者你的联系人可以分享邀请链接。]]> 你的通话 通过中继 未接来电 @@ -564,13 +551,11 @@ 语音消息 语音消息 SimpleX Chat 通话 - 在视频通话中出示二维码,或分享链接。]]> 你的聊天资料 未接来电 待定来电 你的联系人删除了此链接,或者其为已使用的一次性链接。\n要进行连接,请你的联系人创建新链接。 你已经连接到 %1$s。 - 你的聊天资料将被发送 \n给你的联系人 资料和服务器连接 更新网络设置? 只有你可以不可逆地删除消息(你的联系人可以将它们标记为删除)。(24小时) @@ -619,7 +604,6 @@ %d 月 %d 秒 确保 WebRTC ICE 服务器地址格式正确、每行分开且不重复。 - 确保 SMP 服务器地址格式正确、每行分开且不重复。 Markdown 帮助 标记为已验证 建立私密连接 @@ -674,7 +658,6 @@ 发送人已取消文件传输。 分享 发送实时消息 - 此文本在设置中可用 未读 保存的 WebRTC ICE 服务器将被删除。 %s 已验证 @@ -684,7 +667,6 @@ 保存并通知联系人 保存并通知联系人 拒绝 - 为了保护隐私,SimpleX 对你的每一个联系人使用不同的 ID。 TCP 连接超时 收到,禁止 设定1天 @@ -692,7 +674,6 @@ 在浏览器中打开链接可能会降低连接的隐私和安全性。SimpleX 上不受信任的链接将显示为红色。 恢复数据库备份后请输入之前的密码。 此操作无法撤消。 请更新应用程序并联系开发者。 - 任何人都可以托管服务器。 粘贴 PING 次数 禁止发送语音消息。 @@ -711,28 +692,22 @@ SimpleX 消息 %s 未验证 感谢用户——通过 Weblate 做出贡献! - 没有用户标识符。 完全去中心化 - 仅对成员可见。 图像无法解码。 请尝试不同的图像或联系开发者。 主题 此操作无法撤消——所有接收和发送的文件和媒体都将被删除。 低分辨率图片将保留。 角色将更改为%s。 该成员将收到新的邀请。 此操作无法撤消——早于所选的发送和接收的消息将被删除。 这可能需要几分钟时间。 - 此二维码不是链接! 接收地址将变更到不同的服务器。地址更改将在发件人上线后完成。 - 此链接不是有效的连接链接! 开始新的聊天 要与你的联系人验证端到端加密,请比较(或扫描)你设备上的代码。 取消静音 - 更新传输隔离模式? - (从剪贴板扫描或粘贴) 保护队列 揭示 打开 发送失败 未经授权发送 太多图片! - 待办的 更改接收地址? 请让你的联系人启用发送语音消息。 录制语音消息 @@ -741,22 +716,17 @@ 发送 发送实时消息——它会在你键入时为收件人更新 开始新聊天 - (与你的联系人分享) 通过链接连接 设置联系人姓名 你接受的连接将被取消! 你与之共享此链接的联系人将无法连接! 显示二维码 发送问题和想法 - 保护你的隐私和安全的消息传递和应用程序平台。 删去 - 你决定谁可以连接。 - 下一代私密通讯软件 粘贴你收到的链接 已跳过消息 支持 SimpleX Chat 发送链接预览 - SOCKS 代理 停止聊天程序? 停止聊天以便导出、导入或删除聊天数据库。在聊天停止期间,你将无法收发消息。 恢复数据库备份 @@ -774,7 +744,6 @@ 分享一次性链接 此字符串不是连接链接! 给我们发电子邮件 - SMP 服务器 尚不支持发送文件 尝试连接到用于从该连接接收消息的服务器。 尚不支持接收文件 @@ -786,7 +755,6 @@ SimpleX 群链接 SimpleX 链接 发送人已删除连接请求。 - 预设服务器 二维码 传输隔离 分享链接 @@ -810,8 +778,6 @@ 重置颜色 减少电池使用量 为了保护时区,图像/语音文件使用 UTC。 - 使用聊天 - GitHub 存储库 中阅读更多内容。]]> 打开聊天控制台 停止聊天程序 权限被拒绝! @@ -833,7 +799,6 @@ 查看安全码 语音消息 (%1$s) 等待图像中 - 欢迎! 欢迎 %1$s! 当你的联系人设备在线时,你将可以连接,请稍等或稍后查看! 评价此应用程序 @@ -856,7 +821,6 @@ 当你启动应用或在应用程序驻留后台超过30 秒后,你将需要进行身份验证。 连接到 SimpleX Chat 开发者提出任何问题并接收更新 。]]> 你已接受连接 - 你的 SMP 服务器 %1$d 条已跳过消息 %ds 更新内容 @@ -874,11 +838,9 @@ 你的 SimpleX 地址 为终端安装 SimpleX Chat 使用 SimpleX Chat 服务器? - 我们不会在服务器上存储你的任何联系人或消息(一旦发送)。 WebRTC ICE 服务器 中继服务器保护你的 IP 地址,但它可以观察通话的持续时间。 中继服务器仅在必要时使用。其他人可能会观察到你的IP地址。 - 你的 ICE 服务器 视频关闭 你可以通过应用设置/数据库或重启应用开始聊天。 你将 %s 的角色更改为 %s @@ -886,19 +848,16 @@ 你已更改地址 你可以共享链接或二维码——任何人都可以加入该群。如果你稍后将其删除,你不会失去该群的成员。 间接(%1$s) - 在移动应用程序中打开按钮。]]> SimpleX 你将连接到所有群成员。 通过群链接 通过一次性链接 通过联系地址链接 通过浏览器 - 你的服务器 当可用时 使用 .onion 主机 你的 ICE 服务器 simplexmq: v%s (%2s) - 你的聊天由你掌控! 你可以使用 markdown 来编排消息格式: %dh %d 天 @@ -1012,20 +971,14 @@ 视频将在你的联系人完成上传后收到。 服务器需要授权来上传,检查密码。 上传文件 - XFTP 服务器 - 你的 XFTP 服务器 Use .onion hosts 设置为否。]]> 使用 SOCKS 代理 端口 删除文件 对比文件 主机 - 确保 XFTP 服务器地址格式正确、行分隔且不重复。 创建文件 下载文件 - 加载 SMP 服务器错误 - 加载 XFTP 服务器错误 - 保存 SMP 服务器时出错 端口 %d SOCKS 代理设置 SimpleX 锁定模式 @@ -1079,8 +1032,6 @@ 文件将从服务器中删除。 吊销 音频/视频通话 - " -\n在 v5.1 版本中可用" 应用程序密码 波兰语界面 感谢用户——通过 Weblate 做出贡献! @@ -1127,10 +1078,8 @@ 消失于 设置地址错误 自定义主题 - 创建一个地址,让人们与你联系。 创建 SimpleX 地址 输入欢迎消息……(可选) - 不创建地址 输入欢迎消息…… 深色主题 导出主题 @@ -1169,10 +1118,8 @@ 打开数据库中…… 更改聊天资料 你的联系人可以扫描二维码或使用应用程序中的链接来建立连接。 - 你可以将你的地址作为链接或二维码共享——任何人都可以连接到你。 如果你不能亲自见面,可以在视频通话中展示二维码,或分享链接。 了解更多 - 当人们请求连接时,你可以接受或拒绝它。 如果你以后删除你的地址,你不会丢失你的联系人。 用户指南中阅读更多。]]> 界面颜色 @@ -1185,7 +1132,6 @@ 你好! \n用 SimpleX Chat 与我联系:%s 让我们一起在 SimpleX Chat 里聊天 - 你可以以后创建它 分享地址… 你可以与你的联系人分享该地址,让他们与 %s 联系。 预览 @@ -1262,7 +1208,6 @@ 与 %s 协调加密中… 该功能还没支持。请尝试下一个版本。 允许 - 隐身连接 确认发起私聊? 发送 %s: %s @@ -1334,7 +1279,6 @@ 将送达回执发送给 启用已读回执时出错! 更改密码或重启应用后,密码将以明文形式保存在设置中。 - 粘贴你收到的链接以与你的联系人联系… 送达回执 没有选中的聊天 可以加密 @@ -1501,7 +1445,6 @@ %2$s 审核了 %1$d 条消息 显示内容出错 显示消息出错 - 你可以通过设置让它对你的 SimpleX 联系人可见。 未发送历史消息给新成员。 重试 相机不可用 @@ -1763,7 +1706,6 @@ 是 不 当你的服务器或目标服务器不支持私密路由时直接发送消息。 - 备用消息路由 显示消息状态 为了保护你的 IP 地址,私密路由使用你的 SMP 服务器来传送消息。 私密消息路由 @@ -1776,7 +1718,6 @@ 不直接发送消息,即便你的服务器或目标服务器不支持私密路由。 转发服务器:%1$s \n目标服务器错误:%2$s - 消息路由模式 未知服务器! 没有 Tor 或 VPN,这些 XFTP 中继可以看到你的 IP 地址: \n%1$s. @@ -1785,7 +1726,6 @@ 文件 应用将请求确认来自未知服务器的下载(.onion 或启用 SOCKS 代理时除外)。 个人资料主题 - 在新窗口中显示聊天列表 所有颜色模式 应用到 黑 @@ -1852,8 +1792,6 @@ 私密路由出错 已转发的消息 尚无直接连接,消息由管理员转发。 - 其他 SMP 服务器 - 其他 XFTP 服务器 粘贴链接/扫描 显示百分比 不活跃 @@ -1934,8 +1872,6 @@ 总计 块已上传 订阅被忽略 - 已配置的 SMP 服务器 - 已配置的 XFTP 服务器 当前个人资料 传输会话 已上传 @@ -1944,7 +1880,6 @@ 成员不活跃 如果成员变得活跃,可能会在之后传输消息。 发送的消息 - 打开服务器设置 检查更新 检查更新 停用 @@ -1994,10 +1929,8 @@ 无法呼叫群成员 无法给群成员发消息 确认删除联系人? - 连接 邀请 没有过滤的联系人 - 打开 搜索 发送消息来开启通话。 你可以发消息给来自已存档联系人的 %1$s。 @@ -2123,11 +2056,7 @@ 或者私下分享 服务器运营方 网络运营方 - 30 天后将接受已启用的运营方的条款。 继续 - 稍后审阅 - 选择要使用的网络运营方。 - 更新 你可以通过设置配置服务器。 %s.]]> 将于下列日期自动接受已启用的运营方的条款:%s。 @@ -2137,7 +2066,6 @@ 网络运营方 运营方 %s 台服务器 - 网站 无法加载当前条款文本,你可以通过此链接审阅条款: 使用 %s 使用服务器 @@ -2180,7 +2108,6 @@ %s.]]> 在“网络&服务器”设置中启用 Flux,更好地保护元数据隐私。 接受条款出错 - 为了更好的元数据隐私。 添加服务器出错 无媒体和文件服务器。 更新服务器出错 @@ -2189,14 +2116,11 @@ - 在第一条未读消息上打开聊天.\n- 跳转到引用的消息. 审阅条款 SimpleX 地址或一次性链接? - 要稍后创建 SimpleX 地址,请在菜单中轻按“创建 SimpleX 地址” 当前聊天资料的新文件服务器 此消息被删除或尚未收到。 连接达到了未送达消息上限,你的联系人可能处于离线状态。 为了防止链接被替换,你可以比较联系人安全代码。 - 你可以在“网络和服务器”设置中配置运营方。 接受运营方条款的日期:%s - 远程移动设备 或者导入存档文件 小米设备:请在系统设置中开启“自动启动”让通知正常工作。]]> 消息太大! @@ -2204,7 +2128,6 @@ 请减小消息大小或删除媒体并再次发送。 将你的团队成员加入对话。 企业地址 - 端到端加密,私信具备后量子密码安全性。]]> 无后台服务 每 10 分钟检查消息 它如何帮助隐私 @@ -2294,7 +2217,6 @@ 垃圾信息 存档举报? 举报理由? - 将为你存档该举报。 存档了 %s 的举报 举报 1 个举报 @@ -2324,7 +2246,6 @@ 未指定端口时,使用 TCP 端口 %1$s。 使用 web 端口 全部静音 - 取消提及的已读状态 每条消息最多提及 %1$s 名成员! 将为你存档所有举报。 存档所有举报? @@ -2450,7 +2371,6 @@ 发送联络请求? 发送请求 发送无消息请求 - 连接后发送给你的联系人。 升级群链接? 升级 升级地址? @@ -2504,7 +2424,6 @@ 使用隐身个人资料 欢迎联系人👋 来自%1$s群的连接请求 - 此设置用于当前个人资料 群内的联络请求 成员被删除——无法接受请求 只有你的联系人允许的情况下才允许文件和媒体。 @@ -2536,7 +2455,6 @@ 移除并删除消息 所有消息 文件 - 筛选器 图片 链接 搜索文件 @@ -2567,7 +2485,6 @@ 频道 频道 频道链接 - 频道成员 频道名 将为所有订阅者删除频道 —— 此操作无法撤销! 将为你删除频道 —— 此操作无法撤销! @@ -2597,7 +2514,6 @@ 输入中继名… 添加中继出错 创建频道出错 - 打开频道出错 失败 失败 获取链接 @@ -2614,7 +2530,6 @@ 未启用聊天中继。 不是所有中继均已连接 打开频道 - 打开新频道 所有者 所有者和贡献者 预设中继地址 @@ -2792,7 +2707,6 @@ 顶部栏 你的新频道 %1$s 已连接到 %3$d 个中继中的 %2$d 个中继。\n如果取消,频道将被删除 —— 你可以再次创建它。 这是上次活跃的中继。删除它会阻止给订阅者传送消息。 - 添加 添加中继 添加中继 取消并删除频道 @@ -2831,12 +2745,9 @@ %1$d 名所有者 %1$d 名所有者和贡献者 %1$s 支持过 SimpleX Chat。该徽章已于 %2$s 过期。 - 关于 无法验证徽章 频道网页 聊天数据 - 通过频道名连接需要更新的应用版本。 - 通过联系人名称连接需要更新的应用版本。 联系人 贡献者 复制代码 @@ -2847,7 +2758,6 @@ 它将展示给订阅者并用来允许加载预览。 更多隐私 只有您上面的页面可以显示预览。 - 请升级应用 %s 给 SimpleX Chat 众筹投过钱。 %s 支持 SimpleX Chat。 订阅者 @@ -2855,8 +2765,6 @@ 该角色将更改为 "%s"。频道中的每个人都会收到通知。 此徽章无法被验证,可能不是真的。 此群组需要更新版本的应用。要加入请更新应用。 - 不支持的频道名 - 不支持的联系人名 未验证的徽章 所用的聊天中继不支持网页。 网页代码 @@ -2879,7 +2787,6 @@ 无有效链接 解析错误:%1$s 设置 SimpleX 名称 - SimpleX 名称 SimpleX 名称错误 SimpleX 名称未验证 SimpleX 名称 %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 05e3cd8030..357099ddef 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 @@ -14,7 +14,6 @@ 要在端口啟用 SOCKS 代理伺服器嗎 %d?在啟用這個選項之前,必須先啟用代理伺服器。 管理員 然後,選按: - 新增預設伺服器 新增伺服器 接受 認證無效 @@ -166,8 +165,6 @@ 自動銷毀訊息 開啟新的對話 建立私密群組 - 建立一次性邀請連結 - (分享給你的聯絡人) 點擊按鈕 感謝你安裝 SimpleX Chat! 靜音 @@ -234,7 +231,6 @@ 加入群組? 你的個人檔案將傳送給你接收此連結的聯絡人。 你將連接至此群組內的所有成員。 - 連接 錯誤 連接中 你已連接到此聯絡人使用的伺服器以接收訊息。 @@ -243,8 +239,6 @@ 已刪除 已標記為已刪除 在瀏覽器中開啟連結可能會有隱私疑慮和不確定性。不受 SimpleX 信任的連結會顯示紅色。 - 儲存 SMP 伺服器時出錯 - 請確保 SMP 伺服器連結是正確的格式,每行也分隔且不重複。 更新網路配置時出錯 聊天載入失敗 多個聯絡人載入失敗 @@ -293,13 +287,10 @@ 未經授權傳送 傳送失敗 歡迎 %1$s! - 歡迎! - 在設定中可用這文字 連接中… 你被邀請加入至群組 以 %s 身份加入 連接中… - 點擊以開啟新的對話 找不到檔案 語音訊息 (%1$s) 語音訊息… @@ -356,7 +347,6 @@ 傳送實況的訊息 直播訊息! 傳送實況訊息 - 這會即時顯示你在輸入中的文字 - (掃描或使用剪貼薄貼上) 權限被拒絕! SimpleX 在背景運作而不是使用推送通知。]]> 定期通知 @@ -378,7 +368,6 @@ 已連接 已斷開連接 錯誤 - 待處理 切換接收的聯絡地址? 此功能目前還是實驗階段!對方需要使用 4.2 版本或更高的版本才能成功生效。地址修改後,你會在對話中看到新的訊息 - 請你於修改聯絡地址後,測試是否仍然能夠接收來自這聯絡人(或群組內的成員)的訊息。 查看安全碼 @@ -396,13 +385,11 @@ 沒有詳細資料 一次性邀請連結 已複製至你的剪貼薄 - 透過連結/QR 圖碼連接 掃描二維碼 (僅由群組成員儲存) 想和你對話! SimpleX 徽標 透過連結連接 - 預設伺服器 你的伺服器地址 刪除伺服器 你的 ICE 伺服器 @@ -416,7 +403,6 @@ 使用連結連接 掃描二維碼。]]> 設定 - 這個二維碼不是一個連結! 當可用時 核心版本:v%s simplexmq: v%s (%2s) @@ -454,20 +440,13 @@ 電郵 更多 顯示二維碼 - 無效的 QR 圖碼 無效的連結! - 這個連結不是一個有效的連接連結! 已傳送連接請求 當群組的建立人上線,你便會成功連接至群組,請耐心等待! 當你的連接請求已被接受,你便會連接成功,請耐心等待! 當你的聯絡人上線,你便會連接成功,請耐心等待! - 可於視訊通話中出示你的二維碼,或者分享連結。]]> - 你的個人檔案會傳送給 -\n你的聯絡人 - 視訊通話中掃描二維碼 ,或者你可以分享一個邀請連結給此聯絡人。]]> 貼上 這些字串不是連接連結! - 在電話應用程式內的開啟 按鈕。]]> 一次性邀請連結 掃描二維碼 錯誤的安全碼! @@ -481,7 +460,6 @@ 無效的伺服器地址! 此伺服器用於你目前的個人檔案 使用 SimpleX Chat 伺服器? - 你的 SMP 伺服器 目前使用 SimpleX Chat 伺服器。 如何 配置 ICE 伺服器 @@ -496,7 +474,6 @@ 儲存並通知你的多個聯絡人 你的個人檔案,聯絡人和傳送的訊息只會儲存於你的個人裝置內。 你的個人檔案只會和你的聯絡人分享。 - 建立 如何使用 Markdown 語法 你可以使用 Markdown 語法以更清楚標明訊息: 刪除線 @@ -510,9 +487,6 @@ 使用 SOCKS 代理伺服器 儲存並通知群組內的聯絡人 退出並且不儲存記錄 - 你的對話由你控制! - 一個保護你的隱私和傳送安全通訊的應用程式平台。 - 我們不會在伺服器內儲存你的任何聯絡人和訊息(一旦傳送)。 建立個人檔案 已確認回應… 連接到 SimpleX Chat 開發人員提出任何問題並同意更新。]]> @@ -535,7 +509,6 @@ 資料庫密碼和匯出 傳送電郵 SimpleX 鎖定 - SMP 伺服器 預設伺服器地址 測試伺服器 測試多個伺服器 @@ -543,7 +516,6 @@ 伺服器測試失敗! 有一些伺服器測試失敗: 掃描伺服器的二維碼 - 你的伺服器 連接時需要使用 Onion 主機。 \n請注意:如果沒有 .onion 地址,你將無法連接到伺服器。 聊天個人檔案 透過群組連結 @@ -638,16 +610,10 @@ 儲存群組檔案 語音訊息於這個聊天室是禁用的。 允許你的聯絡人可以完全刪除訊息。 - 沒有用戶識別符。 - 未來的訊息平台 - 去中心化的 - 你決定誰可以連接。 - 重新定義隱私 建立你的個人檔案 這是如何運作 它如何影響電池 私下連接 - 任何人都可以託管伺服器。 忽略 語音通話來電 貼上你收到的連結 @@ -670,7 +636,6 @@ 幫助 SimpleX Chat 聊天 開發者工具 - SOCKS 代理伺服器 重新啟動應用程式以匯入對話資料庫。 刪除所有檔案 啟用自動銷毀訊息? @@ -781,7 +746,6 @@ 聯絡人允許 %ds 私人通知 - GitHub內查看更多。]]> 視訊通話來電 掛斷 點對點 @@ -847,7 +811,6 @@ 正在修改聯絡地址為 %s … 受加密的資料庫密碼會再次更新和儲存於金鑰庫。 當發生: \n1. 訊息將在傳送至客戶端後兩天或在伺服器內三十天時過時。 \n2. 訊息解密失敗,因為你或你的聯絡人用了舊的資料庫備份 \n3. 連接被破壞。 - 只有客戶端裝置儲存個人檔案、聯絡人、群組,和訊息。 請放置你的密碼於安全的地方,如果你遺失了密碼,將不可能修改你的密碼。 停止聊天室以匯出對話,匯入或刪除對話資料庫。當聊天室停止後你將不能接收或傳送訊息。 你正在使用匿名聊天模式進入此群組 - 為了避免分享你的真實個人檔案,邀請聯絡人是不允許的。 @@ -860,8 +823,6 @@ 透過聯絡人的邀請連結連接 透過一次性連結連接 傳輸隔離 - 更新傳輸隔離模式? - 為了保護你的隱私,SimpleX 對你的每個聯絡人使用不同的 ID。 當應用程式是運行中 透過設定啟用於上鎖畫面顯示來電通知。 這操作不能還原 - 你目前的個人檔案,聯絡人,訊息和檔案將不可逆地遺失。 @@ -876,13 +837,11 @@ 你已拒絕加入群組 連接中(宣布階段) 已選擇%d 個聯絡人 - 你的 ICE 伺服器 WebRTC ICE 伺服器 更新 添加更多身份選項 聯絡人頭像 個人檔案頭像占位符 - 不受垃圾和騷擾訊息影響 %1$s 希望透過以下方式聯絡你 開啟視訊 翻轉相機 @@ -914,7 +873,6 @@ 更新群組檔案 你修改了 %s 的身份為 %s 連接中(介紹階段) - 使用聊天 透過轉送 關閉視訊 你修改了自己的身份為 %s @@ -1011,10 +969,6 @@ 資料庫 IDs 和傳輸隔離選項。 降級和開啟對話 個人檔案密碼 - 儲存 XFTP 伺服器時出錯 - 請確保 XFTP 伺服器連結格式正確,隔行顯示且不重複。 - 加載 SMP 伺服器時出錯 - 加載 XFTP 伺服器時出錯 建立檔案 伺服器需要認證後才能上載,檢查密碼 對比檔案 @@ -1079,11 +1033,8 @@ 修改鎖定模式 未修改密碼! 語言/視訊通話 - " -\n在 v5.1中可用" 允許你的聯絡人與你進行通話。 上載檔案 - XFTP 伺服器 系統認證 你未能通過認證;請再試一次。 你可以透過設定啟用 SimpleX 鎖定。 @@ -1092,7 +1043,6 @@ \n當一些錯誤出現或你的連結被破壞時會發生。 %1$d 條訊息解密失敗。 使用SOCKS 代理伺服器 - 你的 XFTP 伺服器 %1$d 條訊息已跳過。 影片和檔案和最大上限為1gb 影片 @@ -1110,7 +1060,6 @@ 停止分享聯絡地址? 自動接受 繼續 - 不用建立聯絡地址 輸入歡迎訊息… (可選擇的) 你好!  \n透過 SimpleX Chat 來和我連接吧:%s @@ -1134,7 +1083,6 @@ 外加的輔助 地址 背景 - 建立一個聯絡地址讓其他用戶與你連接。 自定義主題 黑暗主題 設定聯絡地址時出錯 @@ -1156,12 +1104,9 @@ 已刪除所有的應用程式數據。 設定密碼 你可以與聯絡人分享此地址,讓他們使用 %s 進行連接。 - 你可以在稍後建立它 為了連接,你的聯絡人可以掃描二維碼或使用此應用程式的連結。 如果你不能面對面接觸此聯絡人,可於視訊通話中出示你的二維碼,或者分享連結。 當你在稍後刪除你的聯絡地址時,你並不會遺失你的聯絡人。 - 你可以將你的地址作為連接或二維碼以分享 - 任何人都可以連接到你。 - 當有人向你發出連接請求,你可以接受或拒絕請求。 你的聯絡人會保持連接。 你的所有聯絡人會保持連接。更新了的個人檔案將傳送給你的聯絡人。 新增地址至你的個人檔案,以便你的聯絡人可以與其他人分享。更新了的個人檔案將傳送給你的聯絡人。 @@ -1328,13 +1273,11 @@ 應用程式將為新的本機檔案(影片除外)加密。 檢查你的網路連接並重試 所有個人檔案 - 已設定的 SMP 伺服器 聊天主題 通話 允許降級 始終使用私密路由。 以導出聊天資料庫 - 已設定的 XFTP 伺服器 色彩模式 已儲存的聯絡人 模糊媒體 @@ -1437,7 +1380,6 @@ 禁止檔案和媒體! 檔案載入中 檔案錯誤 - 備用訊息路由 如果你或你的目標伺服器不支持私密路由,將不直接傳送訊息。 建立個人檔案 從另一台裝置轉移 @@ -1476,7 +1418,6 @@ 輸入密碼短語 傳送的訊息 PC版處理中 - 隱身模式連接 已刪除對話! 目標伺服器錯誤:%1$s 聊天載入中… @@ -1533,7 +1474,6 @@ 中止地址更改時出錯 顯示通知出錯,請聯繫開發者。 刪除並通知聯絡人 - 訊息路由模式 不使用私密路由。 要進行通話請授予一項或多項權限 在 Android 系統設定中找到此權限並手動授予權限。 @@ -1664,7 +1604,6 @@ 成員非活躍 訊息 訊息將被標記為刪除。收信人可以揭示這些訊息。 - 連接 訊息 停用 下載更新中,請不要關閉應用 @@ -1726,7 +1665,6 @@ 禁止傳送檔案和媒體。 打開群組 只有群組所有者才能啟用檔案和媒體。 - 貼上你收到的連結以與你的聯絡人聯絡… 先前連接的伺服器 其他 無 @@ -1744,17 +1682,13 @@ 正在準備下載 只刪除對話 貼上連結 - 其他 SMP 伺服器 - 其他 XFTP 伺服器 請讓你的聯絡人啟用通話。 畫中畫通話 或安全分享此文件連結 無資訊,試試重新加載 - 打開伺服器設定 私密路由出錯 尚無直接連接,訊息由管理員轉發。 什麼也沒選中 - 開啟 私密路由 打開資料庫文件夾 私密訊息路由 @@ -1859,7 +1793,6 @@ 是 喇叭 解除封鎖 - 網站 透明度 重複 編輯 @@ -1867,7 +1800,6 @@ 選擇 使用者名稱 密碼 - 更新 繼續 已拒絕 系統 @@ -2028,7 +1960,6 @@ 聯絡人已刪除 未同步 聯絡人已停用 - 稍後審核 分享個人檔案 完整連結 已上載檔案 @@ -2077,7 +2008,6 @@ 你已封鎖 %s 連接未就緒。 更好的訊息日期。 - 為了更好的元資料隱私。 你可以再試一次。 使用匿名個人檔案 開啟聊天 @@ -2137,7 +2067,6 @@ 影片 檔案 連結 - 過濾器 %d 個舉報 已棄用的選項 無訂閱 @@ -2226,11 +2155,6 @@ 刪除聊天時發生錯誤 不支援的連線連結 此連結需要較新的應用程式版本。請升級應用程式,或請你的聯絡人傳送相容的連結。 - 不支援的頻道名稱 - 不支援的聯絡人名稱 - 透過頻道名稱連線需要較新的應用程式版本。 - 透過聯絡人名稱連線需要較新的應用程式版本。 - 請升級應用程式。 頻道暫時無法使用 頻道沒有啟用中的中繼。請稍後再嘗試加入。 需要更新應用程式 @@ -2250,7 +2174,6 @@ 應用程式電池用量 / 不受限制。]]> Xiaomi 裝置:請在系統設定中啟用自動啟動,以便通知正常運作。]]> 此訊息已刪除或尚未接收。 - 該檢舉報告將為你封存。 所有檢舉報告將為你封存。 金鑰錯誤或未知連線——此連線很可能已刪除。 伺服器地址與網路設定不相容。 @@ -2273,7 +2196,6 @@ 讓任何人都能聯絡你 列表 %s 中沒有聊天。 聯絡人需要接受… - 稍後可點選選單中的「建立 SimpleX 地址」來建立。 沒有可轉發的內容! 要轉發不含檔案的訊息嗎? 這些訊息在你選取後已被刪除。 @@ -2341,7 +2263,6 @@ 拒絕聯絡請求 傳送者不會收到通知。 成員已刪除,無法接受請求 - 未讀提及 所有列表的名稱和 emoji 都應不同。 所有聊天都將從列表 %s 中移除,且該列表會被刪除 在社交媒體上分享 SimpleX 地址。 @@ -2380,7 +2301,6 @@ 若要接收新版本通知,請開啟穩定版或 Beta 版的定期檢查。 顯示較慢的 API 呼叫 儲存設定時發生錯誤 - 連線後會傳送給你的聯絡人。 或私下分享 SimpleX 地址還是一次性連結? 新的一次性連結 @@ -2395,7 +2315,6 @@ 連結將會變短,且群組檔案會透過此連結分享。 分享舊地址 分享舊連結 - 你可以透過設定,讓你的 SimpleX 聯絡人看見它。 簡介: 簡介太長 要儲存加入審批設定嗎? @@ -2421,7 +2340,6 @@ 人類最古老的自由——不被監視地與另一個人交談——建立在不會背叛它的基礎設施之上。 因為我們摧毀了識別你身份的能力,讓你的自主權永遠不會被奪走。 在你的網路中自由交流。 - 端對端加密傳送,直接訊息具備後量子安全性。]]> 營運商承諾:\n- 保持獨立\n- 盡量減少中繼資料使用\n- 執行已驗證的開源程式碼 你承諾:\n- 只在公開群組中發佈合法內容\n- 尊重其他使用者——不發送垃圾訊息 私隱政策與使用條件。 @@ -2429,11 +2347,8 @@ 應用程式會在每個對話中使用不同營運商,以保護你的私隱。 啟用多於一個營運商時,沒有任何一方擁有足以得知誰與誰通訊的中繼資料。 例如,如果你的聯絡人透過 SimpleX Chat 伺服器接收訊息,你的應用程式會透過 Flux 伺服器傳送訊息。 - 選擇要使用的網路營運商。 這如何有助於私隱 你可以透過設定配置伺服器。 - 30 天後,將會接受已啟用營運商的條件。 - 你可以在「網路與伺服器」設定中配置營運商。 你的網路 網路路由器無法知道\n誰在和誰交談 設定路由器 @@ -2450,7 +2365,6 @@ 應用程式會要求確認來自未知檔案伺服器的下載(.onion 或啟用 SOCKS 代理時除外)。 未使用 Tor 或 VPN 時,你的 IP 地址會對檔案伺服器可見。 移除連結追蹤 - 此設定適用於你目前的個人檔案 這些設定適用於你目前的個人檔案 可在聯絡人和群組設定中覆寫這些設定。 已為 %d 個聯絡人啟用送達回條 @@ -2464,14 +2378,12 @@ 開啟乾淨連結 傳送送達回條給 來自群組的聯絡請求 - 關於 聯絡 支持此專案 聊天數據 說明與支援 更多私隱 進階設定 - 遠端行動裝置 你可以儲存匯出的封存檔。 你可以遷移匯出的數據庫。 部分檔案未匯出 @@ -2486,7 +2398,6 @@ 底部列 頂部列 在新視窗中顯示控制台 - 在新視窗中顯示聊天列表 如果你加入或建立了頻道,它們將永久停止運作。 要離開頻道嗎? 你將停止接收此頻道的訊息。聊天記錄將會保留。 @@ -2535,7 +2446,6 @@ 只有聊天擁有者可以變更偏好設定。 此群組有超過 %1$d 名成員,不會傳送送達回條。 連結 - 頻道成員 聊天中繼 要移除訂閱者嗎? 要刪除成員訊息嗎? @@ -2827,7 +2737,6 @@ 你的個人檔案 %1$s 將與頻道中繼和訂閱者共享。\n中繼可以存取頻道訊息。 設定中繼 失敗 - 新增 新增中繼 新增中繼 沒有可用的中繼 @@ -2843,10 +2752,8 @@ 中繼地址 這是聊天中繼地址,不能用於連接。 開啟頻道 - 開啟新頻道 你的頻道 %1$s 的連結!]]> - 開啟頻道時發生錯誤 要對全部解除封鎖訂閱者嗎? 要啟用連結預覽嗎? 傳送連結預覽可能會向網站透露你的 IP 地址。你可以稍後在「私隱」設定中變更此設定。 diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/phone_supporter.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/phone_supporter.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/phone_supporter.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/phone_supporter_light.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/phone_supporter_light.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/phone_supporter_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index b3a8407839..b8975d80f8 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -24,11 +24,11 @@ android.nonTransitiveRClass=true kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.jvm.target=11 -android.version_name=7.1-beta.2 -android.version_code=377 +android.version_name=7.1-beta.4 +android.version_code=379 -desktop.version_name=7.1-beta.2 -desktop.version_code=160 +desktop.version_name=7.1-beta.4 +desktop.version_code=162 kotlin.version=2.1.20 gradle.plugin.version=8.7.0 diff --git a/apps/multiplatform/product/concepts.md b/apps/multiplatform/product/concepts.md index 5d707cf832..d29ac5c2e5 100644 --- a/apps/multiplatform/product/concepts.md +++ b/apps/multiplatform/product/concepts.md @@ -19,7 +19,7 @@ This document provides a structured mapping between product-level concepts, thei | # | Concept | Product Docs | Spec Docs | Source Files (Kotlin) | Source Files (Haskell) | |---|---------|-------------|-----------|----------------------|----------------------| -| PC1 | Chat List | [README.md](README.md) (Navigation Map) | [spec/client/chat-list.md](../spec/client/chat-list.md) | `common/.../views/chatlist/ChatListView.kt`, `ChatListNavLinkView.kt`, `ChatPreviewView.kt` | `Controller.hs` (`APIGetChats`) | +| PC1 | Chat List | [README.md](README.md) (Navigation Map) | [spec/client/chat-list.md](../spec/client/chat-list.md) | `common/.../views/chatlist/ChatListView.kt`, `ChatListNavLinkView.kt`, `ChatPreviewView.kt`, `GetStakeBanner.kt` | `Controller.hs` (`APIGetChats`) | | PC2 | Direct Chat | [README.md](README.md) (Messaging) | [spec/client/chat-view.md](../spec/client/chat-view.md) | `common/.../views/chat/ChatView.kt`, `ChatInfoView.kt` | `Types.hs` (`Contact`), `Messages.hs` | | PC3 | Group Chat | [README.md](README.md) (Groups) | [spec/client/chat-view.md](../spec/client/chat-view.md) | `common/.../views/chat/ChatView.kt`, `group/GroupChatInfoView.kt` | `Types.hs` (`GroupInfo`, `GroupMember`) | | PC4 | Message Composition | [README.md](README.md) (Messaging) | [spec/client/compose.md](../spec/client/compose.md) | `common/.../views/chat/ComposeView.kt`, `SendMsgView.kt`, `ComposeVoiceView.kt`, `ComposeImageView.kt`, `ComposeFileView.kt` | `Controller.hs` (`APISendMessages`) | diff --git a/apps/multiplatform/product/views/chat-list.md b/apps/multiplatform/product/views/chat-list.md index daa7907c5d..a09994d1e3 100644 --- a/apps/multiplatform/product/views/chat-list.md +++ b/apps/multiplatform/product/views/chat-list.md @@ -115,6 +115,7 @@ Each chat type provides specific dropdown menu items: | One-hand UI card (`ToggleChatListCard`) | `oneHandUICardShown == false` | Dismissible card introducing bottom toolbar mode with toggle switch | | Address creation card (`AddressCreationCard`) | `addressCreationCardShown == false` | Prompts user to create a SimpleX address; tappable card opens `UserAddressLearnMore` | | FAB (new chat button) | Standard mode, search empty, chat running | `FloatingActionButton` at bottom-right, pencil icon, opens `NewChatSheet` | +| Crowdfunding banner (`GetStakeBanner`) | `crowdfundingAvailable()` and not dismissed | Gradient card above the chats, and below the onboarding cards when there are no conversations; opens `GetStakeView`. The dismiss X appears once the banner has been tapped and there is at least one chat; dismissing hides it until hints are reset | ### Empty States @@ -134,3 +135,4 @@ Each chat type provides specific dropdown menu items: | `ChatPreviewView.kt` | `views/chatlist/ChatPreviewView.kt` | | `UserPicker.kt` | `views/chatlist/UserPicker.kt` | | `TagListView.kt` | `views/chatlist/TagListView.kt` | +| `GetStakeBanner.kt` | `views/chatlist/GetStakeBanner.kt` | diff --git a/apps/multiplatform/spec/README.md b/apps/multiplatform/spec/README.md index c5d9a3b4f7..d6a7ec20af 100644 --- a/apps/multiplatform/spec/README.md +++ b/apps/multiplatform/spec/README.md @@ -124,9 +124,9 @@ Common Module (commonMain) | Desktop Init | [`AppCommon.desktop.kt`](../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt#L21) | `fun initApp()` | 21 | | Common App Screen | [`App.kt`](../common/src/commonMain/kotlin/chat/simplex/common/App.kt#L47) | `fun AppScreen()` | 47 | | JNI Bridge | [`Core.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L18) | `external fun initHS()` | 18 | -| Chat Controller | [`SimpleXAPI.kt`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L493) | `object ChatController` | 493 | +| Chat Controller | [`SimpleXAPI.kt`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L525) | `object ChatController` | 525 | | Chat Model | [`ChatModel.kt`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L86) | `object ChatModel` | 86 | -| App Preferences | [`SimpleXAPI.kt`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L94) | `class AppPreferences` | 94 | +| App Preferences | [`SimpleXAPI.kt`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L102) | `class AppPreferences` | 102 | | Platform Interface | [`Platform.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt#L15) | `interface PlatformInterface` | 15 | | Notification Manager | [`NtfManager.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L19) | `abstract class NtfManager` | 19 | | Theme Manager | [`ThemeManager.kt`](../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt#L18) | `object ThemeManager` | 18 | diff --git a/apps/multiplatform/spec/client/chat-list.md b/apps/multiplatform/spec/client/chat-list.md index b0f3750659..a5eeb268fa 100644 --- a/apps/multiplatform/spec/client/chat-list.md +++ b/apps/multiplatform/spec/client/chat-list.md @@ -15,12 +15,13 @@ Source: `common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatLis 7. [Tag System](#7-tag-system) 8. [UserPicker](#8-userpicker) 9. [Source Files](#9-source-files) +10. [Crowdfunding Banner](#10-crowdfunding-banner) --- ## Executive Summary -The Chat List is the landing screen of SimpleX Chat, rendering all conversations for the active user. Built around `ChatListView` (line 126 in `ChatListView.kt`), it provides a searchable, filterable `LazyColumn` of chat previews with a toolbar, tag-based filtering, and a user-switching side panel. The view adapts between one-hand UI mode (toolbar at bottom, reversed list) and standard mode (toolbar at top). Search also accepts SimpleX links for direct connection. +The Chat List is the landing screen of SimpleX Chat, rendering all conversations for the active user. Built around `ChatListView` (line 179 in `ChatListView.kt`), it provides a searchable, filterable `LazyColumn` of chat previews with a toolbar, tag-based filtering, and a user-switching side panel. The view adapts between one-hand UI mode (toolbar at bottom, reversed list) and standard mode (toolbar at top). Search also accepts SimpleX links for direct connection. --- @@ -53,7 +54,7 @@ ChatListView ## 2. ChatListView Composable -**Location:** [`ChatListView.kt#L127`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L127) +**Location:** [`ChatListView.kt#L179`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L179) ```kotlin fun ChatListView( @@ -66,8 +67,8 @@ fun ChatListView( ### Initialization -- Shows "What's New" modal on first launch after update (line ~130), with a 1-second delay. -- On desktop, closing a chat resets audio/video players (line ~138). +- Shows "What's New" modal on first launch after update (line ~185), with a 1-second delay. +- On desktop, closing a chat resets audio/video players (line ~193). ### Layout Modes @@ -88,8 +89,8 @@ The `oneHandUI` preference (`appPrefs.oneHandUI.state`) controls the layout: ### Android-specific -- `SetNotificationsModeAdditions`: Notification permission setup (line ~184). -- `UserPicker`: Overlay side panel for user switching (line ~192). +- `SetNotificationsModeAdditions`: Notification permission setup (line ~243). +- `UserPicker`: Overlay side panel for user switching (line ~247). --- @@ -113,7 +114,7 @@ The `oneHandUI` preference (`appPrefs.oneHandUI.state`) controls the layout: ### Active Filter Types -Defined as sealed class `ActiveFilter` (line ~51): +Defined as sealed class `ActiveFilter` (line ~59): ```kotlin sealed class ActiveFilter { @@ -136,7 +137,7 @@ sealed class ActiveFilter { ### Search Filtering -The `filteredChats` function (line ~1188) applies filters in this order: +The `filteredChats` function (line ~1474) applies filters in this order: 1. **SimpleX link match:** If a pasted link resolved to a known contact/group, show only that chat. 2. **Text search:** Case-insensitive match against `chat.chatInfo.chatViewName`, `chat.chatInfo.fullName`, and `chat.chatInfo.localAlias`. @@ -147,7 +148,7 @@ The `filteredChats` function (line ~1188) applies filters in this order: ### Search Bar -`ChatListSearchBar` (line ~611) provides: +`ChatListSearchBar` (line ~765) provides: - Text input with search icon. - SimpleX link detection: When a pasted string contains a single SimpleX link, it triggers `planAndConnect` for connection, suppressing normal search. - Unread filter toggle button (right side, when search is empty). @@ -158,7 +159,7 @@ The `filteredChats` function (line ~1188) applies filters in this order: ## 5. Chat Preview -**Location:** [`ChatPreviewView.kt#L40`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt#L40) +**Location:** [`ChatPreviewView.kt#L41`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt#L41) ```kotlin fun ChatPreviewView( @@ -224,7 +225,7 @@ On desktop, the currently selected chat (`chatModel.chatId.value == chat.id`) re ### TagsView -**Location:** [`ChatListView.kt#L929`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L929) +**Location:** [`ChatListView.kt#L1214`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L1214) Renders a horizontally scrollable row of tag chips (via `TagsRow`, which is a platform-specific `expect` composable). @@ -244,7 +245,7 @@ Layout logic: ### TagListView -**Location:** [`TagListView.kt#L48`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt#L48) +**Location:** [`TagListView.kt#L47`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt#L47) Full-screen tag management view opened from the "+" button or long-press menu. @@ -312,3 +313,42 @@ Uses `AnimatedViewState` (`GONE`, `VISIBLE`, `HIDING`) with a `MutableStateFlow` | `ShareListView.kt` | Share target list (forwarding flow) | | `TagListView.kt` | Tag management and assignment view | | `UserPicker.kt` | User switching side panel | +| `GetStakeBanner.kt` | Crowdfunding banner and the shared banner card chrome | + +--- + + + +## 10. Crowdfunding Banner + +**Location:** [`GetStakeBanner.kt#L31`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/GetStakeBanner.kt#L31) + +```kotlin +fun GetStakeBanner(showDismiss: Boolean, onTap: () -> Unit, onDismiss: () -> Unit) +``` + +Gradient card inviting the user to invest on Wefunder. Shown only when `crowdfundingAvailable()` — always outside Play Store builds, and in Play builds only while the store country is the US or not yet known — and only while `getStakeBannerDismissed` is false. + +### Placement + +| Where | Condition | Layout | +|-------|-----------|--------| +| Chat list | in `ChatList`'s `LazyColumn`, after `ToggleChatListCard` and before the chats | `Box(Modifier.zIndex(1f).padding(16.dp))` | +| Onboarding | inside `ConnectOnboardingView` (`views/newchat/OnboardingCards.kt`), below the pager, so it shares the pages' width limit on desktop and their dimming while a start modal is open; opens the page in `ModalManager.center` on desktop, `ModalManager.start` on Android | `padding(start/end = DEFAULT_PADDING, bottom = 8.dp)`, in a `Column` where the pager takes `weight(1f)` | + +`crowdfundingAvailable()` launches an effect to load the store country, so both call sites read it in the composable body rather than inside the `LazyColumn` builder. + +### Dismissal + +| Preference | Set by | Effect | +|------------|--------|--------| +| `getStakeBannerTapped` | `openGetStake()` | the dismiss X appears from then on, while there are chats | +| `getStakeBannerDismissed` | the dismiss X | hides the banner in both placements | + +Both are in `AppPreferences.hintPreferences`, so "Reset all hints" restores the banner. The X is never offered below the onboarding cards, so the banner cannot be dismissed before the user has a chat. + +Tapping the card runs `openGetStake()`, which opens `GetStakeView(showFirstImage = true)` as a card modal — `showFirstImage` decides whether the page repeats the first slide's image, which only What's New shows above its own link. + +### Shared card chrome + +`Modifier.bannerCard(onTap)` (size state, gradient brush, `heightIn`, `clip`, `background`, `clickable`) and `BannerDismissButton` are declared separately from `GetStakeBanner` so other banners can adopt the same chrome; the paddings stay with the caller. The gradient reuses `gradientPoints`, `lightStops` and `darkStops` from `views/newchat/OnboardingCards.kt`. diff --git a/apps/multiplatform/spec/impact.md b/apps/multiplatform/spec/impact.md index 3a96638310..ec302e30b5 100644 --- a/apps/multiplatform/spec/impact.md +++ b/apps/multiplatform/spec/impact.md @@ -92,6 +92,7 @@ Path prefix: `common/src/commonMain/kotlin/chat/simplex/common/` | Source File | Product Concepts Affected | Risk Level | Notes | |-------------|--------------------------|------------|-------| | `views/chatlist/ChatListView.kt` | PC1, PC28 | High | Main screen — chat list rendering and search | +| `views/chatlist/GetStakeBanner.kt` | PC1 | Low | Crowdfunding banner and shared banner card chrome | | `views/chatlist/ChatListNavLinkView.kt` | PC1, PC2, PC3 | Medium | Navigation from chat list item to chat | | `views/chatlist/ChatPreviewView.kt` | PC1, PC2, PC3, PC11 | Medium | Chat row preview rendering | | `views/chatlist/TagListView.kt` | PC28 | Medium | Chat tag filter UI | diff --git a/apps/simplex-badge-service/Main.hs b/apps/simplex-badge-service/Main.hs new file mode 100644 index 0000000000..64a2e1d553 --- /dev/null +++ b/apps/simplex-badge-service/Main.hs @@ -0,0 +1,14 @@ +{-# LANGUAGE NamedFieldPuns #-} + +module Main where + +import BadgeService.Options (BadgeServiceOpts (..)) +import BadgeService.Service +import Simplex.Chat.Terminal (terminalChatConfig) + +main :: IO () +main = do + opts@BadgeServiceOpts {runCLI} <- welcomeGetOpts + if runCLI + then badgeServiceCLI opts + else newServiceState >>= badgeService opts terminalChatConfig diff --git a/apps/simplex-badge-service/README.md b/apps/simplex-badge-service/README.md new file mode 100644 index 0000000000..9776a8de05 --- /dev/null +++ b/apps/simplex-badge-service/README.md @@ -0,0 +1,60 @@ +# SimpleX badge service + +Scaffolding for the SimpleX supporter-badge RPC service. The wire protocol is specified in [`docs/protocol/badges-rpc.md`](../../docs/protocol/badges-rpc.md), and the implementation plans live under [`plans/`](../../plans) (`2026-07-30-supporter-badges-v3-ux.md`, `2026-07-31-badges-core-implementation.md`, `2026-08-04-badges-mvp-scope.md`). + +At this stage the service: + +- creates a double-ratchet contact address on first start (service RPC requires DR, see [`docs/protocol/badges-rpc.md`](../../docs/protocol/badges-rpc.md)), +- listens for service requests (`CEvtServiceRequest`) on that address, rejects a request whose `purchaseKey` is not the key the agent verified the signature against, and answers `redeemBadgeCode`, +- issues redemption codes, storing only their `SHA-256` and printing each code once, +- does not accept contact requests — the address is for RPC only, +- owns the `sx_badge_service_`-prefixed tables and its own migrations table (`sx_badge_service_migrations`). + +Every other command still answers `unsupported_version`. Ledger writes, invoices and provider webhooks are left for follow-up per the plans; a redemption issues one credential and reports an empty statement. + +## Build + +Build prerequisites and the general contribution flow are in [`docs/CONTRIBUTING.md`](../../docs/CONTRIBUTING.md). + +``` +cabal build exe:simplex-badge-service +``` + +## Run + +``` +simplex-badge-service --help +``` + +- default (no `--run-cli`): background service mode, no interactive terminal. +- `--run-cli`: interactive CLI that also processes service requests (mirrors `simplex-directory-service --run-cli`). +- `--no-address`: skip address creation on start-up (for operators who provision the address themselves). + +The service cannot sign credentials without an issuer key and refuses to start without one: + +- `--issuer-key-idx IDX` — the index the apps find the matching public key under (`badgePublicKeys` in `ChatConfig`). +- `--issuer-secret SECRET` — the issuer secret from `simplex-chat badge keygen`. + +The service checks the secret against the configured public key at that index and refuses to start +if they disagree: credentials signed with the wrong key cannot be verified by any client, and the +codes redeemed against them would be spent for nothing. + +## Issuing codes + +Issuing a code is an operator command sent to the running service in `--run-cli` mode, not a way +to start it — so codes are issued without a second process touching the service's database: + +``` +//issue [months] [paid|unpaid|free] +//issue supporter 12 +``` + +`months` defaults to 1 and must be between 1 and 255; the status defaults to `free` and records +whether the code was sold (`paid`), is awaiting payment (`unpaid`), or was issued by an operator +(`free`) — redemption never reads it. + +The code is printed once and only its `SHA-256` is stored, so a code that is not copied when it is +shown cannot be recovered. + +Core parses `//...` into `CustomChatCommand` and leaves it to the service's `preCmdHook`, which is +why issuing codes lives in the service rather than in core. diff --git a/apps/simplex-badge-service/src/BadgeService/Options.hs b/apps/simplex-badge-service/src/BadgeService/Options.hs new file mode 100644 index 0000000000..a2cd46fd84 --- /dev/null +++ b/apps/simplex-badge-service/src/BadgeService/Options.hs @@ -0,0 +1,126 @@ +{-# LANGUAGE ApplicativeDo #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module BadgeService.Options + ( BadgeServiceOpts (..), + BadgeIssuerKey (..), + getBadgeServiceOpts, + badgeServiceOpts, + mkChatOpts, + ) +where + +import qualified Data.ByteString.Char8 as B +import qualified Data.Text as T +import Options.Applicative +import Simplex.Chat.Controller (updateStr, versionNumber, versionString) +import Simplex.Chat.Options (ChatCmdLog (..), ChatOpts (..), CoreChatOpts, CreateBotOpts (..), coreChatOptsP) +import Simplex.Messaging.Crypto.BBS (BBSSecretKey) +import Simplex.Messaging.Encoding.String (strDecode) + +data BadgeServiceOpts = BadgeServiceOpts + { coreOptions :: CoreChatOpts, + serviceName :: T.Text, + clientService :: Bool, + noAddress :: Bool, + runCLI :: Bool, + -- the service refuses to start without this: it cannot sign a credential + issuerKey :: Maybe BadgeIssuerKey, + testing :: Bool + } + +-- | The issuer secret that signs credentials, and the index the apps find its public half under. +data BadgeIssuerKey = BadgeIssuerKey + { keyIdx :: Int, + secretKey :: BBSSecretKey + } + +-- BBSSecretKey derives Show, so this is written out to keep the secret out of logs and errors +instance Show BadgeIssuerKey where + show BadgeIssuerKey {keyIdx} = "issuer key " <> show keyIdx + +badgeServiceOpts :: FilePath -> FilePath -> Parser BadgeServiceOpts +badgeServiceOpts appDir defaultDbName = do + coreOptions <- coreChatOptsP appDir defaultDbName + serviceName <- + strOption + ( long "service-name" + <> metavar "SERVICE_NAME" + <> help "The display name of the badge service bot, without *'s and spaces (SimpleX Badges)" + <> value "SimpleX Badges" + ) + clientService <- + switch + ( long "client-service" + <> help "Use client service certificate" + ) + noAddress <- + switch + ( long "no-address" + <> help "skip checking and creating service address" + ) + runCLI <- + switch + ( long "run-cli" + <> help "Run badge service as CLI" + ) + issuerKeyIdx <- + optional $ + option + auto + ( long "issuer-key-idx" + <> metavar "KEY_IDX" + <> help "Index of the issuer key in the app config (required with --issuer-secret)" + ) + issuerSecret <- + optional $ + option + (eitherReader $ strDecode . B.pack) + ( long "issuer-secret" + <> metavar "ISSUER_SECRET" + <> help "Issuer secret from `simplex-chat badge keygen` (base64url)" + ) + pure + BadgeServiceOpts + { coreOptions, + serviceName = T.pack serviceName, + clientService, + noAddress, + runCLI, + issuerKey = BadgeIssuerKey <$> issuerKeyIdx <*> issuerSecret, + testing = False + } + +getBadgeServiceOpts :: FilePath -> FilePath -> IO BadgeServiceOpts +getBadgeServiceOpts appDir defaultDbName = + execParser $ + info + (helper <*> versionOption <*> badgeServiceOpts appDir defaultDbName) + (header versionStr <> fullDesc <> progDesc "Start SimpleX Badge Service with DB_FILE options") + where + versionStr = versionString versionNumber + versionOption = infoOption versionAndUpdate (long "version" <> short 'v' <> help "Show version") + versionAndUpdate = versionStr <> "\n" <> updateStr + +mkChatOpts :: BadgeServiceOpts -> ChatOpts +mkChatOpts BadgeServiceOpts {coreOptions, serviceName, clientService} = + ChatOpts + { coreOptions, + chatCmd = "", + chatCmdDelay = 3, + chatCmdLog = CCLNone, + chatServerPort = Nothing, + optFilesFolder = Nothing, + optTempDirectory = Nothing, + showReactions = False, + showFullLinks = False, + allowInstantFiles = True, + autoAcceptFileSize = 0, + muteNotifications = True, + markRead = False, + createBot = Just CreateBotOpts {botDisplayName = serviceName, allowFiles = False, clientService}, + userDisplayName = Nothing, + userImageFile = Nothing + } diff --git a/apps/simplex-badge-service/src/BadgeService/Service.hs b/apps/simplex-badge-service/src/BadgeService/Service.hs new file mode 100644 index 0000000000..8337f672a4 --- /dev/null +++ b/apps/simplex-badge-service/src/BadgeService/Service.hs @@ -0,0 +1,379 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TupleSections #-} + +module BadgeService.Service + ( ServiceState (..), + newServiceState, + welcomeGetOpts, + checkIssuerKey, + badgeService, + badgeServiceCLI, + badgeServiceResponse, + badgeErrorRetryAfter, + IssueCodeOpts (..), + issueBadgeCode, + ) +where + +import BadgeService.Options +import BadgeService.Store +import BadgeService.Store.Migrate (runBadgeServiceMigrations) +import Control.Applicative (optional) +import Control.Concurrent.STM +import Control.Logger.Simple +import Control.Monad +import Control.Monad.IO.Class (liftIO) +import qualified Data.Aeson as J +import qualified Data.Aeson.KeyMap as KM +import qualified Data.Attoparsec.ByteString.Char8 as A +import Data.ByteString.Char8 (ByteString) +import Data.Char (isSpace) +import Data.Either (fromRight) +import Data.Functor (($>)) +import Data.Maybe (fromMaybe, maybeToList) +import qualified Data.Map.Strict as M +import qualified Data.Text as T +import Data.Time.Clock (UTCTime, getCurrentTime) +import Data.Word (Word32) +import Simplex.Chat.Badges +import Simplex.Chat.Badges.Code +import Simplex.Chat.Badges.Ledger +import Simplex.Chat.Badges.Service +import Simplex.Chat.Badges.Types (BadgeCodePaymentStatus (..)) +import Simplex.Chat.Bot (initializeBotAddress') +import Simplex.Chat.Bot.Store (withDB, withDB') +import Simplex.Chat.Controller +import Simplex.Chat.Core (sendChatCmd, simplexChatCore) +import Simplex.Chat.Options (printDbOpts) +import Simplex.Chat.Terminal (terminalChatConfig) +import Simplex.Chat.Terminal.Main (simplexChatCLI') +import Simplex.Chat.Types (AgentInvId (..), User (..)) +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.BBS (bbsPublicKey) +import Simplex.Messaging.Encoding.String (TextEncoding, strEncode, textDecode) +import Simplex.Messaging.Version (isCompatible) +import Simplex.Messaging.Util (raceAny_, safeDecodeUtf8, tshow) +import System.Directory (getAppUserDataDirectory) +import System.Exit (exitFailure) + +data ServiceState = ServiceState + { serviceCC :: TMVar ChatController, + serviceRequestQ :: TQueue (User, AgentInvId, Maybe C.PublicKeyEd25519, J.Object) + } + +newServiceState :: IO ServiceState +newServiceState = do + serviceCC <- newEmptyTMVarIO + serviceRequestQ <- newTQueueIO + pure ServiceState {serviceCC, serviceRequestQ} + +welcomeGetOpts :: IO BadgeServiceOpts +welcomeGetOpts = do + appDir <- getAppUserDataDirectory "simplex" + opts@BadgeServiceOpts {coreOptions, testing, serviceName} <- getBadgeServiceOpts appDir "simplex_badge_service" + unless testing $ do + putStrLn $ "SimpleX Badge Service v" ++ versionNumber + printDbOpts coreOptions + putStrLn $ "Service name: " ++ T.unpack serviceName + pure opts + +-- | Check the secret is the key trusted at its index: otherwise every code redeemed is burned. +checkIssuerKey :: BadgeServiceOpts -> ChatConfig -> IO (Either String BadgeIssuerKey) +checkIssuerKey BadgeServiceOpts {issuerKey} ChatConfig {badgePublicKeys} = case issuerKey of + Nothing -> pure $ Left "an issuer key is required - pass both --issuer-key-idx and --issuer-secret (see `simplex-chat badge keygen`)" + Just k@BadgeIssuerKey {keyIdx, secretKey} -> + bbsPublicKey secretKey >>= \case + Left e -> pure $ Left $ "issuer secret is not a valid key: " <> e + Right pk -> pure $ case M.lookup keyIdx badgePublicKeys of + Just pk' | pk' == pk -> Right k + Just _ -> Left $ "issuer secret does not match the configured key at index " <> show keyIdx <> ", its public key is " <> T.unpack (safeDecodeUtf8 $ strEncode pk) + Nothing -> Left $ "no configured badge key at index " <> show keyIdx <> ", clients could not verify what this service signs" + +requireIssuerKey :: BadgeServiceOpts -> ChatConfig -> IO BadgeIssuerKey +requireIssuerKey opts cfg = + checkIssuerKey opts cfg >>= either (\e -> putStrLn ("Error: " <> e) >> exitFailure) pure + +badgeService :: BadgeServiceOpts -> ChatConfig -> ServiceState -> IO () +badgeService opts cfg env = do + key <- requireIssuerKey opts cfg + let chatHooks = + defaultChatHooks + { preStartHook = Just $ badgePreStartHook opts, + postStartHook = Just $ badgePostStartHook opts env, + preCmdHook = Just badgeCmdHook + } + -- the reader must not block: outputQ carries every chat event + simplexChatCore cfg {chatHooks} (mkChatOpts opts) $ \_ cc -> + raceAny_ + [ forever $ + atomically (readTBQueue $ outputQ cc) >>= \case + (_, Right (CEvtServiceRequest u reqId sigKey reqData)) -> + atomically $ writeTQueue (serviceRequestQ env) (u, reqId, sigKey, reqData) + _ -> pure (), + processQueuedRequests key env + ] + +badgeServiceCLI :: BadgeServiceOpts -> IO () +badgeServiceCLI opts = do + key <- requireIssuerKey opts terminalChatConfig + env <- newServiceState + let eventHook _cc ev = do + case ev of + Right (CEvtServiceRequest u reqId sigKey reqData) -> + atomically $ writeTQueue (serviceRequestQ env) (u, reqId, sigKey, reqData) + _ -> pure () + pure ev + chatHooks = + defaultChatHooks + { preStartHook = Just $ badgePreStartHook opts, + postStartHook = Just $ badgePostStartHook opts env, + preCmdHook = Just badgeCmdHook, + eventHook = Just eventHook + } + raceAny_ + [ simplexChatCLI' terminalChatConfig {chatHooks} (mkChatOpts opts) Nothing, + processQueuedRequests key env + ] + +-- | issuing codes lives here rather than in core: every user's app would otherwise ship it +badgeCmdHook :: ChatController -> ChatCommand -> IO (Either (Either ChatError ChatResponse) ChatCommand) +badgeCmdHook cc = \case + CustomChatCommand cmd -> Left <$> runBadgeCmd cc cmd + cmd -> pure $ Right cmd + +runBadgeCmd :: ChatController -> ByteString -> IO (Either ChatError ChatResponse) +runBadgeCmd cc cmd = case A.parseOnly issueCmdP cmd of + Left _ -> pure $ chatCmdError "use: //issue supporter|legend|investor [months 1-255] [paid|unpaid|free]" + Right issueOpts -> + issueBadgeCode cc issueOpts >>= \case + Right code -> pure $ Right CRCustomChatResponse {user_ = Nothing, response = "code " <> formatBadgeCode code} + Left e -> pure $ chatCmdError $ "issuing code: " <> e + +issueCmdP :: A.Parser IssueCodeOpts +issueCmdP = + "issue " *> do + badgeType <- badgeTypeP + months_ <- optional (A.space *> A.decimal) + -- outside `optional`, which would otherwise backtrack past a bad count + months <- maybe (pure 1) checkMonths months_ + paymentStatus <- fromMaybe CPSFree <$> optional (A.space *> textTokenP) + A.skipSpace + A.endOfInput + pure IssueCodeOpts {badgeType, months, paymentStatus} + where + checkMonths n + | n >= 1 && n <= (255 :: Int) = pure n + | otherwise = fail "months must be between 1 and 255" + -- BadgeType decodes anything to BTUnknown, so a typo would issue an unusable code + badgeTypeP = + textTokenP >>= \case + BTUnknown t -> fail $ "unknown badge type " <> T.unpack t + bt -> pure bt + textTokenP :: TextEncoding a => A.Parser a + textTokenP = do + t <- A.takeWhile1 (not . isSpace) + maybe (fail "invalid value") pure $ textDecode $ safeDecodeUtf8 t + +data IssueCodeOpts = IssueCodeOpts + { badgeType :: BadgeType, + months :: Int, + paymentStatus :: BadgeCodePaymentStatus + } + +-- | The caller sees the code once; only its hash is stored, so a lost code cannot be recovered. +issueBadgeCode :: ChatController -> IssueCodeOpts -> IO (Either String BadgeCode) +issueBadgeCode cc IssueCodeOpts {badgeType, months, paymentStatus} = do + code <- randomBadgeCode $ random cc + now <- getCurrentTime + r <- withDB' "issueBadgeCode" cc $ \db -> insertBadgeCode db (badgeCodeHash code) badgeType months paymentStatus now + pure $ code <$ r + +processQueuedRequests :: BadgeIssuerKey -> ServiceState -> IO () +processQueuedRequests key env = do + cc <- atomically $ readTMVar $ serviceCC env + forever $ do + (u, reqId, sigKey, reqData) <- atomically $ readTQueue $ serviceRequestQ env + handleServiceRequest key cc u reqId sigKey reqData + +badgePreStartHook :: BadgeServiceOpts -> ChatController -> IO () +badgePreStartHook opts ChatController {config, chatStore} = + runBadgeServiceMigrations opts config chatStore + +badgePostStartHook :: BadgeServiceOpts -> ServiceState -> ChatController -> IO () +badgePostStartHook BadgeServiceOpts {noAddress, testing} env cc = do + -- SREQ delivery gates on this flag; Core starts serviceRequests=False, so the hook must set it. + atomically $ writeTVar (processServiceRequests cc) True + readTVarIO (currentUser cc) >>= \case + Nothing -> putStrLn "No current user" >> exitFailure + -- DR required for service RPC; autoAccept off because badge service ignores contact events. + Just _ -> do + unless noAddress $ initializeBotAddress' (not testing) (Just True) False cc + void $ atomically $ tryPutTMVar (serviceCC env) cc + +handleServiceRequest :: BadgeIssuerKey -> ChatController -> User -> AgentInvId -> Maybe C.PublicKeyEd25519 -> J.Object -> IO () +handleServiceRequest key cc User {userId} reqId sigKey reqData = do + let reqIdT = safeDecodeUtf8 (strEncode reqId) + logInfo $ "badge service request " <> reqIdT + resp <- badgeServiceResponse key cc sigKey reqData + sendChatCmd cc (APISendServiceResponse userId reqId (responseObject resp)) >>= \case + Right _ -> pure () + Left e -> logError $ "badge service response failed for " <> reqIdT <> ": " <> tshow e + +responseObject :: BadgeServiceResponse -> J.Object +responseObject r = case J.toJSON r of + J.Object o -> o + _ -> KM.fromList [("type", J.String "error"), ("code", J.toJSON BSEInternal)] + +errorResponse :: BadgeServiceErrorCode -> BadgeServiceResponse +errorResponse code = BSPError {code, message = Nothing, retryAfter = badgeErrorRetryAfter code} + +-- | Seconds, for the three codes badges-rpc.md marks transient. Every other code is terminal for +-- the command attempted - internal included, which would otherwise press a failing service. +badgeErrorRetryAfter :: BadgeServiceErrorCode -> Maybe Word32 +badgeErrorRetryAfter = \case + BSEPaymentPending -> Just 300 + BSEProviderUnavailable -> Just 300 + BSERateLimited -> Just 60 + _ -> Nothing + + +-- | The agent verified the signature, so sigKey is a key the sender holds - a purchaseKey that +-- differs would let a client claim a purchase it cannot sign for. +badgeServiceResponse :: BadgeIssuerKey -> ChatController -> Maybe C.PublicKeyEd25519 -> J.Object -> IO BadgeServiceResponse +badgeServiceResponse key cc sigKey reqData = case J.fromJSON (J.Object reqData) of + J.Error _ -> pure $ errorResponse BSEBadRequest + J.Success BadgeServiceRequest {version, purchaseKey, request} + | not (version `isCompatible` supportedBadgeServiceVRange) -> pure $ errorResponse BSEUnsupportedVersion + | purchaseKey /= sigKey -> pure $ errorResponse BSEBadRequest + | otherwise -> case request of + BSCRedeemBadgeCode {masterKey, code} -> case purchaseKey of + Just k -> redeemCode key cc k masterKey code + Nothing -> pure $ errorResponse BSEBadRequest + BSCIssueBadge {balance} -> case purchaseKey of + Just k -> issueBadgeCmd key cc k balance + Nothing -> pure $ errorResponse BSEBadRequest + -- every command but redeemBadgeCode needs a key the service already knows: that one + -- creates the purchase, so its key is unknown on a first redemption + _ -> case purchaseKey of + Nothing -> pure $ errorResponse BSEUnsupportedVersion + Just k -> + withDB' "purchaseKeyExists" cc (`purchaseKeyExists` k) >>= \case + Right True -> pure $ errorResponse BSEUnsupportedVersion + Right False -> pure $ errorResponse BSEUnknownPurchaseKey + Left _ -> pure $ errorResponse BSEInternal + +-- | The only clock the service reads, so a test can move both sides of a request together. +badgeNow :: ChatController -> IO UTCTime +badgeNow ChatController {config = ChatConfig {badgeCurrentTime}} = badgeCurrentTime + +randomId :: ChatController -> IO T.Text +randomId cc = safeDecodeUtf8 . strEncode <$> atomically (C.randomBytes 16 (random cc)) + +-- | Neither value comes from the caller: the badge type is the entry's, the expiry is derived +-- from the period end it carries. +credentialForEntry :: BadgeIssuerKey -> BadgeMasterKey -> StatementEntry -> IO (Either String (StatementEntry, BadgeCredential)) +credentialForEntry BadgeIssuerKey {keyIdx, secretKey} masterKey e@StatementEntry {balanceStartTs = periodEnd, balanceBadgeType} = do + let badgeInfo = BadgeInfo {badgeType = balanceBadgeType, badgeExpiry = endOfMondayAfter periodEnd, badgeExtra = ""} + fmap (e,) <$> issueBadge keyIdx secretKey (VerifiedBadgeRequest BadgeRequest {masterKey, badgeInfo}) + +-- | Pairs the issued entry with the one before it, which the writer needs for the period start. +issuanceAfter :: StatementEntry -> (StatementEntry, BadgeCredential) -> (StatementEntry, StatementEntry, BadgeCredential) +issuanceAfter previous (issued, credential) = (previous, issued, credential) + +credentialResponse :: Maybe BadgeCredential -> Maybe T.Text -> [StatementEntry] -> BadgeServiceResponse +credentialResponse credential previousEntryId entries = + BSPBadgeCredential {credential, receipt = Nothing, statement = BadgeStatement {entries, previousEntryId}} + +-- | Nothing is written until the credential is signed, so a signing failure leaves the code +-- unspent rather than spent with nothing behind it. +redeemCode :: BadgeIssuerKey -> ChatController -> C.PublicKeyEd25519 -> BadgeMasterKey -> T.Text -> IO BadgeServiceResponse +redeemCode key cc purchaseKey masterKey codeText = case parseBadgeCode codeText of + Nothing -> pure $ errorResponse BSECodeInvalid + Just code -> + withDB "getBadgeCode" cc (readCode code) >>= \case + Left _ -> pure $ errorResponse BSEInternal + Right (Left resp) -> pure resp + Right (Right IssuedCode {badgeCodeId, badgeType, months}) -> do + now <- badgeNow cc + (grantUuid, issueUuid) <- (,) <$> randomId cc <*> randomId cc + -- the purchase is created here, so there is no ledger to lapse + -- TODO [badges] a top-up grants onto an existing ledger, and must lapse before it or the + -- months it adds are counted from a start already in the past + let granted = grantEntry now grantUuid months SCCode $ emptyEntry now badgeType + -- a grant of at least one month starting now always has a month to issue + case issueEntry now issueUuid granted of + Nothing -> pure $ errorResponse BSEInternal + Just issued -> credentialForEntry key masterKey issued >>= \case + Left e -> logError ("badge service signing failed: " <> T.pack e) $> errorResponse BSEInternal + Right signed -> do + -- re-read: a concurrent redemption may have landed while this one was signing + r <- withDB "writeCodeRedemption" cc $ \db -> + readCode code db >>= \case + Left resp -> pure resp + Right _ -> liftIO $ do + purchaseId <- createCodePurchase db NewCodePurchase {badgeCodeId, purchaseKey, masterKey, badgeType} now + appendLedgerPlan db purchaseId [granted] $ Just $ issuanceAfter granted signed + entries_ <- getLedgerEntries db purchaseId 0 + pure $ maybe (errorResponse BSEInternal) (credentialResponse (Just $ snd signed) Nothing) entries_ + pure $ fromRight (errorResponse BSEInternal) r + where + -- used before signing and again inside the write transaction; every Left is a finished + -- response, an unknown code included + readCode code db = liftIO $ + getBadgeCode db (badgeCodeHash code) >>= \case + Nothing -> pure $ Left $ errorResponse BSECodeInvalid + Just c@IssuedCode {redemption} -> fmap (const c) <$> checkUnspent db redemption + checkUnspent db = \case + CodeUnredeemed -> pure $ Right () + CodeRedeemedUnreadable -> pure $ Left $ errorResponse BSEInternal + CodeRedeemed RedeemedCode {purchaseKey = k, badgePurchaseId, credential} + | k /= purchaseKey -> pure $ Left $ errorResponse BSECodeUsed + -- the whole ledger, so a client that lost the first response still ends holding it + | otherwise -> + maybe (Left $ errorResponse BSEInternal) (Left . credentialResponse (Just credential) Nothing) + <$> getLedgerEntries db badgePurchaseId 0 + +-- | The purchase is reached through the verified signer key and no other way. +issueBadgeCmd :: BadgeIssuerKey -> ChatController -> C.PublicKeyEd25519 -> BadgeBalance -> IO BadgeServiceResponse +issueBadgeCmd key cc purchaseKey BadgeBalance {lastEntry} = do + now <- badgeNow cc + purchase_ <- withDB' "getBadgePurchase" cc $ \db -> do + p_ <- getPurchaseByKey db purchaseKey + forM p_ $ \p@ServicePurchase {badgePurchaseId} -> (p,) <$> getLedgerTip db badgePurchaseId + case purchase_ of + Left _ -> pure $ errorResponse BSEInternal + Right Nothing -> pure $ errorResponse BSEUnknownPurchaseKey + Right (Just (ServicePurchase {badgePurchaseId, masterKey, badgeType}, tip)) -> do + (lapseUuid, issueUuid) <- (,) <$> randomId cc <*> randomId cc + let tipEntry = fromMaybe (emptyEntry now badgeType) tip + lapsed = lapseEntry now lapseUuid tipEntry + current = fromMaybe tipEntry lapsed + case issueEntry now issueUuid current of + Nothing -> writeIssued badgePurchaseId tip (maybeToList lapsed) now Nothing + Just e -> + credentialForEntry key masterKey e >>= \case + Left err -> logError ("badge service signing failed: " <> T.pack err) $> errorResponse BSEInternal + Right signed -> + writeIssued badgePurchaseId tip (maybeToList lapsed) now $ Just $ issuanceAfter current signed + where + -- the rows were computed from a tip that another request may have moved, and an issuance was + -- signed against it - so write only if it is still the tip + writeIssued purchaseId tip rows t issuance_ = do + r <- withDB "issueBadge" cc $ \db -> liftIO $ do + tip' <- getLedgerTip db purchaseId + when (fmap entryId tip' == fmap entryId tip) $ appendLedgerPlan db purchaseId rows issuance_ + issueResponse db purchaseId t + pure $ fromRight (errorResponse BSEInternal) r + -- entries after the one asserted, or the whole ledger when this purchase does not hold it. + -- Only the asserted entry's identity is read, never the months it claims. + issueResponse db purchaseId t = do + let StatementEntry {entryId = assertedUuid} = lastEntry + assertedId <- getLedgerEntryId db purchaseId assertedUuid + -- TODO [badges] when the assertion does not resolve, heal the ledger and restate it as a + -- single opening credit (badges-rpc.md), rather than resending the whole history + entries_ <- getLedgerEntries db purchaseId (fromMaybe 0 assertedId) + credential_ <- getCurrentIssuance db purchaseId t + pure $ maybe (errorResponse BSEInternal) (credentialResponse credential_ (assertedUuid <$ assertedId)) entries_ diff --git a/apps/simplex-badge-service/src/BadgeService/Store.hs b/apps/simplex-badge-service/src/BadgeService/Store.hs new file mode 100644 index 0000000000..dac51a5793 --- /dev/null +++ b/apps/simplex-badge-service/src/BadgeService/Store.hs @@ -0,0 +1,256 @@ +{-# LANGUAGE CPP #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} +{-# LANGUAGE ScopedTypeVariables #-} + +module BadgeService.Store + ( IssuedCode (..), + CodeRedemption (..), + RedeemedCode (..), + NewCodePurchase (..), + ServicePurchase (..), + getBadgeCode, + purchaseKeyExists, + getPurchaseByKey, + getLedgerTip, + getLedgerEntryId, + getLedgerEntries, + getCurrentIssuance, + appendLedgerPlan, + createCodePurchase, + insertBadgeCode, + ) +where + +import qualified Data.Aeson as J +import Data.ByteString.Char8 (ByteString) +import qualified Data.ByteString.Lazy.Char8 as LB +import Data.Int (Int64) +import Data.Text (Text) +import Data.Time.Clock (UTCTime) +import Simplex.Chat.Badges (BadgeCredential, BadgeMasterKey (..), BadgeType) +import Simplex.Chat.Badges.Ledger +import Simplex.Chat.Badges.Service (StatementEntry (..)) +import Simplex.Chat.Badges.Types (BadgeCodePaymentStatus, BadgePurchaseStatus (..)) +import Simplex.Chat.Store.Shared (insertedRowId) +import Simplex.Messaging.Agent.Store.DB (Binary (..)) +import qualified Simplex.Messaging.Agent.Store.DB as DB +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Util (maybeFirstRow, maybeFirstRow') + +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (Only (..), (:.) (..)) +import Database.PostgreSQL.Simple.SqlQQ (sql) +#else +import Database.SQLite.Simple (Only (..), (:.) (..)) +import Database.SQLite.Simple.QQ (sql) +#endif + +data IssuedCode = IssuedCode + { badgeCodeId :: Int64, + badgeType :: BadgeType, + months :: Int, + redemption :: CodeRedemption + } + +-- A code that has a purchase is spent, even if its credential cannot be read. Treating that as +-- an unredeemed code would issue a second credential for it. +data CodeRedemption + = CodeUnredeemed + | CodeRedeemed RedeemedCode + | CodeRedeemedUnreadable + +data RedeemedCode = RedeemedCode + { badgePurchaseId :: Int64, + purchaseKey :: C.PublicKeyEd25519, + credential :: BadgeCredential + } + +-- Its rows and issuance are appended by 'appendLedgerPlan' in the same transaction: a code marked +-- redeemed while another write failed would be spent with no credential, and nothing reissues it. +data NewCodePurchase = NewCodePurchase + { badgeCodeId :: Int64, + purchaseKey :: C.PublicKeyEd25519, + masterKey :: BadgeMasterKey, + badgeType :: BadgeType + } + +data ServicePurchase = ServicePurchase + { badgePurchaseId :: Int64, + masterKey :: BadgeMasterKey, + badgeType :: BadgeType + } + +getBadgeCode :: DB.Connection -> ByteString -> IO (Maybe IssuedCode) +getBadgeCode db codeHash = + maybeFirstRow toCode $ + DB.query + db + [sql| + SELECT c.badge_code_id, c.badge_type, c.months, p.badge_purchase_id, p.purchase_key, i.credential + FROM sx_badge_service_badge_codes c + LEFT JOIN sx_badge_service_badge_purchases p ON p.badge_code_id = c.badge_code_id + LEFT JOIN sx_badge_service_badge_issuances i ON i.badge_purchase_id = p.badge_purchase_id + WHERE c.code_hash = ? + ORDER BY i.period_end DESC + LIMIT 1 + |] + (Only (Binary codeHash)) + where + toCode (badgeCodeId, badgeType, months, purchaseId_, purchaseKey_, credential_) = + IssuedCode {badgeCodeId, badgeType, months, redemption = codeRedemption purchaseId_ purchaseKey_ credential_} + codeRedemption purchaseId_ purchaseKey_ credential_ = case (purchaseId_, purchaseKey_) of + (Just badgePurchaseId, Just purchaseKey) -> case decodeCredential =<< credential_ of + Just credential -> CodeRedeemed RedeemedCode {badgePurchaseId, purchaseKey, credential} + Nothing -> CodeRedeemedUnreadable + _ -> CodeUnredeemed + decodeCredential (Binary bs) = J.decodeStrict' bs + +purchaseKeyExists :: DB.Connection -> C.PublicKeyEd25519 -> IO Bool +purchaseKeyExists db key = + maybeFirstRow' False (\(Only (_ :: Int64)) -> True) $ + DB.query db "SELECT badge_purchase_id FROM sx_badge_service_badge_purchases WHERE purchase_key = ?" (Only key) + +-- | The only route from a command to a purchase, so a client cannot name one it cannot sign for. +getPurchaseByKey :: DB.Connection -> C.PublicKeyEd25519 -> IO (Maybe ServicePurchase) +getPurchaseByKey db key = + maybeFirstRow toPurchase $ + DB.query + db + [sql| + SELECT badge_purchase_id, master_key, current_badge_type + FROM sx_badge_service_badge_purchases + WHERE purchase_key = ? + |] + (Only key) + where + toPurchase (badgePurchaseId, Binary mk, badgeType) = + ServicePurchase {badgePurchaseId, masterKey = BadgeMasterKey mk, badgeType} + +getLedgerTip :: DB.Connection -> Int64 -> IO (Maybe StatementEntry) +getLedgerTip db purchaseId = + maybeFirstRow' Nothing toEntry $ + DB.query + db + [sql| + SELECT entry_uuid, change_months, balance_months, balance_start_ts, balance_anchor_ts, balance_badge_type, + entry_type, entry_credit_type, entry_debit_type, service_created_at + FROM sx_badge_service_badge_ledger + WHERE badge_purchase_id = ? + ORDER BY entry_id DESC + LIMIT 1 + |] + (Only purchaseId) + +-- | The uuid is the client's claim about its last held entry, so the lookup is scoped to its own +-- purchase - an entry_id taken from another ledger would silently skip rows of this one. +getLedgerEntryId :: DB.Connection -> Int64 -> Text -> IO (Maybe Int64) +getLedgerEntryId db purchaseId entryUuid = + maybeFirstRow fromOnly $ + DB.query + db + "SELECT entry_id FROM sx_badge_service_badge_ledger WHERE badge_purchase_id = ? AND entry_uuid = ?" + (purchaseId, entryUuid) + +-- | 0 for the whole ledger, as entry_id starts at 1. 'Nothing' when a stored row has a type this +-- version cannot represent, rather than sending it changed into another. +getLedgerEntries :: DB.Connection -> Int64 -> Int64 -> IO (Maybe [StatementEntry]) +getLedgerEntries db purchaseId afterEntryId = + mapM toEntry + <$> DB.query + db + [sql| + SELECT entry_uuid, change_months, balance_months, balance_start_ts, balance_anchor_ts, balance_badge_type, + entry_type, entry_credit_type, entry_debit_type, service_created_at + FROM sx_badge_service_badge_ledger + WHERE badge_purchase_id = ? AND entry_id > ? + ORDER BY entry_id + |] + (purchaseId, afterEntryId) + +toEntry :: (Text, Int, Int, UTCTime, UTCTime, BadgeType, Text, Maybe Text, Maybe Text, UTCTime) -> Maybe StatementEntry +toEntry (entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType, entryType_, credit_, debit_, createdAt) = + (\entryType -> StatementEntry {entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType, wasPausedSince = Nothing, createdAt, entryType}) + <$> entryTypeFromColumns entryType_ credit_ debit_ + +-- | Answers a repeat inside an issued month, rather than signing the same content twice. +getCurrentIssuance :: DB.Connection -> Int64 -> UTCTime -> IO (Maybe BadgeCredential) +getCurrentIssuance db purchaseId now = do + rs <- + DB.query + db + [sql| + SELECT credential FROM sx_badge_service_badge_issuances + WHERE badge_purchase_id = ? AND period_end > ? + ORDER BY period_end DESC + LIMIT 1 + |] + (purchaseId, now) + pure $ case rs of + [Only (Binary bs)] -> J.decodeStrict' bs + _ -> Nothing + +-- | The issuance is the entry that spends the month and the one before it, which give the period. +-- TODO [badges] also write the reference columns - payment_id, charge_id, from_purchase_id, +-- to_purchase_id - for the entry types that carry one. Only the tag is written today, so a +-- payment, charge, transferIn, upgrade or transferOut row would be stored without its reference. +appendLedgerPlan :: DB.Connection -> Int64 -> [StatementEntry] -> Maybe (StatementEntry, StatementEntry, BadgeCredential) -> IO () +appendLedgerPlan db purchaseId rows issuance_ = do + mapM_ appendRow rows + case issuance_ of + Nothing -> pure () + Just (previous, issued@StatementEntry {entryId, balanceStartTs = periodEnd, balanceBadgeType, createdAt}, credential) -> do + rowId <- appendRow issued + DB.execute + db + [sql| + INSERT INTO sx_badge_service_badge_issuances + (issuance_id, badge_purchase_id, entry_id, badge_type, period_start, period_end, expiry, credential, created_at) + VALUES (?,?,?,?,?,?,?,?,?) + |] + -- the issued entry's uuid is the issuance id: one issuance per such entry, and entry uuids + -- are already unique across the ledger, so nothing has to be drawn for it + ( (entryId, purchaseId, rowId, balanceBadgeType) + :. (balanceStartTs previous, periodEnd, endOfMondayAfter periodEnd, Binary (LB.toStrict $ J.encode credential), createdAt) + ) + where + appendRow StatementEntry {entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType, createdAt, entryType} = do + let (entryTypeT, creditType, debitType) = entryTypeColumns entryType + DB.execute + db + [sql| + INSERT INTO sx_badge_service_badge_ledger + (entry_uuid, badge_purchase_id, change_months, balance_months, balance_start_ts, balance_anchor_ts, + balance_badge_type, service_created_at, created_at, entry_type, entry_credit_type, entry_debit_type) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + |] + ((entryId, purchaseId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs) :. (balanceBadgeType, createdAt, createdAt, entryTypeT, creditType, debitType)) + insertedRowId db + +-- redeemed_at is stamped here, so this must share a transaction with the credential's rows: +-- a code marked spent without one can never be reissued +createCodePurchase :: DB.Connection -> NewCodePurchase -> UTCTime -> IO Int64 +createCodePurchase db NewCodePurchase {badgeCodeId, purchaseKey, masterKey = BadgeMasterKey mk, badgeType} now = do + DB.execute + db + [sql| + INSERT INTO sx_badge_service_badge_purchases + (purchase_key, master_key, initial_badge_type, current_badge_type, status, badge_code_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?) + |] + (purchaseKey, Binary mk, badgeType, badgeType, PSIssued, badgeCodeId, now, now) + purchaseId <- insertedRowId db + DB.execute db "UPDATE sx_badge_service_badge_codes SET redeemed_at = ? WHERE badge_code_id = ?" (now, badgeCodeId) + pure purchaseId + +insertBadgeCode :: DB.Connection -> ByteString -> BadgeType -> Int -> BadgeCodePaymentStatus -> UTCTime -> IO () +insertBadgeCode db codeHash badgeType months paymentStatus now = + DB.execute + db + [sql| + INSERT INTO sx_badge_service_badge_codes (code_hash, badge_type, months, code_payment_status, created_at) + VALUES (?,?,?,?,?) + |] + (Binary codeHash, badgeType, months, paymentStatus, now) diff --git a/apps/simplex-badge-service/src/BadgeService/Store/Migrate.hs b/apps/simplex-badge-service/src/BadgeService/Store/Migrate.hs new file mode 100644 index 0000000000..506043c262 --- /dev/null +++ b/apps/simplex-badge-service/src/BadgeService/Store/Migrate.hs @@ -0,0 +1,40 @@ +{-# LANGUAGE CPP #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module BadgeService.Store.Migrate + ( runBadgeServiceMigrations, + ) +where + +import BadgeService.Options +import Simplex.Chat.Controller (ChatConfig (..)) +import Simplex.Chat.Options (CoreChatOpts (..)) +import Simplex.Chat.Options.DB +import Simplex.Messaging.Agent.Store.Common +import Simplex.Messaging.Agent.Store.Interface (migrateDBSchema) +import Simplex.Messaging.Agent.Store.Shared (MigrationConfig (..), MigrationConfirmation (..)) +import System.Exit (exitFailure) + +#if defined(dbPostgres) +import BadgeService.Store.Postgres.Migrations +#else +import BadgeService.Store.SQLite.Migrations +#endif + +runBadgeServiceMigrations :: BadgeServiceOpts -> ChatConfig -> DBStore -> IO () +runBadgeServiceMigrations opts ChatConfig {confirmMigrations} chatStore = + migrateDBSchema + chatStore + (toDBOpts dbOptions chatSuffix False []) + (Just "sx_badge_service_migrations") + badgeServiceSchemaMigrations + MigrationConfig {confirm, backupPath = Nothing} + >>= either (exit . ("badge service migrations " <>) . show) pure + where + BadgeServiceOpts {coreOptions = CoreChatOpts {dbOptions, yesToUpMigrations}} = opts + confirm = if confirmMigrations == MCConsole && yesToUpMigrations then MCYesUp else confirmMigrations + +exit :: String -> IO a +exit err = putStrLn ("Error: " <> err) >> exitFailure diff --git a/apps/simplex-badge-service/src/BadgeService/Store/Postgres/Migrations.hs b/apps/simplex-badge-service/src/BadgeService/Store/Postgres/Migrations.hs new file mode 100644 index 0000000000..e8b62c2d53 --- /dev/null +++ b/apps/simplex-badge-service/src/BadgeService/Store/Postgres/Migrations.hs @@ -0,0 +1,85 @@ +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module BadgeService.Store.Postgres.Migrations (badgeServiceSchemaMigrations) where + +import Data.List (sortOn) +import Data.Text (Text) +import Simplex.Chat.Store.Postgres.Migrations.M20260915_user_badges (badgeSchema, badgeSchemaDown, withPrefix) +import Simplex.Messaging.Agent.Store.Shared (Migration (..)) +import Text.RawString.QQ (r) + +badgeServiceSchemaMigrations :: [Migration] +badgeServiceSchemaMigrations = sortOn name $ map migration schemaMigrations + where + migration (name, up, down) = Migration {name, up, down} + +schemaMigrations :: [(String, Text, Maybe Text)] +schemaMigrations = + [ ("20260806_badge_service_schema", m20260806_badge_service_schema, Just down_m20260806_badge_service_schema) + ] + +-- the client tables are in the same database, so the service tables are the same names with this prefix +servicePrefix :: Text +servicePrefix = "sx_badge_service_" + +m20260806_badge_service_schema :: Text +m20260806_badge_service_schema = + badgeSchema servicePrefix + <> withPrefix + servicePrefix + [r| +CREATE TABLE @badge_codes( + badge_code_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + code_hash BYTEA NOT NULL, + badge_type TEXT NOT NULL, + months SMALLINT NOT NULL, + code_payment_status TEXT NOT NULL, + redeemed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL, + UNIQUE(code_hash) +); + +ALTER TABLE @badge_purchases ADD COLUMN badge_code_id BIGINT REFERENCES @badge_codes; + +CREATE UNIQUE INDEX @idx_badge_purchases_code ON @badge_purchases(badge_code_id); + +CREATE TABLE @badge_code_invoices( + invoice_id TEXT NOT NULL PRIMARY KEY REFERENCES @invoices ON DELETE CASCADE, + price_id TEXT NOT NULL REFERENCES @badge_prices, + offer_id TEXT REFERENCES @badge_offers, + months SMALLINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX @idx_badge_code_invoices_offer ON @badge_code_invoices(offer_id); + +CREATE INDEX @idx_badge_code_invoices_price ON @badge_code_invoices(price_id); +|] + +down_m20260806_badge_service_schema :: Text +down_m20260806_badge_service_schema = + withPrefix + servicePrefix + [r| +DROP INDEX @idx_badge_code_invoices_offer; +DROP INDEX @idx_badge_code_invoices_price; +DROP TABLE @badge_code_invoices; + +DROP INDEX @idx_badge_purchases_code; +|] + <> badgeSchemaDown servicePrefix + <> withPrefix + servicePrefix + [r| +DROP TABLE @badge_codes; +|] + +{- TODO [badges] deferred with the draft in M20260915_user_badges, service only. + +ALTER TABLE @payments ADD COLUMN receipt_hash BYTEA; + +-- down +ALTER TABLE @payments DROP COLUMN receipt_hash; +-} diff --git a/apps/simplex-badge-service/src/BadgeService/Store/SQLite/Migrations.hs b/apps/simplex-badge-service/src/BadgeService/Store/SQLite/Migrations.hs new file mode 100644 index 0000000000..ce08db0c7a --- /dev/null +++ b/apps/simplex-badge-service/src/BadgeService/Store/SQLite/Migrations.hs @@ -0,0 +1,86 @@ +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module BadgeService.Store.SQLite.Migrations (badgeServiceSchemaMigrations) where + +import Data.List (sortOn) +import Data.Text (Text) +import Database.SQLite.Simple (Query (..)) +import Database.SQLite.Simple.QQ (sql) +import Simplex.Chat.Store.SQLite.Migrations.M20260915_user_badges (badgeSchema, badgeSchemaDown, withPrefix) +import Simplex.Messaging.Agent.Store.Shared (Migration (..)) + +badgeServiceSchemaMigrations :: [Migration] +badgeServiceSchemaMigrations = sortOn name $ map migration schemaMigrations + where + migration (name, up, down) = Migration {name, up = fromQuery up, down = fromQuery <$> down} + +schemaMigrations :: [(String, Query, Maybe Query)] +schemaMigrations = + [ ("20260806_badge_service_schema", m20260806_badge_service_schema, Just down_m20260806_badge_service_schema) + ] + +-- the client tables are in the same database, so the service tables are the same names with this prefix +servicePrefix :: Text +servicePrefix = "sx_badge_service_" + +m20260806_badge_service_schema :: Query +m20260806_badge_service_schema = + badgeSchema servicePrefix + <> withPrefix + servicePrefix + [sql| +CREATE TABLE @badge_codes( + badge_code_id INTEGER PRIMARY KEY AUTOINCREMENT, + code_hash BLOB NOT NULL, + badge_type TEXT NOT NULL, + months INTEGER NOT NULL, + code_payment_status TEXT NOT NULL, + redeemed_at TEXT, + created_at TEXT NOT NULL, + UNIQUE(code_hash) +) STRICT; + +ALTER TABLE @badge_purchases ADD COLUMN badge_code_id INTEGER REFERENCES @badge_codes; + +CREATE UNIQUE INDEX @idx_badge_purchases_code ON @badge_purchases(badge_code_id); + +CREATE TABLE @badge_code_invoices( + invoice_id TEXT NOT NULL PRIMARY KEY REFERENCES @invoices ON DELETE CASCADE, + price_id TEXT NOT NULL REFERENCES @badge_prices, + offer_id TEXT REFERENCES @badge_offers, + months INTEGER NOT NULL, + created_at TEXT NOT NULL +) STRICT; + +CREATE INDEX @idx_badge_code_invoices_offer ON @badge_code_invoices(offer_id); + +CREATE INDEX @idx_badge_code_invoices_price ON @badge_code_invoices(price_id); +|] + +down_m20260806_badge_service_schema :: Query +down_m20260806_badge_service_schema = + withPrefix + servicePrefix + [sql| +DROP INDEX @idx_badge_code_invoices_offer; +DROP INDEX @idx_badge_code_invoices_price; +DROP TABLE @badge_code_invoices; + +DROP INDEX @idx_badge_purchases_code; +|] + <> badgeSchemaDown servicePrefix + <> withPrefix + servicePrefix + [sql| +DROP TABLE @badge_codes; +|] + +{- TODO [badges] deferred with the draft in M20260915_user_badges, service only. + +ALTER TABLE @payments ADD COLUMN receipt_hash BLOB; + +-- down +ALTER TABLE @payments DROP COLUMN receipt_hash; +-} diff --git a/apps/simplex-directory-service/src/Directory/Options.hs b/apps/simplex-directory-service/src/Directory/Options.hs index 89709e66d9..03f4d7bb0c 100644 --- a/apps/simplex-directory-service/src/Directory/Options.hs +++ b/apps/simplex-directory-service/src/Directory/Options.hs @@ -41,6 +41,7 @@ data DirectoryOpts = DirectoryOpts linkCheckInterval :: Int, prohibitedToObserver :: Bool, alwaysCaptcha :: Bool, + alwaysObserver :: Bool, knocking :: Bool, testing :: Bool } @@ -170,6 +171,11 @@ directoryOpts appDir defaultDbName = do ( long "always-captcha" <> help "Require a captcha from joining members in all groups, regardless of per-group filter settings" ) + alwaysObserver <- + switch + ( long "always-observer" + <> help "Make joining members observers in all groups, regardless of per-group setting in directory" + ) knocking <- switch ( long "knocking" @@ -197,6 +203,7 @@ directoryOpts appDir defaultDbName = do linkCheckInterval, prohibitedToObserver, alwaysCaptcha, + alwaysObserver, knocking, testing = False } diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index 61ed7e66af..f10cbf44fd 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -47,9 +47,9 @@ 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.Bot.Store import Simplex.Chat.Controller import Simplex.Chat.Core import Simplex.Chat.Library.Internal (setGroupLinkData) @@ -216,7 +216,7 @@ directoryPostStartHook opts@DirectoryOpts {noAddress, testing} env cc = readTVarIO (currentUser cc) >>= \case Nothing -> putStrLn "No current user" >> exitFailure Just User {userId, profile = p@LocalProfile {preferences}} -> do - unless noAddress $ initializeBotAddress' (not testing) cc + unless noAddress $ initializeBotAddress' (not testing) Nothing True cc void $ atomically $ tryPutTMVar (serviceCC env) cc listingsUpdated env let cmds = fromMaybe [] $ preferences >>= commands_ @@ -268,7 +268,7 @@ directoryService opts cfg = do acceptMemberHook :: DirectoryOpts -> ServiceState -> GroupInfo -> GroupLinkInfo -> Profile -> IO (Either GroupRejectionReason (GroupAcceptance, GroupMemberRole)) acceptMemberHook - DirectoryOpts {profileNameLimit, alwaysCaptcha, knocking} + DirectoryOpts {profileNameLimit, alwaysCaptcha, alwaysObserver, knocking} ServiceState {blockedWordsCfg} g GroupLinkInfo {memberRole} @@ -279,7 +279,7 @@ acceptMemberHook if | knocking -> (GAPendingReview, memberRole) | alwaysCaptcha || useMemberFilter img (passCaptcha a) -> (GAPendingApproval, GRMember) - | useMemberFilter img (makeObserver a) -> (GAAccepted, GRObserver) + | alwaysObserver || useMemberFilter img (makeObserver a) -> (GAAccepted, GRObserver) | otherwise -> (GAAccepted, memberRole) where checkName :: ExceptT GroupRejectionReason IO () @@ -314,7 +314,7 @@ readBlockedWordsConfig DirectoryOpts {blockedFragmentsFile, blockedWordsFile, na pure BlockedWordsConfig {blockedFragments, blockedWords, extensionRules, spelling} directoryServiceEvent :: DirectoryOpts -> ServiceState -> User -> ChatController -> DirectoryEvent -> IO () -directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, ownersGroup, searchResults, prohibitedToObserver, alwaysCaptcha} env@ServiceState {searchRequests} user@User {userId} cc = \case +directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, ownersGroup, searchResults, prohibitedToObserver, alwaysCaptcha, alwaysObserver} env@ServiceState {searchRequests} user@User {userId} cc = \case DEContactConnected ct -> deContactConnected ct DEGroupInvitation {contact = ct, groupInfo = g, fromMemberRole, memberRole} -> deGroupInvitation ct g fromMemberRole memberRole DEServiceJoinedGroup ctId g owner -> deServiceJoinedGroup ctId g owner @@ -566,14 +566,12 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o notifyAdminUsers $ "The group " <> groupReference toGroup <> " is updated" <> byMember <> "." checkRolesSendToApprove gr' n' processProfileChange gr byMember n' = - withDB' "getGroupLink" cc (\db -> runExceptT $ getGroupLink db user toGroup) >>= \case + getGroupAndRegLink cc user groupId >>= \case Left e -> linkReadError $ T.pack e - Right (Left SEGroupLinkNotFound {}) -> profileChange Nothing - Right (Left e) -> linkReadError $ tshow e - Right (Right gLink) -> profileChange $ Just gLink + Right (g, _, gLink_) -> profileChange g gLink_ where linkReadError e = logError $ "Error reading group link for " <> groupReference toGroup <> ": " <> e - profileChange gLink_ + profileChange g gLink_ | not (linkOnlyChange gLink_) = sendForApproval byMember n' | groupRegStatus gr == GRSActive = do notifyOwner gr $ @@ -581,7 +579,7 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o <> "!\nThe group is listed in directory." notifyAdminUsers $ "The group " <> groupReference toGroup <> " is updated" <> byMember <> " - only link or whitespace changes.\nThe group remained listed in directory." forM_ gLink_ $ \gLink -> - updateGroupLinkData cc user toGroup gLink >>= \case + updateGroupLinkData cc user g gLink >>= \case Right _ -> pure () Left e -> logError $ "Error updating group link data for " <> groupReference toGroup <> ": " <> tshow e | otherwise = pure () @@ -678,7 +676,7 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o approvePendingMember :: DirectoryMemberAcceptance -> GroupInfo -> GroupMember -> IO () approvePendingMember a g@GroupInfo {groupId} m@GroupMember {memberProfile = LocalProfile {displayName, image}} = do 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_ + let role = if alwaysObserver || useMemberFilter image (makeObserver a) then GRObserver else maybe GRMember (\GroupLinkInfo {memberRole} -> memberRole) gli_ gmId = groupMemberId' m sendChatCmd cc (APIAcceptMember groupId gmId role) >>= \case Right CRMemberAccepted {member} -> do @@ -1283,7 +1281,7 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o deAdminCommand ct ciId cmd | knownCt `elem` adminUsers || knownCt `elem` superUsers = case cmd of DCApproveGroup {groupId, displayName = n, groupApprovalId, promote} -> - withGroupRegLink sendReply groupId n $ \g gr@GroupReg {userGroupRegId = ugrId, promoted} curLink_ -> + withGroupRegLink sendReply groupId n $ \gik@(GIK g _) gr@GroupReg {userGroupRegId = ugrId, promoted} curLink_ -> case groupRegStatus gr of GRSPendingApproval gaId | gaId == groupApprovalId -> do @@ -1300,7 +1298,7 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o let grPromoted' | promoted || knownCt `elem` superUsers = fromMaybe promoted promote | otherwise = False - gLink_ <- if isPublicGroup_ then pure (Right Nothing) else approvedGroupLink g curLink_ + gLink_ <- if isPublicGroup_ then pure (Right Nothing) else approvedGroupLink gik curLink_ case gLink_ of Left e -> sendReply e Right gLink' -> @@ -1464,14 +1462,14 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o mkSendReply :: Contact -> ChatItemId -> Text -> IO () mkSendReply ct ciId = sendComposedMessage cc ct (Just ciId) . MCText - withGroupRegLink :: (Text -> IO ()) -> GroupId -> GroupName -> (GroupInfo -> GroupReg -> Maybe GroupLink -> IO ()) -> IO () + withGroupRegLink :: (Text -> IO ()) -> GroupId -> GroupName -> (GroupInfoKeys -> GroupReg -> Maybe GroupLink -> IO ()) -> IO () withGroupRegLink sendReply gId = withGroupRegLink_ sendReply gId . Just - withGroupRegLink_ :: (Text -> IO ()) -> GroupId -> Maybe GroupName -> (GroupInfo -> GroupReg -> Maybe GroupLink -> IO ()) -> IO () + withGroupRegLink_ :: (Text -> IO ()) -> GroupId -> Maybe GroupName -> (GroupInfoKeys -> GroupReg -> Maybe GroupLink -> IO ()) -> IO () withGroupRegLink_ sendReply gId gName_ action = getGroupAndRegLink cc user gId >>= \case Left e -> sendReply $ "Group " <> tshow gId <> " error (getGroup): " <> T.pack e - Right (g@GroupInfo {groupProfile = GroupProfile {displayName}}, gr, gLink_) + Right (g@(GIK GroupInfo {groupProfile = GroupProfile {displayName}} _), gr, gLink_) | maybe False (displayName ==) gName_ -> action g gr gLink_ | otherwise -> @@ -1482,7 +1480,7 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o withGroupAndReg_ :: (Text -> IO ()) -> GroupId -> Maybe GroupName -> (GroupInfo -> GroupReg -> IO ()) -> IO () withGroupAndReg_ sendReply gId gName_ action = - withGroupRegLink_ sendReply gId gName_ $ \g gr _ -> action g gr + withGroupRegLink_ sendReply gId gName_ $ \(GIK g _) gr _ -> action g gr getOwnersInfo :: [(GroupInfo, GroupReg)] -> IO [((GroupInfo, GroupReg), Maybe (Either String Contact))] getOwnersInfo gs = @@ -1560,7 +1558,7 @@ getGroupLink' :: ChatController -> User -> GroupInfo -> IO (Either String GroupL getGroupLink' cc user gInfo = withDB "getGroupLink" cc $ \db -> withExceptT groupDBError $ getGroupLink db user gInfo -updateGroupLinkData :: ChatController -> User -> GroupInfo -> GroupLink -> IO (Either ChatError GroupLink) +updateGroupLinkData :: ChatController -> User -> GroupInfoKeys -> GroupLink -> IO (Either ChatError GroupLink) updateGroupLinkData cc user gInfo gLink = runReaderT (runExceptT $ setGroupLinkData NRMBackground user gInfo gLink) cc setGroupLinkRole :: ChatController -> GroupInfo -> GroupMemberRole -> IO (Maybe CreatedLinkContact) diff --git a/apps/simplex-directory-service/src/Directory/Store.hs b/apps/simplex-directory-service/src/Directory/Store.hs index ea7f7f5ee0..98226805bc 100644 --- a/apps/simplex-directory-service/src/Directory/Store.hs +++ b/apps/simplex-directory-service/src/Directory/Store.hs @@ -68,13 +68,13 @@ import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) import Data.Time.Clock (UTCTime (..), getCurrentTime) import Directory.Search -import Directory.Util +import Simplex.Chat.Bot.Store import Simplex.Chat.Controller import Simplex.Chat.Names (claimDomain) 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.Store.Shared (GroupKeysRow, groupInfoQueryFields, groupInfoQueryFrom, mkGroupKeys, toGroupInfo_) import Simplex.Chat.Types import Simplex.Chat.Types.Shared (GroupMemberRole (..)) import Simplex.Messaging.Agent.Protocol (CreatedConnLink (..), SimplexDomain) @@ -309,12 +309,17 @@ getGroupReg_ db gId = |] (Only gId) -getGroupAndRegLink :: ChatController -> User -> GroupId -> IO (Either String (GroupInfo, GroupReg, Maybe GroupLink)) +getGroupAndRegLink :: ChatController -> User -> GroupId -> IO (Either String (GroupInfoKeys, GroupReg, Maybe GroupLink)) getGroupAndRegLink cc user@User {userId, userContactId} gId = withDB "getGroupAndRegLink" cc $ \db -> do currentTs <- liftIO getCurrentTime - ExceptT $ firstRow (toGroupInfoRegLink currentTs (storeCxt cc) user) ("group " ++ show gId ++ " not found") $ - DB.query db (groupReqQuery <> " AND g.group_id = ?") (userId, userContactId, gId) + (g, gksData, gr, gLink_) <- + ExceptT $ firstRow (toGroupInfoKeysRegLink currentTs cxt user) ("group " ++ show gId ++ " not found") $ + DB.query db (groupReqQuery <> " AND g.group_id = ?") (userId, userContactId, gId) + gks <- withExceptT groupDBError $ mkGroupKeys db cxt g gksData + pure (GIK g gks, gr, gLink_) + where + cxt = storeCxt cc getUserGroupReg :: ChatController -> User -> ContactId -> UserGroupRegId -> IO (Either String (GroupInfo, GroupReg)) getUserGroupReg cc user@User {userId, userContactId} ctId ugrId = @@ -438,7 +443,12 @@ toGroupInfoReg currentTs cxt user row = let (g, gr, _) = toGroupInfoRegLink curr toGroupInfoRegLink :: UTCTime -> StoreCxt -> User -> (GroupInfoRow :. GroupRegRow :. GroupLinkRow) -> (GroupInfo, GroupReg, Maybe GroupLink) toGroupInfoRegLink currentTs cxt User {userContactId} (groupRow :. grRow :. linkRow) = - (toGroupInfo currentTs cxt userContactId [] groupRow, rowToGroupReg grRow, toMaybeGroupLink linkRow) + (toGroupInfo_ currentTs cxt userContactId [] groupRow, rowToGroupReg grRow, toMaybeGroupLink linkRow) + +toGroupInfoKeysRegLink :: UTCTime -> StoreCxt -> User -> (GroupInfoRow :. GroupRegRow :. GroupLinkRow) -> (GroupInfo, GroupKeysRow, GroupReg, Maybe GroupLink) +toGroupInfoKeysRegLink currentTs cxt User {userContactId} (groupRow :. grRow :. linkRow) = + let (g, gksData) = toGroupInfo currentTs cxt userContactId [] groupRow + in (g, gksData, rowToGroupReg grRow, toMaybeGroupLink linkRow) type GroupRegRow = (GroupId, UserGroupRegId, ContactId, Maybe GroupMemberId, GroupRegStatus, BoolInt, UTCTime) diff --git a/apps/simplex-support-bot/src/messages.ts b/apps/simplex-support-bot/src/messages.ts index 33f7f9ccef..e7b125740a 100644 --- a/apps/simplex-support-bot/src/messages.ts +++ b/apps/simplex-support-bot/src/messages.ts @@ -1,11 +1,10 @@ import {isWeekend} from "./util.js" export const welcomeMessage = `Hello! This is a *SimpleX team* support bot - not an AI. -*Join public groups* at https://simplex.chat/directory or [via directory bot](https://smp4.simplex.im/a#lXUjJW5vHYQzoLYgmi8GbxkGP41_kjefFvBrdwg-0Ok) -We just launched [equity crowdfunding on Wefunder](https://wefunder.com/simplex.chat)! +Discover public groups: [simplex.chat/directory](https://simplex.chat/directory) -Please ask any questions about SimpleX Chat and about our crowdfunding.` +Join the livestream about SimpleX roadmap and equity crowdfunding: [simplex.chat/livestream](https://simplex.chat/livestream) (September 15, at 17:00 UTC)` export function queueMessage(timezone: string, grokEnabled: boolean): string { const hours = isWeekend(timezone) ? "48" : "24" diff --git a/assets/multiplatform/resources/MR/images/phone_supporter@2x.png b/assets/multiplatform/resources/MR/images/phone_supporter@2x.png new file mode 100644 index 0000000000..858385c526 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/phone_supporter@2x.png differ diff --git a/assets/multiplatform/resources/MR/images/phone_supporter@3x.png b/assets/multiplatform/resources/MR/images/phone_supporter@3x.png new file mode 100644 index 0000000000..58b4ecc1bd Binary files /dev/null and b/assets/multiplatform/resources/MR/images/phone_supporter@3x.png differ diff --git a/assets/multiplatform/resources/MR/images/phone_supporter_light@2x.png b/assets/multiplatform/resources/MR/images/phone_supporter_light@2x.png new file mode 100644 index 0000000000..6e87c1a72b Binary files /dev/null and b/assets/multiplatform/resources/MR/images/phone_supporter_light@2x.png differ diff --git a/assets/multiplatform/resources/MR/images/phone_supporter_light@3x.png b/assets/multiplatform/resources/MR/images/phone_supporter_light@3x.png new file mode 100644 index 0000000000..046e60fdbb Binary files /dev/null and b/assets/multiplatform/resources/MR/images/phone_supporter_light@3x.png differ diff --git a/blog/20260919-simplex-supporter-badges.md b/blog/20260919-simplex-supporter-badges.md new file mode 100644 index 0000000000..ebe19ef431 --- /dev/null +++ b/blog/20260919-simplex-supporter-badges.md @@ -0,0 +1,64 @@ +--- +layout: layouts/article.html +title: "SimpleX Supporter Badges — Send Larger Files That Stay Available Longer, Without Being Identified" +date: 2026-09-19 +previewBody: blog_previews/20260919.html +image: images/20260919-badge-screen.png +permalink: "/blog/20260919-simplex-supporter-badges.html" +--- + +# SimpleX Supporter Badges — Send Larger Files That Stay Available Longer, Without Being Identified + +**Published:** Sep 19, 2026 + +You can now support SimpleX Chat and get a supporter badge, larger files and longer file storage — from v7.1 beta[^beta]. Watch how to buy a badge. + +## A paid feature that cannot identify you + + + + +A supporter badge is shown on your profile to your contacts, group members and channel subscribers. With a badge you can send files up to 2GB, or 5GB with a legend badge, instead of 1GB, and servers keep your files for longer — 7 days with a supporter badge and 21 days with a legend badge. + +A badge is an anonymous credential stored in your profile on your device. The credential itself is not sent to anyone: to show the badge to a contact or to present it to a server, the app generates a new zero-knowledge proof that reveals only the badge type and the expiry date, and no two proofs can be linked to each other or to the purchase, by the badge service, your contacts or servers. Credentials are issued for one month at a time, and all badges expire on the same day of the week, so a badge places you among all supporters of that week and nothing more. + +This is only possible because the network has no user identifiers — in other messengers a paid feature is attached to the account, so the operator knows who paid and what they do with the feature. + +Read more about badges in the [whitepaper](https://github.com/simplex-chat/simplex-chat/blob/master/docs/protocol/badges-overview.md): what they grant, how they are issued and presented, and their privacy and security model. + +## How to get a badge + + + +Buy a code on [simplex.chat/badges](https://simplex.chat/badges/), paying by card, Bitcoin or Monero, and redeem it in the app: open Settings, tap **Supporter perks**, and enter the code. The badge appears on your profile. The badge does not renew by itself, and no account is created. + +The v7.1 release will add purchases in the app: via the app store, or by card or cryptocurrency if you downloaded the app from GitHub or F-Droid. + +## What badges will do next + +The same mechanism will be used for other resources that cost the network more than the default. Two uses are coming: + +- **Backups.** A backup will be a link whose content the app updates in place, so the same link restores the latest state. A badge will extend how long the backup is kept on the servers — that is, how long the app can stay offline before the backup is lost. +- **Better limits on servers.** Servers that verify the badge will apply higher rate limits for creating messaging queues, uploading file chunks, and registering notification tokens. + +## Community Crowdfunding + + + +Investors in our [equity crowdfunding on Wefunder](https://wefunder.com/simplex.chat?utm_source=blog) receive badges as perks: + +| Investment | Badge | +|---|---| +| $100 | supporter, 1 month | +| $250 | supporter, 3 months | +| $1,000 | supporter, 12 months, or legend, 1 month | +| $2,500 | supporter, 12 months, or legend, 3 months | +| $10,000 | legend, 12 months | + +If you invest $500 or more by September 22, you will also receive [a public SimpleX name](https://simplex.domains?utm_source=blog) for 7 years[^name]. + +Learn more and invest on Wefunder: [https://wefunder.com/simplex.chat](https://wefunder.com/simplex.chat?utm_source=blog) + +[^name]: After September 22, investors of $500 or more receive a name for 5 years as early bird investors, and for 3 years after that — ahead of the public launch of names on December 12. + +[^beta]: v7.1 beta is available via [Play Store](https://play.google.com/store/apps/details?id=chat.simplex.app) (Android beta), [TestFlight](https://testflight.apple.com/join/DWuT2LQu) (iOS), our [F-Droid repo](https://simplex.chat/fdroid/) and [GitHub](https://github.com/simplex-chat/simplex-chat/releases) (Android and desktop). diff --git a/blog/README.md b/blog/README.md index 3a1c545b4f..b7940a370d 100644 --- a/blog/README.md +++ b/blog/README.md @@ -1,5 +1,14 @@ # Blog +Sep 19, 2026 [SimpleX Supporter Badges - Send Larger Files That Stay Available Longer, Without Being Identified](./20260919-simplex-supporter-badges.md) + +Supporter badges are available in v7.1 beta: a badge on your profile, larger files and longer file storage - and the purchase cannot be linked to your profile. + +Investors in our equity crowdfunding receive badges as perks. If you invest $500 or more by September 22, you will also receive a public SimpleX name for 7 years. + + +--- + Aug 20, 2026 [Equity Crowdfunding Launched - You Can Get a Stake in SimpleX Chat](./20260819-simplex-chat-crowdfunding.md) SimpleX is the first and the only messaging network without user identifiers of any kind. Our equity crowdfunding round is now launched on Wefunder, so network users, and anybody else, can get a stake in SimpleX Chat - the company that builds it. diff --git a/blog/images/20260819-wefunder.jpg b/blog/images/20260819-wefunder.jpg index 6586d4dfe7..252c3c1e85 100644 Binary files a/blog/images/20260819-wefunder.jpg and b/blog/images/20260819-wefunder.jpg differ diff --git a/blog/images/20260919-badge-screen.png b/blog/images/20260919-badge-screen.png new file mode 100644 index 0000000000..beece83da6 Binary files /dev/null and b/blog/images/20260919-badge-screen.png differ diff --git a/blog/images/20260919-buy-badge.jpg b/blog/images/20260919-buy-badge.jpg new file mode 100644 index 0000000000..da7625aee9 Binary files /dev/null and b/blog/images/20260919-buy-badge.jpg differ diff --git a/blog/images/20260919-phone-supporter-light.png b/blog/images/20260919-phone-supporter-light.png new file mode 100644 index 0000000000..07362c870d Binary files /dev/null and b/blog/images/20260919-phone-supporter-light.png differ diff --git a/blog/images/20260919-phone-supporter.png b/blog/images/20260919-phone-supporter.png new file mode 100644 index 0000000000..2f36b060c7 Binary files /dev/null and b/blog/images/20260919-phone-supporter.png differ diff --git a/bots/api/COMMANDS.md b/bots/api/COMMANDS.md index d88181b778..f90e8e08da 100644 --- a/bots/api/COMMANDS.md +++ b/bots/api/COMMANDS.md @@ -79,6 +79,10 @@ This file is generated automatically. - [StartChat](#startchat) - [APIStopChat](#apistopchat) +[Remote control commands](#remote-control-commands) +- [ConnectRemoteCtrl](#connectremotectrl) +- [VerifyRemoteCtrlSession](#verifyremotectrlsession) + --- @@ -231,6 +235,10 @@ UserProfileUpdated: User profile updated. - toProfile: [Profile](./TYPES.md#profile) - updateSummary: [UserProfileUpdateSummary](./TYPES.md#userprofileupdatesummary) +UserProfileNoChange: User profile was not changed. +- type: "userProfileNoChange" +- user: [User](./TYPES.md#user) + ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" - chatError: [ChatError](./TYPES.md#chaterror) @@ -505,15 +513,15 @@ Share user address card **Syntax**: ``` -/_share address +/_share address ``` ```javascript -'/_share address' + ChatRef.cmdString(toSendRef) // JavaScript +'/_share address ' + ChatRef.cmdString(toSendRef) // JavaScript ``` ```python -'/_share address' + ChatRef_cmd_string(toSendRef) # Python +'/_share address ' + ChatRef_cmd_string(toSendRef) # Python ``` **Response**: @@ -1529,15 +1537,15 @@ Connect via prepared SimpleX link. The link can be 1-time invitation link, conta **Syntax**: ``` -/_connect [ ] +/_connect [ incognito=on][ ] ``` ```javascript -'/_connect ' + userId + (preparedLink_ ? ' ' + CreatedConnLink.cmdString(preparedLink_) : '') // JavaScript +'/_connect ' + userId + (incognito ? ' incognito=on' : '') + (preparedLink_ ? ' ' + CreatedConnLink.cmdString(preparedLink_) : '') // JavaScript ``` ```python -'/_connect ' + str(userId) + ((' ' + CreatedConnLink_cmd_string(preparedLink_)) if preparedLink_ is not None else '') # Python +'/_connect ' + str(userId) + (' incognito=on' if incognito else '') + ((' ' + CreatedConnLink_cmd_string(preparedLink_)) if preparedLink_ is not None else '') # Python ``` **Responses**: @@ -1609,6 +1617,33 @@ SentInvitation: Invitation sent to contact address. - connection: [PendingContactConnection](./TYPES.md#pendingcontactconnection) - customUserProfile: [Profile](./TYPES.md#profile)? +ConnectionPlan: Connection link information. +- type: "connectionPlan" +- user: [User](./TYPES.md#user) +- connLink: [CreatedConnLink](./TYPES.md#createdconnlink) +- planSimplexName: [SimplexNameInfo](./TYPES.md#simplexnameinfo)? +- otherSimplexName: [SimplexNameInfo](./TYPES.md#simplexnameinfo)? +- connectionPlan: [ConnectionPlan](./TYPES.md#connectionplan) + +SentInvitationToContact: Invitation sent to contact (when connecting via SimpleX name to a known contact address).. +- type: "sentInvitationToContact" +- user: [User](./TYPES.md#user) +- contact: [Contact](./TYPES.md#contact) +- customUserProfile: [Profile](./TYPES.md#profile)? + +StartedConnectionToContact: Connection to contact started (when connecting via prepared contact).. +- type: "startedConnectionToContact" +- user: [User](./TYPES.md#user) +- contact: [Contact](./TYPES.md#contact) +- customUserProfile: [Profile](./TYPES.md#profile)? + +StartedConnectionToGroup: Connection to channel started (when connecting via channel link).. +- type: "startedConnectionToGroup" +- user: [User](./TYPES.md#user) +- groupInfo: [GroupInfo](./TYPES.md#groupinfo) +- customUserProfile: [Profile](./TYPES.md#profile)? +- relayResults: [[RelayConnectionResult](./TYPES.md#relayconnectionresult)] + ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" - chatError: [ChatError](./TYPES.md#chaterror) @@ -1782,21 +1817,21 @@ Get chat previews. Supports time-based pagination — use this instead of APILis **Parameters**: - userId: int64 - pendingConnections: bool -- pagination: [PaginationByTime](./TYPES.md#paginationbytime) +- pagination: [PaginationByTime](./TYPES.md#paginationbytime)? - query: [ChatListQuery](./TYPES.md#chatlistquery) **Syntax**: ``` -/_get chats [ pcc=on] +/_get chats [ pcc=on][ ] ``` ```javascript -'/_get chats ' + userId + (pendingConnections ? ' pcc=on' : '') + ' ' + PaginationByTime.cmdString(pagination) + ' ' + JSON.stringify(query) // JavaScript +'/_get chats ' + userId + (pendingConnections ? ' pcc=on' : '') + (pagination ? ' ' + PaginationByTime.cmdString(pagination) : '') + ' ' + JSON.stringify(query) // JavaScript ``` ```python -'/_get chats ' + str(userId) + (' pcc=on' if pendingConnections else '') + ' ' + PaginationByTime_cmd_string(pagination) + ' ' + json.dumps(query) # Python +'/_get chats ' + str(userId) + (' pcc=on' if pendingConnections else '') + ((' ' + PaginationByTime_cmd_string(pagination)) if pagination is not None else '') + ' ' + json.dumps(query) # Python ``` **Responses**: @@ -1854,6 +1889,7 @@ GroupDeletedUser: User deleted group. - user: [User](./TYPES.md#user) - groupInfo: [GroupInfo](./TYPES.md#groupinfo) - msgSigned: bool +- localDeletion: bool ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" @@ -2369,3 +2405,83 @@ ChatStopped: Chat stopped. - type: "chatStopped" --- + + +## Remote control commands + +Allows a bot to accept an incoming remote control session from a SimpleX Desktop client, giving the desktop live access to the bot's SimpleX instance. + + +### ConnectRemoteCtrl + +Connect to a remote controller using an OOB invitation link. + +*Network usage*: interactive. + +**Parameters**: +- remoteInvitation: string + +**Syntax**: + +``` +/crc +``` + +```javascript +'/crc ' + remoteInvitation // JavaScript +``` + +```python +'/crc ' + remoteInvitation # Python +``` + +**Responses**: + +RemoteCtrlConnecting: Remote controller is connecting.. +- type: "remoteCtrlConnecting" +- remoteCtrl_: [RemoteCtrlInfo](./TYPES.md#remotectrlinfo)? +- ctrlAppInfo: [CtrlAppInfo](./TYPES.md#ctrlappinfo) +- appVersion: string + +ChatCmdError: Command error (only used in WebSockets API). +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + +--- + + +### VerifyRemoteCtrlSession + +Verify the remote controller session code to complete the connection. + +*Network usage*: no. + +**Parameters**: +- sessionCode: string + +**Syntax**: + +``` +/verify remote ctrl +``` + +```javascript +'/verify remote ctrl ' + sessionCode // JavaScript +``` + +```python +'/verify remote ctrl ' + sessionCode # Python +``` + +**Responses**: + +RemoteCtrlConnected: Remote controller session connected.. +- type: "remoteCtrlConnected" +- remoteCtrl: [RemoteCtrlInfo](./TYPES.md#remotectrlinfo) +- compression: bool + +ChatCmdError: Command error (only used in WebSockets API). +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + +--- diff --git a/bots/api/EVENTS.md b/bots/api/EVENTS.md index d2744951cd..5416fe4c0e 100644 --- a/bots/api/EVENTS.md +++ b/bots/api/EVENTS.md @@ -72,6 +72,10 @@ This file is generated automatically. - [ServiceRequest](#servicerequest) - [ServiceReplySent](#servicereplysent) +[Remote control events](#remote-control-events) +- [RemoteCtrlSessionCode](#remotectrlsessioncode) +- [RemoteCtrlStopped](#remotectrlstopped) + [Error events](#error-events) - [MessageError](#messageerror) - [ChatError](#chaterror) @@ -793,6 +797,37 @@ Correlate `connectionId` with the connection ID from the response to [APISendSer --- +## Remote control events + +Bots that act as remote control hosts receive these events during the remote control session lifecycle. + + +### RemoteCtrlSessionCode + +Remote controller session code ready for verification. + +Use [VerifyRemoteCtrlSession](./COMMANDS.md#verifyremotectrlsession) to complete the connection. + +**Record type**: +- type: "remoteCtrlSessionCode" +- remoteCtrl_: [RemoteCtrlInfo](./TYPES.md#remotectrlinfo)? +- sessionCode: string + +--- + + +### RemoteCtrlStopped + +Remote controller session stopped. + +**Record type**: +- type: "remoteCtrlStopped" +- rcsState: [RemoteCtrlSessionState](./TYPES.md#remotectrlsessionstate) +- rcStopReason: [RemoteCtrlStopReason](./TYPES.md#remotectrlstopreason) + +--- + + ## Error events Bots may log these events for debugging. There will be many error events - this does NOT indicate a malfunction - e.g., they may happen because of bad network connectivity, or because messages may be delivered to deleted chats for a short period of time (they will be ignored). diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 63bc9a6e7b..f1122979a9 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -10,9 +10,12 @@ This file is generated automatically. - [AgentCryptoError](#agentcryptoerror) - [AgentErrorType](#agenterrortype) - [AgentServiceError](#agentserviceerror) +- [AppVersionRange](#appversionrange) - [AutoAccept](#autoaccept) - [BadgeInfo](#badgeinfo) - [BadgeProof](#badgeproof) +- [BadgeRedeemError](#badgeredeemerror) +- [BadgeServiceErrorCode](#badgeserviceerrorcode) - [BadgeStatus](#badgestatus) - [BadgeType](#badgetype) - [BlockingInfo](#blockinginfo) @@ -77,6 +80,7 @@ This file is generated automatically. - [CreatedConnLink](#createdconnlink) - [CryptoFile](#cryptofile) - [CryptoFileArgs](#cryptofileargs) +- [CtrlAppInfo](#ctrlappinfo) - [DroppedMsg](#droppedmsg) - [E2EInfo](#e2einfo) - [ErrorType](#errortype) @@ -85,6 +89,7 @@ This file is generated automatically. - [FileError](#fileerror) - [FileErrorType](#fileerrortype) - [FileInvitation](#fileinvitation) +- [FileProhibited](#fileprohibited) - [FileProtocol](#fileprotocol) - [FileStatus](#filestatus) - [FileTransferMeta](#filetransfermeta) @@ -100,7 +105,6 @@ This file is generated automatically. - [GroupFeature](#groupfeature) - [GroupFeatureEnabled](#groupfeatureenabled) - [GroupInfo](#groupinfo) -- [GroupKeys](#groupkeys) - [GroupLink](#grouplink) - [GroupLinkOwner](#grouplinkowner) - [GroupLinkPlan](#grouplinkplan) @@ -115,7 +119,6 @@ This file is generated automatically. - [GroupPreferences](#grouppreferences) - [GroupProfile](#groupprofile) - [GroupRelay](#grouprelay) -- [GroupRootKey](#grouprootkey) - [GroupShortLinkData](#groupshortlinkdata) - [GroupShortLinkInfo](#groupshortlinkinfo) - [GroupSummary](#groupsummary) @@ -169,8 +172,12 @@ This file is generated automatically. - [RcvGroupEvent](#rcvgroupevent) - [RcvMsgError](#rcvmsgerror) - [RelayCapabilities](#relaycapabilities) +- [RelayConnectionResult](#relayconnectionresult) - [RelayProfile](#relayprofile) - [RelayStatus](#relaystatus) +- [RemoteCtrlInfo](#remotectrlinfo) +- [RemoteCtrlSessionState](#remotectrlsessionstate) +- [RemoteCtrlStopReason](#remotectrlstopreason) - [ReportReason](#reportreason) - [RoleGroupPreference](#rolegrouppreference) - [SMPAgentError](#smpagenterror) @@ -212,7 +219,6 @@ This file is generated automatically. - [UserProfileUpdateSummary](#userprofileupdatesummary) - [UserPwdHash](#userpwdhash) - [VersionRange](#versionrange) -- [WalletError](#walleterror) - [XFTPErrorType](#xftperrortype) - [XFTPRcvFile](#xftprcvfile) - [XFTPSndFile](#xftpsndfile) @@ -386,6 +392,17 @@ BadSignature: - type: "badSignature" +--- + +## AppVersionRange + +Remote controller app version range (min and max as version strings). + +**Record type**: +- minVersion: string +- maxVersion: string + + --- ## AutoAccept @@ -415,6 +432,60 @@ BadSignature: - badgeInfo: [BadgeInfo](#badgeinfo) +--- + +## BadgeRedeemError + +**Discriminated union type**: + +InvalidCode: +- type: "invalidCode" + +ServiceNotConfigured: +- type: "serviceNotConfigured" + +BadgeActive: +- type: "badgeActive" + +ServiceError: +- type: "serviceError" +- serviceError: [BadgeServiceErrorCode](#badgeserviceerrorcode) + +InvalidResponse: +- type: "invalidResponse" +- message: string + +UnknownKeyIndex: +- type: "unknownKeyIndex" + +CredentialNotVerified: +- type: "credentialNotVerified" + + +--- + +## BadgeServiceErrorCode + +**Enum type**: +- "bad_request" +- "unsupported_version" +- "unknown_purchase_key" +- "unknown_offer_id" +- "offer_disabled" +- "offer_mismatch" +- "product_unavailable" +- "payment_not_entitled" +- "payment_pending" +- "provider_unavailable" +- "rate_limited" +- "code_invalid" +- "code_used" +- "code_expired" +- "receipt_invalid" +- "receipt_used" +- "internal" + + --- ## BadgeStatus @@ -749,6 +820,7 @@ LocalRcv: - fileStatus: [CIFileStatus](#cifilestatus) - fileProtocol: [FileProtocol](#fileprotocol) - fileExpires: UTCTime? +- fileProhibited: [FileProhibited](#fileprohibited)? --- @@ -1159,10 +1231,6 @@ SimplexDomainNotReady: - simplexDomain: [SimplexDomain](#simplexdomain) - simplexDomainError: [SimplexDomainError](#simplexdomainerror) -Wallet: -- type: "wallet" -- walletError: [WalletError](#walleterror) - NotResolvedLocally: - type: "notResolvedLocally" @@ -1346,6 +1414,10 @@ CommandError: - type: "commandError" - message: string +BadgeRedeemError: +- type: "badgeRedeemError" +- badgeRedeemError: [BadgeRedeemError](#badgeredeemerror) + AgentCommandError: - type: "agentCommandError" - message: string @@ -1971,6 +2043,18 @@ connFullLink + ((' ' + connShortLink) if connShortLink is not None else '') # Py - fileNonce: string +--- + +## CtrlAppInfo + +Remote controller application info. + +**Record type**: +- appVersionRange: [AppVersionRange](#appversionrange) +- deviceName: string +- compression: bool + + --- ## DroppedMsg @@ -2130,6 +2214,16 @@ NO_FILE: - fileConnReq: string? - fileInline: [InlineFileMode](#inlinefilemode)? - fileDescr: [FileDescr](#filedescr)? +- fileBadge: [BadgeProof](#badgeproof)? + + +--- + +## FileProhibited + +**Record type**: +- maxSize: int64 +- badgeStatus: [BadgeStatus](#badgestatus)? --- @@ -2400,20 +2494,9 @@ MemberSupport: - rosterVersion: int64? - membersRequireAttention: int - viaGroupLinkUri: string? -- groupKeys: [GroupKeys](#groupkeys)? - groupDomainVerified: bool? ---- - -## GroupKeys - -**Record type**: -- publicGroupId: string -- groupRootKey: [GroupRootKey](#grouprootkey) -- memberPrivKey: string - - --- ## GroupLink @@ -2635,21 +2718,6 @@ UpdateRequired: - relayCap: [RelayCapabilities](#relaycapabilities) ---- - -## GroupRootKey - -**Discriminated union type**: - -Private: -- type: "private" -- rootPrivKey: string - -Public: -- type: "public" -- rootPubKey: string - - --- ## GroupShortLinkData @@ -3447,6 +3515,7 @@ Cancelled: - fileId: int64 - xftpRcvFile: [XFTPRcvFile](#xftprcvfile)? - fileInvitation: [FileInvitation](#fileinvitation) +- fileProhibited: [FileProhibited](#fileprohibited)? - fileStatus: [RcvFileStatus](#rcvfilestatus) - fileType: [FileType](#filetype) - rcvFileInline: [InlineFileMode](#inlinefilemode)? @@ -3554,6 +3623,15 @@ ParseError: - webDomain: string? +--- + +## RelayConnectionResult + +**Record type**: +- relayMember: [GroupMember](#groupmember) +- relayError: [ChatError](#chaterror)? + + --- ## RelayProfile @@ -3579,6 +3657,62 @@ ParseError: - "rejected" +--- + +## RemoteCtrlInfo + +**Record type**: +- remoteCtrlId: int64 +- ctrlDeviceName: string +- sessionState: [RemoteCtrlSessionState](#remotectrlsessionstate)? + + +--- + +## RemoteCtrlSessionState + +**Discriminated union type**: + +Starting: +- type: "starting" + +Searching: +- type: "searching" + +Connecting: +- type: "connecting" + +PendingConfirmation: +- type: "pendingConfirmation" +- sessionCode: string + +Connected: +- type: "connected" +- sessionCode: string + + +--- + +## RemoteCtrlStopReason + +**Discriminated union type**: + +DiscoveryFailed: +- type: "discoveryFailed" +- chatError: [ChatError](#chaterror) + +ConnectionFailed: +- type: "connectionFailed" +- chatError: [ChatError](#chaterror) + +SetupFailed: +- type: "setupFailed" +- chatError: [ChatError](#chaterror) + +Disconnected: +- type: "disconnected" + + --- ## ReportReason @@ -4499,38 +4633,6 @@ Handshake: - maxVersion: int ---- - -## WalletError - -**Discriminated union type**: - -NoMaster: -- type: "noMaster" - -MasterExists: -- type: "masterExists" - -BadMnemonic: -- type: "badMnemonic" - -HiddenProfile: -- type: "hiddenProfile" - -AccountBound: -- type: "accountBound" - -CounterUnknown: -- type: "counterUnknown" - -IndexTooLarge: -- type: "indexTooLarge" - -Derivation: -- type: "derivation" -- derivationError: string - - --- ## XFTPErrorType diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index eda162616d..3435bdb6e5 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -80,7 +80,7 @@ chatCommandsDocsData = [ ("APICreateMyAddress", ["server_"], "Create bot address.", ["CRUserContactLinkCreated", "CRChatCmdError"], [], Just UNInteractive, "/_address " <> Param "userId" <> OnOffParam "pq_ratchet" "pqRatchet" Nothing), ("APIDeleteMyAddress", [], "Delete bot address.", ["CRUserContactLinkDeleted", "CRChatCmdError"], [], Just UNBackground, "/_delete_address " <> Param "userId"), ("APIShowMyAddress", [], "Get bot address and settings.", ["CRUserContactLink", "CRChatCmdError"], [], Nothing, "/_show_address " <> Param "userId"), - ("APISetProfileAddress", [], "Add address to bot profile.", ["CRUserProfileUpdated", "CRChatCmdError"], [], Just UNInteractive, "/_profile_address " <> Param "userId" <> " " <> OnOff "enable"), + ("APISetProfileAddress", [], "Add address to bot profile.", ["CRUserProfileUpdated", "CRUserProfileNoChange", "CRChatCmdError"], [], Just UNInteractive, "/_profile_address " <> Param "userId" <> " " <> OnOff "enable"), ("APISetAddressSettings", [], "Set bot address settings.", ["CRUserContactLinkUpdated", "CRChatCmdError"], [], Just UNInteractive, "/_address_settings " <> Param "userId" <> OnOffParam "pq_ratchet" "pqRatchet" Nothing <> " " <> Json "settings") ] ), @@ -98,7 +98,7 @@ chatCommandsDocsData = ("APIDeleteChatItem", [], "Delete message.", ["CRChatItemsDeleted", "CRChatCmdError"], [], Just UNBackground, "/_delete item " <> Param "chatRef" <> " " <> Join ',' "chatItemIds" <> " " <> Param "deleteMode"), ("APIDeleteMemberChatItem", [], "Moderate message. Requires Moderator role (and higher than message author's).", ["CRChatItemsDeleted", "CRChatCmdError"], [], Just UNBackground, "/_delete member item #" <> Param "groupId" <> " " <> Join ',' "chatItemIds"), ("APIChatItemReaction", [], "Add/remove message reaction.", ["CRChatItemReaction", "CRChatCmdError"], [], Just UNBackground, "/_reaction " <> Param "chatRef" <> " " <> Param "chatItemId" <> " " <> OnOff "add" <> " " <> Json "reaction"), - ("APIShareMyAddress", [], "Share user address card", ["CRChatMsgContent"], [], Nothing, "/_share address" <> Param "toSendRef"), + ("APIShareMyAddress", [], "Share user address card", ["CRChatMsgContent"], [], Nothing, "/_share address " <> Param "toSendRef"), ("APIShareChatMsgContent", [], "Share channel address", ["CRChatMsgContent"], [], Nothing, "/_share chat content " <> Param "shareChatRef" <> " " <> Param "toSendRef") ] ), @@ -140,8 +140,8 @@ chatCommandsDocsData = [ ("APIAddContact", [], "Create 1-time invitation link.", ["CRInvitation", "CRChatCmdError"], [], Just UNInteractive, "/_connect " <> Param "userId" <> OnOffParam "incognito" "incognito" (Just False)), -- `Maybe` in `connectTarget :: Maybe ConnectTarget` is used to signal parse failure to the runtime (the handler returns CEInvalidConnReq on Nothing); it is NOT API-level optionality. The parameter is required from callers. ("APIConnectPlan", [], "Determine SimpleX link type and if the bot is already connected via this link or name.", ["CRConnectionPlan", "CRChatCmdError"], [], Just UNInteractive, "/_connect plan " <> Param "userId" <> " " <> Param "connectTarget"), - ("APIConnect", [], "Connect via prepared SimpleX link. The link can be 1-time invitation link, contact address or group link.", ["CRSentConfirmation", "CRContactAlreadyExists", "CRSentInvitation", "CRChatCmdError"], [], Just UNInteractive, "/_connect " <> Param "userId" <> Optional "" (" " <> Param "$0") "preparedLink_"), - ("Connect", [], "Connect via SimpleX link or name as string in the active user profile.", ["CRSentConfirmation", "CRContactAlreadyExists", "CRSentInvitation", "CRChatCmdError"], [], Just UNInteractive, "/connect" <> Optional "" (" " <> Param "$0") "connTarget_"), + ("APIConnect", [], "Connect via prepared SimpleX link. The link can be 1-time invitation link, contact address or group link.", ["CRSentConfirmation", "CRContactAlreadyExists", "CRSentInvitation", "CRChatCmdError"], [], Just UNInteractive, "/_connect " <> Param "userId" <> OnOffParam "incognito" "incognito" (Just False) <> Optional "" (" " <> Param "$0") "preparedLink_"), + ("Connect", [], "Connect via SimpleX link or name as string in the active user profile.", ["CRSentConfirmation", "CRContactAlreadyExists", "CRSentInvitation", "CRConnectionPlan", "CRSentInvitationToContact", "CRStartedConnectionToContact", "CRStartedConnectionToGroup", "CRChatCmdError"], [], Just UNInteractive, "/connect" <> Optional "" (" " <> Param "$0") "connTarget_"), ("APIAcceptContact", ["incognito"], "Accept contact request.", ["CRAcceptingContactRequest", "CRChatCmdError"], [], Just UNInteractive, "/_accept " <> Param "contactReqId"), ("APIRejectContact", [], "Reject contact request. The user who sent the request is **not notified**.", ["CRContactRequestRejected", "CRChatCmdError"], [], Nothing, "/_reject " <> Param "contactReqId") ] @@ -150,7 +150,7 @@ chatCommandsDocsData = "Commands to list and delete conversations.", [ ("APIListContacts", [], "Get contacts.", ["CRContactsList", "CRChatCmdError"], [], Nothing, "/_contacts " <> Param "userId"), ("APIListGroups", [], "Get groups.", ["CRGroupsList", "CRChatCmdError"], [], Nothing, "/_groups " <> Param "userId" <> Optional "" (" @" <> Param "$0") "contactId_" <> Optional "" (" " <> Param "$0") "search"), - ("APIGetChats", [], "Get chat previews. Supports time-based pagination — use this instead of APIListContacts / APIListGroups when scanning at scale (those load every record into memory and fail on large databases).", ["CRApiChats", "CRChatCmdError"], [], Nothing, "/_get chats " <> Param "userId" <> OnOffParam "pcc" "pendingConnections" (Just False) <> " " <> Param "pagination" <> " " <> Json "query"), + ("APIGetChats", [], "Get chat previews. Supports time-based pagination — use this instead of APIListContacts / APIListGroups when scanning at scale (those load every record into memory and fail on large databases).", ["CRApiChats", "CRChatCmdError"], [], Nothing, "/_get chats " <> Param "userId" <> OnOffParam "pcc" "pendingConnections" (Just False) <> Optional "" (" " <> Param "$0") "pagination" <> " " <> Json "query"), ("APIDeleteChat", [], "Delete chat.", ["CRContactDeleted", "CRContactConnectionDeleted", "CRGroupDeletedUser", "CRChatCmdError"], [], Just UNBackground, "/_delete " <> Param "chatRef" <> " " <> Param "chatDeleteMode"), ("APISetGroupCustomData", [], "Set group custom data.", ["CRCmdOk", "CRChatCmdError"], [], Nothing, "/_set custom #" <> Param "groupId" <> Optional "" (" " <> Json "$0") "customData"), ("APISetContactCustomData", [], "Set contact custom data.", ["CRCmdOk", "CRChatCmdError"], [], Nothing, "/_set custom @" <> Param "contactId" <> Optional "" (" " <> Json "$0") "customData"), @@ -202,6 +202,12 @@ chatCommandsDocsData = [ ("StartChat", [], "Start chat controller.", ["CRChatStarted", "CRChatRunning"], [], Nothing, "/_start" <> OnOffParam "main" "mainApp" Nothing <> OnOffParam "snd_files" "enableSndFiles" (Just True) <> OnOffParam "service_requests" "serviceRequests" (Just False)), ("APIStopChat", [], "Stop chat controller.", ["CRChatStopped"], [], Nothing, "/_stop") ] + ), + ( "Remote control commands", + "Allows a bot to accept an incoming remote control session from a SimpleX Desktop client, giving the desktop live access to the bot's SimpleX instance.", + [ ("ConnectRemoteCtrl", [], "Connect to a remote controller using an OOB invitation link.", ["CRRemoteCtrlConnecting", "CRChatCmdError"], [], Just UNInteractive, "/crc " <> Param "remoteInvitation"), + ("VerifyRemoteCtrlSession", [], "Verify the remote controller session code to complete the connection.", ["CRRemoteCtrlConnected", "CRChatCmdError"], [], Nothing, "/verify remote ctrl " <> Param "sessionCode") + ] ) ] @@ -336,6 +342,7 @@ undocumentedCommands = [ "APIAbortSwitchContact", "APIAbortSwitchGroupMember", "APIAcceptConditions", + "APIAckBadgeAlert", "APIActivateChat", "APIAddGroupShortLink", "APIAddMyAddressShortLink", @@ -376,6 +383,8 @@ undocumentedCommands = "APIExportWalletAccount", "APIExportWalletMnemonic", "APIGetAppSettings", + "APIGetBadgeLedger", + "APIGetBadgeState", "APIGetCallInvitations", "APIGetChat", "APIGetChatContentTypes", @@ -405,6 +414,7 @@ undocumentedCommands = "APIPlanForwardChatItems", "APIPrepareContact", "APIPrepareGroup", + "APIRedeemBadgeCode", "APIRegisterToken", "APIRejectCall", "APIReorderChatTags", @@ -459,7 +469,6 @@ undocumentedCommands = "APIVerifyToken", "CheckChatRunning", "ConfirmRemoteCtrl", - "ConnectRemoteCtrl", "CustomChatCommand", "DebugEvent", "DebugLocks", @@ -506,6 +515,5 @@ undocumentedCommands = "SwitchRemoteHost", "TestChatRelay", "TestProtoServer", - "TestStorageEncryption", - "VerifyRemoteCtrlSession" + "TestStorageEncryption" ] diff --git a/bots/src/API/Docs/Events.hs b/bots/src/API/Docs/Events.hs index 1590ad54d5..dab06e9262 100644 --- a/bots/src/API/Docs/Events.hs +++ b/bots/src/API/Docs/Events.hs @@ -152,6 +152,13 @@ chatEventsDocsData = ], [] ), + ( "Remote control events", + "Bots that act as remote control hosts receive these events during the remote control session lifecycle.", + [ ("CEvtRemoteCtrlSessionCode", "Remote controller session code ready for verification.\n\nUse [VerifyRemoteCtrlSession](./COMMANDS.md#verifyremotectrlsession) to complete the connection."), + ("CEvtRemoteCtrlStopped", "Remote controller session stopped.") + ], + [] + ), ( "Error events", "Bots may log these events for debugging. \ \There will be many error events - this does NOT indicate a malfunction - \ @@ -172,6 +179,8 @@ undocumentedEvents = "CEvtAgentConnsDeleted", "CEvtAgentRcvQueuesDeleted", "CEvtAgentUserDeleted", + "CEvtBadgeAlert", + "CEvtBadgeChanged", "CEvtBusinessRequestAlreadyAccepted", "CEvtCallAnswer", "CEvtCallEnded", @@ -203,8 +212,6 @@ undocumentedEvents = "CEvtRcvFileProgressXFTP", "CEvtRcvStandaloneFileComplete", "CEvtRemoteCtrlFound", - "CEvtRemoteCtrlSessionCode", - "CEvtRemoteCtrlStopped", "CEvtRemoteHostConnected", "CEvtRemoteHostSessionCode", "CEvtRemoteHostStopped", diff --git a/bots/src/API/Docs/Responses.hs b/bots/src/API/Docs/Responses.hs index 40bb918b92..e6b0024374 100644 --- a/bots/src/API/Docs/Responses.hs +++ b/bots/src/API/Docs/Responses.hs @@ -88,11 +88,16 @@ chatResponsesDocsData = ("CRRcvFileAccepted", "File accepted to be received"), ("CRRcvFileAcceptedSndCancelled", "File accepted, but no longer sent"), ("CRRcvFileCancelled", "Cancelled receiving file"), + ("CRRemoteCtrlConnected", "Remote controller session connected."), + ("CRRemoteCtrlConnecting", "Remote controller is connecting."), ("CRSentConfirmation", "Confirmation sent to one-time invitation"), ("CRSentGroupInvitation", "Group invitation sent"), ("CRSentInvitation", "Invitation sent to contact address"), + ("CRSentInvitationToContact", "Invitation sent to contact (when connecting via SimpleX name to a known contact address)."), ("CRServiceReplyAccepted", "Service reply accepted for delivery. `connectionId` correlates the reply delivery event."), ("CRSndFileCancelled", "Cancelled sending file"), + ("CRStartedConnectionToContact", "Connection to contact started (when connecting via prepared contact)."), + ("CRStartedConnectionToGroup", "Connection to channel started (when connecting via channel link)."), ("CRUserAcceptedGroupSent", "User accepted group invitation"), ("CRUserContactLink", "User contact address"), ("CRUserContactLinkCreated", "User contact address created"), @@ -129,6 +134,9 @@ undocumentedResponses = "CRAppSettings", "CRArchiveExported", "CRArchiveImported", + "CRBadgeLedger", + "CRBadgeRedeemed", + "CRBadgeState", "CRBroadcastSent", "CRCallInvitations", "CRChatCleared", @@ -188,13 +196,10 @@ undocumentedResponses = "CRQueueInfo", "CRRcvStandaloneFileCreated", "CRReactionMembers", - "CRRemoteCtrlConnected", - "CRRemoteCtrlConnecting", "CRRemoteCtrlList", "CRRemoteFileStored", "CRRemoteHostList", "CRRemoteHostStarted", - "CRSentInvitationToContact", "CRServerOperatorConditions", "CRServerTestResult", "CRServiceResponse", @@ -202,8 +207,6 @@ undocumentedResponses = "CRSndStandaloneFileCreated", "CRSQLResult", "CRStandaloneFileInfo", - "CRStartedConnectionToContact", - "CRStartedConnectionToGroup", "CRTagsUpdated", "CRUsageConditions", "CRUserPrivacy", diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index c856735027..121f88ed63 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -35,6 +35,7 @@ import Simplex.Chat.Store.Shared import Simplex.Chat.Operators import Simplex.Messaging.Agent.Store.Entity (DBStored (..)) import Simplex.Chat.Badges +import Simplex.Chat.Badges.Service import Simplex.Chat.Names import Simplex.Chat.Wallet (WalletError (..)) import Simplex.Chat.Types @@ -50,6 +51,7 @@ import Simplex.Messaging.Parsers (dropPrefix, fstToLower) import Simplex.Messaging.Protocol (BlockingInfo (..), BlockingReason (..), CommandError (..), ErrorType (..), NameErrorType (..), NetworkError (..), ProxyError (..)) import Simplex.Messaging.Protocol.Types (ClientNotice (..)) import Simplex.Messaging.Transport +import Simplex.Chat.Remote.Types (CtrlAppInfo (..)) import Simplex.RemoteControl.Types import System.Console.ANSI.Types (Color (..)) @@ -212,8 +214,11 @@ chatTypesDocsData = (sti @AgentCryptoError, STUnion, "", ["RATCHET_EARLIER", "RATCHET_SKIPPED"], "", ""), -- TODO add fields to types (sti @AgentErrorType, STUnion, "", [], "", ""), (sti @AgentServiceError, STUnion, "ASE", [], "", ""), + (STI "AppVersionRange" [RecordTypeInfo "AppVersionRange" [FieldInfo "minVersion" (TIType (ST TString [])), FieldInfo "maxVersion" (TIType (ST TString []))]], STRecord, "", [], "", "Remote controller app version range (min and max as version strings)."), (sti @AutoAccept, STRecord, "", [], "", ""), (sti @BadgeProof, STRecord, "", [], "", ""), + (sti @BadgeRedeemError, STUnion, "BRE", [], "", ""), + (sti @BadgeServiceErrorCode, STEnum' (consSep "BSE" '_'), "", ["BSEUnknown"], "", ""), (sti @BlockingInfo, STRecord, "", [], "", ""), (sti @BlockingReason, STEnum, "BR", [], "", ""), (sti @BrokerErrorType, STUnion, "", [], "", ""), @@ -244,6 +249,7 @@ chatTypesDocsData = (sti @CIReactionCount, STRecord, "", [], "", ""), (sti @CITimed, STRecord, "", [], "", ""), (sti @ClientNotice, STRecord, "", [], "", ""), + (sti @CtrlAppInfo, STRecord, "", [], "", "Remote controller application info."), (sti @Color, STEnum, "", [], "", ""), (sti @CommandError, STUnion, "", [], "", ""), (sti @CommandErrorType, STUnion, "", [], "", ""), @@ -271,6 +277,7 @@ chatTypesDocsData = (sti @FileError, STUnion, "FileErr", [], "", ""), (sti @FileErrorType, STUnion, "", [], "", ""), (sti @FileInvitation, STRecord, "", [], "", ""), + (sti @FileProhibited, STRecord, "", [], "", ""), (sti @FileProtocol, STEnum' (consLower "FP"), "", [], "", ""), (sti @FileStatus, STEnum, "FS", [], "", ""), (sti @FileTransferMeta, STRecord, "", [], "", ""), @@ -285,8 +292,6 @@ chatTypesDocsData = (sti @GroupFeature, STEnum, "GF", [], "", ""), (sti @GroupFeatureEnabled, STEnum, "FE", [], "", ""), (sti @GroupInfo, STRecord, "", [], "", ""), - (sti @GroupKeys, STRecord, "", [], "", ""), - (sti @GroupRootKey, STUnion, "GRK", [], "", ""), (sti @GroupLink, STRecord, "", [], "", ""), (sti @GroupLinkOwner, STRecord, "", [], "", ""), (sti @GroupLinkPlan, STUnion, "GLP", [], "", ""), @@ -354,8 +359,12 @@ chatTypesDocsData = (sti @RcvGroupEvent, STUnion, "RGE", [], "", ""), (sti @RcvMsgError, STUnion, "RME", [], "", ""), (sti @RelayCapabilities, STRecord, "", [], "", ""), + (sti @RelayConnectionResult, STRecord, "", [], "", ""), (sti @RelayProfile, STRecord, "", [], "", ""), (sti @RelayStatus, STEnum, "RS", [], "", ""), + (sti @RemoteCtrlInfo, STRecord, "", [], "", ""), + (sti @RemoteCtrlSessionState, STUnion, "RCS", [], "", ""), + (sti @RemoteCtrlStopReason, STUnion, "RCSR", [], "", ""), (sti @ReportReason, STEnum' (dropPfxSfx "RR" ""), "", ["RRUnknown"], "", ""), (sti @RoleGroupPreference, STRecord, "", [], "", ""), (sti @SecurityCode, STRecord, "", [], "", ""), @@ -442,6 +451,8 @@ deriving instance Generic AgentErrorType deriving instance Generic AgentServiceError deriving instance Generic AutoAccept deriving instance Generic BadgeProof +deriving instance Generic BadgeRedeemError +deriving instance Generic BadgeServiceErrorCode deriving instance Generic BlockingInfo deriving instance Generic BlockingReason deriving instance Generic BrokerErrorType @@ -472,6 +483,7 @@ deriving instance Generic CIMentionMember deriving instance Generic CIReactionCount deriving instance Generic CITimed deriving instance Generic ClientNotice +deriving instance Generic CtrlAppInfo deriving instance Generic Color deriving instance Generic CommandError deriving instance Generic CommandErrorType @@ -499,6 +511,7 @@ deriving instance Generic FileDescr deriving instance Generic FileError deriving instance Generic FileErrorType deriving instance Generic FileInvitation +deriving instance Generic FileProhibited deriving instance Generic FileProtocol deriving instance Generic FileStatus deriving instance Generic FileTransferMeta @@ -513,8 +526,6 @@ deriving instance Generic GroupChatScopeInfo deriving instance Generic GroupFeature deriving instance Generic GroupFeatureEnabled deriving instance Generic GroupInfo -deriving instance Generic GroupKeys -deriving instance Generic GroupRootKey deriving instance Generic GroupLink deriving instance Generic GroupLinkOwner deriving instance Generic GroupLinkPlan @@ -589,8 +600,12 @@ deriving instance Generic RcvFileTransfer deriving instance Generic RcvGroupEvent deriving instance Generic RcvMsgError deriving instance Generic RelayCapabilities +deriving instance Generic RelayConnectionResult deriving instance Generic RelayProfile deriving instance Generic RelayStatus +deriving instance Generic RemoteCtrlInfo +deriving instance Generic RemoteCtrlSessionState +deriving instance Generic RemoteCtrlStopReason deriving instance Generic ReportReason deriving instance Generic SecurityCode deriving instance Generic SimplexDomain diff --git a/bots/src/API/TypeInfo.hs b/bots/src/API/TypeInfo.hs index e225659c4d..59bfe4e52e 100644 --- a/bots/src/API/TypeInfo.hs +++ b/bots/src/API/TypeInfo.hs @@ -200,6 +200,7 @@ toTypeInfo tr = "AgentInvId", "AgentRcvFileId", "AgentSndFileId", + "AppVersion", "BadgeMasterKey", "B64UrlByteString", "BBSProof", @@ -219,6 +220,7 @@ toTypeInfo tr = "ProofPresHeader", "PublicKey", "ProtocolServer", + "RCSignedInvitation", "SbKey", "SharedMsgId", "Signature", diff --git a/docs/protocol/badges-overview.md b/docs/protocol/badges-overview.md new file mode 100644 index 0000000000..41ed3c5eaa --- /dev/null +++ b/docs/protocol/badges-overview.md @@ -0,0 +1,291 @@ +Revision 1, 2026-09-18 + +# SimpleX Supporter Badges + +## Table of contents + +- [Introduction](#introduction) + - [What is a badge](#what-is-a-badge) + - [What a badge grants](#what-a-badge-grants) + - [What a badge discloses](#what-a-badge-discloses) + - [Comparison with Signal badges](#comparison-with-signal-badges) + - [Non-goals](#non-goals) +- [Architecture](#architecture) + - [Participants](#participants) + - [Service requests](#service-requests) + - [Buying and redeeming](#buying-and-redeeming) + - [Monthly issuance](#monthly-issuance) + - [Presentation](#presentation) + - [Servers](#servers) +- [Cryptographic primitives](#cryptographic-primitives) +- [Security](#security) + - [Design objectives](#design-objectives) + - [Threat model](#threat-model) +- [Future work](#future-work) + + +## Introduction + +The goal is for SimpleX Chat to be partly funded by its users, who buy supporter badges. A badge is shown on the profile of the person who bought it, and it raises the limits that other users and servers apply to that person's files. This document describes what a badge is, what it grants, how it is issued and presented, and what it discloses about the holder. + +A badge is stored in the user's profile on the device, and it is presented as proofs that cannot be linked to each other or to the purchase. The SimpleX network has no accounts, and a paid feature must not introduce one: in other messengers a paid feature is attached to an account, and the operator sees in one record who paid and what they did with the feature. + +### What is a badge + +A badge is a credential issued by the badge service, a bot operated by SimpleX Chat. The credential is a [BBS signature](https://datatracker.ietf.org/doc/draft-irtf-cfrg-bbs-signatures/) over four values: a master key generated by the app; the expiry date; the badge type, currently `supporter` or `legend`; and a reserved field that is currently empty. + +The holder of a BBS signature can prove that the issuer signed certain values, disclose some of those values, and keep the other values and the signature itself secret. The credential itself is therefore not sent to anyone. When the badge is to be shown or used, the app generates a proof that discloses the badge type, the expiry date and the reserved field, names the issuer key by its index, and hides the master key and the signature. Two proofs from the same credential cannot be linked to each other, by the issuer or by anyone else. Each proof is generated for one context, whether a conversation, a file or a session with a server, and is not accepted in any other. + +The transport protocols of the network call the same credential an entitlement: a name, an expiry and an extra string, disclosed in a proof. The chat application puts the badge type into the name, so a server that verifies an entitlement and a contact who sees a badge are looking at different zero-knowledge proofs of the same credential. + +### What a badge grants + +- A badge is shown on the profile to contacts, to the members of groups, and to the subscribers of channels. +- A badge allows larger files. The default limit is 1GB; a supporter can send files up to 2GB, and a legend up to 5GB. The limit is applied by the recipient, whose app verifies the sender's proof before it accepts a file above the default size. +- A badge extends the storage of files on servers. An XFTP server stores a file for 48 hours by default. It may be configured to store files for longer when they are uploaded by the holder of a badge of a given type; the example values in the server configuration file are 7 and 21 days for supporter and legend badge holders, respectively. + +Two other uses are planned and described under [Future work](#future-work): + +- A backup will be a link whose content the app replaces each time it makes a new backup, so that the same link restores the latest state. A badge will raise the size of the backup and the time it is stored, and the storage time is then the period the app can remain offline before the backup is lost. +- Servers that verify the proof will apply higher rate limits when creating messaging queues, file chunks and notification tokens. + +### What a badge discloses + +The badge service holds the purchase key, the number of months bought, and the payment record of the purchase. A card payment is linked to the purchase, as in any other service; a user who wants the payment itself to be private may pay with cryptocurrency. In either case the record is held by the service alone, and the service does not learn where the badge is later shown or used, because a zero-knowledge proof contains nothing that refers back to the credential or the purchase. + +A party that verifies a proof learns the badge type and the expiry date. Since all credentials expiring in the same week have the same expiry date, these two values place the holder among the supporters whose badges expire that week. + +### Comparison with Signal badges + +Signal sells donation badges using [receipt credentials](https://eprint.iacr.org/2019/1416.pdf). The client obtains a credential against a payment without revealing its account, and then presents the credential once to the account server, which records its serial number, level and expiry against the account and attaches the badge to the account. The payment is not linked to the account cryptographically. The same mechanism is used for Signal's paid backups. + +The differences follow from Signal having accounts and SimpleX not having them. + +In Signal: + +- The server holds the list of accounts that have badges. +- The credential can be verified only by the server that issued it. +- The credential is presented once, after which the badge is a property of the account. +- The client requests the credential and redeems it in a single sequence of jobs without delay, so the server can match the request made under the payment with the redemption made under the account by their times and by network address. +- The expiry of a credential is rounded up to the next day. + +In SimpleX: + +- A server verifies a proof for one session and cannot link that session to any other. +- A credential can be verified against the issuer's public key by contacts, channel relays and independently operated servers. +- A credential is presented every time the badge is shown or used, with a new proof each time. +- The request for a renewed credential and the profile update that presents it are made on different days. +- All credentials expiring in the same week have the same expiry. + +| Property | Signal badges | SimpleX badges | +|---|---|---| +| Account required | Yes | No | +| Operator holds the list of badge holders | Yes | No | +| Verified by | The issuing server | Any party with the issuer's public key | +| Presentations per credential | One | Unlimited | +| Proof bound to the context of presentation | No | Yes | +| Expiry rounded up to | The next day | The end of the following Monday | +| Issuance and presentation on different days | No | Yes | +| Usable on independently operated servers | No | Yes | + +### Non-goals + +- A badge does not restrict anything that is available today: the defaults are unchanged, and a badge only raises them. +- A badge does not create an identity: it has no persistent identifier, it is not linked across conversations, and an incognito profile does not show it. +- A badge cannot be transferred: a code can be redeemed once, and the credential obtained with it is usable only with the master key it was issued for. +- A badge does not exempt its holder from the limits a server applies: it lowers the cost of a resource without removing the limit on it. +- A badge cannot be revoked; credentials are issued for one month at a time instead. + +## Architecture + +``` + ┌─────────────────┐ code ┌──────────────┐ purchase ┌──────────┐ + │ Web purchase │ ────────> │ Holder │ ────────────> │ Issuer │ + └─────────────────┘ │ (the app) │ <──────────── └──────────┘ + └──────┬───────┘ credential + │ every month + │ a new proof each time + ┌─────────────────────────┼───────────────────────┐ + │ │ │ + ┌────▼─────┐ ┌─────▼──────┐ ┌─────▼──────┐ + │ Contacts,│ │ File │ │ The user's │ + │ groups, │ │ recipients │ │ own servers│ + │ channels │ └────────────┘ └────────────┘ + └──────────┘ +``` + +### Participants + +- The issuer is the badge service, a bot on the SimpleX network with a contact address. It holds the secret key with which credentials are signed. Apps and servers hold a list of eight issuer public keys, and every credential includes the index of the key that signed it, so the service can move to the next key without a release of apps or servers. +- The holder is the app. It keeps the credential in the user's profile, generates proofs on the device, and renews the credential every month. +- The verifiers are contacts, group members, channel relays, the recipients of files, and the user's own servers. A verifier holds the issuer public keys and no other information about badges. + +### Service requests + +The app and the badge service communicate through one-off service requests, a primitive of the SimpleX agent described in [One-off requests to service addresses](https://github.com/simplex-chat/simplexmq/blob/master/rfcs/2026-07-11-service-rpc.md). Although the service has a contact address, it is not a contact of the app, and no conversation with it exists. + +A request is a single message to the service's address. The app establishes a double ratchet, with post-quantum key agreement, from the keys published in the service's address link, encrypts the request with the double ratchet, and sends it to the address queue on the service's server in the same way as any message to a server chosen by another party, that is, through a proxy server when private routing is enabled (which is the default). With the request the app sends the address of a reply queue that it created for this request on one of its own servers. The service decrypts the request with a receiving ratchet initialised from its private keys, sends the reply to the reply queue under the same ratchet, and deletes its state; the app deletes the reply queue and the ratchet when it has the reply or when the request times out. + +This exchange has the following properties: + +- Unlike a request made directly to the service over HTTP, the app does not connect to the service, nor the service to the app. Each communicates only with SMP servers, so the service does not see the app's network address, and with private routing the service's server does not see it either. +- Unlike a chat connection, which is a persistent channel through which all requests made over it can be linked to each other, a service request does not create persistent state. Each request uses new keys and a new reply queue, so two requests from the same app cannot be linked by the service or by servers. +- A reply that decrypts proves that it came from the holder of the keys published in the service's link and signed by its root key, so a server cannot substitute a reply. +- The badge service answers a repeated request with the same result and does not execute the operation twice, so the app can repeat a request whose reply was lost. + +A purchase is identified by an Ed25519 key pair that the app generates for it and does not use for any other purpose. Every request concerning the purchase, the redemption and each monthly renewal, is signed with this key, and the signature covers the request together with a value derived from the ratchet of that exchange, so it is valid for that exchange only and cannot be replayed. The agent verifies the signature and delivers the verified public key to the service with the request, and the service accepts a request about a purchase only when the verified key is the purchase key. + +### Buying and redeeming + +A badge is bought for one or more months, either on the web or in the app. + +On the web the purchase produces a code, which is then redeemed in the app. The code is the only data passed from the web site to the app: the web site does not learn which app redeems a code, and the app does not see the payment. The code is generated in the buyer's browser, and the service stores only its hash; at redemption the app presents the code itself, and the service matches it against the stored hash. A badge issued without a sale, for example in compensation for a problem, is a code generated by the operator and redeemed in the same way. + +In the app, the user pays by card, in cryptocurrency, or through the app store. The app requests an invoice from the service, or presents the receipt of the app store, and the service issues the credential once the payment is confirmed. + +In both cases the app generates the master key and the purchase key pair before the purchase. To redeem a code, the app sends the code and the master key to the service, which issues the credential. A code redeemed a second time with the same purchase key returns the same credential; a code redeemed with a different purchase key is refused. + +### Monthly issuance + +A credential is issued for one month at a time, however many months were bought. The service keeps a count of the months remaining for each purchase key, and issues the next credential when the app asks for it. Unused months, e.g. if the app was offline, lapse at the next badge issuance. + +Credentials are issued monthly to limit what the expiry date discloses. If credentials were issued for the whole term, a user who bought a year would hold a credential expiring on a day a year ahead, when few other credentials expire, and this date would be disclosed in every proof for a year. Instead, every credential is issued for one month, and its expiry is rounded to the end of the Monday following the end of the paid month (UTC). For example, if the paid month ends on Wednesday 14 October 2026, the credential expires at the end of Monday 19 October, and so does every credential whose month ends between Monday 12 and Sunday 18 October. All credentials whose paid month ends in the same week thus expire at the same instant. + +The renewal is split over two days for the same reason. The app requests the next credential on the day before the current one expires, and when the current one expires, it switches to the new one and sends its updated profile to its contacts. In the example above, the app asks the service for the new credential on Monday 19 October and starts showing it on Tuesday 20 October, so an observer of both the request and the profile update cannot correlate them by time. + +The renewal runs in the background and does not require any action from the user. Recipients accept a badge for seven days after its expiry, and servers for one day, so a renewal delayed by a few days is not visible to contacts. + +### Presentation + +A BBS proof is generated over a string, called the presentation header, and is verified only against the same string. The string binds the proof to the context in which it is presented. Without it, a proof received in one conversation could be copied and presented in another. Between apps the string is sent with the proof, and the recipient checks that it is the string it expects; servers know the string already, so it is not sent to them. + +The context of a conversation is the same string over which message signatures are computed: + +- In a direct chat it is a hash derived from the state of the end-to-end encryption, which only the two sides hold. +- In a group it is the member's identifier together with the member's signing key. +- In a channel it is the channel's identifier together with the member's identifier, or the channel's identifier alone when a message is sent in the name of the channel. + +A session with a server is identified by the TLS session identifier, which both sides derive from the TLS handshake and which differs on every connection. + +Each time the app sends its profile, whether to a new contact, to a group it joins, or to everyone when the profile is updated, it generates a new proof and includes it. The proof is bound to the conversation in which the profile is sent: in a direct chat to the connection with the contact, in a group to the member's identity in that group, and in a channel to the member's identity established by the channel's roster. In a group the badge is accepted only from a message signed with the member's key, and in a channel it is verified against the key that the roster establishes for the member. + +A file larger than the default limit is sent with two proofs. The first is placed in the file invitation, the message that announces the file, and is bound to the conversation and to the size of the file; the recipient's app verifies it when the invitation arrives. The second is placed in the file description, the record of where the chunks of the file are stored and how they are decrypted, and is bound in addition to a hash of the description and to the storage time of the file; the app verifies it before the download begins. + +### Servers + +The client presents the entitlement proof in the transport handshake, bound to the TLS session identifier. The server verifies it once, when the session is established, and applies the result to every command in the session. + +The client presents the proof only to its own servers, that is, to those configured for the profile, which are identified by the certificate fingerprint pinned in TLS. It does not present the proof to a server whose address it received from a contact or found in a file description, because a file description names the servers on which the chunks are stored, and these are chosen by the sender. If the app presented its proof to every server it connected to, a sender could store a file on a server of their own and learn, when the file is downloaded, that the person downloading it holds a badge. + +On XFTP servers the proof extends the storage time of files. The server grants the smaller of the time requested by the client and the maximum configured for the badge type, and returns the resulting expiry. SMP servers and notification servers accept the same proof in their handshakes but do not yet make use of it. + +The exchange between the app and the badge service, that is, the commands, responses and errors, is described in the [badge service protocol](./badges-rpc.md). + + +## Cryptographic primitives + +Badges are built on [BBS signatures](https://datatracker.ietf.org/doc/draft-irtf-cfrg-bbs-signatures/), in the BLS12-381-SHA-256 suite, implemented by [libbbs](https://github.com/Fraunhofer-AISEC/libbbs) over [blst](https://github.com/supranational/blst). A credential is a signature over four values under the header `SimpleX badges v1`; a proof discloses three of them and is 304 bytes long. BBS was chosen for four properties that the design needs together: + +- A proof can be verified by anyone who holds the issuer's public key, so contacts and independently operated servers verify badges without consulting the issuer. +- A proof discloses only the values selected. +- A proof cannot be linked to any other proof of the same credential. +- A proof is generated over a presentation header, so it can be bound to the context in which it is presented. + +Credentials of the kind Signal uses can be verified only by their issuer, and a receipt credential discloses its serial number when presented, so it is presented once. Single-use tokens, such as blind signatures and the tokens of Privacy Pass, are likewise spent when presented and do not include values such as an expiry or a type. + +Service requests are protected by the double ratchet of the [SimpleX agent](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/agent-protocol.md), with X448 key agreement and the sntrup761 KEM, established from keys published in the service's link. The purchase keys with which requests are signed, and the member keys that form part of the conversation context in groups, are Ed25519 keys. Codes are hashed with SHA-256, and the file description in the context of a file proof with SHA-512. + + +## Security + +### Design objectives + +1. A proof discloses no value that links it to another proof or to the purchase. +2. A proof used in one context, whether a session, a conversation or a file, cannot be used in another context. +3. The timing of presentations does not identify the holder: all credentials expiring in the same week share the same expiry, and the renewal request and the profile update are made on different days. +4. Requests to the badge service cannot be linked to each other, to a profile or to a network address, and a request about a purchase can be made only by the holder of the purchase key. +5. A credential cannot be forged: the issuer keys are fixed in apps and servers, and the app verifies a credential before storing it. +6. A missing or failed proof leaves the default limit in place, and a server cannot be configured to grant a badge type less than the default. + +### Threat model + +This threat model assumes the [SimpleX network threat model](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/security.md) and addresses the threats specific to badges. + +**The badge service** + +*can:* + +- See the purchase key of every badge, the master key generated for it, the number of months bought, and the payment record. +- Issue any credential, or refuse to issue one, as it holds the issuer key. + +*cannot:* + +- Connect a purchase with a profile, a contact, a group or a session with a server - a proof contains nothing that refers back to the purchase. +- Learn where a badge is shown or used. +- Learn the network address of the app - requests reach the service through SMP servers, on connections created for the request. + +**A contact, a group member or a channel relay** + +*can:* + +- See the badge type and the expiry date. + +*cannot:* + +- Tell whether a badge seen in another conversation belongs to the same person. +- Reuse a profile proof or a file proof in another context. +- Distinguish the holder from the other supporters whose badges expire in the same week. + +**A server operator** + +*can:* + +- See the badge type and the expiry date once in each session. +- Group together the sessions of the supporters whose badges expire in the same week. + +*cannot:* + +- Reuse the proof on another connection. +- Link a session to the purchase, to a profile or to sessions on other servers, except through the week of expiry. +- Obtain a proof from a client that does not use the server. + +**The server that hosts the badge service's address** + +*can:* + +- See that requests arrive for the service. + +*cannot:* + +- See the content of requests. +- See the network address of the app when private routing is used. +- See the replies, which are sent to a queue on a server chosen by the app. + +**One badge on many machines** + +Since proofs are unlinkable, a server cannot count the sessions of a single credential, so the benefit granted by a badge must be limited without counting sessions. For this reason a badge lowers the cost of a resource without removing the limit on it, and limits per session remain in force. + +**Compromise of the user's device or backup** + +An attacker who obtains the credential and the purchase key can use the badge until it expires and renew it while months remain. There is no revocation, so the loss is limited to the months bought. + +**Interception of a code** + +A code is a bearer secret until it is redeemed; once redeemed, it is refused to any other purchase key. + +**A passive network observer** + +*can:* + +- Observe SimpleX traffic between clients and servers. + +*cannot:* + +- See a proof - proofs to servers are inside TLS, in a handshake block of fixed size, and proofs to other users and requests to the badge service are inside end-to-end encrypted messages. +- Distinguish a session in which a proof was presented from a session without one. + +## Future work + +- Backups will be stored on XFTP servers, and a badge will raise their size and their storage time, as described under [What a badge grants](#what-a-badge-grants). +- SMP servers and notification servers will verify the proof. A server that verifies it can charge the holder of a badge less for creating queues, file chunks and notification tokens (under the planned proof-of-work scheme, the required effort is divided by a factor configured for each badge type), and can apply higher rate limits and larger quotas. +- Subscriptions that renew automatically, the transfer of remaining months to a new device, and the pausing of a prepaid badge are planned. diff --git a/docs/protocol/badges-rpc.md b/docs/protocol/badges-rpc.md new file mode 100644 index 0000000000..8ea70e1d19 --- /dev/null +++ b/docs/protocol/badges-rpc.md @@ -0,0 +1,66 @@ +# Badge service RPC protocol + +Schema: `badges-rpc.schema.json`, definitions `request` and `response`. Types: `Simplex.Chat.Badges.Service`. Model: `plans/2026-07-30-supporter-badges-v3-ux.md` §3 — cited below as "model". + +## Transport + +Service RPC (`plans/2026-07-22-service-rpc-chat.md`, branch `rpc`): the request travels in `APISendServiceRequest.request`, the response in `CRServiceResponse.responseData`; one response per request; per-call timeout. + +A request is an envelope: `version` — the client's protocol version; `purchaseKey`; `request` — the command, discriminated on `type`. Responses are discriminated on `type`. The service is deployed ahead of app releases, answers within the client's `version`, and rejects clients older than it supports with `unsupported_version`. + +## Identity + +Each purchase runs under a fresh Ed25519 key pair; `purchaseKey` is its public part and identifies the badge. The service cannot link purchases of one user; the exceptions are the declared upgrades below. `getBadgeCatalog` may omit `purchaseKey`: unsigned, it returns the catalog alone; signed, its response adds the purchase's `badgeStatement` — a client holding a lapsed badge checks for credits in the same request that prices a new purchase, and buys under a fresh key only when the statement shows none. Every other command requires the key and is signed with it. The agent delivers the verified signer key alongside the request; the service rejects a `purchaseKey` that differs from it with `bad_request`. + +A purchase record is created by `redeemBadgeCode`, by `getBadgeInvoice`, or by `purchaseBadge` funded with `apple`, `google`, or `receipt`. Those commands accept a key the service holds no record of — on a first purchase it always will. Every other command answers `unknown_purchase_key` for such a key. + +## Idempotency + +A timeout hides the outcome, so the client repeats the identical signed request at its next trigger, never on a poll timer. + +- `getBadgeInvoice` — returns the open invoice again; a new invoice is created only when none is open. +- `redeemBadgeCode` — a code already redeemed by the signing key returns the same `badgeCredential` and writes nothing; redeemed by another key, `code_used`. The client must therefore keep the key it first signed with, or a retry cannot be recognised. +- `purchaseBadge` — a payment already credited returns the same `badgeCredential` and writes nothing. +- `upgradeBadgeSubscription` — evidence already applied returns the same result and writes nothing. +- `issueBadge` — repeated within an issued period, returns the cached credential and writes nothing. +- `purchaseBadge` with a `receipt` — presented again by the same key it returns the same result; presented by another key, `receipt_used`. + +## Commands + +`purchaseBadge` and `upgradeBadgeSubscription` carry `badgeRequest`, the signer's input (`BadgeRequest`, `Simplex.Chat.Badges`): the service signs exactly this content or rejects the command. The proposed `badgeExpiry` is capped by the funded coverage (`sundayAfter`, model §3) and is required — a credential always expires, and a badge that does not is expressed as a long finite term; `badgeExtra` is reserved and must be empty. `issueBadge` carries no `badgeRequest`: the tier, the master key and the expiry are all the service's own, so there is nothing for the client to state. + +- `getBadgeCatalog` → `badgeCatalog` — the prices and offers; signed, also the purchase's `badgeStatement`. Store builds never send it: prices come from the store and SKUs from app config. +- `getBadgeInvoice` → `badgeInvoice` — prices the purchase for `badgeInfo` and `paymentVia` (`card` — Stripe; `crypto` — btc, xmr). The response holds the generic `invoice` — `invoiceId`, `price`, `discount`, the upgrade `credit`, `amount` = price − discount − credit, `currency`, `expiresAt`, and `paymentTo` (`url` for card; `address` and `cryptoAmount` for crypto) — beside the badge part, `badgeType` and `months`. `priceId` pins the price the client displayed; `offerId` selects a discounted duration, and its absence buys one month at that price. Price and offer status is checked here only: `deprecated` is still accepted, `disabled` is rejected; a badge type with no active price yields `product_unavailable`. +- `redeemBadgeCode` → `badgeCredential` — redeems a code, records the credit, and issues the first credential, in one round trip. It carries `masterKey` and `code` and no `badgeRequest`: a code states no tier and no expiry, so the credential is what reports them. Errors: `code_invalid` for an unknown or malformed code, `code_used` when another key redeemed it, `code_expired` past a redemption deadline. +- `purchaseBadge` → `badgeCredential` — verifies the funding (`apple` JWS offline; `google` token via the Publisher API; `invoice` against webhook-confirmed settlement, `payment_pending` until it lands; `receipt`), records the credit, and issues the first credential, in one round trip. The response `receipt` is the recovery bearer secret (model § recovery); the service stores its hash; lifetime badges receive none. + - Funding by `receipt` is a transfer (post-MVP): the unissued months of the purchase that receipt belongs to move to the signing key, recorded as `debit(transferOut)` on the source and `credit(transferIn)` on the new purchase, and the presented receipt is retired for a fresh one. The transferred period's issuance debits a month like any other. Lifetime badges hold no receipt, so support handles them. +- `upgradeBadgeSubscription` → `badgeCredential` — the app-led store subscription change, on the same key: verifies the store evidence of the replaced subscription and records the new plan; an immediate upgrade returns the new credential, a deferred change returns none. +- `issueBadge` → `badgeCredential` — issues the next period from the balance, the only source of issuance. It carries `balance` alone: the credential is signed with the purchase's stored master key, for the type the balance funds, expiring at the `sundayAfter` of the period issued. The ledger is advanced first; the credential is signed before the `debit(badge)` and issuance rows are written, in one transaction. An exhausted balance yields no `credential`; the `statement` shows why. Issuing on a paused badge resumes it (model 2.13). +- `pauseBadge` (post-MVP) → `badgeCredential` — suspends issuance and lapse (model 2.13). + +## Upgrades + +Always a new purchase under a new key, except store subscriptions, where the store owns the change. + +- Non-store: `getBadgeInvoice.upgrade` — `fromPurchaseKey`, the old purchase's `receipt`, `receiptSignature` binding the old key to the new, and the asserted old `balance`. The invoice returns the conversion `credit`; settlement records `debit(upgrade)` on the old purchase and the credit on the new. +- Store one-time: an upgrade SKU at a fixed discounted price; `purchaseBadge.upgrade` — `fromPurchaseKey`, `receipt`, `receiptSignature` — proves eligibility (an unexpired cheaper badge), because the store cannot gate who buys the SKU. +- Store subscription, app-led: the native subscription-group flow (Apple — immediate, with the store's prorated refund; Google — per replacement mode), then `upgradeBadgeSubscription` with the new evidence. +- Store subscription, sheet-led, and every downgrade: the client sends nothing — the service discovers the change from provider state and notifications, and each renewal credits months of the charged badge type. + +## Catalog + +`prices` — `priceId`, `badgeType`, `monthPrice`, `currency`, `status`, `createdAt`; `offers` — `offerId`, `priceId`? (absent applies to any price), `months`, `discount`, `status`, `createdAt`. An offer states a discount, as free months or a percentage; a duration without one is priced at `months × monthPrice`. Repricing appends a price and deprecates the old, which is still accepted at invoice creation; deprecated prices and offers are sent so that a refresh cannot remove what the client pinned, and disabled ones are omitted. Rendering is app-driven — tiers and durations come from app resources, and one without a price is shown disabled. + +## Statement and balance + +The ledger is written by the service alone (model §3); the client keeps a verbatim replica and computes the effective balance from its last entry and the time. Month boundaries are counted from `balanceAnchorTs`, the start of the current run of months, and not from `balanceStartTs` — counting from the moving start would compound the day-of-month clipping of a short month, so a run beginning 31 January would reach 28 February and never return to the 31st. The service sets a new anchor only where a lapsed run restarts; months granted while coverage still runs extend it on its existing anchor. + +`statement` — `entries`, and `previousEntryId` when they attach after an entry the client holds; its absence marks entries that attach to nothing. Each entry states `entryId`, the signed `changeMonths`, the resulting `balanceMonths`, `balanceStartTs`, `balanceAnchorTs`, and `balanceBadgeType`, `wasPausedSince` on the entry ending a pause, `createdAt`, and `entryType` — `credit`: `payment {invoiceId?}`, `code`, `charge {chargeId}`, `support`, `transferIn {fromPurchaseKey}`, `opening`; `debit`: `refund`, `upgrade {toPurchaseKey}`, `transferOut {toPurchaseKey}`, `support`, `badge`, `lapse`. A code grant is `code` rather than `payment` with no `invoiceId`: the invoice of a code belongs to whoever bought it, and the redeemer's ledger must never reference it. An unknown type is stored as received and decoded after an app upgrade. + +`balance` — `lastEntry`, the client's last entry, asserting the position and the months it believes it holds. + +An assertion that names an entry the service holds is a prefix: the service proceeds and returns what follows it. Otherwise the service heals its own ledger first — provider evidence for charges, time for lapses — proceeds, and returns either the complete history or one `opening` credit. An `opening` entry is an absolute restatement: the ledger is reset to the amount it states, without relation to the preceding entry, which also serves a new device and, later, the discarding of old history into a brought-forward balance. + +## Errors + +`retryAfter` marks the transient codes: `payment_pending`, `provider_unavailable`, `rate_limited`. `offer_disabled` calls for a catalog refresh. `code_invalid` covers unknown, malformed and revoked codes alike, so a guesser learns nothing from the difference; `code_used` — redeemed under another key; `code_expired` — past its redemption deadline. `receipt_invalid` covers unknown receipts. All other codes are terminal for the attempted command. diff --git a/docs/protocol/badges-rpc.schema.json b/docs/protocol/badges-rpc.schema.json new file mode 100644 index 0000000000..01581a5bac --- /dev/null +++ b/docs/protocol/badges-rpc.schema.json @@ -0,0 +1,393 @@ +{ + "metadata": { + "description": "JTD schema for badge service RPC requests and responses, see badges-rpc.md." + }, + "definitions": { + "base64url": { + "type": "string", + "metadata": {"format": "base64url encoded string"} + }, + "purchaseKey": { + "ref": "base64url", + "metadata": {"comment": "must match the RPC transport key"} + }, + "currencyAmount": { + "type": "uint32", + "metadata": {"comment": "USD etc. are in minor units, following Stripe etc. convention"} + }, + "badgeInfo": { + "properties": { + "badgeType": {"type": "string"}, + "badgeExtra": {"type": "string"}, + "badgeExpiry": {"type": "timestamp"} + } + }, + "badgeRequest": { + "properties": { + "masterKey": {"ref": "base64url"}, + "badgeInfo": {"ref": "badgeInfo"} + } + }, + "credential": { + "properties": { + "badgeKeyIdx": {"type": "uint16"}, + "masterKey": {"ref": "base64url"}, + "signature": {"ref": "base64url"}, + "badgeInfo": {"ref": "badgeInfo"} + } + }, + "paymentVia": { + "discriminator": "type", + "mapping": { + "card": { + "properties": {"provider": {"enum": ["stripe"]}} + }, + "crypto": { + "properties": {"currency": {"enum": ["btc", "xmr"]}} + } + } + }, + "paymentTo": { + "discriminator": "type", + "mapping": { + "card": { + "properties": { + "provider": {"enum": ["stripe"]}, + "url": {"type": "string"} + } + }, + "crypto": { + "properties": { + "currency": {"enum": ["btc", "xmr"]}, + "address": {"type": "string"}, + "cryptoAmount": {"type": "string"} + } + } + } + }, + "invoice": { + "properties": { + "invoiceId": {"type": "string"}, + "price": {"ref": "currencyAmount"}, + "amount": { + "ref": "currencyAmount", + "metadata": {"comment": "price - discount - credit"} + }, + "currency": {"type": "string"}, + "expiresAt": {"type": "timestamp"}, + "paymentTo": {"ref": "paymentTo"} + }, + "optionalProperties": { + "discount": { + "ref": "currencyAmount", + "metadata": {"comment": "discount amount from the price"} + }, + "credit": { + "ref": "currencyAmount", + "metadata": {"comment": "credit for upgrade"} + } + } + }, + "payment": { + "discriminator": "type", + "mapping": { + "apple": { + "properties": {"jws": {"type": "string"}} + }, + "google": { + "properties": {"token": {"type": "string"}} + }, + "invoice": { + "properties": {"invoiceId": {"type": "string"}} + }, + "receipt": { + "properties": { + "receipt": { + "type": "string", + "metadata": {"comment": "transfer of unissued months"} + } + } + } + } + }, + "upgrade": { + "properties": { + "fromPurchaseKey": {"ref": "base64url"}, + "receipt": {"type": "string"}, + "receiptSignature": {"ref": "base64url"}, + "balance": {"ref": "balance"} + } + }, + "catalog": { + "properties": { + "prices": {"elements": {"ref": "badgePrice"}}, + "offers": {"elements": {"ref": "badgeOffer"}} + } + }, + "badgePrice": { + "properties": { + "priceId": {"type": "string"}, + "badgeType": {"type": "string"}, + "monthPrice": {"ref": "currencyAmount"}, + "currency": {"type": "string"}, + "status": {"ref": "badgeItemStatus"}, + "createdAt": {"type": "timestamp"} + } + }, + "badgeOffer": { + "properties": { + "offerId": {"type": "string"}, + "months": {"type": "uint8"}, + "discount": {"ref": "offerDiscount"}, + "status": {"ref": "badgeItemStatus"}, + "createdAt": {"type": "timestamp"} + }, + "optionalProperties": { + "priceId": { + "type": "string", + "metadata": {"comment": "absent applies to any price"} + } + } + }, + "offerDiscount": { + "discriminator": "type", + "mapping": { + "freeMonths": { + "properties": {"freeMonths": {"type": "uint8"}} + }, + "discount": { + "properties": { + "discount": { + "type": "uint8", + "metadata": {"comment": "percent"} + } + } + } + } + }, + "badgeItemStatus": { + "enum": ["active", "deprecated", "disabled"], + "metadata": {"comment": "disabled is not sent"} + }, + "statement": { + "properties": { + "entries": {"elements": {"ref": "ledgerEntry"}} + }, + "optionalProperties": { + "previousEntryId": { + "type": "string", + "metadata": {"comment": "matches the client's asserted entryId, absent for the full ledger"} + } + } + }, + "balance": { + "properties": { + "lastEntry": {"ref": "ledgerEntry"} + } + }, + "ledgerEntry": { + "properties": { + "entryId": {"type": "string"}, + "changeMonths": {"type": "int16"}, + "balanceMonths": {"type": "uint16"}, + "balanceStartTs": {"type": "timestamp"}, + "balanceAnchorTs": { + "type": "timestamp", + "metadata": {"comment": "start of the current run of months; boundaries are counted from it"} + }, + "balanceBadgeType": {"type": "string"}, + "createdAt": {"type": "timestamp"}, + "entryType": {"ref": "ledgerEntryType"} + }, + "optionalProperties": { + "wasPausedSince": {"type": "timestamp"} + } + }, + "ledgerEntryType": { + "discriminator": "type", + "mapping": { + "credit": { + "properties": {"credit": {"ref": "ledgerCredit"}} + }, + "debit": { + "properties": {"debit": {"ref": "ledgerDebit"}} + } + } + }, + "ledgerCredit": { + "discriminator": "type", + "mapping": { + "payment": { + "optionalProperties": { + "invoiceId": { + "type": "string", + "metadata": {"comment": "absent for store payments"} + } + } + }, + "code": { + "optionalProperties": {}, + "metadata": {"comment": "a redeemed code; its invoice belongs to the buyer, not the redeemer"} + }, + "charge": { + "properties": {"chargeId": {"type": "string"}} + }, + "support": { + "optionalProperties": {} + }, + "transferIn": { + "properties": {"fromPurchaseKey": {"ref": "base64url"}} + }, + "opening": { + "optionalProperties": {} + } + } + }, + "ledgerDebit": { + "discriminator": "type", + "mapping": { + "refund": { + "optionalProperties": {} + }, + "upgrade": { + "properties": {"toPurchaseKey": {"ref": "base64url"}} + }, + "transferOut": { + "properties": {"toPurchaseKey": {"ref": "base64url"}} + }, + "support": { + "optionalProperties": {} + }, + "badge": { + "optionalProperties": {} + }, + "lapse": { + "optionalProperties": {} + } + } + }, + "request": { + "properties": { + "version": {"type": "uint16"}, + "request": {"ref": "command"} + }, + "optionalProperties": { + "purchaseKey": { + "ref": "purchaseKey", + "metadata": {"comment": "optional for getBadgeCatalog, required for other commands"} + } + } + }, + "command": { + "discriminator": "type", + "mapping": { + "getBadgeCatalog": { + "optionalProperties": {} + }, + "getBadgeInvoice": { + "properties": { + "priceId": {"type": "string"}, + "badgeInfo": {"ref": "badgeInfo"}, + "paymentVia": {"ref": "paymentVia"} + }, + "optionalProperties": { + "offerId": { + "type": "string", + "metadata": {"comment": "absent for 1 month at the badge price"} + }, + "upgrade": { + "ref": "upgrade", + "metadata": {"comment": "upgrade non-store badge"} + } + } + }, + "redeemBadgeCode": { + "properties": { + "masterKey": {"ref": "base64url"}, + "code": {"type": "string"} + } + }, + "purchaseBadge": { + "properties": { + "badgeRequest": {"ref": "badgeRequest"}, + "payment": {"ref": "payment"} + }, + "optionalProperties": { + "upgrade": { + "ref": "upgrade", + "metadata": {"comment": "upgrade store 1-time badge"} + } + } + }, + "upgradeBadgeSubscription": { + "properties": { + "badgeRequest": {"ref": "badgeRequest"}, + "payment": { + "ref": "payment", + "metadata": {"comment": "store payments only"} + }, + "balance": {"ref": "balance"} + } + }, + "issueBadge": { + "properties": { + "balance": {"ref": "balance"} + } + }, + "pauseBadge": { + "optionalProperties": {} + } + } + }, + "response": { + "discriminator": "type", + "mapping": { + "badgeCatalog": { + "properties": { + "catalog": {"ref": "catalog"} + }, + "optionalProperties": { + "badgeStatement": { + "ref": "statement", + "metadata": {"comment": "for signed getBadgeCatalog"} + } + } + }, + "badgeInvoice": { + "properties": { + "invoice": {"ref": "invoice"}, + "badgeType": {"type": "string"}, + "months": {"type": "uint8"} + } + }, + "badgeCredential": { + "properties": { + "statement": {"ref": "statement"} + }, + "optionalProperties": { + "credential": { + "ref": "credential", + "metadata": {"comment": "absent when no balance to issueBadge or no current credential for pause"} + }, + "receipt": { + "type": "string", + "metadata": {"comment": "not provided for lifetime badges"} + } + } + }, + "error": { + "properties": { + "code": { + "type": "string", + "metadata": {"comment": "BadgeServiceErrorCode"} + } + }, + "optionalProperties": { + "message": {"type": "string"}, + "retryAfter": {"type": "uint32"} + } + } + } + } + } +} diff --git a/docs/rfcs/2026-09-10-wallet-keys.md b/docs/rfcs/2026-09-10-wallet-keys.md index 058da43cbf..3d2a8a1626 100644 --- a/docs/rfcs/2026-09-10-wallet-keys.md +++ b/docs/rfcs/2026-09-10-wallet-keys.md @@ -169,7 +169,7 @@ A null `account_index` marks an account whose key was imported rather than deriv - `src/Simplex/Chat/Wallet.hs`, derivation, pure. - `src/Simplex/Chat/Store/Wallets.hs`, the two tables. -- `src/Simplex/Chat/Store/SQLite/Migrations/M20260908_wallet_seeds.hs` and the Postgres twin. +- `src/Simplex/Chat/Store/SQLite/Migrations/M20260921_wallet_seeds.hs` and the Postgres twin. - `tests/WalletTests.hs`. - `tests/SchemaDump.hs` and `tests/PostgresSchemaDump.hs`, which selected what to test by taking every migration after the last one without a reverse step, and now take every migration from the first one that has a reverse step, applying any that has none. - Derivation uses the `BIP32` and `BIP39` modules already in simplexmq and adds no dependency. diff --git a/flake.nix b/flake.nix index fdd041bd88..84f84c34ec 100644 --- a/flake.nix +++ b/flake.nix @@ -393,6 +393,7 @@ "chat_encrypt_file" "chat_encrypt_media" "chat_migrate_init" + "chat_migrate_init_queue" "chat_parse_markdown" "chat_parse_server" "chat_parse_uri" @@ -405,6 +406,7 @@ "chat_send_remote_cmd" "chat_send_remote_cmd_retry" "chat_valid_name" + "chat_parse_badge_code" "chat_json_length" "chat_badge_keygen" "chat_badge_issue" @@ -514,6 +516,7 @@ "chat_encrypt_file" "chat_encrypt_media" "chat_migrate_init" + "chat_migrate_init_queue" "chat_parse_markdown" "chat_parse_server" "chat_parse_uri" @@ -526,6 +529,7 @@ "chat_send_remote_cmd" "chat_send_remote_cmd_retry" "chat_valid_name" + "chat_parse_badge_code" "chat_json_length" "chat_badge_keygen" "chat_badge_issue" diff --git a/libsimplex.dll.def b/libsimplex.dll.def index ec4125193f..79b88dc782 100644 --- a/libsimplex.dll.def +++ b/libsimplex.dll.def @@ -2,7 +2,9 @@ LIBRARY libsimplex EXPORTS hs_init hs_init_with_rtsopts + hs_thread_done chat_migrate_init + chat_migrate_init_queue chat_close_store chat_send_cmd chat_send_cmd_retry @@ -15,6 +17,7 @@ EXPORTS chat_parse_uri chat_password_hash chat_valid_name + chat_parse_badge_code chat_json_length chat_badge_keygen chat_badge_issue diff --git a/packages/simplex-chat-client/types/typescript/package.json b/packages/simplex-chat-client/types/typescript/package.json index b0830995dd..4d353cbdae 100644 --- a/packages/simplex-chat-client/types/typescript/package.json +++ b/packages/simplex-chat-client/types/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@simplex-chat/types", - "version": "0.11.2", + "version": "0.11.4", "description": "TypeScript types for SimpleX Chat bot libraries", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/simplex-chat-client/types/typescript/src/commands.ts b/packages/simplex-chat-client/types/typescript/src/commands.ts index 0dfd63ba88..c217ef944c 100644 --- a/packages/simplex-chat-client/types/typescript/src/commands.ts +++ b/packages/simplex-chat-client/types/typescript/src/commands.ts @@ -59,7 +59,7 @@ export interface APISetProfileAddress { } export namespace APISetProfileAddress { - export type Response = CR.UserProfileUpdated | CR.ChatCmdError + export type Response = CR.UserProfileUpdated | CR.UserProfileNoChange | CR.ChatCmdError export function cmdString(self: APISetProfileAddress): string { return '/_profile_address ' + self.userId + ' ' + (self.enable ? 'on' : 'off') @@ -178,7 +178,7 @@ export namespace APIShareMyAddress { export type Response = CR.ChatMsgContent export function cmdString(self: APIShareMyAddress): string { - return '/_share address' + T.ChatRef.cmdString(self.toSendRef) + return '/_share address ' + T.ChatRef.cmdString(self.toSendRef) } } @@ -570,7 +570,7 @@ export namespace APIConnect { export type Response = CR.SentConfirmation | CR.ContactAlreadyExists | CR.SentInvitation | CR.ChatCmdError export function cmdString(self: APIConnect): string { - return '/_connect ' + self.userId + (self.preparedLink_ ? ' ' + T.CreatedConnLink.cmdString(self.preparedLink_) : '') + return '/_connect ' + self.userId + (self.incognito ? ' incognito=on' : '') + (self.preparedLink_ ? ' ' + T.CreatedConnLink.cmdString(self.preparedLink_) : '') } } @@ -582,7 +582,15 @@ export interface Connect { } export namespace Connect { - export type Response = CR.SentConfirmation | CR.ContactAlreadyExists | CR.SentInvitation | CR.ChatCmdError + export type Response = + | CR.SentConfirmation + | CR.ContactAlreadyExists + | CR.SentInvitation + | CR.ConnectionPlan + | CR.SentInvitationToContact + | CR.StartedConnectionToContact + | CR.StartedConnectionToGroup + | CR.ChatCmdError export function cmdString(self: Connect): string { return '/connect' + (self.connTarget_ ? ' ' + self.connTarget_ : '') @@ -656,7 +664,7 @@ export namespace APIListGroups { export interface APIGetChats { userId: number // int64 pendingConnections: boolean - pagination: T.PaginationByTime + pagination?: T.PaginationByTime query: T.ChatListQuery } @@ -664,7 +672,7 @@ export namespace APIGetChats { export type Response = CR.ApiChats | CR.ChatCmdError export function cmdString(self: APIGetChats): string { - return '/_get chats ' + self.userId + (self.pendingConnections ? ' pcc=on' : '') + ' ' + T.PaginationByTime.cmdString(self.pagination) + ' ' + JSON.stringify(self.query) + return '/_get chats ' + self.userId + (self.pendingConnections ? ' pcc=on' : '') + (self.pagination ? ' ' + T.PaginationByTime.cmdString(self.pagination) : '') + ' ' + JSON.stringify(self.query) } } @@ -897,3 +905,34 @@ export namespace APIStopChat { return '/_stop' } } + +// Remote control commands +// Allows a bot to accept an incoming remote control session from a SimpleX Desktop client, giving the desktop live access to the bot's SimpleX instance. + +// Connect to a remote controller using an OOB invitation link. +// Network usage: interactive. +export interface ConnectRemoteCtrl { + remoteInvitation: string +} + +export namespace ConnectRemoteCtrl { + export type Response = CR.RemoteCtrlConnecting | CR.ChatCmdError + + export function cmdString(self: ConnectRemoteCtrl): string { + return '/crc ' + self.remoteInvitation + } +} + +// Verify the remote controller session code to complete the connection. +// Network usage: no. +export interface VerifyRemoteCtrlSession { + sessionCode: string +} + +export namespace VerifyRemoteCtrlSession { + export type Response = CR.RemoteCtrlConnected | CR.ChatCmdError + + export function cmdString(self: VerifyRemoteCtrlSession): string { + return '/verify remote ctrl ' + self.sessionCode + } +} diff --git a/packages/simplex-chat-client/types/typescript/src/events.ts b/packages/simplex-chat-client/types/typescript/src/events.ts index 59e0c3274d..5b644ee41e 100644 --- a/packages/simplex-chat-client/types/typescript/src/events.ts +++ b/packages/simplex-chat-client/types/typescript/src/events.ts @@ -52,6 +52,8 @@ export type ChatEvent = | CEvt.SubscriptionStatus | CEvt.ServiceRequest | CEvt.ServiceReplySent + | CEvt.RemoteCtrlSessionCode + | CEvt.RemoteCtrlStopped | CEvt.MessageError | CEvt.ChatError | CEvt.ChatErrors @@ -106,6 +108,8 @@ export namespace CEvt { | "subscriptionStatus" | "serviceRequest" | "serviceReplySent" + | "remoteCtrlSessionCode" + | "remoteCtrlStopped" | "messageError" | "chatError" | "chatErrors" @@ -471,6 +475,18 @@ export namespace CEvt { connectionId: string } + export interface RemoteCtrlSessionCode extends Interface { + type: "remoteCtrlSessionCode" + remoteCtrl_?: T.RemoteCtrlInfo + sessionCode: string + } + + export interface RemoteCtrlStopped extends Interface { + type: "remoteCtrlStopped" + rcsState: T.RemoteCtrlSessionState + rcStopReason: T.RemoteCtrlStopReason + } + export interface MessageError extends Interface { type: "messageError" user: T.User diff --git a/packages/simplex-chat-client/types/typescript/src/responses.ts b/packages/simplex-chat-client/types/typescript/src/responses.ts index b2d29490c8..647427f0dc 100644 --- a/packages/simplex-chat-client/types/typescript/src/responses.ts +++ b/packages/simplex-chat-client/types/typescript/src/responses.ts @@ -47,11 +47,16 @@ export type ChatResponse = | CR.RcvFileAccepted | CR.RcvFileAcceptedSndCancelled | CR.RcvFileCancelled + | CR.RemoteCtrlConnected + | CR.RemoteCtrlConnecting | CR.SentConfirmation | CR.SentGroupInvitation | CR.SentInvitation + | CR.SentInvitationToContact | CR.ServiceReplyAccepted | CR.SndFileCancelled + | CR.StartedConnectionToContact + | CR.StartedConnectionToGroup | CR.UserAcceptedGroupSent | CR.UserContactLink | CR.UserContactLinkCreated @@ -108,11 +113,16 @@ export namespace CR { | "rcvFileAccepted" | "rcvFileAcceptedSndCancelled" | "rcvFileCancelled" + | "remoteCtrlConnected" + | "remoteCtrlConnecting" | "sentConfirmation" | "sentGroupInvitation" | "sentInvitation" + | "sentInvitationToContact" | "serviceReplyAccepted" | "sndFileCancelled" + | "startedConnectionToContact" + | "startedConnectionToGroup" | "userAcceptedGroupSent" | "userContactLink" | "userContactLinkCreated" @@ -246,6 +256,7 @@ export namespace CR { user: T.User groupInfo: T.GroupInfo msgSigned: boolean + localDeletion: boolean } export interface GroupLink extends Interface { @@ -406,6 +417,19 @@ export namespace CR { rcvFileTransfer: T.RcvFileTransfer } + export interface RemoteCtrlConnected extends Interface { + type: "remoteCtrlConnected" + remoteCtrl: T.RemoteCtrlInfo + compression: boolean + } + + export interface RemoteCtrlConnecting extends Interface { + type: "remoteCtrlConnecting" + remoteCtrl_?: T.RemoteCtrlInfo + ctrlAppInfo: T.CtrlAppInfo + appVersion: string + } + export interface SentConfirmation extends Interface { type: "sentConfirmation" user: T.User @@ -428,6 +452,13 @@ export namespace CR { customUserProfile?: T.Profile } + export interface SentInvitationToContact extends Interface { + type: "sentInvitationToContact" + user: T.User + contact: T.Contact + customUserProfile?: T.Profile + } + export interface ServiceReplyAccepted extends Interface { type: "serviceReplyAccepted" user: T.User @@ -442,6 +473,21 @@ export namespace CR { sndFileTransfers: T.SndFileTransfer[] } + export interface StartedConnectionToContact extends Interface { + type: "startedConnectionToContact" + user: T.User + contact: T.Contact + customUserProfile?: T.Profile + } + + export interface StartedConnectionToGroup extends Interface { + type: "startedConnectionToGroup" + user: T.User + groupInfo: T.GroupInfo + customUserProfile?: T.Profile + relayResults: T.RelayConnectionResult[] + } + export interface UserAcceptedGroupSent extends Interface { type: "userAcceptedGroupSent" user: T.User diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index a8c131bcff..d7db7f259f 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -223,6 +223,12 @@ export namespace AgentServiceError { type: "badSignature" } } +// Remote controller app version range (min and max as version strings). + +export interface AppVersionRange { + minVersion: string + maxVersion: string +} export interface AutoAccept { acceptIncognito: boolean @@ -241,6 +247,80 @@ export interface BadgeProof { badgeInfo: BadgeInfo } +export type BadgeRedeemError = + | BadgeRedeemError.InvalidCode + | BadgeRedeemError.ServiceNotConfigured + | BadgeRedeemError.BadgeActive + | BadgeRedeemError.ServiceError + | BadgeRedeemError.InvalidResponse + | BadgeRedeemError.UnknownKeyIndex + | BadgeRedeemError.CredentialNotVerified + +export namespace BadgeRedeemError { + export type Tag = + | "invalidCode" + | "serviceNotConfigured" + | "badgeActive" + | "serviceError" + | "invalidResponse" + | "unknownKeyIndex" + | "credentialNotVerified" + + interface Interface { + type: Tag + } + + export interface InvalidCode extends Interface { + type: "invalidCode" + } + + export interface ServiceNotConfigured extends Interface { + type: "serviceNotConfigured" + } + + export interface BadgeActive extends Interface { + type: "badgeActive" + } + + export interface ServiceError extends Interface { + type: "serviceError" + serviceError: BadgeServiceErrorCode + } + + export interface InvalidResponse extends Interface { + type: "invalidResponse" + message: string + } + + export interface UnknownKeyIndex extends Interface { + type: "unknownKeyIndex" + } + + export interface CredentialNotVerified extends Interface { + type: "credentialNotVerified" + } +} + +export enum BadgeServiceErrorCode { + Bad_request = "bad_request", + Unsupported_version = "unsupported_version", + Unknown_purchase_key = "unknown_purchase_key", + Unknown_offer_id = "unknown_offer_id", + Offer_disabled = "offer_disabled", + Offer_mismatch = "offer_mismatch", + Product_unavailable = "product_unavailable", + Payment_not_entitled = "payment_not_entitled", + Payment_pending = "payment_pending", + Provider_unavailable = "provider_unavailable", + Rate_limited = "rate_limited", + Code_invalid = "code_invalid", + Code_used = "code_used", + Code_expired = "code_expired", + Receipt_invalid = "receipt_invalid", + Receipt_used = "receipt_used", + Internal = "internal", +} + export enum BadgeStatus { Active = "active", Expired = "expired", @@ -693,6 +773,7 @@ export interface CIFile { fileStatus: CIFileStatus fileProtocol: FileProtocol fileExpires?: string // ISO-8601 timestamp + fileProhibited?: FileProhibited } export type CIFileStatus = @@ -1099,7 +1180,6 @@ export type ChatErrorType = | ChatErrorType.ChatStoreChanged | ChatErrorType.InvalidConnReq | ChatErrorType.SimplexDomainNotReady - | ChatErrorType.Wallet | ChatErrorType.NotResolvedLocally | ChatErrorType.UnsupportedConnReq | ChatErrorType.ConnReqMessageProhibited @@ -1147,6 +1227,7 @@ export type ChatErrorType = | ChatErrorType.AgentVersion | ChatErrorType.AgentNoSubResult | ChatErrorType.CommandError + | ChatErrorType.BadgeRedeemError | ChatErrorType.AgentCommandError | ChatErrorType.InvalidFileDescription | ChatErrorType.ConnectionIncognitoChangeProhibited @@ -1179,7 +1260,6 @@ export namespace ChatErrorType { | "chatStoreChanged" | "invalidConnReq" | "simplexDomainNotReady" - | "wallet" | "notResolvedLocally" | "unsupportedConnReq" | "connReqMessageProhibited" @@ -1227,6 +1307,7 @@ export namespace ChatErrorType { | "agentVersion" | "agentNoSubResult" | "commandError" + | "badgeRedeemError" | "agentCommandError" | "invalidFileDescription" | "connectionIncognitoChangeProhibited" @@ -1341,11 +1422,6 @@ export namespace ChatErrorType { simplexDomainError: SimplexDomainError } - export interface Wallet extends Interface { - type: "wallet" - walletError: WalletError - } - export interface NotResolvedLocally extends Interface { type: "notResolvedLocally" } @@ -1576,6 +1652,11 @@ export namespace ChatErrorType { message: string } + export interface BadgeRedeemError extends Interface { + type: "badgeRedeemError" + badgeRedeemError: BadgeRedeemError + } + export interface AgentCommandError extends Interface { type: "agentCommandError" message: string @@ -2217,6 +2298,13 @@ export interface CryptoFileArgs { fileKey: string fileNonce: string } +// Remote controller application info. + +export interface CtrlAppInfo { + appVersionRange: AppVersionRange + deviceName: string + compression: boolean +} export interface DroppedMsg { brokerTs: string // ISO-8601 timestamp @@ -2434,6 +2522,12 @@ export interface FileInvitation { fileConnReq?: string fileInline?: InlineFileMode fileDescr?: FileDescr + fileBadge?: BadgeProof +} + +export interface FileProhibited { + maxSize: number // int64 + badgeStatus?: BadgeStatus } export enum FileProtocol { @@ -2702,16 +2796,9 @@ export interface GroupInfo { rosterVersion?: number // int64 membersRequireAttention: number // int viaGroupLinkUri?: string - groupKeys?: GroupKeys groupDomainVerified?: boolean } -export interface GroupKeys { - publicGroupId: string - groupRootKey: GroupRootKey - memberPrivKey: string -} - export interface GroupLink { userContactLinkId: number // int64 connLinkContact: CreatedConnLink @@ -2905,26 +2992,6 @@ export interface GroupRelay { relayCap: RelayCapabilities } -export type GroupRootKey = GroupRootKey.Private | GroupRootKey.Public - -export namespace GroupRootKey { - export type Tag = "private" | "public" - - interface Interface { - type: Tag - } - - export interface Private extends Interface { - type: "private" - rootPrivKey: string - } - - export interface Public extends Interface { - type: "public" - rootPubKey: string - } -} - export interface GroupShortLinkData { groupProfile: GroupProfile publicGroupData?: PublicGroupData @@ -3791,6 +3858,7 @@ export interface RcvFileTransfer { fileId: number // int64 xftpRcvFile?: XFTPRcvFile fileInvitation: FileInvitation + fileProhibited?: FileProhibited fileStatus: RcvFileStatus fileType: FileType rcvFileInline?: InlineFileMode @@ -3953,6 +4021,11 @@ export interface RelayCapabilities { webDomain?: string } +export interface RelayConnectionResult { + relayMember: GroupMember + relayError?: ChatError +} + export interface RelayProfile { displayName: string fullName: string @@ -3970,6 +4043,87 @@ export enum RelayStatus { Rejected = "rejected", } +export interface RemoteCtrlInfo { + remoteCtrlId: number // int64 + ctrlDeviceName: string + sessionState?: RemoteCtrlSessionState +} + +export type RemoteCtrlSessionState = + | RemoteCtrlSessionState.Starting + | RemoteCtrlSessionState.Searching + | RemoteCtrlSessionState.Connecting + | RemoteCtrlSessionState.PendingConfirmation + | RemoteCtrlSessionState.Connected + +export namespace RemoteCtrlSessionState { + export type Tag = + | "starting" + | "searching" + | "connecting" + | "pendingConfirmation" + | "connected" + + interface Interface { + type: Tag + } + + export interface Starting extends Interface { + type: "starting" + } + + export interface Searching extends Interface { + type: "searching" + } + + export interface Connecting extends Interface { + type: "connecting" + } + + export interface PendingConfirmation extends Interface { + type: "pendingConfirmation" + sessionCode: string + } + + export interface Connected extends Interface { + type: "connected" + sessionCode: string + } +} + +export type RemoteCtrlStopReason = + | RemoteCtrlStopReason.DiscoveryFailed + | RemoteCtrlStopReason.ConnectionFailed + | RemoteCtrlStopReason.SetupFailed + | RemoteCtrlStopReason.Disconnected + +export namespace RemoteCtrlStopReason { + export type Tag = "discoveryFailed" | "connectionFailed" | "setupFailed" | "disconnected" + + interface Interface { + type: Tag + } + + export interface DiscoveryFailed extends Interface { + type: "discoveryFailed" + chatError: ChatError + } + + export interface ConnectionFailed extends Interface { + type: "connectionFailed" + chatError: ChatError + } + + export interface SetupFailed extends Interface { + type: "setupFailed" + chatError: ChatError + } + + export interface Disconnected extends Interface { + type: "disconnected" + } +} + export enum ReportReason { Spam = "spam", Content = "content", @@ -5146,65 +5300,6 @@ export interface VersionRange { maxVersion: number // int } -export type WalletError = - | WalletError.NoMaster - | WalletError.MasterExists - | WalletError.BadMnemonic - | WalletError.HiddenProfile - | WalletError.AccountBound - | WalletError.CounterUnknown - | WalletError.IndexTooLarge - | WalletError.Derivation - -export namespace WalletError { - export type Tag = - | "noMaster" - | "masterExists" - | "badMnemonic" - | "hiddenProfile" - | "accountBound" - | "counterUnknown" - | "indexTooLarge" - | "derivation" - - interface Interface { - type: Tag - } - - export interface NoMaster extends Interface { - type: "noMaster" - } - - export interface MasterExists extends Interface { - type: "masterExists" - } - - export interface BadMnemonic extends Interface { - type: "badMnemonic" - } - - export interface HiddenProfile extends Interface { - type: "hiddenProfile" - } - - export interface AccountBound extends Interface { - type: "accountBound" - } - - export interface CounterUnknown extends Interface { - type: "counterUnknown" - } - - export interface IndexTooLarge extends Interface { - type: "indexTooLarge" - } - - export interface Derivation extends Interface { - type: "derivation" - derivationError: string - } -} - export type XFTPErrorType = | XFTPErrorType.BLOCK | XFTPErrorType.SESSION diff --git a/packages/simplex-chat-nodejs/binding.gyp b/packages/simplex-chat-nodejs/binding.gyp index 09c63cecba..cfa6d61039 100644 --- a/packages/simplex-chat-nodejs/binding.gyp +++ b/packages/simplex-chat-nodejs/binding.gyp @@ -11,6 +11,8 @@ ], "cflags!": [ "-fno-exceptions" ], "cflags_cc!": [ "-fno-exceptions" ], + "xcode_settings": { "GCC_ENABLE_CPP_EXCEPTIONS": "YES" }, + "msvs_settings": { "VCCLCompilerTool": { "ExceptionHandling": 1 } }, "defines": [ "NAPI_DISABLE_CPP_EXCEPTIONS" ], "conditions": [ ["OS=='mac'", { diff --git a/packages/simplex-chat-nodejs/cpp/simplex.cc b/packages/simplex-chat-nodejs/cpp/simplex.cc index 97233eff6f..bcca2b994a 100644 --- a/packages/simplex-chat-nodejs/cpp/simplex.cc +++ b/packages/simplex-chat-nodejs/cpp/simplex.cc @@ -3,6 +3,14 @@ #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include #include "simplex.h" namespace simplex { @@ -81,6 +89,11 @@ class ResultAsyncWorker : public AsyncWorker { return ctrl_; } + // the worker thread reads this object's memory until the worker completes + void KeepAlive(Object obj) { + keep_alive_ = Persistent(obj); + } + protected: std::string result_; uintptr_t ctrl_ = 0; @@ -88,6 +101,7 @@ class ResultAsyncWorker : public AsyncWorker { private: ExecuteFn execute_fn_; ResultProcessor result_processor_; + ObjectReference keep_alive_; }; class BinaryAsyncWorker : public AsyncWorker { @@ -97,21 +111,25 @@ class BinaryAsyncWorker : public AsyncWorker { BinaryAsyncWorker(Function& callback, ExecuteFn execute_fn) : AsyncWorker(callback), execute_fn_(std::move(execute_fn)) {} + ~BinaryAsyncWorker() { + free(original_buf); + } + void Execute() override { execute_fn_(this); } void OnOK() override { HandleScope scope(Env()); - if (original_buf == nullptr || binary_len == 0) { - Callback().Call({Env().Null(), Env().Undefined()}); + char* buf = original_buf; + original_buf = nullptr; + if (binary_len == 0) { + free(buf); + Callback().Call({Env().Null(), Buffer::New(Env(), 0)}); return; } - char* data_ptr = original_buf + 5; - auto finalizer = [](Napi::Env env, char* finalize_data, char* orig) { - free(orig); - }; - Napi::Buffer buffer = Napi::Buffer::New(Env(), data_ptr, binary_len, finalizer, original_buf); + // Copies when the runtime forbids external buffers (Electron); the finalizer then runs immediately. + Buffer buffer = Buffer::NewOrCopy(Env(), buf + 5, binary_len, [](Napi::Env, char*, char* orig) { free(orig); }, buf); Callback().Call({Env().Null(), buffer}); } @@ -176,6 +194,159 @@ Napi::Promise CreatePromiseAndCallback(Env env, Function& cb_out) { return deferred.Promise(); } +const char* const RECEIVER_STOPPED = "chat receiver stopped"; + +struct RecvRequest { + int wait = 0; + std::shared_ptr deferred; +}; + +// Holds the event loop open only while receives are pending; used only on the JS main thread. +class PendingReceives { + public: + explicit PendingReceives(ThreadSafeFunction tsfn) : tsfn_(tsfn) {} + + const ThreadSafeFunction& Tsfn() const { + return tsfn_; + } + + void Add(Napi::Env env) { + if (count_++ == 0) tsfn_.Ref(env); + } + + void Remove(Napi::Env env) { + if (--count_ == 0) tsfn_.Unref(env); + } + + private: + ThreadSafeFunction tsfn_; + size_t count_ = 0; +}; + +// A blocking receive would hold a libuv pool thread for up to `wait`, stalling fs, dns and crypto. +class Receiver { + public: + // Returns nullptr with a pending JS exception if the TSFN cannot be created, throws std::system_error if the thread cannot start. + static std::shared_ptr Start(Napi::Env env, chat_ctrl ctrl) { + ThreadSafeFunction tsfn = ThreadSafeFunction::New(env, Function::New(env, [](const CallbackInfo&) {}), "chat_recv_msg_wait", 0, 1); + if (env.IsExceptionPending()) { + return nullptr; + } + auto receiver = std::make_shared(ctrl, tsfn); + try { + receiver->thread_ = std::thread(&Receiver::Run, receiver.get()); + } catch (const std::system_error&) { + tsfn.Release(); + throw; + } + return receiver; + } + + Receiver(chat_ctrl ctrl, ThreadSafeFunction tsfn) : ctrl_(ctrl), pending_(std::make_shared(tsfn)) {} + + Receiver(const Receiver&) = delete; + Receiver& operator=(const Receiver&) = delete; + + ~Receiver() { + Stop(); + } + + void Enqueue(Napi::Env env, RecvRequest request) { + pending_->Add(env); + { + std::lock_guard lock(mutex_); + queue_.push_back(std::move(request)); + } + cv_.notify_one(); + } + + void RequestStop() { + { + std::lock_guard lock(mutex_); + stop_ = true; + } + cv_.notify_one(); + } + + // Waits for the receive in progress, so it must not run on the JS main thread outside env teardown. + void Stop() { + RequestStop(); + if (thread_.joinable()) { + thread_.join(); + } + } + + private: + void Run() { + for (;;) { + RecvRequest request; + { + std::unique_lock lock(mutex_); + cv_.wait(lock, [this] { return stop_ || !queue_.empty(); }); + if (stop_) break; + request = std::move(queue_.front()); + queue_.pop_front(); + } + char* c_res = chat_recv_msg_wait(ctrl_, request.wait); + napi_status status = Settle(request.deferred, [c_res](Napi::Env env, Promise::Deferred& deferred) { + if (c_res == nullptr) { + deferred.Reject(Error::New(env, "chat_recv_msg_wait failed").Value()); + } else { + deferred.Resolve(String::New(env, c_res)); + free(c_res); + } + }); + if (status != napi_ok) { + free(c_res); + } + } + std::deque unserved; + { + std::lock_guard lock(mutex_); + unserved.swap(queue_); + } + for (RecvRequest& request : unserved) { + Settle(request.deferred, [](Napi::Env env, Promise::Deferred& deferred) { + deferred.Reject(Error::New(env, RECEIVER_STOPPED).Value()); + }); + } + pending_->Tsfn().Release(); + // Each OS thread that enters Haskell keeps an RTS task record until it calls hs_thread_done. + hs_thread_done(); + } + + template + napi_status Settle(std::shared_ptr deferred, SettleFn settle) { + // The callback may run after this Receiver is destroyed, so it owns the pending count. + std::shared_ptr pending = pending_; + return pending->Tsfn().BlockingCall([pending, deferred, settle](Napi::Env env, Function) { + settle(env, *deferred); + pending->Remove(env); + }); + } + + const chat_ctrl ctrl_; + const std::shared_ptr pending_; + std::mutex mutex_; + std::condition_variable cv_; + std::deque queue_; + bool stop_ = false; + std::thread thread_; +}; + +// Keyed by chat_ctrl, accessed only on the JS main thread. +using Receivers = std::unordered_map>; + +std::shared_ptr TakeReceiver(Receivers& receivers, chat_ctrl ctrl) { + auto it = receivers.find(reinterpret_cast(ctrl)); + if (it == receivers.end()) { + return nullptr; + } + std::shared_ptr receiver = std::move(it->second); + receivers.erase(it); + return receiver; +} + // Common result processors ResultAsyncWorker::ResultProcessor MigrateResultProcessor() { return [](ResultAsyncWorker* worker, Napi::Env env) { @@ -215,6 +386,39 @@ Value ChatMigrateInit(const CallbackInfo& args) { return promise; } +Value ChatMigrateInitQueue(const CallbackInfo& args) { + Env env = args.Env(); + if (args.Length() < 4 || !args[0].IsString() || !args[1].IsString() || !args[2].IsString() || !args[3].IsNumber()) { + TypeError::New(env, "Expected three string arguments and number").ThrowAsJavaScriptException(); + return env.Undefined(); + } + + std::string path = args[0].As().Utf8Value(); + std::string key = args[1].As().Utf8Value(); + std::string confirm = args[2].As().Utf8Value(); + Number queue_size_arg = args[3].As(); + int queue_size = queue_size_arg.Int32Value(); + if (static_cast(queue_size) != queue_size_arg.DoubleValue()) { + RangeError::New(env, "Expected 32-bit integer queue size").ThrowAsJavaScriptException(); + return env.Undefined(); + } + + Function cb; + Promise promise = CreatePromiseAndCallback(env, cb); + + auto execute_fn = [path, key, confirm, queue_size](ResultAsyncWorker* worker) { + chat_ctrl ctrl = nullptr; + char* c_res = chat_migrate_init_queue(path.c_str(), key.c_str(), confirm.c_str(), queue_size, &ctrl); + worker->SetCtrl(reinterpret_cast(ctrl)); + HandleCResult(worker, c_res, "chat_migrate_init_queue"); + }; + + ResultAsyncWorker* worker = new ResultAsyncWorker(cb, std::move(execute_fn), MigrateResultProcessor()); + worker->Queue(); + + return promise; +} + Value ChatCloseStore(const CallbackInfo& args) { Env env = args.Env(); if (args.Length() < 1 || !args[0].IsBigInt()) { @@ -223,11 +427,15 @@ Value ChatCloseStore(const CallbackInfo& args) { } chat_ctrl ctrl = FromChatCtrlBigInt(args[0]); + std::shared_ptr receiver = TakeReceiver(*static_cast(args.Data()), ctrl); Function cb; Promise promise = CreatePromiseAndCallback(env, cb); - auto execute_fn = [ctrl](ResultAsyncWorker* worker) { + auto execute_fn = [ctrl, receiver](ResultAsyncWorker* worker) { + if (receiver) { + receiver->Stop(); + } char* c_res = chat_close_store(ctrl); HandleCResult(worker, c_res, "chat_close_store"); }; @@ -271,44 +479,63 @@ Value ChatRecvMsgWait(const CallbackInfo& args) { chat_ctrl ctrl = FromChatCtrlBigInt(args[0]); int wait = static_cast(args[1].As().Int32Value()); + Receivers& receivers = *static_cast(args.Data()); - Function cb; - Promise promise = CreatePromiseAndCallback(env, cb); + auto deferred = std::make_shared(Promise::Deferred::New(env)); + auto it = receivers.find(reinterpret_cast(ctrl)); + if (it == receivers.end()) { + std::shared_ptr receiver; + try { + receiver = Receiver::Start(env, ctrl); + } catch (const std::system_error& e) { + deferred->Reject(Error::New(env, e.what()).Value()); + return deferred->Promise(); + } + if (!receiver) { + return env.Undefined(); + } + it = receivers.emplace(reinterpret_cast(ctrl), std::move(receiver)).first; + } + it->second->Enqueue(env, {wait, deferred}); - auto execute_fn = [ctrl, wait](ResultAsyncWorker* worker) { - char* c_res = chat_recv_msg_wait(ctrl, wait); - HandleCResult(worker, c_res, "chat_recv_msg_wait"); - }; - - ResultAsyncWorker* worker = new ResultAsyncWorker(cb, std::move(execute_fn)); - worker->Queue(); - - return promise; + return deferred->Promise(); } Value ChatWriteFile(const CallbackInfo& args) { Env env = args.Env(); - if (args.Length() < 3 || !args[0].IsBigInt() || !args[1].IsString() || !args[2].IsArrayBuffer()) { - TypeError::New(env, "Expected bigint (ctrl), string (path), ArrayBuffer").ThrowAsJavaScriptException(); + if (args.Length() < 3 || !args[0].IsBigInt() || !args[1].IsString() || !(args[2].IsArrayBuffer() || args[2].IsTypedArray())) { + TypeError::New(env, "Expected bigint (ctrl), string (path), ArrayBuffer or Uint8Array").ThrowAsJavaScriptException(); return env.Undefined(); } chat_ctrl ctrl = FromChatCtrlBigInt(args[0]); std::string path = args[1].As().Utf8Value(); - ArrayBuffer ab = args[2].As(); - char* data = static_cast(ab.Data()); - size_t len = ab.ByteLength(); + char* data; + size_t len; + if (args[2].IsArrayBuffer()) { + ArrayBuffer ab = args[2].As(); + data = static_cast(ab.Data()); + len = ab.ByteLength(); + } else { + TypedArray view = args[2].As(); + data = static_cast(view.ArrayBuffer().Data()) + view.ByteOffset(); + len = view.ByteLength(); + } + if (len > static_cast(INT_MAX)) { + RangeError::New(env, "Buffer is too large").ThrowAsJavaScriptException(); + return env.Undefined(); + } Function cb; Promise promise = CreatePromiseAndCallback(env, cb); - auto execute_fn = [ctrl, path, ab, data, len](ResultAsyncWorker* worker) { - (void)ab; // to keep ArrayBuffer alive + auto execute_fn = [ctrl, path, data, len](ResultAsyncWorker* worker) { char* c_res = chat_write_file(ctrl, path.c_str(), data, static_cast(len)); HandleCResult(worker, c_res, "chat_write_file"); }; ResultAsyncWorker* worker = new ResultAsyncWorker(cb, std::move(execute_fn)); + worker->KeepAlive(args[2].As()); worker->Queue(); return promise; @@ -410,10 +637,19 @@ Value ChatDecryptFile(const CallbackInfo& args) { Object Init(Env env, Object exports) { haskell_init(); + auto* receivers = new Receivers(); + // Stopping all receivers before joining any bounds teardown by the longest in-flight receive. + env.AddCleanupHook([receivers]() { + for (auto& entry : *receivers) { + entry.second->RequestStop(); + } + delete receivers; + }); exports.Set("chat_migrate_init", Function::New(env, ChatMigrateInit)); - exports.Set("chat_close_store", Function::New(env, ChatCloseStore)); + exports.Set("chat_migrate_init_queue", Function::New(env, ChatMigrateInitQueue)); + exports.Set("chat_close_store", Function::New(env, ChatCloseStore, "chat_close_store", receivers)); exports.Set("chat_send_cmd", Function::New(env, ChatSendCmd)); - exports.Set("chat_recv_msg_wait", Function::New(env, ChatRecvMsgWait)); + exports.Set("chat_recv_msg_wait", Function::New(env, ChatRecvMsgWait, "chat_recv_msg_wait", receivers)); exports.Set("chat_write_file", Function::New(env, ChatWriteFile)); exports.Set("chat_read_file", Function::New(env, ChatReadFile)); exports.Set("chat_encrypt_file", Function::New(env, ChatEncryptFile)); diff --git a/packages/simplex-chat-nodejs/cpp/simplex.h b/packages/simplex-chat-nodejs/cpp/simplex.h index 8e579626ed..ddc052946a 100644 --- a/packages/simplex-chat-nodejs/cpp/simplex.h +++ b/packages/simplex-chat-nodejs/cpp/simplex.h @@ -11,11 +11,13 @@ extern "C" void hs_init(int argc, char **argv[]); extern "C" void hs_init_with_rtsopts(int * argc, char **argv[]); +extern "C" void hs_thread_done(void); typedef long* chat_ctrl; // the last parameter is used to return the pointer to chat controller extern "C" char *chat_migrate_init(const char *path, const char *key, const char *confirm, chat_ctrl *ctrl); +extern "C" char *chat_migrate_init_queue(const char *path, const char *key, const char *confirm, const int queueSize, chat_ctrl *ctrl); extern "C" char *chat_close_store(chat_ctrl ctrl); extern "C" char *chat_reopen_store(chat_ctrl ctrl); extern "C" char *chat_send_cmd(chat_ctrl ctrl, const char *cmd); diff --git a/packages/simplex-chat-nodejs/docs/Namespace.bot.md b/packages/simplex-chat-nodejs/docs/Namespace.bot.md index 4b9171d0f7..7adeeec0c7 100644 --- a/packages/simplex-chat-nodejs/docs/Namespace.bot.md +++ b/packages/simplex-chat-nodejs/docs/Namespace.bot.md @@ -21,3 +21,4 @@ It automates creating and updating of the bot profile, address and bot commands ## Functions - [run](bot.Function.run.md) +- [subscribeChatItems](bot.Function.subscribeChatItems.md) diff --git a/packages/simplex-chat-nodejs/docs/api.Class.ChatApi.md b/packages/simplex-chat-nodejs/docs/api.Class.ChatApi.md index b81677b976..287099906b 100644 --- a/packages/simplex-chat-nodejs/docs/api.Class.ChatApi.md +++ b/packages/simplex-chat-nodejs/docs/api.Class.ChatApi.md @@ -26,7 +26,7 @@ Defined in: [src/api.ts:103](../src/api.ts#L103) > **get** **ctrl**(): `bigint` -Defined in: [src/api.ts:329](../src/api.ts#L329) +Defined in: [src/api.ts:344](../src/api.ts#L344) Chat controller reference @@ -42,7 +42,7 @@ Chat controller reference > **get** **initialized**(): `boolean` -Defined in: [src/api.ts:315](../src/api.ts#L315) +Defined in: [src/api.ts:330](../src/api.ts#L330) Chat controller is initialized @@ -58,7 +58,7 @@ Chat controller is initialized > **get** **started**(): `boolean` -Defined in: [src/api.ts:322](../src/api.ts#L322) +Defined in: [src/api.ts:337](../src/api.ts#L337) Chat controller is started @@ -72,7 +72,7 @@ Chat controller is started > **apiAcceptContactRequest**(`contactReqId`): `Promise`\<`Contact`\> -Defined in: [src/api.ts:731](../src/api.ts#L731) +Defined in: [src/api.ts:750](../src/api.ts#L750) Accept contact request. Network usage: interactive. @@ -93,7 +93,7 @@ Network usage: interactive. > **apiAcceptMember**(`groupId`, `groupMemberId`, `memberRole`): `Promise`\<`GroupMember`\> -Defined in: [src/api.ts:551](../src/api.ts#L551) +Defined in: [src/api.ts:570](../src/api.ts#L570) Accept group member. Requires Admin role. Network usage: background. @@ -122,7 +122,7 @@ Network usage: background. > **apiAddMember**(`groupId`, `contactId`, `memberRole`): `Promise`\<`GroupMember`\> -Defined in: [src/api.ts:531](../src/api.ts#L531) +Defined in: [src/api.ts:550](../src/api.ts#L550) Add contact to group. Requires bot to have Admin role. Network usage: interactive. @@ -151,7 +151,7 @@ Network usage: interactive. > **apiBlockMembersForAll**(`groupId`, `groupMemberIds`, `blocked`): `Promise`\<`void`\> -Defined in: [src/api.ts:571](../src/api.ts#L571) +Defined in: [src/api.ts:590](../src/api.ts#L590) Block members. Requires Moderator role. Network usage: background. @@ -180,7 +180,7 @@ Network usage: background. > **apiCancelFile**(`fileId`): `Promise`\<`void`\> -Defined in: [src/api.ts:521](../src/api.ts#L521) +Defined in: [src/api.ts:540](../src/api.ts#L540) Cancel file. Network usage: background. @@ -199,9 +199,9 @@ Network usage: background. ### apiChatItemReaction() -> **apiChatItemReaction**(`chatType`, `chatId`, `chatItemId`, `add`, `reaction`): `Promise`\<`ChatItemDeletion`[]\> +> **apiChatItemReaction**(`chatType`, `chatId`, `chatItemId`, `add`, `reaction`): `Promise`\<`ACIReaction`\> -Defined in: [src/api.ts:495](../src/api.ts#L495) +Defined in: [src/api.ts:513](../src/api.ts#L513) Add/remove message reaction. Network usage: background. @@ -230,15 +230,15 @@ Network usage: background. #### Returns -`Promise`\<`ChatItemDeletion`[]\> +`Promise`\<`ACIReaction`\> *** ### apiConnect() -> **apiConnect**(`userId`, `incognito`, `preparedLink?`): `Promise`\<[`ConnReqType`](api.Enumeration.ConnReqType.md)\> +> **apiConnect**(`userId`, `incognito`, `preparedLink`): `Promise`\<[`ConnReqType`](api.Enumeration.ConnReqType.md)\> -Defined in: [src/api.ts:700](../src/api.ts#L700) +Defined in: [src/api.ts:719](../src/api.ts#L719) Connect via prepared SimpleX link. The link can be 1-time invitation link, contact address or group link Network usage: interactive. @@ -253,7 +253,7 @@ Network usage: interactive. `boolean` -##### preparedLink? +##### preparedLink `CreatedConnLink` @@ -267,7 +267,7 @@ Network usage: interactive. > **apiConnectActiveUser**(`connLink`): `Promise`\<[`ConnReqType`](api.Enumeration.ConnReqType.md)\> -Defined in: [src/api.ts:709](../src/api.ts#L709) +Defined in: [src/api.ts:728](../src/api.ts#L728) Connect via SimpleX link as string in the active user profile. Network usage: interactive. @@ -288,7 +288,7 @@ Network usage: interactive. > **apiConnectPlan**(`userId`, `connectionLink`): `Promise`\<\[`ConnectionPlan`, `CreatedConnLink`\]\> -Defined in: [src/api.ts:690](../src/api.ts#L690) +Defined in: [src/api.ts:709](../src/api.ts#L709) Determine SimpleX link type and if the bot is already connected via this link. Network usage: interactive. @@ -313,7 +313,7 @@ Network usage: interactive. > **apiCreateActiveUser**(`profile?`): `Promise`\<`User`\> -Defined in: [src/api.ts:849](../src/api.ts#L849) +Defined in: [src/api.ts:887](../src/api.ts#L887) Create new user profile Network usage: no. @@ -334,7 +334,7 @@ Network usage: no. > **apiCreateGroupLink**(`groupId`, `memberRole`): `Promise`\<`string`\> -Defined in: [src/api.ts:631](../src/api.ts#L631) +Defined in: [src/api.ts:650](../src/api.ts#L650) Create group link. Network usage: interactive. @@ -359,7 +359,7 @@ Network usage: interactive. > **apiCreateLink**(`userId`): `Promise`\<`string`\> -Defined in: [src/api.ts:677](../src/api.ts#L677) +Defined in: [src/api.ts:696](../src/api.ts#L696) Create 1-time invitation link. Network usage: interactive. @@ -380,7 +380,7 @@ Network usage: interactive. > **apiCreateMemberContact**(`groupId`, `groupMemberId`): `Promise`\<`Contact`\> -Defined in: [src/api.ts:915](../src/api.ts#L915) +Defined in: [src/api.ts:953](../src/api.ts#L953) Create a direct message contact with a group member. Returns the created contact. @@ -406,7 +406,7 @@ Network usage: interactive. > **apiCreateUserAddress**(`userId`): `Promise`\<`CreatedConnLink`\> -Defined in: [src/api.ts:346](../src/api.ts#L346) +Defined in: [src/api.ts:361](../src/api.ts#L361) Create bot address. Network usage: interactive. @@ -427,7 +427,7 @@ Network usage: interactive. > **apiDeleteChat**(`chatType`, `chatId`, `deleteMode?`): `Promise`\<`void`\> -Defined in: [src/api.ts:771](../src/api.ts#L771) +Defined in: [src/api.ts:809](../src/api.ts#L809) Delete chat. Network usage: background. @@ -456,7 +456,7 @@ Network usage: background. > **apiDeleteChatItems**(`chatType`, `chatId`, `chatItemIds`, `deleteMode`): `Promise`\<`ChatItemDeletion`[]\> -Defined in: [src/api.ts:470](../src/api.ts#L470) +Defined in: [src/api.ts:488](../src/api.ts#L488) Delete message. Network usage: background. @@ -489,7 +489,7 @@ Network usage: background. > **apiDeleteGroupLink**(`groupId`): `Promise`\<`void`\> -Defined in: [src/api.ts:653](../src/api.ts#L653) +Defined in: [src/api.ts:672](../src/api.ts#L672) Delete group link. Network usage: background. @@ -510,7 +510,7 @@ Network usage: background. > **apiDeleteMemberChatItem**(`groupId`, `chatItemIds`): `Promise`\<`ChatItemDeletion`[]\> -Defined in: [src/api.ts:485](../src/api.ts#L485) +Defined in: [src/api.ts:503](../src/api.ts#L503) Moderate message. Requires Moderator role (and higher than message author's). Network usage: background. @@ -535,7 +535,7 @@ Network usage: background. > **apiDeleteUser**(`userId`, `delSMPQueues`, `viewPwd?`): `Promise`\<`void`\> -Defined in: [src/api.ts:879](../src/api.ts#L879) +Defined in: [src/api.ts:917](../src/api.ts#L917) Delete user profile. Network usage: background. @@ -564,7 +564,7 @@ Network usage: background. > **apiDeleteUserAddress**(`userId`): `Promise`\<`void`\> -Defined in: [src/api.ts:356](../src/api.ts#L356) +Defined in: [src/api.ts:371](../src/api.ts#L371) Deletes a user address. Network usage: background. @@ -585,7 +585,7 @@ Network usage: background. > **apiGetActiveUser**(): `Promise`\<`User` \| `undefined`\> -Defined in: [src/api.ts:829](../src/api.ts#L829) +Defined in: [src/api.ts:867](../src/api.ts#L867) Get active user profile Network usage: no. @@ -600,7 +600,7 @@ Network usage: no. > **apiGetChat**(`chatType`, `chatId`, `count`): `Promise`\<`any`\> -Defined in: [src/api.ts:819](../src/api.ts#L819) +Defined in: [src/api.ts:857](../src/api.ts#L857) Get chat items. Network usage: no. @@ -625,11 +625,48 @@ Network usage: no. *** +### apiGetChats() + +> **apiGetChats**(`userId`, `pagination`, `query?`, `pendingConnections?`): `Promise`\<`AChat`[]\> + +Defined in: [src/api.ts:794](../src/api.ts#L794) + +Get chat previews (paginated). +Network usage: no. + +Prefer this over apiListContacts / apiListGroups for any scan: those +methods load every record into memory in a single response and will fail +on large databases. + +#### Parameters + +##### userId + +`number` + +##### pagination + +`Last` + +##### query? + +`ChatListQuery` = `...` + +##### pendingConnections? + +`boolean` = `false` + +#### Returns + +`Promise`\<`AChat`[]\> + +*** + ### apiGetGroupLink() > **apiGetGroupLink**(`groupId`): `Promise`\<`GroupLink`\> -Defined in: [src/api.ts:662](../src/api.ts#L662) +Defined in: [src/api.ts:681](../src/api.ts#L681) Get group link. Network usage: no. @@ -650,7 +687,7 @@ Network usage: no. > **apiGetGroupLinkStr**(`groupId`): `Promise`\<`string`\> -Defined in: [src/api.ts:668](../src/api.ts#L668) +Defined in: [src/api.ts:687](../src/api.ts#L687) #### Parameters @@ -668,7 +705,7 @@ Defined in: [src/api.ts:668](../src/api.ts#L668) > **apiGetUserAddress**(`userId`): `Promise`\<`UserContactLink` \| `undefined`\> -Defined in: [src/api.ts:366](../src/api.ts#L366) +Defined in: [src/api.ts:381](../src/api.ts#L381) Get bot address and settings. Network usage: no. @@ -689,7 +726,7 @@ Network usage: no. > **apiJoinGroup**(`groupId`): `Promise`\<`GroupInfo`\> -Defined in: [src/api.ts:541](../src/api.ts#L541) +Defined in: [src/api.ts:560](../src/api.ts#L560) Join group. Network usage: interactive. @@ -710,7 +747,7 @@ Network usage: interactive. > **apiLeaveGroup**(`groupId`): `Promise`\<`GroupInfo`\> -Defined in: [src/api.ts:591](../src/api.ts#L591) +Defined in: [src/api.ts:610](../src/api.ts#L610) Leave group. Network usage: background. @@ -731,7 +768,7 @@ Network usage: background. > **apiListContacts**(`userId`): `Promise`\<`Contact`[]\> -Defined in: [src/api.ts:751](../src/api.ts#L751) +Defined in: [src/api.ts:770](../src/api.ts#L770) Get contacts. Network usage: no. @@ -752,7 +789,7 @@ Network usage: no. > **apiListGroups**(`userId`, `contactId?`, `search?`): `Promise`\<`GroupInfo`[]\> -Defined in: [src/api.ts:761](../src/api.ts#L761) +Defined in: [src/api.ts:780](../src/api.ts#L780) Get groups. Network usage: no. @@ -781,7 +818,7 @@ Network usage: no. > **apiListMembers**(`groupId`): `Promise`\<`GroupMember`[]\> -Defined in: [src/api.ts:601](../src/api.ts#L601) +Defined in: [src/api.ts:620](../src/api.ts#L620) Get group members. Network usage: no. @@ -802,7 +839,7 @@ Network usage: no. > **apiListUsers**(): `Promise`\<`UserInfo`[]\> -Defined in: [src/api.ts:859](../src/api.ts#L859) +Defined in: [src/api.ts:897](../src/api.ts#L897) Get all user profiles Network usage: no. @@ -817,7 +854,7 @@ Network usage: no. > **apiNewGroup**(`userId`, `groupProfile`): `Promise`\<`GroupInfo`\> -Defined in: [src/api.ts:611](../src/api.ts#L611) +Defined in: [src/api.ts:630](../src/api.ts#L630) Create group. Network usage: no. @@ -842,7 +879,7 @@ Network usage: no. > **apiReceiveFile**(`fileId`): `Promise`\<`AChatItem`\> -Defined in: [src/api.ts:511](../src/api.ts#L511) +Defined in: [src/api.ts:529](../src/api.ts#L529) Receive file. Network usage: no. @@ -863,7 +900,7 @@ Network usage: no. > **apiRejectContactRequest**(`contactReqId`): `Promise`\<`void`\> -Defined in: [src/api.ts:741](../src/api.ts#L741) +Defined in: [src/api.ts:760](../src/api.ts#L760) Reject contact request. The user who sent the request is **not notified**. Network usage: no. @@ -884,7 +921,7 @@ Network usage: no. > **apiRemoveMembers**(`groupId`, `memberIds`, `withMessages?`): `Promise`\<`GroupMember`[]\> -Defined in: [src/api.ts:581](../src/api.ts#L581) +Defined in: [src/api.ts:600](../src/api.ts#L600) Remove members. Requires Admin role. Network usage: background. @@ -913,7 +950,7 @@ Network usage: background. > **apiSendMemberContactInvitation**(`contactId`, `message?`): `Promise`\<`Contact`\> -Defined in: [src/api.ts:926](../src/api.ts#L926) +Defined in: [src/api.ts:964](../src/api.ts#L964) Send a direct message invitation to a group member contact. The contact must have been created with [apiCreateMemberContact](#apicreatemembercontact). @@ -939,7 +976,7 @@ Network usage: interactive. > **apiSendMessages**(`chat`, `messages`, `liveMessage?`): `Promise`\<`AChatItem`[]\> -Defined in: [src/api.ts:415](../src/api.ts#L415) +Defined in: [src/api.ts:432](../src/api.ts#L432) Send messages. Network usage: background. @@ -968,7 +1005,7 @@ Network usage: background. > **apiSendTextMessage**(`chat`, `text`, `inReplyTo?`): `Promise`\<`AChatItem`[]\> -Defined in: [src/api.ts:437](../src/api.ts#L437) +Defined in: [src/api.ts:455](../src/api.ts#L455) Send text message. Network usage: background. @@ -997,7 +1034,7 @@ Network usage: background. > **apiSendTextReply**(`chatItem`, `text`): `Promise`\<`AChatItem`[]\> -Defined in: [src/api.ts:445](../src/api.ts#L445) +Defined in: [src/api.ts:463](../src/api.ts#L463) Send text message in reply to received message. Network usage: background. @@ -1022,7 +1059,7 @@ Network usage: background. > **apiSetActiveUser**(`userId`, `viewPwd?`): `Promise`\<`User`\> -Defined in: [src/api.ts:869](../src/api.ts#L869) +Defined in: [src/api.ts:907](../src/api.ts#L907) Set active user profile Network usage: no. @@ -1047,7 +1084,7 @@ Network usage: no. > **apiSetAddressSettings**(`userId`, `__namedParameters`): `Promise`\<`void`\> -Defined in: [src/api.ts:398](../src/api.ts#L398) +Defined in: [src/api.ts:415](../src/api.ts#L415) Set bot address settings. Network usage: interactive. @@ -1072,7 +1109,7 @@ Network usage: interactive. > **apiSetAutoAcceptMemberContacts**(`userId`, `onOff`): `Promise`\<`void`\> -Defined in: [src/api.ts:808](../src/api.ts#L808) +Defined in: [src/api.ts:846](../src/api.ts#L846) Set auto-accept member contacts. Network usage: no. @@ -1097,7 +1134,7 @@ Network usage: no. > **apiSetContactCustomData**(`contactId`, `customData?`): `Promise`\<`void`\> -Defined in: [src/api.ts:798](../src/api.ts#L798) +Defined in: [src/api.ts:836](../src/api.ts#L836) Set contact custom data. Network usage: no. @@ -1122,7 +1159,7 @@ Network usage: no. > **apiSetContactPrefs**(`contactId`, `preferences`): `Promise`\<`void`\> -Defined in: [src/api.ts:905](../src/api.ts#L905) +Defined in: [src/api.ts:943](../src/api.ts#L943) Configure chat preference overrides for the contact. Network usage: background. @@ -1147,7 +1184,7 @@ Network usage: background. > **apiSetGroupCustomData**(`groupId`, `customData?`): `Promise`\<`void`\> -Defined in: [src/api.ts:788](../src/api.ts#L788) +Defined in: [src/api.ts:826](../src/api.ts#L826) Set group custom data. Network usage: no. @@ -1172,7 +1209,7 @@ Network usage: no. > **apiSetGroupLinkMemberRole**(`groupId`, `memberRole`): `Promise`\<`void`\> -Defined in: [src/api.ts:644](../src/api.ts#L644) +Defined in: [src/api.ts:663](../src/api.ts#L663) Set member role for group link. Network usage: no. @@ -1197,7 +1234,7 @@ Network usage: no. > **apiSetMembersRole**(`groupId`, `groupMemberIds`, `memberRole`): `Promise`\<`void`\> -Defined in: [src/api.ts:561](../src/api.ts#L561) +Defined in: [src/api.ts:580](../src/api.ts#L580) Set members role. Requires Admin role. Network usage: background. @@ -1226,7 +1263,7 @@ Network usage: background. > **apiSetProfileAddress**(`userId`, `enable`): `Promise`\<`UserProfileUpdateSummary`\> -Defined in: [src/api.ts:384](../src/api.ts#L384) +Defined in: [src/api.ts:399](../src/api.ts#L399) Add address to bot profile. Network usage: interactive. @@ -1251,7 +1288,7 @@ Network usage: interactive. > **apiUpdateChatItem**(`chatType`, `chatId`, `chatItemId`, `msgContent`, `liveMessage`): `Promise`\<`ChatItem`\> -Defined in: [src/api.ts:453](../src/api.ts#L453) +Defined in: [src/api.ts:471](../src/api.ts#L471) Update message. Network usage: background. @@ -1288,7 +1325,7 @@ Network usage: background. > **apiUpdateGroupProfile**(`groupId`, `groupProfile`): `Promise`\<`GroupInfo`\> -Defined in: [src/api.ts:621](../src/api.ts#L621) +Defined in: [src/api.ts:640](../src/api.ts#L640) Update group profile. Network usage: background. @@ -1313,7 +1350,7 @@ Network usage: background. > **apiUpdateProfile**(`userId`, `profile`): `Promise`\<`UserProfileUpdateSummary` \| `undefined`\> -Defined in: [src/api.ts:889](../src/api.ts#L889) +Defined in: [src/api.ts:927](../src/api.ts#L927) Update user profile. Network usage: background. @@ -1338,9 +1375,10 @@ Network usage: background. > **close**(): `Promise`\<`void`\> -Defined in: [src/api.ts:148](../src/api.ts#L148) +Defined in: [src/api.ts:158](../src/api.ts#L158) -Close chat database. +Stop chat controller and close chat database. +The database is not closed if stopping fails. Usually doesn't need to be called in chat bots. #### Returns @@ -1353,7 +1391,7 @@ Usually doesn't need to be called in chat bots. > **off**\<`K`\>(`event`, `subscriber?`): `void` -Defined in: [src/api.ts:287](../src/api.ts#L287) +Defined in: [src/api.ts:302](../src/api.ts#L302) Unsubscribe all or a specific handler from a specific event. @@ -1387,7 +1425,7 @@ An optional subscriber function for the event. > **offAny**(`receiver?`): `void` -Defined in: [src/api.ts:303](../src/api.ts#L303) +Defined in: [src/api.ts:318](../src/api.ts#L318) Unsubscribe all or a specific handler from any events. @@ -1411,7 +1449,7 @@ An optional subscriber function for the event. > **on**\<`K`\>(`subscribers`): `void` -Defined in: [src/api.ts:197](../src/api.ts#L197) +Defined in: [src/api.ts:212](../src/api.ts#L212) Subscribe multiple event handlers at once. @@ -1441,7 +1479,7 @@ If the same function is subscribed to event. > **on**\<`K`\>(`event`, `subscriber`): `void` -Defined in: [src/api.ts:205](../src/api.ts#L205) +Defined in: [src/api.ts:220](../src/api.ts#L220) Subscribe a handler to a specific event. @@ -1479,7 +1517,7 @@ If the same function is subscribed to event. > **onAny**(`receiver`): `void` -Defined in: [src/api.ts:228](../src/api.ts#L228) +Defined in: [src/api.ts:243](../src/api.ts#L243) Subscribe a handler to any event. @@ -1505,7 +1543,7 @@ If the same function is subscribed to event. > **once**\<`K`\>(`event`, `subscriber`): `void` -Defined in: [src/api.ts:239](../src/api.ts#L239) +Defined in: [src/api.ts:254](../src/api.ts#L254) Subscribe a handler to a specific event to be delivered one time. @@ -1543,13 +1581,13 @@ If the same function is subscribed to event. > **recvChatEvent**(`wait?`): `Promise`\<`ChatEvent` \| `undefined`\> -Defined in: [src/api.ts:338](../src/api.ts#L338) +Defined in: [src/api.ts:353](../src/api.ts#L353) #### Parameters ##### wait? -`number` = `5_000_000` +`number` = `500_000` #### Returns @@ -1561,7 +1599,7 @@ Defined in: [src/api.ts:338](../src/api.ts#L338) > **sendChatCmd**(`cmd`): `Promise`\<`ChatResponse`\> -Defined in: [src/api.ts:334](../src/api.ts#L334) +Defined in: [src/api.ts:349](../src/api.ts#L349) #### Parameters @@ -1579,7 +1617,7 @@ Defined in: [src/api.ts:334](../src/api.ts#L334) > **startChat**(): `Promise`\<`void`\> -Defined in: [src/api.ts:122](../src/api.ts#L122) +Defined in: [src/api.ts:124](../src/api.ts#L124) Start chat controller. Must be called with the existing user profile. @@ -1593,10 +1631,10 @@ Start chat controller. Must be called with the existing user profile. > **stopChat**(): `Promise`\<`void`\> -Defined in: [src/api.ts:136](../src/api.ts#L136) +Defined in: [src/api.ts:147](../src/api.ts#L147) Stop chat controller. -Must be called before closing the database. +`close` calls it before closing the database. Usually doesn't need to be called in chat bots. #### Returns @@ -1611,7 +1649,7 @@ Usually doesn't need to be called in chat bots. > **wait**\<`K`\>(`event`): `Promise`\<`ChatEvent` & `object`\> -Defined in: [src/api.ts:247](../src/api.ts#L247) +Defined in: [src/api.ts:262](../src/api.ts#L262) Waits for specific event, with an optional predicate. Returns `undefined` on timeout if specified. @@ -1636,7 +1674,7 @@ Returns `undefined` on timeout if specified. > **wait**\<`K`\>(`event`, `predicate`): `Promise`\<`ChatEvent` & `object`\> -Defined in: [src/api.ts:248](../src/api.ts#L248) +Defined in: [src/api.ts:263](../src/api.ts#L263) Waits for specific event, with an optional predicate. Returns `undefined` on timeout if specified. @@ -1665,7 +1703,7 @@ Returns `undefined` on timeout if specified. > **wait**\<`K`\>(`event`, `timeout`): `Promise`\ -Defined in: [src/api.ts:249](../src/api.ts#L249) +Defined in: [src/api.ts:264](../src/api.ts#L264) Waits for specific event, with an optional predicate. Returns `undefined` on timeout if specified. @@ -1694,7 +1732,7 @@ Returns `undefined` on timeout if specified. > **wait**\<`K`\>(`event`, `predicate`, `timeout`): `Promise`\ -Defined in: [src/api.ts:250](../src/api.ts#L250) +Defined in: [src/api.ts:265](../src/api.ts#L265) Waits for specific event, with an optional predicate. Returns `undefined` on timeout if specified. @@ -1727,9 +1765,9 @@ Returns `undefined` on timeout if specified. ### init() -> `static` **init**(`db`, `confirm?`): `Promise`\<`ChatApi`\> +> `static` **init**(`db`, `confirm?`, `queueSize?`): `Promise`\<`ChatApi`\> -Defined in: [src/api.ts:110](../src/api.ts#L110) +Defined in: [src/api.ts:111](../src/api.ts#L111) Initializes the ChatApi. @@ -1747,6 +1785,12 @@ Database configuration (sqlite or postgres). Migration confirmation mode. +##### queueSize? + +`number` + +Size of internal queues, the core default is used when omitted. + #### Returns `Promise`\<`ChatApi`\> diff --git a/packages/simplex-chat-nodejs/docs/bot.Function.run.md b/packages/simplex-chat-nodejs/docs/bot.Function.run.md index 3c33e6c7d4..0af2644bc2 100644 --- a/packages/simplex-chat-nodejs/docs/bot.Function.run.md +++ b/packages/simplex-chat-nodejs/docs/bot.Function.run.md @@ -8,7 +8,7 @@ > **run**(`__namedParameters`): `Promise`\<\[[`ChatApi`](api.Class.ChatApi.md), `User`, `UserContactLink` \| `undefined`\]\> -Defined in: [src/bot.ts:47](../src/bot.ts#L47) +Defined in: [src/bot.ts:48](../src/bot.ts#L48) ## Parameters diff --git a/packages/simplex-chat-nodejs/docs/bot.Function.subscribeChatItems.md b/packages/simplex-chat-nodejs/docs/bot.Function.subscribeChatItems.md new file mode 100644 index 0000000000..5397cf3aeb --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/bot.Function.subscribeChatItems.md @@ -0,0 +1,27 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [bot](Namespace.bot.md) / subscribeChatItems + +# Function: subscribeChatItems() + +> **subscribeChatItems**(`bot`, `onMessage`, `commands`): `void` + +Defined in: [src/bot.ts:108](../src/bot.ts#L108) + +## Parameters + +### bot + +[`ChatApi`](api.Class.ChatApi.md) + +### onMessage + +((`chatItem`, `content`) => `void` \| `Promise`\<`void`\>) \| `undefined` + +### commands + +## Returns + +`void` diff --git a/packages/simplex-chat-nodejs/docs/bot.Interface.BotConfig.md b/packages/simplex-chat-nodejs/docs/bot.Interface.BotConfig.md index 4624b1608b..f763f414da 100644 --- a/packages/simplex-chat-nodejs/docs/bot.Interface.BotConfig.md +++ b/packages/simplex-chat-nodejs/docs/bot.Interface.BotConfig.md @@ -6,7 +6,7 @@ # Interface: BotConfig -Defined in: [src/bot.ts:35](../src/bot.ts#L35) +Defined in: [src/bot.ts:36](../src/bot.ts#L36) ## Properties @@ -14,7 +14,7 @@ Defined in: [src/bot.ts:35](../src/bot.ts#L35) > **dbOpts**: [`BotDbOpts`](bot.TypeAlias.BotDbOpts.md) -Defined in: [src/bot.ts:37](../src/bot.ts#L37) +Defined in: [src/bot.ts:38](../src/bot.ts#L38) *** @@ -22,7 +22,7 @@ Defined in: [src/bot.ts:37](../src/bot.ts#L37) > `optional` **events?**: [`EventSubscribers`](api.TypeAlias.EventSubscribers.md) -Defined in: [src/bot.ts:44](../src/bot.ts#L44) +Defined in: [src/bot.ts:45](../src/bot.ts#L45) *** @@ -30,7 +30,7 @@ Defined in: [src/bot.ts:44](../src/bot.ts#L44) > `optional` **onCommands?**: `object` -Defined in: [src/bot.ts:41](../src/bot.ts#L41) +Defined in: [src/bot.ts:42](../src/bot.ts#L42) #### Index Signature @@ -42,7 +42,7 @@ Defined in: [src/bot.ts:41](../src/bot.ts#L41) > `optional` **onMessage?**: (`chatItem`, `content`) => `void` \| `Promise`\<`void`\> -Defined in: [src/bot.ts:39](../src/bot.ts#L39) +Defined in: [src/bot.ts:40](../src/bot.ts#L40) #### Parameters @@ -64,7 +64,7 @@ Defined in: [src/bot.ts:39](../src/bot.ts#L39) > **options**: [`BotOptions`](bot.Interface.BotOptions.md) -Defined in: [src/bot.ts:38](../src/bot.ts#L38) +Defined in: [src/bot.ts:39](../src/bot.ts#L39) *** @@ -72,4 +72,4 @@ Defined in: [src/bot.ts:38](../src/bot.ts#L38) > **profile**: `Profile` -Defined in: [src/bot.ts:36](../src/bot.ts#L36) +Defined in: [src/bot.ts:37](../src/bot.ts#L37) diff --git a/packages/simplex-chat-nodejs/docs/bot.Interface.BotOptions.md b/packages/simplex-chat-nodejs/docs/bot.Interface.BotOptions.md index eee56b879a..c5d5df5193 100644 --- a/packages/simplex-chat-nodejs/docs/bot.Interface.BotOptions.md +++ b/packages/simplex-chat-nodejs/docs/bot.Interface.BotOptions.md @@ -6,7 +6,7 @@ # Interface: BotOptions -Defined in: [src/bot.ts:11](../src/bot.ts#L11) +Defined in: [src/bot.ts:12](../src/bot.ts#L12) ## Properties @@ -14,7 +14,7 @@ Defined in: [src/bot.ts:11](../src/bot.ts#L11) > `optional` **addressSettings?**: [`BotAddressSettings`](api.Interface.BotAddressSettings.md) -Defined in: [src/bot.ts:15](../src/bot.ts#L15) +Defined in: [src/bot.ts:16](../src/bot.ts#L16) *** @@ -22,7 +22,7 @@ Defined in: [src/bot.ts:15](../src/bot.ts#L15) > `optional` **allowFiles?**: `boolean` -Defined in: [src/bot.ts:16](../src/bot.ts#L16) +Defined in: [src/bot.ts:17](../src/bot.ts#L17) *** @@ -30,7 +30,7 @@ Defined in: [src/bot.ts:16](../src/bot.ts#L16) > `optional` **commands?**: `ChatBotCommand`[] -Defined in: [src/bot.ts:17](../src/bot.ts#L17) +Defined in: [src/bot.ts:18](../src/bot.ts#L18) *** @@ -38,7 +38,7 @@ Defined in: [src/bot.ts:17](../src/bot.ts#L17) > `optional` **createAddress?**: `boolean` -Defined in: [src/bot.ts:12](../src/bot.ts#L12) +Defined in: [src/bot.ts:13](../src/bot.ts#L13) *** @@ -46,7 +46,7 @@ Defined in: [src/bot.ts:12](../src/bot.ts#L12) > `optional` **logContacts?**: `boolean` -Defined in: [src/bot.ts:19](../src/bot.ts#L19) +Defined in: [src/bot.ts:20](../src/bot.ts#L20) *** @@ -54,7 +54,7 @@ Defined in: [src/bot.ts:19](../src/bot.ts#L19) > `optional` **logNetwork?**: `boolean` -Defined in: [src/bot.ts:20](../src/bot.ts#L20) +Defined in: [src/bot.ts:21](../src/bot.ts#L21) *** @@ -62,7 +62,7 @@ Defined in: [src/bot.ts:20](../src/bot.ts#L20) > `optional` **updateAddress?**: `boolean` -Defined in: [src/bot.ts:13](../src/bot.ts#L13) +Defined in: [src/bot.ts:14](../src/bot.ts#L14) *** @@ -70,7 +70,7 @@ Defined in: [src/bot.ts:13](../src/bot.ts#L13) > `optional` **updateProfile?**: `boolean` -Defined in: [src/bot.ts:14](../src/bot.ts#L14) +Defined in: [src/bot.ts:15](../src/bot.ts#L15) *** @@ -78,4 +78,4 @@ Defined in: [src/bot.ts:14](../src/bot.ts#L14) > `optional` **useBotProfile?**: `boolean` -Defined in: [src/bot.ts:18](../src/bot.ts#L18) +Defined in: [src/bot.ts:19](../src/bot.ts#L19) diff --git a/packages/simplex-chat-nodejs/docs/bot.TypeAlias.BotDbOpts.md b/packages/simplex-chat-nodejs/docs/bot.TypeAlias.BotDbOpts.md index b035f41355..5aeb298a09 100644 --- a/packages/simplex-chat-nodejs/docs/bot.TypeAlias.BotDbOpts.md +++ b/packages/simplex-chat-nodejs/docs/bot.TypeAlias.BotDbOpts.md @@ -15,3 +15,7 @@ Defined in: [src/bot.ts:7](../src/bot.ts#L7) ### confirmMigrations? > `optional` **confirmMigrations?**: [`MigrationConfirmation`](core.Enumeration.MigrationConfirmation.md) + +### queueSize? + +> `optional` **queueSize?**: `number` diff --git a/packages/simplex-chat-nodejs/docs/core.Class.ChatAPIError.md b/packages/simplex-chat-nodejs/docs/core.Class.ChatAPIError.md index 5bd0722f0c..3953d777da 100644 --- a/packages/simplex-chat-nodejs/docs/core.Class.ChatAPIError.md +++ b/packages/simplex-chat-nodejs/docs/core.Class.ChatAPIError.md @@ -6,7 +6,7 @@ # Class: ChatAPIError -Defined in: [src/core.ts:92](../src/core.ts#L92) +Defined in: [src/core.ts:95](../src/core.ts#L95) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:92](../src/core.ts#L92) > **new ChatAPIError**(`message`, `chatError?`): `ChatAPIError` -Defined in: [src/core.ts:93](../src/core.ts#L93) +Defined in: [src/core.ts:96](../src/core.ts#L96) #### Parameters @@ -44,7 +44,7 @@ Defined in: [src/core.ts:93](../src/core.ts#L93) > **chatError**: `ChatError` \| `undefined` = `undefined` -Defined in: [src/core.ts:93](../src/core.ts#L93) +Defined in: [src/core.ts:96](../src/core.ts#L96) *** @@ -52,7 +52,7 @@ Defined in: [src/core.ts:93](../src/core.ts#L93) > **message**: `string` -Defined in: [src/core.ts:93](../src/core.ts#L93) +Defined in: [src/core.ts:96](../src/core.ts#L96) #### Inherited from diff --git a/packages/simplex-chat-nodejs/docs/core.Class.ChatInitError.md b/packages/simplex-chat-nodejs/docs/core.Class.ChatInitError.md index 0feceae4fd..649090e3ac 100644 --- a/packages/simplex-chat-nodejs/docs/core.Class.ChatInitError.md +++ b/packages/simplex-chat-nodejs/docs/core.Class.ChatInitError.md @@ -6,7 +6,7 @@ # Class: ChatInitError -Defined in: [src/core.ts:116](../src/core.ts#L116) +Defined in: [src/core.ts:119](../src/core.ts#L119) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:116](../src/core.ts#L116) > **new ChatInitError**(`message`, `dbMigrationError`): `ChatInitError` -Defined in: [src/core.ts:117](../src/core.ts#L117) +Defined in: [src/core.ts:120](../src/core.ts#L120) #### Parameters @@ -44,7 +44,7 @@ Defined in: [src/core.ts:117](../src/core.ts#L117) > **dbMigrationError**: [`DBMigrationError`](core.TypeAlias.DBMigrationError.md) -Defined in: [src/core.ts:117](../src/core.ts#L117) +Defined in: [src/core.ts:120](../src/core.ts#L120) *** @@ -52,7 +52,7 @@ Defined in: [src/core.ts:117](../src/core.ts#L117) > **message**: `string` -Defined in: [src/core.ts:117](../src/core.ts#L117) +Defined in: [src/core.ts:120](../src/core.ts#L120) #### Inherited from diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorMigration.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorMigration.md index 02cf84b763..fea7bfb531 100644 --- a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorMigration.md +++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorMigration.md @@ -6,7 +6,7 @@ # Interface: ErrorMigration -Defined in: [src/core.ts:144](../src/core.ts#L144) +Defined in: [src/core.ts:152](../src/core.ts#L152) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:144](../src/core.ts#L144) > **dbFile**: `string` -Defined in: [src/core.ts:146](../src/core.ts#L146) +Defined in: [src/core.ts:154](../src/core.ts#L154) *** @@ -26,7 +26,7 @@ Defined in: [src/core.ts:146](../src/core.ts#L146) > **migrationError**: [`MigrationError`](core.TypeAlias.MigrationError.md) -Defined in: [src/core.ts:147](../src/core.ts#L147) +Defined in: [src/core.ts:155](../src/core.ts#L155) *** @@ -34,7 +34,7 @@ Defined in: [src/core.ts:147](../src/core.ts#L147) > **type**: `"errorMigration"` -Defined in: [src/core.ts:145](../src/core.ts#L145) +Defined in: [src/core.ts:153](../src/core.ts#L153) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorNotADatabase.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorNotADatabase.md index 18e2429081..eb0bfb11cb 100644 --- a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorNotADatabase.md +++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorNotADatabase.md @@ -6,7 +6,7 @@ # Interface: ErrorNotADatabase -Defined in: [src/core.ts:139](../src/core.ts#L139) +Defined in: [src/core.ts:147](../src/core.ts#L147) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:139](../src/core.ts#L139) > **dbFile**: `string` -Defined in: [src/core.ts:141](../src/core.ts#L141) +Defined in: [src/core.ts:149](../src/core.ts#L149) *** @@ -26,7 +26,7 @@ Defined in: [src/core.ts:141](../src/core.ts#L141) > **type**: `"errorNotADatabase"` -Defined in: [src/core.ts:140](../src/core.ts#L140) +Defined in: [src/core.ts:148](../src/core.ts#L148) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorSQL.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorSQL.md index 4d85b04197..ffaa364f9b 100644 --- a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorSQL.md +++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorSQL.md @@ -6,7 +6,7 @@ # Interface: ErrorSQL -Defined in: [src/core.ts:150](../src/core.ts#L150) +Defined in: [src/core.ts:158](../src/core.ts#L158) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:150](../src/core.ts#L150) > **dbFile**: `string` -Defined in: [src/core.ts:152](../src/core.ts#L152) +Defined in: [src/core.ts:160](../src/core.ts#L160) *** @@ -26,7 +26,7 @@ Defined in: [src/core.ts:152](../src/core.ts#L152) > **migrationSQLError**: `string` -Defined in: [src/core.ts:153](../src/core.ts#L153) +Defined in: [src/core.ts:161](../src/core.ts#L161) *** @@ -34,7 +34,7 @@ Defined in: [src/core.ts:153](../src/core.ts#L153) > **type**: `"errorSQL"` -Defined in: [src/core.ts:151](../src/core.ts#L151) +Defined in: [src/core.ts:159](../src/core.ts#L159) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidConfirmation.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidConfirmation.md index 34dea63aed..67711030da 100644 --- a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidConfirmation.md +++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidConfirmation.md @@ -6,7 +6,7 @@ # Interface: InvalidConfirmation -Defined in: [src/core.ts:135](../src/core.ts#L135) +Defined in: [src/core.ts:139](../src/core.ts#L139) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:135](../src/core.ts#L135) > **type**: `"invalidConfirmation"` -Defined in: [src/core.ts:136](../src/core.ts#L136) +Defined in: [src/core.ts:140](../src/core.ts#L140) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidQueueSize.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidQueueSize.md new file mode 100644 index 0000000000..9093b5e9b1 --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidQueueSize.md @@ -0,0 +1,25 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [core](Namespace.core.md) / [DBMigrationError](core.Namespace.DBMigrationError.md) / InvalidQueueSize + +# Interface: InvalidQueueSize + +Defined in: [src/core.ts:143](../src/core.ts#L143) + +## Extends + +- `Interface` + +## Properties + +### type + +> **type**: `"invalidQueueSize"` + +Defined in: [src/core.ts:144](../src/core.ts#L144) + +#### Overrides + +`Interface.type` diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.TypeAlias.Tag.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.TypeAlias.Tag.md index a3ef341601..b04f2f5af1 100644 --- a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.TypeAlias.Tag.md +++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.TypeAlias.Tag.md @@ -6,6 +6,6 @@ # Type Alias: Tag -> **Tag** = `"invalidConfirmation"` \| `"errorNotADatabase"` \| `"errorMigration"` \| `"errorSQL"` +> **Tag** = `"invalidConfirmation"` \| `"invalidQueueSize"` \| `"errorNotADatabase"` \| `"errorMigration"` \| `"errorSQL"` -Defined in: [src/core.ts:129](../src/core.ts#L129) +Defined in: [src/core.ts:133](../src/core.ts#L133) diff --git a/packages/simplex-chat-nodejs/docs/core.Enumeration.MigrationConfirmation.md b/packages/simplex-chat-nodejs/docs/core.Enumeration.MigrationConfirmation.md index 7dfd4991bf..48e250c7d4 100644 --- a/packages/simplex-chat-nodejs/docs/core.Enumeration.MigrationConfirmation.md +++ b/packages/simplex-chat-nodejs/docs/core.Enumeration.MigrationConfirmation.md @@ -6,7 +6,7 @@ # Enumeration: MigrationConfirmation -Defined in: [src/core.ts:101](../src/core.ts#L101) +Defined in: [src/core.ts:104](../src/core.ts#L104) Migration confirmation mode @@ -16,7 +16,7 @@ Migration confirmation mode > **Console**: `"console"` -Defined in: [src/core.ts:104](../src/core.ts#L104) +Defined in: [src/core.ts:107](../src/core.ts#L107) *** @@ -24,7 +24,7 @@ Defined in: [src/core.ts:104](../src/core.ts#L104) > **Error**: `"error"` -Defined in: [src/core.ts:105](../src/core.ts#L105) +Defined in: [src/core.ts:108](../src/core.ts#L108) *** @@ -32,7 +32,7 @@ Defined in: [src/core.ts:105](../src/core.ts#L105) > **YesUp**: `"yesUp"` -Defined in: [src/core.ts:102](../src/core.ts#L102) +Defined in: [src/core.ts:105](../src/core.ts#L105) *** @@ -40,4 +40,4 @@ Defined in: [src/core.ts:102](../src/core.ts#L102) > **YesUpDown**: `"yesUpDown"` -Defined in: [src/core.ts:103](../src/core.ts#L103) +Defined in: [src/core.ts:106](../src/core.ts#L106) diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatCloseStore.md b/packages/simplex-chat-nodejs/docs/core.Function.chatCloseStore.md index deeb3213fd..4b0324b92c 100644 --- a/packages/simplex-chat-nodejs/docs/core.Function.chatCloseStore.md +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatCloseStore.md @@ -8,7 +8,7 @@ > **chatCloseStore**(`ctrl`): `Promise`\<`void`\> -Defined in: [src/core.ts:17](../src/core.ts#L17) +Defined in: [src/core.ts:20](../src/core.ts#L20) Close chat store diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatDecryptFile.md b/packages/simplex-chat-nodejs/docs/core.Function.chatDecryptFile.md index 434aeeaae8..b6786fe1cb 100644 --- a/packages/simplex-chat-nodejs/docs/core.Function.chatDecryptFile.md +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatDecryptFile.md @@ -8,7 +8,7 @@ > **chatDecryptFile**(`fromPath`, `__namedParameters`, `toPath`): `Promise`\<`void`\> -Defined in: [src/core.ts:73](../src/core.ts#L73) +Defined in: [src/core.ts:76](../src/core.ts#L76) Decrypt file diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatEncryptFile.md b/packages/simplex-chat-nodejs/docs/core.Function.chatEncryptFile.md index 6aa0ad2923..9e30e55a23 100644 --- a/packages/simplex-chat-nodejs/docs/core.Function.chatEncryptFile.md +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatEncryptFile.md @@ -8,7 +8,7 @@ > **chatEncryptFile**(`ctrl`, `fromPath`, `toPath`): `Promise`\<[`CryptoArgs`](core.Interface.CryptoArgs.md)\> -Defined in: [src/core.ts:65](../src/core.ts#L65) +Defined in: [src/core.ts:68](../src/core.ts#L68) Encrypt file diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatMigrateInit.md b/packages/simplex-chat-nodejs/docs/core.Function.chatMigrateInit.md index 9116026f56..dbe4912520 100644 --- a/packages/simplex-chat-nodejs/docs/core.Function.chatMigrateInit.md +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatMigrateInit.md @@ -6,9 +6,9 @@ # Function: chatMigrateInit() -> **chatMigrateInit**(`dbPath`, `dbKey`, `confirm`): `Promise`\<`bigint`\> +> **chatMigrateInit**(`dbPath`, `dbKey`, `confirm`, `queueSize?`): `Promise`\<`bigint`\> -Defined in: [src/core.ts:7](../src/core.ts#L7) +Defined in: [src/core.ts:8](../src/core.ts#L8) Initialize chat controller @@ -26,6 +26,12 @@ Initialize chat controller [`MigrationConfirmation`](core.Enumeration.MigrationConfirmation.md) +### queueSize? + +`number` + +Size of internal queues, the core default is used when omitted. + ## Returns `Promise`\<`bigint`\> diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatReadFile.md b/packages/simplex-chat-nodejs/docs/core.Function.chatReadFile.md index 27de43e63c..f6713f22d8 100644 --- a/packages/simplex-chat-nodejs/docs/core.Function.chatReadFile.md +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatReadFile.md @@ -6,9 +6,9 @@ # Function: chatReadFile() -> **chatReadFile**(`path`, `__namedParameters`): `Promise`\<`ArrayBuffer`\> +> **chatReadFile**(`path`, `__namedParameters`): `Promise`\<`Buffer`\<`ArrayBufferLike`\>\> -Defined in: [src/core.ts:58](../src/core.ts#L58) +Defined in: [src/core.ts:61](../src/core.ts#L61) Read buffer from encrypted file @@ -24,4 +24,4 @@ Read buffer from encrypted file ## Returns -`Promise`\<`ArrayBuffer`\> +`Promise`\<`Buffer`\<`ArrayBufferLike`\>\> diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatRecvMsgWait.md b/packages/simplex-chat-nodejs/docs/core.Function.chatRecvMsgWait.md index 9bf44d6523..719cf610a1 100644 --- a/packages/simplex-chat-nodejs/docs/core.Function.chatRecvMsgWait.md +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatRecvMsgWait.md @@ -8,7 +8,7 @@ > **chatRecvMsgWait**(`ctrl`, `wait`): `Promise`\<`ChatEvent` \| `undefined`\> -Defined in: [src/core.ts:37](../src/core.ts#L37) +Defined in: [src/core.ts:40](../src/core.ts#L40) Receive chat event diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatSendCmd.md b/packages/simplex-chat-nodejs/docs/core.Function.chatSendCmd.md index 2dfcba45b4..4296a714cf 100644 --- a/packages/simplex-chat-nodejs/docs/core.Function.chatSendCmd.md +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatSendCmd.md @@ -8,7 +8,7 @@ > **chatSendCmd**(`ctrl`, `cmd`): `Promise`\<`ChatResponse`\> -Defined in: [src/core.ts:25](../src/core.ts#L25) +Defined in: [src/core.ts:28](../src/core.ts#L28) Send chat command as string diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatWriteFile.md b/packages/simplex-chat-nodejs/docs/core.Function.chatWriteFile.md index 3b1d770fbb..4ca640d8c4 100644 --- a/packages/simplex-chat-nodejs/docs/core.Function.chatWriteFile.md +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatWriteFile.md @@ -8,7 +8,7 @@ > **chatWriteFile**(`ctrl`, `path`, `buffer`): `Promise`\<[`CryptoArgs`](core.Interface.CryptoArgs.md)\> -Defined in: [src/core.ts:50](../src/core.ts#L50) +Defined in: [src/core.ts:53](../src/core.ts#L53) Write buffer to encrypted file @@ -24,7 +24,7 @@ Write buffer to encrypted file ### buffer -`ArrayBuffer` +`ArrayBuffer` \| `Uint8Array`\<`ArrayBufferLike`\> ## Returns diff --git a/packages/simplex-chat-nodejs/docs/core.Interface.APIResult.md b/packages/simplex-chat-nodejs/docs/core.Interface.APIResult.md index 8d18997ec4..2ede6d9ffb 100644 --- a/packages/simplex-chat-nodejs/docs/core.Interface.APIResult.md +++ b/packages/simplex-chat-nodejs/docs/core.Interface.APIResult.md @@ -6,7 +6,7 @@ # Interface: APIResult\ -Defined in: [src/core.ts:87](../src/core.ts#L87) +Defined in: [src/core.ts:90](../src/core.ts#L90) ## Type Parameters @@ -20,7 +20,7 @@ Defined in: [src/core.ts:87](../src/core.ts#L87) > `optional` **error?**: `ChatError` -Defined in: [src/core.ts:89](../src/core.ts#L89) +Defined in: [src/core.ts:92](../src/core.ts#L92) *** @@ -28,4 +28,4 @@ Defined in: [src/core.ts:89](../src/core.ts#L89) > `optional` **result?**: `R` -Defined in: [src/core.ts:88](../src/core.ts#L88) +Defined in: [src/core.ts:91](../src/core.ts#L91) diff --git a/packages/simplex-chat-nodejs/docs/core.Interface.CryptoArgs.md b/packages/simplex-chat-nodejs/docs/core.Interface.CryptoArgs.md index eddcb0bc5a..51a55b86b6 100644 --- a/packages/simplex-chat-nodejs/docs/core.Interface.CryptoArgs.md +++ b/packages/simplex-chat-nodejs/docs/core.Interface.CryptoArgs.md @@ -6,7 +6,7 @@ # Interface: CryptoArgs -Defined in: [src/core.ts:111](../src/core.ts#L111) +Defined in: [src/core.ts:114](../src/core.ts#L114) File encryption key and nonce @@ -16,7 +16,7 @@ File encryption key and nonce > **fileKey**: `string` -Defined in: [src/core.ts:112](../src/core.ts#L112) +Defined in: [src/core.ts:115](../src/core.ts#L115) *** @@ -24,4 +24,4 @@ Defined in: [src/core.ts:112](../src/core.ts#L112) > **fileNonce**: `string` -Defined in: [src/core.ts:113](../src/core.ts#L113) +Defined in: [src/core.ts:116](../src/core.ts#L116) diff --git a/packages/simplex-chat-nodejs/docs/core.Interface.UpMigration.md b/packages/simplex-chat-nodejs/docs/core.Interface.UpMigration.md index 32f6c267aa..9e1fca49b4 100644 --- a/packages/simplex-chat-nodejs/docs/core.Interface.UpMigration.md +++ b/packages/simplex-chat-nodejs/docs/core.Interface.UpMigration.md @@ -6,7 +6,7 @@ # Interface: UpMigration -Defined in: [src/core.ts:185](../src/core.ts#L185) +Defined in: [src/core.ts:193](../src/core.ts#L193) ## Properties @@ -14,7 +14,7 @@ Defined in: [src/core.ts:185](../src/core.ts#L185) > **upName**: `string` -Defined in: [src/core.ts:186](../src/core.ts#L186) +Defined in: [src/core.ts:194](../src/core.ts#L194) *** @@ -22,4 +22,4 @@ Defined in: [src/core.ts:186](../src/core.ts#L186) > **withDown**: `boolean` -Defined in: [src/core.ts:187](../src/core.ts#L187) +Defined in: [src/core.ts:195](../src/core.ts#L195) diff --git a/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTREDifferent.md b/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTREDifferent.md index 8dab81a3a3..f85fb492be 100644 --- a/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTREDifferent.md +++ b/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTREDifferent.md @@ -6,7 +6,7 @@ # Interface: MTREDifferent -Defined in: [src/core.ts:206](../src/core.ts#L206) +Defined in: [src/core.ts:214](../src/core.ts#L214) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:206](../src/core.ts#L206) > **downMigrations**: `string`[] -Defined in: [src/core.ts:208](../src/core.ts#L208) +Defined in: [src/core.ts:216](../src/core.ts#L216) *** @@ -26,7 +26,7 @@ Defined in: [src/core.ts:208](../src/core.ts#L208) > **type**: `"different"` -Defined in: [src/core.ts:207](../src/core.ts#L207) +Defined in: [src/core.ts:215](../src/core.ts#L215) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTRENoDown.md b/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTRENoDown.md index 1de634e40e..f031ac8982 100644 --- a/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTRENoDown.md +++ b/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTRENoDown.md @@ -6,7 +6,7 @@ # Interface: MTRENoDown -Defined in: [src/core.ts:201](../src/core.ts#L201) +Defined in: [src/core.ts:209](../src/core.ts#L209) ## Extends @@ -14,20 +14,20 @@ Defined in: [src/core.ts:201](../src/core.ts#L201) ## Properties +### dbMigrations + +> **dbMigrations**: `string`[] + +Defined in: [src/core.ts:211](../src/core.ts#L211) + +*** + ### type > **type**: `"noDown"` -Defined in: [src/core.ts:202](../src/core.ts#L202) +Defined in: [src/core.ts:210](../src/core.ts#L210) #### Overrides `Interface.type` - -*** - -### upMigrations - -> **upMigrations**: [`UpMigration`](core.Interface.UpMigration.md) - -Defined in: [src/core.ts:203](../src/core.ts#L203) diff --git a/packages/simplex-chat-nodejs/docs/core.MTRError.TypeAlias.Tag.md b/packages/simplex-chat-nodejs/docs/core.MTRError.TypeAlias.Tag.md index 768baa0068..59501b539a 100644 --- a/packages/simplex-chat-nodejs/docs/core.MTRError.TypeAlias.Tag.md +++ b/packages/simplex-chat-nodejs/docs/core.MTRError.TypeAlias.Tag.md @@ -8,4 +8,4 @@ > **Tag** = `"noDown"` \| `"different"` -Defined in: [src/core.ts:195](../src/core.ts#L195) +Defined in: [src/core.ts:203](../src/core.ts#L203) diff --git a/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEDowngrade.md b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEDowngrade.md index a2742cbff2..f6202b9456 100644 --- a/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEDowngrade.md +++ b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEDowngrade.md @@ -6,7 +6,7 @@ # Interface: MEDowngrade -Defined in: [src/core.ts:174](../src/core.ts#L174) +Defined in: [src/core.ts:182](../src/core.ts#L182) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:174](../src/core.ts#L174) > **downMigrations**: `string`[] -Defined in: [src/core.ts:176](../src/core.ts#L176) +Defined in: [src/core.ts:184](../src/core.ts#L184) *** @@ -26,7 +26,7 @@ Defined in: [src/core.ts:176](../src/core.ts#L176) > **type**: `"downgrade"` -Defined in: [src/core.ts:175](../src/core.ts#L175) +Defined in: [src/core.ts:183](../src/core.ts#L183) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEUpgrade.md b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEUpgrade.md index 08fe7d56e3..7467895b74 100644 --- a/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEUpgrade.md +++ b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEUpgrade.md @@ -6,7 +6,7 @@ # Interface: MEUpgrade -Defined in: [src/core.ts:169](../src/core.ts#L169) +Defined in: [src/core.ts:177](../src/core.ts#L177) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:169](../src/core.ts#L169) > **type**: `"upgrade"` -Defined in: [src/core.ts:170](../src/core.ts#L170) +Defined in: [src/core.ts:178](../src/core.ts#L178) #### Overrides @@ -28,6 +28,6 @@ Defined in: [src/core.ts:170](../src/core.ts#L170) ### upMigrations -> **upMigrations**: [`UpMigration`](core.Interface.UpMigration.md) +> **upMigrations**: [`UpMigration`](core.Interface.UpMigration.md)[] -Defined in: [src/core.ts:171](../src/core.ts#L171) +Defined in: [src/core.ts:179](../src/core.ts#L179) diff --git a/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MigrationError.md b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MigrationError.md index cd811a5747..7b2546fcd5 100644 --- a/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MigrationError.md +++ b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MigrationError.md @@ -6,7 +6,7 @@ # Interface: MigrationError -Defined in: [src/core.ts:179](../src/core.ts#L179) +Defined in: [src/core.ts:187](../src/core.ts#L187) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:179](../src/core.ts#L179) > **mtrError**: [`MTRError`](core.TypeAlias.MTRError.md) -Defined in: [src/core.ts:181](../src/core.ts#L181) +Defined in: [src/core.ts:189](../src/core.ts#L189) *** @@ -26,7 +26,7 @@ Defined in: [src/core.ts:181](../src/core.ts#L181) > **type**: `"migrationError"` -Defined in: [src/core.ts:180](../src/core.ts#L180) +Defined in: [src/core.ts:188](../src/core.ts#L188) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.MigrationError.TypeAlias.Tag.md b/packages/simplex-chat-nodejs/docs/core.MigrationError.TypeAlias.Tag.md index 5ef6e70b08..e2e1dcb33f 100644 --- a/packages/simplex-chat-nodejs/docs/core.MigrationError.TypeAlias.Tag.md +++ b/packages/simplex-chat-nodejs/docs/core.MigrationError.TypeAlias.Tag.md @@ -8,4 +8,4 @@ > **Tag** = `"upgrade"` \| `"downgrade"` \| `"migrationError"` -Defined in: [src/core.ts:163](../src/core.ts#L163) +Defined in: [src/core.ts:171](../src/core.ts#L171) diff --git a/packages/simplex-chat-nodejs/docs/core.Namespace.DBMigrationError.md b/packages/simplex-chat-nodejs/docs/core.Namespace.DBMigrationError.md index 95ddfa5b24..889a10ddba 100644 --- a/packages/simplex-chat-nodejs/docs/core.Namespace.DBMigrationError.md +++ b/packages/simplex-chat-nodejs/docs/core.Namespace.DBMigrationError.md @@ -12,6 +12,7 @@ - [ErrorNotADatabase](core.DBMigrationError.Interface.ErrorNotADatabase.md) - [ErrorSQL](core.DBMigrationError.Interface.ErrorSQL.md) - [InvalidConfirmation](core.DBMigrationError.Interface.InvalidConfirmation.md) +- [InvalidQueueSize](core.DBMigrationError.Interface.InvalidQueueSize.md) ## Type Aliases diff --git a/packages/simplex-chat-nodejs/docs/core.TypeAlias.DBMigrationError.md b/packages/simplex-chat-nodejs/docs/core.TypeAlias.DBMigrationError.md index 6473b3ef60..3f4797fb8e 100644 --- a/packages/simplex-chat-nodejs/docs/core.TypeAlias.DBMigrationError.md +++ b/packages/simplex-chat-nodejs/docs/core.TypeAlias.DBMigrationError.md @@ -6,6 +6,6 @@ # Type Alias: DBMigrationError -> **DBMigrationError** = [`InvalidConfirmation`](core.DBMigrationError.Interface.InvalidConfirmation.md) \| [`ErrorNotADatabase`](core.DBMigrationError.Interface.ErrorNotADatabase.md) \| [`ErrorMigration`](core.DBMigrationError.Interface.ErrorMigration.md) \| [`ErrorSQL`](core.DBMigrationError.Interface.ErrorSQL.md) +> **DBMigrationError** = [`InvalidConfirmation`](core.DBMigrationError.Interface.InvalidConfirmation.md) \| [`InvalidQueueSize`](core.DBMigrationError.Interface.InvalidQueueSize.md) \| [`ErrorNotADatabase`](core.DBMigrationError.Interface.ErrorNotADatabase.md) \| [`ErrorMigration`](core.DBMigrationError.Interface.ErrorMigration.md) \| [`ErrorSQL`](core.DBMigrationError.Interface.ErrorSQL.md) -Defined in: [src/core.ts:122](../src/core.ts#L122) +Defined in: [src/core.ts:125](../src/core.ts#L125) diff --git a/packages/simplex-chat-nodejs/docs/core.TypeAlias.MTRError.md b/packages/simplex-chat-nodejs/docs/core.TypeAlias.MTRError.md index 11aa5b7c24..66d6a092dc 100644 --- a/packages/simplex-chat-nodejs/docs/core.TypeAlias.MTRError.md +++ b/packages/simplex-chat-nodejs/docs/core.TypeAlias.MTRError.md @@ -8,4 +8,4 @@ > **MTRError** = [`MTRENoDown`](core.MTRError.Interface.MTRENoDown.md) \| [`MTREDifferent`](core.MTRError.Interface.MTREDifferent.md) -Defined in: [src/core.ts:190](../src/core.ts#L190) +Defined in: [src/core.ts:198](../src/core.ts#L198) diff --git a/packages/simplex-chat-nodejs/docs/core.TypeAlias.MigrationError.md b/packages/simplex-chat-nodejs/docs/core.TypeAlias.MigrationError.md index c15b679769..6c0ba77541 100644 --- a/packages/simplex-chat-nodejs/docs/core.TypeAlias.MigrationError.md +++ b/packages/simplex-chat-nodejs/docs/core.TypeAlias.MigrationError.md @@ -8,4 +8,4 @@ > **MigrationError** = [`MEUpgrade`](core.MigrationError.Interface.MEUpgrade.md) \| [`MEDowngrade`](core.MigrationError.Interface.MEDowngrade.md) \| [`MigrationError`](core.MigrationError.Interface.MigrationError.md) -Defined in: [src/core.ts:157](../src/core.ts#L157) +Defined in: [src/core.ts:165](../src/core.ts#L165) diff --git a/packages/simplex-chat-nodejs/package.json b/packages/simplex-chat-nodejs/package.json index b578194679..f405be260d 100644 --- a/packages/simplex-chat-nodejs/package.json +++ b/packages/simplex-chat-nodejs/package.json @@ -1,6 +1,6 @@ { "name": "simplex-chat", - "version": "7.1.0-beta.2", + "version": "7.1.0-beta.4", "main": "dist/index.js", "types": "dist/index.d.ts", "files": [ @@ -24,7 +24,7 @@ "docs": "typedoc" }, "dependencies": { - "@simplex-chat/types": "^0.11.2", + "@simplex-chat/types": "^0.11.4", "extract-zip": "^2.0.1", "fast-deep-equal": "^3.1.3", "node-addon-api": "^8.5.0" diff --git a/packages/simplex-chat-nodejs/src/api.ts b/packages/simplex-chat-nodejs/src/api.ts index 958304a8ea..8c11e9fde3 100644 --- a/packages/simplex-chat-nodejs/src/api.ts +++ b/packages/simplex-chat-nodejs/src/api.ts @@ -106,13 +106,15 @@ export class ChatApi { * Initializes the ChatApi. * @param {DbConfig} db - Database configuration (sqlite or postgres). * @param {core.MigrationConfirmation} [confirm=core.MigrationConfirmation.YesUp] - Migration confirmation mode. + * @param {number} [queueSize] - Size of internal queues, the core default is used when omitted. */ static async init( db: DbConfig, - confirm = core.MigrationConfirmation.YesUp + confirm = core.MigrationConfirmation.YesUp, + queueSize?: number ): Promise { const [path, key] = dbConfigToMigrateArgs(db) - const ctrl = await core.chatMigrateInit(path, key, confirm) + const ctrl = await core.chatMigrateInit(path, key, confirm, queueSize) return new ChatApi(ctrl) } @@ -120,39 +122,52 @@ export class ChatApi { * Start chat controller. Must be called with the existing user profile. */ async startChat(): Promise { + if (this.eventsLoop) throw new Error("chat already started") + const ctrl = this.ctrl this.receiveEvents = true this.eventsLoop = this.runEventsLoop() - const r = await this.sendChatCmd(CC.StartChat.cmdString({mainApp: true, enableSndFiles: true})) + let r: ChatResponse + try { + r = await core.chatSendCmd(ctrl, CC.StartChat.cmdString({mainApp: true, enableSndFiles: true, serviceRequests: false})) + } catch (e) { + await this.stopEventsLoop() + throw e + } if (r.type !== "chatStarted" && r.type !== "chatRunning") { + await this.stopEventsLoop() throw new ChatCommandError("error starting chat", r) } } - + /** * Stop chat controller. - * Must be called before closing the database. + * `close` calls it before closing the database. * Usually doesn't need to be called in chat bots. */ async stopChat(): Promise { const r = await this.sendChatCmd("/_stop") - if (r.type !== "chatStopped") throw new ChatCommandError("error starting chat", r) - this.receiveEvents = false - if (this.eventsLoop) await this.eventsLoop - this.eventsLoop = undefined + if (r.type !== "chatStopped") throw new ChatCommandError("error stopping chat", r) + await this.stopEventsLoop() } /** - * Close chat database. + * Stop chat controller and close chat database. + * The database is not closed if stopping fails. * Usually doesn't need to be called in chat bots. */ async close(): Promise { - this.receiveEvents = false - if (this.eventsLoop) await this.eventsLoop - this.eventsLoop = undefined + // a running controller keeps using the database connections that closing frees + await this.stopChat() await core.chatCloseStore(this.ctrl) this.ctrl_ = undefined } - + + private async stopEventsLoop(): Promise { + this.receiveEvents = false + if (this.eventsLoop) await this.eventsLoop + this.eventsLoop = undefined + } + private async runEventsLoop(): Promise { while (this.receiveEvents) { try { @@ -335,7 +350,7 @@ export class ChatApi { return await core.chatSendCmd(this.ctrl, cmd) } - async recvChatEvent(wait: number = 5_000_000): Promise { + async recvChatEvent(wait: number = 500_000): Promise { return await core.chatRecvMsgWait(this.ctrl, wait) } @@ -386,8 +401,10 @@ export class ChatApi { switch (r.type) { case "userProfileUpdated": return r.updateSummary + case "userProfileNoChange": + return {updateSuccesses: 0, updateFailures: 0, changedContacts: []} default: - throw new ChatCommandError("error loading user address", r) + throw new ChatCommandError("error setting profile address", r) } } @@ -460,7 +477,7 @@ export class ChatApi { updatedMessage: {msgContent, mentions: {}}, }) ) - if (r.type === "chatItemUpdated") return r.chatItem.chatItem + if (r.type === "chatItemUpdated" || r.type === "chatItemNotChanged") return r.chatItem.chatItem throw new ChatCommandError("error updating chat item", r) } @@ -499,10 +516,10 @@ export class ChatApi { chatItemId: number, add: boolean, reaction: T.MsgReaction - ) { + ): Promise { const r = await this.sendChatCmd(CC.APIChatItemReaction.cmdString({chatRef: {chatType, chatId}, chatItemId, add, reaction})) - if (r.type === "chatItemsDeleted") return r.chatItemDeletions - throw new ChatCommandError("error setting item reaction", r) + if (r.type === "chatItemReaction") return r.reaction + throw new ChatCommandError("error setting item reaction", r) } /** @@ -512,6 +529,7 @@ export class ChatApi { async apiReceiveFile(fileId: number): Promise { const r = await this.sendChatCmd(CC.ReceiveFile.cmdString({fileId, userApprovedRelays: true})) if (r.type === "rcvFileAccepted") return r.chatItem + if (r.type === "rcvFileAcceptedSndCancelled") throw new ChatCommandError("file cancelled by sender", r) throw new ChatCommandError("error receiving file", r) } @@ -698,7 +716,7 @@ export class ChatApi { * Connect via prepared SimpleX link. The link can be 1-time invitation link, contact address or group link * Network usage: interactive. */ - async apiConnect(userId: number, incognito: boolean, preparedLink?: T.CreatedConnLink): Promise { + async apiConnect(userId: number, incognito: boolean, preparedLink: T.CreatedConnLink): Promise { const r = await this.sendChatCmd(CC.APIConnect.cmdString({userId, incognito, preparedLink_: preparedLink})) return this.handleConnectResult(r) } @@ -740,7 +758,7 @@ export class ChatApi { * Network usage: no. */ async apiRejectContactRequest(contactReqId: number): Promise { - const r = await this.sendChatCmd(CC.APIRejectContact.cmdString({contactReqId})) + const r = await this.sendChatCmd(CC.APIRejectContact.cmdString({contactReqId, notify: false})) if (r.type === "contactRequestRejected") return throw new ChatCommandError("error rejecting contact request", r) } diff --git a/packages/simplex-chat-nodejs/src/bot.ts b/packages/simplex-chat-nodejs/src/bot.ts index f6cb753d27..68e787ca3e 100644 --- a/packages/simplex-chat-nodejs/src/bot.ts +++ b/packages/simplex-chat-nodejs/src/bot.ts @@ -6,6 +6,7 @@ import equal = require("fast-deep-equal") export type BotDbOpts = api.DbConfig & { confirmMigrations?: core.MigrationConfirmation + queueSize?: number } export interface BotOptions { @@ -45,10 +46,9 @@ export interface BotConfig { } export async function run({profile, dbOpts, options = defaultOpts, onMessage, onCommands = {}, events = {}}: BotConfig): Promise<[api.ChatApi, T.User, T.UserContactLink | undefined]> { - const bot = await api.ChatApi.init(dbOpts, dbOpts.confirmMigrations || core.MigrationConfirmation.YesUp) + const bot = await api.ChatApi.init(dbOpts, dbOpts.confirmMigrations || core.MigrationConfirmation.YesUp, dbOpts.queueSize) const opts = fullOptions(options) - if (onMessage) subscribeMessages(bot, onMessage) - if (Object.keys(onCommands).length > 0) subscribeCommands(bot, onCommands) + if (onMessage || Object.keys(onCommands).length > 0) subscribeChatItems(bot, onMessage, onCommands) if (Object.keys(events).length > 0) bot.on(events) subscribeLogEvents(bot, opts) const botProfile = mkBotProfile(profile, opts) @@ -105,40 +105,27 @@ function mkBotProfile(profile: T.Profile, opts: Required): T.Profile return profile } -function subscribeMessages(bot: api.ChatApi, onMessage: (chatItem: T.AChatItem, content: T.MsgContent) => void | Promise) { +export function subscribeChatItems( + bot: api.ChatApi, + onMessage: ((chatItem: T.AChatItem, content: T.MsgContent) => void | Promise) | undefined, + commands: {[K in string]?: ((chatItem: T.AChatItem, command: util.BotCommand) => void | Promise)} +) { bot.on("newChatItems", async ({chatItems}) => { for (const ci of chatItems) { - if (ci.chatItem.content.type === "rcvMsgContent") { - try { - const p = onMessage(ci, ci.chatItem.content.msgContent) - if (p instanceof Promise) await p - } catch (e) { - console.log("message processing error", e) - } + const content = ci.chatItem.content + if (content.type !== "rcvMsgContent") continue + const cmd = util.ciBotCommand(ci.chatItem) + const cmdFunc = cmd && (commands[cmd.keyword] || commands[""]) + try { + if (cmd && cmdFunc) await cmdFunc(ci, cmd) + else if (onMessage) await onMessage(ci, content.msgContent) + } catch (e) { + console.log(cmd && cmdFunc ? `${cmd.keyword} command processing error` : "message processing error", e) } } }) } -function subscribeCommands(bot: api.ChatApi, commands: {[K in string]?: ((chatItem: T.AChatItem, command: util.BotCommand) => void | Promise)}) { - bot.on("newChatItems", async (evt) => { - for (const ci of evt.chatItems) { - const cmd = util.ciBotCommand(ci.chatItem) - if (cmd) { - const cmdFunc = commands[cmd.keyword] || commands[""] - if (cmdFunc) { - try { - const p = cmdFunc(ci, cmd) - if (p instanceof Promise) await p - } catch(e) { - console.log(`${cmd} command processing error`, e) - } - } - } - } - }) -} - function subscribeLogEvents(bot: api.ChatApi, opts: Required) { if (opts.logContacts) { bot.on({ diff --git a/packages/simplex-chat-nodejs/src/core.ts b/packages/simplex-chat-nodejs/src/core.ts index 949c2356af..49664fed71 100644 --- a/packages/simplex-chat-nodejs/src/core.ts +++ b/packages/simplex-chat-nodejs/src/core.ts @@ -3,9 +3,12 @@ import * as simplex from "./simplex" /** * Initialize chat controller + * @param {number} [queueSize] - Size of internal queues, the core default is used when omitted. */ -export async function chatMigrateInit(dbPath: string, dbKey: string, confirm: MigrationConfirmation): Promise { - const [ctrl, res] = await simplex.chat_migrate_init(dbPath, dbKey, confirm) +export async function chatMigrateInit(dbPath: string, dbKey: string, confirm: MigrationConfirmation, queueSize?: number): Promise { + const [ctrl, res] = queueSize === undefined + ? await simplex.chat_migrate_init(dbPath, dbKey, confirm) + : await simplex.chat_migrate_init_queue(dbPath, dbKey, confirm, queueSize) const json = JSON.parse(res) if (json.type === 'ok') return ctrl throw new ChatInitError("Database or migration error (see dbMigrationError property)", json as DBMigrationError) @@ -47,7 +50,7 @@ export async function chatRecvMsgWait(ctrl: bigint, wait: number): Promise { +export async function chatWriteFile(ctrl: bigint, path: string, buffer: ArrayBuffer | Uint8Array): Promise { const res = await simplex.chat_write_file(ctrl, path, buffer) return cryptoArgsResult(res) } @@ -55,7 +58,7 @@ export async function chatWriteFile(ctrl: bigint, path: string, buffer: ArrayBuf /** * Read buffer from encrypted file */ -export async function chatReadFile(path: string, {fileKey, fileNonce}: CryptoArgs): Promise { +export async function chatReadFile(path: string, {fileKey, fileNonce}: CryptoArgs): Promise { return await simplex.chat_read_file(path, fileKey, fileNonce) } @@ -121,12 +124,13 @@ export class ChatInitError extends Error { export type DBMigrationError = | DBMigrationError.InvalidConfirmation + | DBMigrationError.InvalidQueueSize | DBMigrationError.ErrorNotADatabase // invalid/corrupt database file or incorrect encryption key | DBMigrationError.ErrorMigration | DBMigrationError.ErrorSQL export namespace DBMigrationError { - export type Tag = "invalidConfirmation" | "errorNotADatabase" | "errorMigration" | "errorSQL" + export type Tag = "invalidConfirmation" | "invalidQueueSize" | "errorNotADatabase" | "errorMigration" | "errorSQL" interface Interface { type: Tag @@ -136,6 +140,10 @@ export namespace DBMigrationError { type: "invalidConfirmation" } + export interface InvalidQueueSize extends Interface { + type: "invalidQueueSize" + } + export interface ErrorNotADatabase extends Interface { type: "errorNotADatabase" dbFile: string @@ -168,7 +176,7 @@ export namespace MigrationError { export interface MEUpgrade extends Interface { type: "upgrade" - upMigrations: UpMigration + upMigrations: UpMigration[] } export interface MEDowngrade extends Interface { @@ -200,7 +208,7 @@ export namespace MTRError { export interface MTRENoDown extends Interface { type: "noDown" - upMigrations: UpMigration + dbMigrations: string[] } export interface MTREDifferent extends Interface { diff --git a/packages/simplex-chat-nodejs/src/download-libs.js b/packages/simplex-chat-nodejs/src/download-libs.js index 3f10023f88..cb92699e69 100644 --- a/packages/simplex-chat-nodejs/src/download-libs.js +++ b/packages/simplex-chat-nodejs/src/download-libs.js @@ -4,8 +4,12 @@ const path = require('path'); const extract = require('extract-zip'); const GITHUB_REPO = 'simplex-chat/simplex-chat-libs'; -const RELEASE_TAG = 'v7.1.0-beta.2'; +const RELEASE_TAG = 'v7.1.0-beta.4'; const BACKEND = (process.env.SIMPLEX_BACKEND || process.env.npm_config_simplex_backend || 'sqlite').toLowerCase(); +// A locally built libsimplex, copied into libs/ rather than loaded in place: +// the addon's RUNPATH is $ORIGIN/../../libs. +const LIBS_DIR_OVERRIDE = process.env.SIMPLEX_LIBS_DIR || process.env.npm_config_simplex_libs_dir; +const LIB_NAMES = ['libsimplex.so', 'libsimplex.dylib', 'libsimplex.dll']; if (BACKEND !== 'sqlite' && BACKEND !== 'postgres') { console.error(`✗ Invalid SIMPLEX_BACKEND: "${BACKEND}". Must be "sqlite" or "postgres".`); @@ -83,8 +87,31 @@ function isAlreadyInstalled() { } } +// No version check: the files behind SIMPLEX_LIBS_DIR change on every rebuild. +function installFromOverride() { + if (!fs.existsSync(LIBS_DIR_OVERRIDE)) { + throw new Error(`SIMPLEX_LIBS_DIR does not exist: ${LIBS_DIR_OVERRIDE}`); + } + const lib = LIB_NAMES.find((name) => fs.existsSync(path.join(LIBS_DIR_OVERRIDE, name))); + if (!lib) { + throw new Error(`No ${LIB_NAMES.join(' / ')} in SIMPLEX_LIBS_DIR: ${LIBS_DIR_OVERRIDE}`); + } + console.log(`Using libraries from SIMPLEX_LIBS_DIR: ${LIBS_DIR_OVERRIDE}`); + cleanLibsDirectory(); + copyDirSync(LIBS_DIR_OVERRIDE, LIBS_DIR); + // Not a release tag: a later install without the variable sees a mismatch + // and replaces these files with the released ones. + fs.writeFileSync(INSTALLED_FILE, `${LIBS_DIR_OVERRIDE}:${BACKEND}`, 'utf-8'); + console.log(`✓ Installed ${lib} and its runtime libraries from ${LIBS_DIR_OVERRIDE}`); +} + async function install() { try { + if (LIBS_DIR_OVERRIDE) { + installFromOverride(); + return; + } + // Check if already installed if (isAlreadyInstalled()) { return; diff --git a/packages/simplex-chat-nodejs/src/simplex.d.ts b/packages/simplex-chat-nodejs/src/simplex.d.ts index 10c2f6608a..1e0ca825a6 100644 --- a/packages/simplex-chat-nodejs/src/simplex.d.ts +++ b/packages/simplex-chat-nodejs/src/simplex.d.ts @@ -1,10 +1,11 @@ // These functions are defined in CPP add-on ../cpp/simplex.cc export function chat_migrate_init(dbPath: string, dbKey: string, confirm: string): Promise<[bigint, string]> +export function chat_migrate_init_queue(dbPath: string, dbKey: string, confirm: string, queueSize: number): Promise<[bigint, string]> export function chat_close_store(ctrl: bigint): Promise export function chat_send_cmd(ctrl: bigint, cmd: string): Promise export function chat_recv_msg_wait(ctrl: bigint, wait: number): Promise -export function chat_write_file(ctrl: bigint, path: string, buffer: ArrayBuffer): Promise -export function chat_read_file(path: string, key: string, nonce: string): Promise +export function chat_write_file(ctrl: bigint, path: string, buffer: ArrayBuffer | Uint8Array): Promise +export function chat_read_file(path: string, key: string, nonce: string): Promise export function chat_encrypt_file(ctrl: bigint, fromPath: string, toPath: string): Promise export function chat_decrypt_file(fromPath: string, key: string, nonce: string, toPath: string): Promise diff --git a/packages/simplex-chat-nodejs/src/util.ts b/packages/simplex-chat-nodejs/src/util.ts index f7365e731c..dffb0ce1bc 100644 --- a/packages/simplex-chat-nodejs/src/util.ts +++ b/packages/simplex-chat-nodejs/src/util.ts @@ -78,7 +78,7 @@ export interface BotCommand { export function ciBotCommand(chatItem: T.ChatItem): BotCommand | undefined { const msg = ciContentText(chatItem)?.trim() if (msg) { - const r = msg.match(/^\/([^\s]+)(.*)/) + const r = msg.match(/^\/([^\s]+)([\s\S]*)/) if (r && r.length >= 3) { return {keyword: r[1], params: r[2].trim()} } diff --git a/packages/simplex-chat-nodejs/tests/api.test.ts b/packages/simplex-chat-nodejs/tests/api.test.ts index 99d511371c..e3ddca2440 100644 --- a/packages/simplex-chat-nodejs/tests/api.test.ts +++ b/packages/simplex-chat-nodejs/tests/api.test.ts @@ -58,8 +58,8 @@ describe("API tests (use preset servers)", () => { await bob.stopChat() await alice.close() await bob.close() - await expect(alice.startChat).rejects.toThrow() - await expect(bob.startChat).rejects.toThrow() + await expect(alice.startChat()).rejects.toThrow("chat api controller not initialized") + await expect(bob.startChat()).rejects.toThrow("chat api controller not initialized") expect(servers.length).toBe(2) expect(servers[0] !== servers[1]).toBe(true) expect(eventCount > 0).toBe(true) diff --git a/packages/simplex-chat-nodejs/tests/api.unit.test.ts b/packages/simplex-chat-nodejs/tests/api.unit.test.ts new file mode 100644 index 0000000000..c5a4367b11 --- /dev/null +++ b/packages/simplex-chat-nodejs/tests/api.unit.test.ts @@ -0,0 +1,102 @@ +import {ChatResponse, T} from "@simplex-chat/types" +import * as api from "../src/api" +import * as core from "../src/core" + +const user = {userId: 1} as T.User + +async function chatWithResponse(response: object): Promise { + jest.spyOn(core, "chatMigrateInit").mockResolvedValue(BigInt(1)) + jest.spyOn(core, "chatSendCmd").mockResolvedValue(response as ChatResponse) + return api.ChatApi.init({type: "sqlite", filePrefix: "unused"}) +} + +afterEach(() => jest.restoreAllMocks()) + +describe("documented success responses", () => { + it("apiChatItemReaction returns the reaction", async () => { + const reaction = {chatReaction: {reaction: {type: "emoji", emoji: "👍"}}} + const chat = await chatWithResponse({type: "chatItemReaction", user, added: true, reaction}) + await expect(chat.apiChatItemReaction(T.ChatType.Direct, 1, 2, true, {type: "emoji", emoji: "👍"})).resolves.toEqual(reaction) + }) + + it("apiUpdateChatItem accepts chatItemNotChanged", async () => { + const chatItem = {meta: {itemId: 2}} + const chat = await chatWithResponse({type: "chatItemNotChanged", user, chatItem: {chatItem}}) + await expect(chat.apiUpdateChatItem(T.ChatType.Direct, 1, 2, {type: "text", text: "same"}, false)).resolves.toEqual(chatItem) + }) + + it("apiSetProfileAddress accepts userProfileNoChange", async () => { + const chat = await chatWithResponse({type: "userProfileNoChange", user}) + await expect(chat.apiSetProfileAddress(1, true)).resolves.toEqual({updateSuccesses: 0, updateFailures: 0, changedContacts: []}) + }) + + it("apiReceiveFile reports a file cancelled by sender", async () => { + const chat = await chatWithResponse({type: "rcvFileAcceptedSndCancelled", user, rcvFileTransfer: {}}) + await expect(chat.apiReceiveFile(3)).rejects.toThrow("file cancelled by sender") + }) +}) + +describe("startChat lifecycle", () => { + function chatWithResponses(...responses: object[]): Promise { + jest.spyOn(core, "chatMigrateInit").mockResolvedValue(BigInt(1)) + jest.spyOn(core, "chatRecvMsgWait").mockImplementation(() => new Promise(resolve => setTimeout(() => resolve(undefined), 10))) + const send = jest.spyOn(core, "chatSendCmd") + for (const r of responses) send.mockResolvedValueOnce(r as ChatResponse) + return api.ChatApi.init({type: "sqlite", filePrefix: "unused"}) + } + + it("rejects a second start", async () => { + const chat = await chatWithResponses({type: "chatStarted"}, {type: "chatStopped"}) + await chat.startChat() + await expect(chat.startChat()).rejects.toThrow("chat already started") + await chat.stopChat() + }) + + it("stops the events loop when start fails", async () => { + const chat = await chatWithResponses({type: "chatCmdError"}) + await expect(chat.startChat()).rejects.toThrow("error starting chat") + expect(chat.started).toBe(false) + }) + + it("rejects start after close", async () => { + const chat = await chatWithResponses({type: "chatStopped"}) + jest.spyOn(core, "chatCloseStore").mockResolvedValue() + await chat.close() + await expect(chat.startChat()).rejects.toThrow("chat api controller not initialized") + }) + + it("stops the chat before closing the store", async () => { + const chat = await chatWithResponses() + const calls: string[] = [] + jest.mocked(core.chatSendCmd).mockImplementation(async (_ctrl, cmd) => { + calls.push(`send ${cmd}`) + return {type: "chatStopped"} as ChatResponse + }) + jest.spyOn(core, "chatCloseStore").mockImplementation(async () => { calls.push("closeStore") }) + await chat.close() + expect(calls).toEqual(["send /_stop", "closeStore"]) + expect(chat.initialized).toBe(false) + }) + + it("does not close the store when stopping fails", async () => { + const chat = await chatWithResponses({type: "chatCmdError"}) + const closeStore = jest.spyOn(core, "chatCloseStore").mockResolvedValue() + await expect(chat.close()).rejects.toThrow("error stopping chat") + expect(closeStore).not.toHaveBeenCalled() + expect(chat.initialized).toBe(true) + }) + + it("reports stop failures as stop errors", async () => { + const chat = await chatWithResponses({type: "chatStarted"}, {type: "chatCmdError"}, {type: "chatStopped"}) + await chat.startChat() + await expect(chat.stopChat()).rejects.toThrow("error stopping chat") + expect(chat.started).toBe(true) + await chat.stopChat() + }) + + it("receives with a 500 ms wait", async () => { + const chat = await chatWithResponses() + await chat.recvChatEvent() + expect(core.chatRecvMsgWait).toHaveBeenCalledWith(BigInt(1), 500_000) + }) +}) diff --git a/packages/simplex-chat-nodejs/tests/bot.unit.test.ts b/packages/simplex-chat-nodejs/tests/bot.unit.test.ts new file mode 100644 index 0000000000..39f9358996 --- /dev/null +++ b/packages/simplex-chat-nodejs/tests/bot.unit.test.ts @@ -0,0 +1,50 @@ +import {ChatEvent, T} from "@simplex-chat/types" +import * as api from "../src/api" +import {subscribeChatItems} from "../src/bot" + +type Handler = (evt: ChatEvent) => Promise + +function fakeBot(): {bot: api.ChatApi, deliver: (items: T.AChatItem[]) => Promise} { + let handler: Handler | undefined + const bot = {on: (_event: string, h: Handler) => { handler = h }} as unknown as api.ChatApi + const deliver = (chatItems: T.AChatItem[]) => handler!({type: "newChatItems", chatItems} as unknown as ChatEvent) + return {bot, deliver} +} + +function item(type: "rcvMsgContent" | "sndMsgContent", text: string): T.AChatItem { + return {chatItem: {content: {type, msgContent: {type: "text", text}}}} as unknown as T.AChatItem +} + +describe("subscribeChatItems", () => { + it("sends a known command only to its handler", async () => { + const {bot, deliver} = fakeBot() + const calls: string[] = [] + subscribeChatItems(bot, async () => { calls.push("message") }, {help: async () => { calls.push("help") }}) + await deliver([item("rcvMsgContent", "/help")]) + expect(calls).toEqual(["help"]) + }) + + it("sends an unknown command to the fallback handler", async () => { + const {bot, deliver} = fakeBot() + const calls: string[] = [] + subscribeChatItems(bot, async () => { calls.push("message") }, {"": async (_ci, cmd) => { calls.push(`fallback:${cmd.keyword}`) }}) + await deliver([item("rcvMsgContent", "/unknown")]) + expect(calls).toEqual(["fallback:unknown"]) + }) + + it("sends unhandled commands and text to onMessage", async () => { + const {bot, deliver} = fakeBot() + const calls: string[] = [] + subscribeChatItems(bot, async (_ci, content) => { calls.push(`message:${(content as T.MsgContent & {text: string}).text}`) }, {help: async () => { calls.push("help") }}) + await deliver([item("rcvMsgContent", "/unknown"), item("rcvMsgContent", "hello")]) + expect(calls).toEqual(["message:/unknown", "message:hello"]) + }) + + it("ignores sent items", async () => { + const {bot, deliver} = fakeBot() + const calls: string[] = [] + subscribeChatItems(bot, async () => { calls.push("message") }, {help: async () => { calls.push("help") }}) + await deliver([item("sndMsgContent", "/help"), item("sndMsgContent", "hello")]) + expect(calls).toEqual([]) + }) +}) diff --git a/packages/simplex-chat-nodejs/tests/commands.test.ts b/packages/simplex-chat-nodejs/tests/commands.test.ts new file mode 100644 index 0000000000..61ce43bd63 --- /dev/null +++ b/packages/simplex-chat-nodejs/tests/commands.test.ts @@ -0,0 +1,13 @@ +import {CC} from "@simplex-chat/types" + +describe("APIConnect.cmdString", () => { + const preparedLink_ = {connFullLink: "L"} + + it("renders incognito=on", () => { + expect(CC.APIConnect.cmdString({userId: 1, incognito: true, preparedLink_})).toBe("/_connect 1 incognito=on L") + }) + + it("omits incognito when off", () => { + expect(CC.APIConnect.cmdString({userId: 1, incognito: false, preparedLink_})).toBe("/_connect 1 L") + }) +}) diff --git a/packages/simplex-chat-nodejs/tests/core.test.ts b/packages/simplex-chat-nodejs/tests/core.test.ts index 141f35746d..8eb106a5b5 100644 --- a/packages/simplex-chat-nodejs/tests/core.test.ts +++ b/packages/simplex-chat-nodejs/tests/core.test.ts @@ -1,3 +1,4 @@ +import {execFile, spawnSync} from "child_process"; import * as fs from "fs"; import * as path from "path"; import {core} from "../src/index"; @@ -9,17 +10,34 @@ describe("Core tests", () => { beforeEach(() => fs.mkdirSync(tmpDir, {recursive: true})); afterEach(() => fs.rmSync(tmpDir, {recursive: true, force: true})); + async function stopAndClose(ctrl: bigint): Promise { + await expect(core.chatSendCmd(ctrl, "/_stop")).resolves.toMatchObject({type: "chatStopped"}); + await core.chatCloseStore(ctrl); + } + it("should initialize chat controller", async () => { const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp); expect(typeof ctrl).toBe("bigint"); - await expect(core.chatCloseStore(ctrl)).resolves.toBe(undefined); + await expect(stopAndClose(ctrl)).resolves.toBe(undefined); await expect(core.chatMigrateInit(dbPath, "wrong_key", core.MigrationConfirmation.YesUp)).rejects.toMatchObject({ message: "Database or migration error (see dbMigrationError property)", dbMigrationError: expect.objectContaining({type: "errorNotADatabase"}) }); }); - + + it("should initialize chat controller with queue size", async () => { + const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp, 65536); + expect(typeof ctrl).toBe("bigint"); + await expect(stopAndClose(ctrl)).resolves.toBe(undefined); + + await expect(core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp, 0)).rejects.toMatchObject({ + dbMigrationError: {type: "invalidQueueSize"} + }); + await expect(core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp, 2 ** 31)).rejects.toThrow("Expected 32-bit integer queue size"); + await expect(core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp, 1.5)).rejects.toThrow("Expected 32-bit integer queue size"); + }); + it("should send command and receive event", async () => { const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp); @@ -41,7 +59,7 @@ describe("Core tests", () => { chatError: expect.objectContaining({type: "error"}) }); - await core.chatCloseStore(ctrl); + await stopAndClose(ctrl); }); it("should write/read encrypted file from/to buffer", async () => { @@ -59,7 +77,25 @@ describe("Core tests", () => { await expect(core.chatWriteFile(ctrl, path.join(tmpDir, "unknown", "unknown.txt"), buffer)).rejects.toThrow(); await expect(core.chatReadFile(path.join(tmpDir, "unknown.txt"), cryptoArgs)).rejects.toThrow(); - await core.chatCloseStore(ctrl); + await stopAndClose(ctrl); + }); + + it("should write the view of a Uint8Array and read an empty file", async () => { + const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp); + + const viewPath = path.join(tmpDir, "view.txt"); + const viewArgs = await core.chatWriteFile(ctrl, viewPath, Buffer.from("xxabcxx").subarray(2, 5)); + const view = await core.chatReadFile(viewPath, viewArgs); + expect(Buffer.isBuffer(view)).toBe(true); + expect(view.toString()).toBe("abc"); + + const emptyPath = path.join(tmpDir, "empty.txt"); + const emptyArgs = await core.chatWriteFile(ctrl, emptyPath, new Uint8Array(0)); + const empty = await core.chatReadFile(emptyPath, emptyArgs); + expect(Buffer.isBuffer(empty)).toBe(true); + expect(empty.length).toBe(0); + + await stopAndClose(ctrl); }); it("should encrypt/decrypt file", async () => { @@ -80,6 +116,118 @@ describe("Core tests", () => { await expect(core.chatEncryptFile(ctrl, path.join(tmpDir, "unknown.txt"), encryptedPath)).rejects.toThrow(); await expect(core.chatDecryptFile(path.join(tmpDir, "unknown.txt"), cryptoArgs, decryptedPath)).rejects.toThrow(); - await core.chatCloseStore(ctrl); + await stopAndClose(ctrl); }); + + it("should not block the libuv pool while receiving", async () => { + const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp); + const receives = [1, 2, 3, 4].map(() => core.chatRecvMsgWait(ctrl, 2_000_000)); + const start = Date.now(); + await fs.promises.stat(tmpDir); + expect(Date.now() - start).toBeLessThan(200); + await Promise.all(receives); + await stopAndClose(ctrl); + }, 10000); + + const itOnLinux = process.platform === "linux" ? it : it.skip; + + // Thread count is read from /proc/self/task, which only exists on Linux. + itOnLinux("should keep the thread count constant while receiving", async () => { + const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp); + for (let i = 0; i < 10; i++) await core.chatRecvMsgWait(ctrl, 1); + const threadCount = () => fs.readdirSync("/proc/self/task").length; + const warmCount = threadCount(); + const counts = new Set(); + for (let i = 0; i < 200; i++) { + await core.chatRecvMsgWait(ctrl, 1); + counts.add(threadCount()); + } + expect({warmCount, counts: [...counts]}).toEqual({warmCount, counts: [warmCount]}); + await stopAndClose(ctrl); + }, 30000); + + it("should receive on two controllers concurrently", async () => { + const ctrlA = await core.chatMigrateInit(path.join(tmpDir, "simplex_a"), "key", core.MigrationConfirmation.YesUp); + const ctrlB = await core.chatMigrateInit(path.join(tmpDir, "simplex_b"), "key", core.MigrationConfirmation.YesUp); + const start = Date.now(); + await expect(Promise.all([core.chatRecvMsgWait(ctrlA, 1_000_000), core.chatRecvMsgWait(ctrlB, 1_000_000)])) + .resolves.toEqual([undefined, undefined]); + const elapsed = Date.now() - start; + expect(elapsed).toBeGreaterThanOrEqual(900); + expect(elapsed).toBeLessThan(1800); + await stopAndClose(ctrlA); + await stopAndClose(ctrlB); + }, 10000); + + it("should receive events of one controller in order", async () => { + const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp); + for (const action of ["first", "second"]) { + await expect(core.chatSendCmd(ctrl, `/debug event {"type": "timedAction", "action": "${action}", "durationMilliseconds": 1}`)) + .resolves.toMatchObject({type: "cmdOk"}); + } + const events = await Promise.all([core.chatRecvMsgWait(ctrl, 500_000), core.chatRecvMsgWait(ctrl, 500_000)]); + expect(events).toMatchObject([ + {type: "timedAction", action: "first"}, + {type: "timedAction", action: "second"} + ]); + await stopAndClose(ctrl); + }, 10000); + + it("should close the store while a receive is in flight", async () => { + const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp); + // starts the receiver thread, so the next request only has to wake it + await core.chatRecvMsgWait(ctrl, 1); + const settle = (p: Promise) => p.then((event) => ({event}), (e: Error) => ({error: e.message})); + const receives = Promise.all([settle(core.chatRecvMsgWait(ctrl, 3_000_000)), settle(core.chatRecvMsgWait(ctrl, 3_000_000))]); + // the thread enters the first receive within this margin even under load, so the second one is still queued at close + await new Promise((resolve) => setTimeout(resolve, 500)); + await expect(core.chatSendCmd(ctrl, "/_stop")).resolves.toMatchObject({type: "chatStopped"}); + let closed = false; + const close = core.chatCloseStore(ctrl).then(() => { closed = true; }); + const timerStart = Date.now(); + const timerDelay = await new Promise((resolve) => setTimeout(() => resolve(Date.now() - timerStart), 10)); + expect({closed, timerDelayBelow100ms: timerDelay < 100}).toEqual({closed: false, timerDelayBelow100ms: true}); + await close; + expect(await receives).toEqual([{event: undefined}, {error: "chat receiver stopped"}]); + }, 10000); + + it("should let the process exit while a receiver is idle", () => { + const childDbPath = path.resolve(tmpDir, "simplex_child"); + const script = ` + const simplex = require("./build/Release/simplex.node"); + simplex.chat_migrate_init(${JSON.stringify(childDbPath)}, "key", "yesUp") + .then(([ctrl]) => simplex.chat_recv_msg_wait(ctrl, 1)) + .then((res) => console.log("received " + JSON.stringify(res))); + `; + const child = spawnSync(process.execPath, ["-e", script], {cwd: path.join(__dirname, ".."), timeout: 10000, encoding: "utf8"}); + if (child.status !== 0 || child.signal !== null) console.log("child stderr:", child.stderr); + expect({status: child.status, signal: child.signal, stdout: child.stdout.trim()}) + .toEqual({status: 0, signal: null, stdout: 'received ""'}); + }, 15000); + + it("should not crash when closing stopped controllers repeatedly", async () => { + const script = ` + const fs = require("fs"), path = require("path"); + const simplex = require("./build/Release/simplex.node"); + (async () => { + for (let i = 0; i < 40; i++) { + const dir = fs.mkdtempSync(path.join(${JSON.stringify(path.resolve(tmpDir))}, "close-")); + const [ctrl] = await simplex.chat_migrate_init(path.join(dir, "simplex"), "key", "yesUp"); + await simplex.chat_send_cmd(ctrl, "/v"); + await simplex.chat_send_cmd(ctrl, "/_stop"); + const res = await simplex.chat_close_store(ctrl); + fs.rmSync(dir, {recursive: true, force: true}); + if (res !== "") throw new Error("close failed: " + res); + } + })(); + `; + const runChild = () => new Promise<{code: number | null, signal: NodeJS.Signals | null, stderr: string}>((resolve) => { + const child = execFile(process.execPath, ["-e", script], {cwd: path.join(__dirname, ".."), timeout: 150000}, (_error, _stdout, stderr) => + resolve({code: child.exitCode, signal: child.signalCode, stderr})); + }); + const childCount = 3; + const results = await Promise.all(Array.from({length: childCount}, runChild)); + for (const r of results) if (r.code !== 0 || r.signal !== null) console.log("child stderr:", r.stderr); + expect(results.map(({code, signal}) => ({code, signal}))).toEqual(Array(childCount).fill({code: 0, signal: null})); + }, 180000); }); diff --git a/packages/simplex-chat-nodejs/tests/util.test.ts b/packages/simplex-chat-nodejs/tests/util.test.ts index 4fe3140edc..29224cf1cc 100644 --- a/packages/simplex-chat-nodejs/tests/util.test.ts +++ b/packages/simplex-chat-nodejs/tests/util.test.ts @@ -34,4 +34,8 @@ describe("ciBotCommand", () => { const ci = {content: {type: "rcvDeleted"}} as T.ChatItem expect(ciBotCommand(ci)).toBeUndefined() }) + + it("keeps multi-line params", () => { + expect(ciBotCommand(rcvText("/review line1\nline2"))).toEqual({keyword: "review", params: "line1\nline2"}) + }) }) diff --git a/packages/simplex-chat-python/src/simplex_chat/_native.py b/packages/simplex-chat-python/src/simplex_chat/_native.py index 4c408479bb..009ff9a1e0 100644 --- a/packages/simplex-chat-python/src/simplex_chat/_native.py +++ b/packages/simplex-chat-python/src/simplex_chat/_native.py @@ -16,7 +16,7 @@ import urllib.request import zipfile from ctypes import POINTER, c_char_p, c_int, c_uint8, c_void_p from pathlib import Path -from typing import Literal +from typing import Any, Literal from ._version import LIBS_VERSION @@ -166,7 +166,8 @@ _backend: Backend | None = None def _load_libc() -> ctypes.CDLL: if sys.platform == "win32": - return ctypes.CDLL("msvcrt") + # libsimplex.dll allocates results with UCRT malloc; msvcrt free would corrupt the heap. + return ctypes.CDLL("ucrtbase") return ctypes.CDLL(None) # libc on POSIX is the process's own symbol table @@ -255,3 +256,19 @@ def lib() -> ctypes.CDLL: if _lib is None: raise RuntimeError("lib_for() must be called before lib()") return _lib + + +QUEUE_SIZE_UNSUPPORTED = ( + "loaded libsimplex does not export chat_migrate_init_queue; queue size needs a newer libsimplex" +) + + +def migrate_init_queue() -> Any: + """`chat_migrate_init_queue`, which older libsimplex releases do not export.""" + try: + fn = lib().chat_migrate_init_queue + except AttributeError as e: + raise RuntimeError(QUEUE_SIZE_UNSUPPORTED) from e + fn.argtypes = [c_char_p, c_char_p, c_char_p, c_int, POINTER(c_void_p)] + fn.restype = c_void_p + return fn diff --git a/packages/simplex-chat-python/src/simplex_chat/_version.py b/packages/simplex-chat-python/src/simplex_chat/_version.py index a335c71d03..9c60a54bb1 100644 --- a/packages/simplex-chat-python/src/simplex_chat/_version.py +++ b/packages/simplex-chat-python/src/simplex_chat/_version.py @@ -5,5 +5,5 @@ Bump both together for normal releases. For wrapper-only fixes use a PEP 440 post-release: __version__ = "6.5.2.post1", LIBS_VERSION unchanged. """ -__version__ = "7.1.0b2" # PEP 440 — read by hatchling for wheel metadata -LIBS_VERSION = "7.1.0-beta.2" # simplex-chat-libs release tag (no 'v' prefix) +__version__ = "7.1.0b4" # PEP 440 — read by hatchling for wheel metadata +LIBS_VERSION = "7.1.0-beta.4" # simplex-chat-libs release tag (no 'v' prefix) diff --git a/packages/simplex-chat-python/src/simplex_chat/api.py b/packages/simplex-chat-python/src/simplex_chat/api.py index e3d36c45df..876230ef39 100644 --- a/packages/simplex-chat-python/src/simplex_chat/api.py +++ b/packages/simplex-chat-python/src/simplex_chat/api.py @@ -2,7 +2,9 @@ from __future__ import annotations +import asyncio import json +from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass from typing import Any, Literal @@ -65,17 +67,20 @@ class ChatApi: def __init__(self, ctrl: int): self._ctrl: int | None = ctrl self._started = False + self._recv_executor: ThreadPoolExecutor | None = None @classmethod async def init( cls, db: Db, confirm: MigrationConfirmation = MigrationConfirmation.YES_UP, + queue_size: int | None = None, ) -> ChatApi: path_or_prefix, key_or_conn, backend = _db_to_migrate_args(db) # Trigger lazy lib load with the right backend BEFORE chat_migrate_init. - _native.lib_for(backend) - ctrl = await core.chat_migrate_init(path_or_prefix, key_or_conn, confirm) + # It may download ~100 MB, so it must not block the event loop. + await asyncio.to_thread(_native.lib_for, backend) + ctrl = await core.chat_migrate_init(path_or_prefix, key_or_conn, confirm, queue_size) return cls(ctrl) @property @@ -114,6 +119,14 @@ class ChatApi: self._started = False async def close(self) -> None: + """Stop the chat and close its store; the store stays open if stopping fails.""" + # a running controller keeps using the database connections that closing frees + await self.stop_chat() + if self._recv_executor is not None: + # Waits for a receive already in flight (up to wait_us) so the store + # never closes underneath one; run off-loop since shutdown blocks. + await asyncio.to_thread(self._recv_executor.shutdown, wait=True) + self._recv_executor = None await core.chat_close_store(self.ctrl) self._ctrl = None self._started = False @@ -122,7 +135,14 @@ class ChatApi: return await core.chat_send_cmd(self.ctrl, cmd) async def recv_chat_event(self, wait_us: int = 500_000) -> CEvt.ChatEvent | None: - return await core.chat_recv_msg_wait(self.ctrl, wait_us) + ctrl = self.ctrl # raises before touching the executor if close() was called + if self._recv_executor is None: + # A receive blocks for up to wait_us almost back to back, so it would + # otherwise pin one of the default executor's few worker threads. + self._recv_executor = ThreadPoolExecutor( + max_workers=1, thread_name_prefix="simplex-recv" + ) + return await core.chat_recv_msg_wait(ctrl, wait_us, self._recv_executor) # ------------------------------------------------------------------ # # Address commands @@ -158,6 +178,8 @@ class ChatApi: ) if r["type"] == "userProfileUpdated": return r["updateSummary"] + if r["type"] == "userProfileNoChange": + return {"updateSuccesses": 0, "updateFailures": 0, "changedContacts": []} raise ChatCommandError("error setting profile address", r) async def api_set_address_settings(self, user_id: int, settings: T.AddressSettings) -> None: @@ -236,6 +258,8 @@ class ChatApi: ) if r["type"] == "chatItemUpdated": return r["chatItem"]["chatItem"] + if r["type"] == "chatItemNotChanged": + return r["chatItem"]["chatItem"] raise ChatCommandError("error updating chat item", r) async def api_delete_chat_items( @@ -302,6 +326,8 @@ class ChatApi: ) if r["type"] == "rcvFileAccepted": return r["chatItem"] + if r["type"] == "rcvFileAcceptedSndCancelled": + raise ChatCommandError("file cancelled by sender", r) raise ChatCommandError("error receiving file", r) async def api_cancel_file(self, file_id: int) -> None: @@ -477,12 +503,13 @@ class ChatApi: self, user_id: int, incognito: bool, - prepared_link: T.CreatedConnLink | None = None, + prepared_link: T.CreatedConnLink, ) -> ConnReqType: - args: CC.APIConnect = {"userId": user_id, "incognito": incognito} - if prepared_link is not None: - args["preparedLink_"] = prepared_link - r = await self.send_chat_cmd(CC.APIConnect_cmd_string(args)) + r = await self.send_chat_cmd( + CC.APIConnect_cmd_string( + {"userId": user_id, "incognito": incognito, "preparedLink_": prepared_link} + ) + ) return self._handle_connect_result(r) async def api_connect_active_user(self, conn_link: str) -> ConnReqType: diff --git a/packages/simplex-chat-python/src/simplex_chat/bot.py b/packages/simplex-chat-python/src/simplex_chat/bot.py index b3e5b5ec03..2fa0ed39b4 100644 --- a/packages/simplex-chat-python/src/simplex_chat/bot.py +++ b/packages/simplex-chat-python/src/simplex_chat/bot.py @@ -90,6 +90,7 @@ class Bot(Client): welcome: str | T.MsgContent | None = None, commands: list[BotCommand] | None = None, confirm_migrations: MigrationConfirmation = MigrationConfirmation.YES_UP, + queue_size: int | None = None, create_address: bool = True, update_address: bool = True, update_profile: bool = True, @@ -103,6 +104,7 @@ class Bot(Client): profile=profile, db=db, confirm_migrations=confirm_migrations, + queue_size=queue_size, update_profile=update_profile, log_contacts=log_contacts, log_network=log_network, diff --git a/packages/simplex-chat-python/src/simplex_chat/client.py b/packages/simplex-chat-python/src/simplex_chat/client.py index 8ec955b54a..476dd70ac3 100644 --- a/packages/simplex-chat-python/src/simplex_chat/client.py +++ b/packages/simplex-chat-python/src/simplex_chat/client.py @@ -149,6 +149,7 @@ class Client: profile: Profile, db: Db, confirm_migrations: MigrationConfirmation = MigrationConfirmation.YES_UP, + queue_size: int | None = None, update_profile: bool = True, log_contacts: bool = False, log_network: bool = False, @@ -156,6 +157,7 @@ class Client: self._profile = profile self._db = db self._confirm_migrations = confirm_migrations + self._queue_size = queue_size self._update_profile = update_profile self._log_contacts = log_contacts self._log_network = log_network @@ -343,7 +345,7 @@ class Client: # do post-start setup (profile sync; Bot adds address sync). # `_stop_event` is never cleared: a stop requested during startup has # to survive into the receive loop. A stopped client is spent. - self._api = await ChatApi.init(self._db, self._confirm_migrations) + self._api = await ChatApi.init(self._db, self._confirm_migrations, self._queue_size) try: user = await self._ensure_active_user() await self._api.start_chat() @@ -362,11 +364,6 @@ class Client: api = self._api if api is None: return - if api.started: - try: - await api.stop_chat() - except Exception: - log.exception("stop_chat failed during init rollback") try: await api.close() except Exception: @@ -379,13 +376,16 @@ class Client: if api is None: return # Null out the reference up-front so the Client appears closed even - # if stop_chat / close raise — otherwise `client.api` would still + # if close raises — otherwise `client.api` would still # hand back a half-shutdown controller after `async with` exits. self._api = None try: - await api.stop_chat() - finally: await api.close() + except BaseException: + # A failed stop leaves the store open; keep it so the caller can retry. + if api.initialized: + self._api = api + raise async def _post_start(self, user: T.User) -> None: """Hook for subclasses to add work between `start_chat` and serving. @@ -617,7 +617,7 @@ class Client: # message resolve a future no one is waiting on. if waiter in waiters: waiters.remove(waiter) - if not waiters: + if not waiters and self._reply_waiters.get(contact_id) is waiters: self._reply_waiters.pop(contact_id, None) async def _receive_loop(self) -> None: diff --git a/packages/simplex-chat-python/src/simplex_chat/core.py b/packages/simplex-chat-python/src/simplex_chat/core.py index 4fc847f7de..adebde1b56 100644 --- a/packages/simplex-chat-python/src/simplex_chat/core.py +++ b/packages/simplex-chat-python/src/simplex_chat/core.py @@ -9,6 +9,7 @@ from __future__ import annotations import asyncio import ctypes import json +from concurrent.futures import Executor from enum import StrEnum from typing import Any, TypedDict @@ -102,7 +103,9 @@ async def chat_send_cmd(ctrl: int, cmd: str) -> CR.ChatResponse: raise ChatAPIError(f"invalid chat command result: {raw[:200]}") -async def chat_recv_msg_wait(ctrl: int, wait_us: int = 500_000) -> CEvt.ChatEvent | None: +async def chat_recv_msg_wait( + ctrl: int, wait_us: int = 500_000, executor: Executor | None = None +) -> CEvt.ChatEvent | None: def _call() -> str: # On timeout, the C side returns a non-NULL pointer to a single NUL byte # (see Mobile.hs `fromMaybe ""`), so `_read_and_free` returns "" — no @@ -110,7 +113,10 @@ async def chat_recv_msg_wait(ctrl: int, wait_us: int = 500_000) -> CEvt.ChatEven ptr = _native.lib().chat_recv_msg_wait(ctrl, wait_us) return _read_and_free(ptr) - raw = await asyncio.to_thread(_call) + if executor is None: + raw = await asyncio.to_thread(_call) + else: + raw = await asyncio.get_running_loop().run_in_executor(executor, _call) if not raw: return None parsed = json.loads(raw) @@ -122,17 +128,29 @@ async def chat_recv_msg_wait(ctrl: int, wait_us: int = 500_000) -> CEvt.ChatEven raise ChatAPIError(f"invalid chat event: {raw[:200]}") -async def chat_migrate_init(db_path: str, db_key: str, confirm: MigrationConfirmation) -> int: - """Initialize chat controller. Returns opaque ctrl pointer as Python int.""" +async def chat_migrate_init( + db_path: str, + db_key: str, + confirm: MigrationConfirmation, + queue_size: int | None = None, +) -> int: + """Initialize chat controller. Returns opaque ctrl pointer as Python int. + + `queue_size` is the size of internal queues; the core default is used when None. + """ + # ctypes silently wraps ints that do not fit C int. + if queue_size is not None and ctypes.c_int(queue_size).value != queue_size: + raise ValueError(f"queue_size {queue_size} does not fit C int") + + init_queue = _native.migrate_init_queue() if queue_size is not None else None def _call() -> tuple[int, str]: ctrl = ctypes.c_void_p() - ptr = _native.lib().chat_migrate_init( - db_path.encode("utf-8"), - db_key.encode("utf-8"), - confirm.encode("utf-8"), - ctypes.byref(ctrl), - ) + args = (db_path.encode("utf-8"), db_key.encode("utf-8"), confirm.encode("utf-8")) + if init_queue is None: + ptr = _native.lib().chat_migrate_init(*args, ctypes.byref(ctrl)) + else: + ptr = init_queue(*args, queue_size, ctypes.byref(ctrl)) return (ctrl.value or 0, _read_and_free(ptr)) ctrl_val, raw = await asyncio.to_thread(_call) diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_commands.py b/packages/simplex-chat-python/src/simplex_chat/types/_commands.py index 086b3cfd29..e6155c9ce7 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_commands.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_commands.py @@ -56,7 +56,7 @@ class APISetProfileAddress(TypedDict): def APISetProfileAddress_cmd_string(self: APISetProfileAddress) -> str: return '/_profile_address ' + str(self['userId']) + ' ' + ('on' if self['enable'] else 'off') -APISetProfileAddress_Response = CR.UserProfileUpdated | CR.ChatCmdError +APISetProfileAddress_Response = CR.UserProfileUpdated | CR.UserProfileNoChange | CR.ChatCmdError # Set bot address settings. @@ -156,7 +156,7 @@ class APIShareMyAddress(TypedDict): def APIShareMyAddress_cmd_string(self: APIShareMyAddress) -> str: - return '/_share address' + T.ChatRef_cmd_string(self['toSendRef']) + return '/_share address ' + T.ChatRef_cmd_string(self['toSendRef']) APIShareMyAddress_Response = CR.ChatMsgContent @@ -498,7 +498,7 @@ class APIConnect(TypedDict): def APIConnect_cmd_string(self: APIConnect) -> str: - return '/_connect ' + str(self['userId']) + ((' ' + T.CreatedConnLink_cmd_string(self.get('preparedLink_'))) if self.get('preparedLink_') is not None else '') + return '/_connect ' + str(self['userId']) + (' incognito=on' if self['incognito'] else '') + ((' ' + T.CreatedConnLink_cmd_string(self.get('preparedLink_'))) if self.get('preparedLink_') is not None else '') APIConnect_Response = CR.SentConfirmation | CR.ContactAlreadyExists | CR.SentInvitation | CR.ChatCmdError @@ -513,7 +513,16 @@ class Connect(TypedDict): def Connect_cmd_string(self: Connect) -> str: return '/connect' + ((' ' + self.get('connTarget_')) if self.get('connTarget_') is not None else '') -Connect_Response = CR.SentConfirmation | CR.ContactAlreadyExists | CR.SentInvitation | CR.ChatCmdError +Connect_Response = ( + CR.SentConfirmation + | CR.ContactAlreadyExists + | CR.SentInvitation + | CR.ConnectionPlan + | CR.SentInvitationToContact + | CR.StartedConnectionToContact + | CR.StartedConnectionToGroup + | CR.ChatCmdError +) # Accept contact request. @@ -575,12 +584,12 @@ APIListGroups_Response = CR.GroupsList | CR.ChatCmdError class APIGetChats(TypedDict): userId: int # int64 pendingConnections: bool - pagination: "T.PaginationByTime" + pagination: NotRequired["T.PaginationByTime"] query: "T.ChatListQuery" def APIGetChats_cmd_string(self: APIGetChats) -> str: - return '/_get chats ' + str(self['userId']) + (' pcc=on' if self['pendingConnections'] else '') + ' ' + T.PaginationByTime_cmd_string(self['pagination']) + ' ' + json.dumps(self['query']) + return '/_get chats ' + str(self['userId']) + (' pcc=on' if self['pendingConnections'] else '') + ((' ' + T.PaginationByTime_cmd_string(self.get('pagination'))) if self.get('pagination') is not None else '') + ' ' + json.dumps(self['query']) APIGetChats_Response = CR.ApiChats | CR.ChatCmdError @@ -787,3 +796,30 @@ def APIStopChat_cmd_string(self: APIStopChat) -> str: APIStopChat_Response = CR.ChatStopped + +# Remote control commands +# Allows a bot to accept an incoming remote control session from a SimpleX Desktop client, giving the desktop live access to the bot's SimpleX instance. + +# Connect to a remote controller using an OOB invitation link. +# Network usage: interactive. +class ConnectRemoteCtrl(TypedDict): + remoteInvitation: str + + +def ConnectRemoteCtrl_cmd_string(self: ConnectRemoteCtrl) -> str: + return '/crc ' + self['remoteInvitation'] + +ConnectRemoteCtrl_Response = CR.RemoteCtrlConnecting | CR.ChatCmdError + + +# Verify the remote controller session code to complete the connection. +# Network usage: no. +class VerifyRemoteCtrlSession(TypedDict): + sessionCode: str + + +def VerifyRemoteCtrlSession_cmd_string(self: VerifyRemoteCtrlSession) -> str: + return '/verify remote ctrl ' + self['sessionCode'] + +VerifyRemoteCtrlSession_Response = CR.RemoteCtrlConnected | CR.ChatCmdError + diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_events.py b/packages/simplex-chat-python/src/simplex_chat/types/_events.py index 58ead11837..bd73637ef8 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_events.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_events.py @@ -314,6 +314,16 @@ class ServiceReplySent(TypedDict): type: Literal["serviceReplySent"] connectionId: str +class RemoteCtrlSessionCode(TypedDict): + type: Literal["remoteCtrlSessionCode"] + remoteCtrl_: NotRequired["T.RemoteCtrlInfo"] + sessionCode: str + +class RemoteCtrlStopped(TypedDict): + type: Literal["remoteCtrlStopped"] + rcsState: "T.RemoteCtrlSessionState" + rcStopReason: "T.RemoteCtrlStopReason" + class MessageError(TypedDict): type: Literal["messageError"] user: "T.User" @@ -377,12 +387,14 @@ ChatEvent = ( | SubscriptionStatus | ServiceRequest | ServiceReplySent + | RemoteCtrlSessionCode + | RemoteCtrlStopped | MessageError | ChatError | ChatErrors ) -ChatEvent_Tag = Literal["contactConnected", "contactUpdated", "contactDeletedByContact", "receivedContactRequest", "newMemberContactReceivedInv", "contactSndReady", "newChatItems", "chatItemReaction", "chatItemsDeleted", "chatItemUpdated", "groupChatItemsDeleted", "chatItemsStatusesUpdated", "receivedGroupInvitation", "userJoinedGroup", "groupUpdated", "joinedGroupMember", "memberRole", "deletedMember", "leftMember", "deletedMemberUser", "groupDeleted", "connectedToGroupMember", "memberAcceptedByOther", "memberBlockedForAll", "groupMemberUpdated", "groupLinkDataUpdated", "groupRelayUpdated", "rcvFileDescrReady", "rcvFileComplete", "sndFileCompleteXFTP", "rcvFileStart", "rcvFileSndCancelled", "rcvFileAccepted", "rcvFileError", "rcvFileWarning", "sndFileError", "sndFileWarning", "acceptingContactRequest", "acceptingBusinessRequest", "contactConnecting", "businessLinkConnecting", "joinedGroupMemberConnecting", "groupLinkConnecting", "hostConnected", "hostDisconnected", "subscriptionStatus", "serviceRequest", "serviceReplySent", "messageError", "chatError", "chatErrors"] +ChatEvent_Tag = Literal["contactConnected", "contactUpdated", "contactDeletedByContact", "receivedContactRequest", "newMemberContactReceivedInv", "contactSndReady", "newChatItems", "chatItemReaction", "chatItemsDeleted", "chatItemUpdated", "groupChatItemsDeleted", "chatItemsStatusesUpdated", "receivedGroupInvitation", "userJoinedGroup", "groupUpdated", "joinedGroupMember", "memberRole", "deletedMember", "leftMember", "deletedMemberUser", "groupDeleted", "connectedToGroupMember", "memberAcceptedByOther", "memberBlockedForAll", "groupMemberUpdated", "groupLinkDataUpdated", "groupRelayUpdated", "rcvFileDescrReady", "rcvFileComplete", "sndFileCompleteXFTP", "rcvFileStart", "rcvFileSndCancelled", "rcvFileAccepted", "rcvFileError", "rcvFileWarning", "sndFileError", "sndFileWarning", "acceptingContactRequest", "acceptingBusinessRequest", "contactConnecting", "businessLinkConnecting", "joinedGroupMemberConnecting", "groupLinkConnecting", "hostConnected", "hostDisconnected", "subscriptionStatus", "serviceRequest", "serviceReplySent", "remoteCtrlSessionCode", "remoteCtrlStopped", "messageError", "chatError", "chatErrors"] class OnEventDecorator(Protocol): @@ -681,6 +693,18 @@ class OnEventDecorator(Protocol): Callable[["ServiceReplySent"], Awaitable[None]], ]: ... + @overload + def __call__(self, event: Literal["remoteCtrlSessionCode"], /) -> Callable[ + [Callable[["RemoteCtrlSessionCode"], Awaitable[None]]], + Callable[["RemoteCtrlSessionCode"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["remoteCtrlStopped"], /) -> Callable[ + [Callable[["RemoteCtrlStopped"], Awaitable[None]]], + Callable[["RemoteCtrlStopped"], Awaitable[None]], + ]: ... + @overload def __call__(self, event: Literal["messageError"], /) -> Callable[ [Callable[["MessageError"], Awaitable[None]]], diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_responses.py b/packages/simplex-chat-python/src/simplex_chat/types/_responses.py index 393c20f311..e3885fc16a 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_responses.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_responses.py @@ -103,6 +103,7 @@ class GroupDeletedUser(TypedDict): user: "T.User" groupInfo: "T.GroupInfo" msgSigned: bool + localDeletion: bool class GroupLink(TypedDict): type: Literal["groupLink"] @@ -239,6 +240,17 @@ class RcvFileCancelled(TypedDict): chatItem_: NotRequired["T.AChatItem"] rcvFileTransfer: "T.RcvFileTransfer" +class RemoteCtrlConnected(TypedDict): + type: Literal["remoteCtrlConnected"] + remoteCtrl: "T.RemoteCtrlInfo" + compression: bool + +class RemoteCtrlConnecting(TypedDict): + type: Literal["remoteCtrlConnecting"] + remoteCtrl_: NotRequired["T.RemoteCtrlInfo"] + ctrlAppInfo: "T.CtrlAppInfo" + appVersion: str + class SentConfirmation(TypedDict): type: Literal["sentConfirmation"] user: "T.User" @@ -258,6 +270,12 @@ class SentInvitation(TypedDict): connection: "T.PendingContactConnection" customUserProfile: NotRequired["T.Profile"] +class SentInvitationToContact(TypedDict): + type: Literal["sentInvitationToContact"] + user: "T.User" + contact: "T.Contact" + customUserProfile: NotRequired["T.Profile"] + class ServiceReplyAccepted(TypedDict): type: Literal["serviceReplyAccepted"] user: "T.User" @@ -270,6 +288,19 @@ class SndFileCancelled(TypedDict): fileTransferMeta: "T.FileTransferMeta" sndFileTransfers: list["T.SndFileTransfer"] +class StartedConnectionToContact(TypedDict): + type: Literal["startedConnectionToContact"] + user: "T.User" + contact: "T.Contact" + customUserProfile: NotRequired["T.Profile"] + +class StartedConnectionToGroup(TypedDict): + type: Literal["startedConnectionToGroup"] + user: "T.User" + groupInfo: "T.GroupInfo" + customUserProfile: NotRequired["T.Profile"] + relayResults: list["T.RelayConnectionResult"] + class UserAcceptedGroupSent(TypedDict): type: Literal["userAcceptedGroupSent"] user: "T.User" @@ -367,11 +398,16 @@ ChatResponse = ( | RcvFileAccepted | RcvFileAcceptedSndCancelled | RcvFileCancelled + | RemoteCtrlConnected + | RemoteCtrlConnecting | SentConfirmation | SentGroupInvitation | SentInvitation + | SentInvitationToContact | ServiceReplyAccepted | SndFileCancelled + | StartedConnectionToContact + | StartedConnectionToGroup | UserAcceptedGroupSent | UserContactLink | UserContactLinkCreated @@ -384,4 +420,4 @@ ChatResponse = ( | ApiChats ) -ChatResponse_Tag = Literal["acceptingContactRequest", "activeUser", "chatItemNotChanged", "chatItemReaction", "chatItemUpdated", "chatItemsDeleted", "chatMsgContent", "chatRunning", "chatStarted", "chatStopped", "cmdOk", "chatCmdError", "connectionPlan", "contactAlreadyExists", "contactConnectionDeleted", "contactDeleted", "contactPrefsUpdated", "contactRequestRejected", "contactsList", "groupDeletedUser", "groupLink", "groupLinkCreated", "groupLinkDeleted", "groupCreated", "publicGroupCreated", "publicGroupCreationFailed", "groupRelays", "groupRelaysAdded", "groupRelaysAddFailed", "relayGroupAllowed", "groupMembers", "groupUpdated", "groupsList", "groupDomainVerified", "invitation", "leftMemberUser", "memberAccepted", "membersBlockedForAllUser", "membersRoleUser", "newChatItems", "rcvFileAccepted", "rcvFileAcceptedSndCancelled", "rcvFileCancelled", "sentConfirmation", "sentGroupInvitation", "sentInvitation", "serviceReplyAccepted", "sndFileCancelled", "userAcceptedGroupSent", "userContactLink", "userContactLinkCreated", "userContactLinkDeleted", "userContactLinkUpdated", "userDeletedMembers", "userProfileUpdated", "userProfileNoChange", "usersList", "apiChats"] +ChatResponse_Tag = Literal["acceptingContactRequest", "activeUser", "chatItemNotChanged", "chatItemReaction", "chatItemUpdated", "chatItemsDeleted", "chatMsgContent", "chatRunning", "chatStarted", "chatStopped", "cmdOk", "chatCmdError", "connectionPlan", "contactAlreadyExists", "contactConnectionDeleted", "contactDeleted", "contactPrefsUpdated", "contactRequestRejected", "contactsList", "groupDeletedUser", "groupLink", "groupLinkCreated", "groupLinkDeleted", "groupCreated", "publicGroupCreated", "publicGroupCreationFailed", "groupRelays", "groupRelaysAdded", "groupRelaysAddFailed", "relayGroupAllowed", "groupMembers", "groupUpdated", "groupsList", "groupDomainVerified", "invitation", "leftMemberUser", "memberAccepted", "membersBlockedForAllUser", "membersRoleUser", "newChatItems", "rcvFileAccepted", "rcvFileAcceptedSndCancelled", "rcvFileCancelled", "remoteCtrlConnected", "remoteCtrlConnecting", "sentConfirmation", "sentGroupInvitation", "sentInvitation", "sentInvitationToContact", "serviceReplyAccepted", "sndFileCancelled", "startedConnectionToContact", "startedConnectionToGroup", "userAcceptedGroupSent", "userContactLink", "userContactLinkCreated", "userContactLinkDeleted", "userContactLinkUpdated", "userDeletedMembers", "userProfileUpdated", "userProfileNoChange", "usersList", "apiChats"] diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_types.py b/packages/simplex-chat-python/src/simplex_chat/types/_types.py index d845849c00..ab06a24f53 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -165,6 +165,12 @@ AgentServiceError = ( AgentServiceError_Tag = Literal["rejected", "timeout", "noPendingRequest", "notDRAddress", "badSignature"] +# Remote controller app version range (min and max as version strings). + +class AppVersionRange(TypedDict): + minVersion: str + maxVersion: str + class AutoAccept(TypedDict): acceptIncognito: bool @@ -179,6 +185,43 @@ class BadgeProof(TypedDict): proof: str badgeInfo: "BadgeInfo" +class BadgeRedeemError_invalidCode(TypedDict): + type: Literal["invalidCode"] + +class BadgeRedeemError_serviceNotConfigured(TypedDict): + type: Literal["serviceNotConfigured"] + +class BadgeRedeemError_badgeActive(TypedDict): + type: Literal["badgeActive"] + +class BadgeRedeemError_serviceError(TypedDict): + type: Literal["serviceError"] + serviceError: "BadgeServiceErrorCode" + +class BadgeRedeemError_invalidResponse(TypedDict): + type: Literal["invalidResponse"] + message: str + +class BadgeRedeemError_unknownKeyIndex(TypedDict): + type: Literal["unknownKeyIndex"] + +class BadgeRedeemError_credentialNotVerified(TypedDict): + type: Literal["credentialNotVerified"] + +BadgeRedeemError = ( + BadgeRedeemError_invalidCode + | BadgeRedeemError_serviceNotConfigured + | BadgeRedeemError_badgeActive + | BadgeRedeemError_serviceError + | BadgeRedeemError_invalidResponse + | BadgeRedeemError_unknownKeyIndex + | BadgeRedeemError_credentialNotVerified +) + +BadgeRedeemError_Tag = Literal["invalidCode", "serviceNotConfigured", "badgeActive", "serviceError", "invalidResponse", "unknownKeyIndex", "credentialNotVerified"] + +BadgeServiceErrorCode = Literal["bad_request", "unsupported_version", "unknown_purchase_key", "unknown_offer_id", "offer_disabled", "offer_mismatch", "product_unavailable", "payment_not_entitled", "payment_pending", "provider_unavailable", "rate_limited", "code_invalid", "code_used", "code_expired", "receipt_invalid", "receipt_used", "internal"] + BadgeStatus = Literal["active", "expired", "expiredOld", "failed", "unknownKey"] BadgeType = Literal["supporter", "legend", "investor"] @@ -482,6 +525,7 @@ class CIFile(TypedDict): fileStatus: "CIFileStatus" fileProtocol: "FileProtocol" fileExpires: NotRequired[str] # ISO-8601 timestamp + fileProhibited: NotRequired["FileProhibited"] class CIFileStatus_sndStored(TypedDict): type: Literal["sndStored"] @@ -839,10 +883,6 @@ class ChatErrorType_simplexDomainNotReady(TypedDict): simplexDomain: "SimplexDomain" simplexDomainError: "SimplexDomainError" -class ChatErrorType_wallet(TypedDict): - type: Literal["wallet"] - walletError: "WalletError" - class ChatErrorType_notResolvedLocally(TypedDict): type: Literal["notResolvedLocally"] @@ -1026,6 +1066,10 @@ class ChatErrorType_commandError(TypedDict): type: Literal["commandError"] message: str +class ChatErrorType_badgeRedeemError(TypedDict): + type: Literal["badgeRedeemError"] + badgeRedeemError: "BadgeRedeemError" + class ChatErrorType_agentCommandError(TypedDict): type: Literal["agentCommandError"] message: str @@ -1077,7 +1121,6 @@ ChatErrorType = ( | ChatErrorType_chatStoreChanged | ChatErrorType_invalidConnReq | ChatErrorType_simplexDomainNotReady - | ChatErrorType_wallet | ChatErrorType_notResolvedLocally | ChatErrorType_unsupportedConnReq | ChatErrorType_connReqMessageProhibited @@ -1125,6 +1168,7 @@ ChatErrorType = ( | ChatErrorType_agentVersion | ChatErrorType_agentNoSubResult | ChatErrorType_commandError + | ChatErrorType_badgeRedeemError | ChatErrorType_agentCommandError | ChatErrorType_invalidFileDescription | ChatErrorType_connectionIncognitoChangeProhibited @@ -1135,7 +1179,7 @@ ChatErrorType = ( | ChatErrorType_exception ) -ChatErrorType_Tag = Literal["noActiveUser", "noConnectionUser", "noSndFileUser", "noRcvFileUser", "userUnknown", "userExists", "chatRelayExists", "differentActiveUser", "cantDeleteActiveUser", "cantDeleteLastUser", "cantHideLastUser", "hiddenUserAlwaysMuted", "emptyUserPassword", "userAlreadyHidden", "userNotHidden", "invalidDisplayName", "chatNotStarted", "chatNotStopped", "chatStoreChanged", "invalidConnReq", "simplexDomainNotReady", "wallet", "notResolvedLocally", "unsupportedConnReq", "connReqMessageProhibited", "contactNotReady", "contactNotActive", "contactDisabled", "connectionDisabled", "groupUserRole", "groupMemberInitialRole", "contactIncognitoCantInvite", "groupIncognitoCantInvite", "groupContactRole", "groupDuplicateMember", "groupDuplicateMemberId", "groupNotJoined", "groupMemberNotActive", "cantBlockMemberForSelf", "groupMemberUserRemoved", "groupMemberNotFound", "groupCantResendInvitation", "groupInternal", "fileNotFound", "fileSize", "fileAlreadyReceiving", "fileCancelled", "fileCancel", "fileAlreadyExists", "fileWrite", "fileSend", "fileRcvChunk", "fileInternal", "fileImageType", "fileImageSize", "fileNotReceived", "fileNotApproved", "fallbackToSMPProhibited", "inlineFileProhibited", "invalidForward", "invalidChatItemUpdate", "invalidChatItemDelete", "hasCurrentCall", "noCurrentCall", "callContact", "directMessagesProhibited", "agentVersion", "agentNoSubResult", "commandError", "agentCommandError", "invalidFileDescription", "connectionIncognitoChangeProhibited", "connectionUserChangeProhibited", "peerChatVRangeIncompatible", "relayTestError", "internalError", "exception"] +ChatErrorType_Tag = Literal["noActiveUser", "noConnectionUser", "noSndFileUser", "noRcvFileUser", "userUnknown", "userExists", "chatRelayExists", "differentActiveUser", "cantDeleteActiveUser", "cantDeleteLastUser", "cantHideLastUser", "hiddenUserAlwaysMuted", "emptyUserPassword", "userAlreadyHidden", "userNotHidden", "invalidDisplayName", "chatNotStarted", "chatNotStopped", "chatStoreChanged", "invalidConnReq", "simplexDomainNotReady", "notResolvedLocally", "unsupportedConnReq", "connReqMessageProhibited", "contactNotReady", "contactNotActive", "contactDisabled", "connectionDisabled", "groupUserRole", "groupMemberInitialRole", "contactIncognitoCantInvite", "groupIncognitoCantInvite", "groupContactRole", "groupDuplicateMember", "groupDuplicateMemberId", "groupNotJoined", "groupMemberNotActive", "cantBlockMemberForSelf", "groupMemberUserRemoved", "groupMemberNotFound", "groupCantResendInvitation", "groupInternal", "fileNotFound", "fileSize", "fileAlreadyReceiving", "fileCancelled", "fileCancel", "fileAlreadyExists", "fileWrite", "fileSend", "fileRcvChunk", "fileInternal", "fileImageType", "fileImageSize", "fileNotReceived", "fileNotApproved", "fallbackToSMPProhibited", "inlineFileProhibited", "invalidForward", "invalidChatItemUpdate", "invalidChatItemDelete", "hasCurrentCall", "noCurrentCall", "callContact", "directMessagesProhibited", "agentVersion", "agentNoSubResult", "commandError", "badgeRedeemError", "agentCommandError", "invalidFileDescription", "connectionIncognitoChangeProhibited", "connectionUserChangeProhibited", "peerChatVRangeIncompatible", "relayTestError", "internalError", "exception"] ChatFeature = Literal["timedMessages", "fullDelete", "reactions", "voice", "files", "calls", "sessions"] @@ -1565,6 +1609,13 @@ class CryptoFileArgs(TypedDict): fileKey: str fileNonce: str +# Remote controller application info. + +class CtrlAppInfo(TypedDict): + appVersionRange: "AppVersionRange" + deviceName: str + compression: bool + class DroppedMsg(TypedDict): brokerTs: str # ISO-8601 timestamp attempts: int # int @@ -1717,6 +1768,11 @@ class FileInvitation(TypedDict): fileConnReq: NotRequired[str] fileInline: NotRequired["InlineFileMode"] fileDescr: NotRequired["FileDescr"] + fileBadge: NotRequired["BadgeProof"] + +class FileProhibited(TypedDict): + maxSize: int # int64 + badgeStatus: NotRequired["BadgeStatus"] FileProtocol = Literal["SMP", "XFTP", "LOCAL"] @@ -1899,14 +1955,8 @@ class GroupInfo(TypedDict): rosterVersion: NotRequired[int] # int64 membersRequireAttention: int # int viaGroupLinkUri: NotRequired[str] - groupKeys: NotRequired["GroupKeys"] groupDomainVerified: NotRequired[bool] -class GroupKeys(TypedDict): - publicGroupId: str - groupRootKey: "GroupRootKey" - memberPrivKey: str - class GroupLink(TypedDict): userContactLinkId: int # int64 connLinkContact: "CreatedConnLink" @@ -2041,18 +2091,6 @@ class GroupRelay(TypedDict): relayLink: NotRequired[str] relayCap: "RelayCapabilities" -class GroupRootKey_private(TypedDict): - type: Literal["private"] - rootPrivKey: str - -class GroupRootKey_public(TypedDict): - type: Literal["public"] - rootPubKey: str - -GroupRootKey = GroupRootKey_private | GroupRootKey_public - -GroupRootKey_Tag = Literal["private", "public"] - class GroupShortLinkData(TypedDict): groupProfile: "GroupProfile" publicGroupData: NotRequired["PublicGroupData"] @@ -2659,6 +2697,7 @@ class RcvFileTransfer(TypedDict): fileId: int # int64 xftpRcvFile: NotRequired["XFTPRcvFile"] fileInvitation: "FileInvitation" + fileProhibited: NotRequired["FileProhibited"] fileStatus: "RcvFileStatus" fileType: "FileType" rcvFileInline: NotRequired["InlineFileMode"] @@ -2772,6 +2811,10 @@ RcvMsgError_Tag = Literal["dropped", "parseError"] class RelayCapabilities(TypedDict): webDomain: NotRequired[str] +class RelayConnectionResult(TypedDict): + relayMember: "GroupMember" + relayError: NotRequired["ChatError"] + class RelayProfile(TypedDict): displayName: str fullName: str @@ -2780,6 +2823,62 @@ class RelayProfile(TypedDict): RelayStatus = Literal["new", "invited", "accepted", "acknowledgedRoster", "active", "inactive", "rejected"] +class RemoteCtrlInfo(TypedDict): + remoteCtrlId: int # int64 + ctrlDeviceName: str + sessionState: NotRequired["RemoteCtrlSessionState"] + +class RemoteCtrlSessionState_starting(TypedDict): + type: Literal["starting"] + +class RemoteCtrlSessionState_searching(TypedDict): + type: Literal["searching"] + +class RemoteCtrlSessionState_connecting(TypedDict): + type: Literal["connecting"] + +class RemoteCtrlSessionState_pendingConfirmation(TypedDict): + type: Literal["pendingConfirmation"] + sessionCode: str + +class RemoteCtrlSessionState_connected(TypedDict): + type: Literal["connected"] + sessionCode: str + +RemoteCtrlSessionState = ( + RemoteCtrlSessionState_starting + | RemoteCtrlSessionState_searching + | RemoteCtrlSessionState_connecting + | RemoteCtrlSessionState_pendingConfirmation + | RemoteCtrlSessionState_connected +) + +RemoteCtrlSessionState_Tag = Literal["starting", "searching", "connecting", "pendingConfirmation", "connected"] + +class RemoteCtrlStopReason_discoveryFailed(TypedDict): + type: Literal["discoveryFailed"] + chatError: "ChatError" + +class RemoteCtrlStopReason_connectionFailed(TypedDict): + type: Literal["connectionFailed"] + chatError: "ChatError" + +class RemoteCtrlStopReason_setupFailed(TypedDict): + type: Literal["setupFailed"] + chatError: "ChatError" + +class RemoteCtrlStopReason_disconnected(TypedDict): + type: Literal["disconnected"] + +RemoteCtrlStopReason = ( + RemoteCtrlStopReason_discoveryFailed + | RemoteCtrlStopReason_connectionFailed + | RemoteCtrlStopReason_setupFailed + | RemoteCtrlStopReason_disconnected +) + +RemoteCtrlStopReason_Tag = Literal["discoveryFailed", "connectionFailed", "setupFailed", "disconnected"] + ReportReason = Literal["spam", "content", "community", "profile", "other"] class RoleGroupPreference(TypedDict): @@ -3618,44 +3717,6 @@ class VersionRange(TypedDict): minVersion: int # int maxVersion: int # int -class WalletError_noMaster(TypedDict): - type: Literal["noMaster"] - -class WalletError_masterExists(TypedDict): - type: Literal["masterExists"] - -class WalletError_badMnemonic(TypedDict): - type: Literal["badMnemonic"] - -class WalletError_hiddenProfile(TypedDict): - type: Literal["hiddenProfile"] - -class WalletError_accountBound(TypedDict): - type: Literal["accountBound"] - -class WalletError_counterUnknown(TypedDict): - type: Literal["counterUnknown"] - -class WalletError_indexTooLarge(TypedDict): - type: Literal["indexTooLarge"] - -class WalletError_derivation(TypedDict): - type: Literal["derivation"] - derivationError: str - -WalletError = ( - WalletError_noMaster - | WalletError_masterExists - | WalletError_badMnemonic - | WalletError_hiddenProfile - | WalletError_accountBound - | WalletError_counterUnknown - | WalletError_indexTooLarge - | WalletError_derivation -) - -WalletError_Tag = Literal["noMaster", "masterExists", "badMnemonic", "hiddenProfile", "accountBound", "counterUnknown", "indexTooLarge", "derivation"] - class XFTPErrorType_BLOCK(TypedDict): type: Literal["BLOCK"] diff --git a/packages/simplex-chat-python/src/simplex_chat/util.py b/packages/simplex-chat-python/src/simplex_chat/util.py index e5fbbf3fab..92f2fa52cf 100644 --- a/packages/simplex-chat-python/src/simplex_chat/util.py +++ b/packages/simplex-chat-python/src/simplex_chat/util.py @@ -101,7 +101,7 @@ def ci_content_text(chat_item: T.ChatItem) -> str | None: return None -_BOT_COMMAND_RE = re.compile(r"^/([^\s]+)(.*)$") +_BOT_COMMAND_RE = re.compile(r"^/([^\s]+)(.*)$", re.DOTALL) def ci_bot_command(chat_item: T.ChatItem) -> tuple[str, str] | None: diff --git a/packages/simplex-chat-python/tests/test_api.py b/packages/simplex-chat-python/tests/test_api.py index 09b5ce4c03..1b20ec0639 100644 --- a/packages/simplex-chat-python/tests/test_api.py +++ b/packages/simplex-chat-python/tests/test_api.py @@ -7,6 +7,7 @@ shape it accepts. from __future__ import annotations +import asyncio from typing import Any import pytest @@ -172,3 +173,46 @@ async def test_a_failed_custom_data_write_raises(): api = FakeCtrl({"type": "chatCmdError"}) with pytest.raises(ChatCommandError): await api.api_merge_group_custom_data({"groupId": 9}, "mine", 1) + + +# ---------------------------------------------------------------------- # +# Documented success responses +# ---------------------------------------------------------------------- # + + +def test_update_chat_item_accepts_not_changed(): + chat_item = {"meta": {"itemId": 2}} + api = FakeCtrl({"type": "chatItemNotChanged", "chatItem": {"chatItem": chat_item}}) + msg_content = {"type": "text", "text": "same"} + assert asyncio.run(api.api_update_chat_item("direct", 1, 2, msg_content)) == chat_item + + +def test_set_profile_address_accepts_no_change(): + api = FakeCtrl({"type": "userProfileNoChange"}) + summary = asyncio.run(api.api_set_profile_address(1, True)) + assert summary == {"updateSuccesses": 0, "updateFailures": 0, "changedContacts": []} + + +def test_receive_file_reports_cancelled_by_sender(): + api = FakeCtrl({"type": "rcvFileAcceptedSndCancelled", "rcvFileTransfer": {}}) + with pytest.raises(ChatCommandError, match="file cancelled by sender"): + asyncio.run(api.api_receive_file(3)) + + +async def test_init_loads_library_off_the_event_loop(monkeypatch): + import threading + + from simplex_chat import _native, core + from simplex_chat.api import SqliteDb + + threads: list[int] = [] + monkeypatch.setattr(_native, "lib_for", lambda _backend: threads.append(threading.get_ident())) + + async def fake_migrate_init(*_args): + return 1 + + monkeypatch.setattr(core, "chat_migrate_init", fake_migrate_init) + + loop_thread = threading.get_ident() + await ChatApi.init(SqliteDb(file_prefix="/tmp/unused")) + assert threads and threads[0] != loop_thread diff --git a/packages/simplex-chat-python/tests/test_client_and_waiters.py b/packages/simplex-chat-python/tests/test_client_and_waiters.py index d40c74a0eb..1ecf146ec3 100644 --- a/packages/simplex-chat-python/tests/test_client_and_waiters.py +++ b/packages/simplex-chat-python/tests/test_client_and_waiters.py @@ -14,11 +14,13 @@ import pytest from simplex_chat import ( Bot, BotProfile, + ChatCommandError, Client, ContactAlreadyExistsError, Profile, SqliteDb, ) +from simplex_chat.core import MigrationConfirmation class FakeApi: @@ -292,6 +294,31 @@ def test_send_and_wait_parallel_different_contacts(): assert (a, b) == ("A", "B") +def test_send_and_wait_keeps_waiter_registered_during_previous_cleanup(): + bot, _api = _bot_with_fake_api() + + def reply(text: str) -> dict[str, Any]: + return {"type": "newChatItems", "chatItems": [ + { + "chatInfo": {"type": "direct", "contact": {"contactId": 42}}, + "chatItem": {"content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": text}}}, + } + ]} + + async def go() -> tuple[str, str]: + first = asyncio.create_task(bot.send_and_wait(42, "a", timeout=2.0)) + await asyncio.sleep(0) + # Created before the reply is dispatched, so it registers before `first` runs its cleanup. + second = asyncio.create_task(bot.send_and_wait(42, "b", timeout=2.0)) + await bot._dispatch_event(reply("ra")) # type: ignore[arg-type] + await asyncio.sleep(0) + await asyncio.sleep(0) + await bot._dispatch_event(reply("rb")) # type: ignore[arg-type] + return (await first).text or "", (await second).text or "" + + assert asyncio.run(go()) == ("ra", "rb") + + # --------------------------------------------------------------------------- # connect_to # --------------------------------------------------------------------------- @@ -466,6 +493,10 @@ def test_aexit_nulls_api_even_if_close_raises(monkeypatch): async def stop_chat(self): pass + @property + def initialized(self): + return False + async def close(self): raise RuntimeError("close failed") @@ -498,6 +529,83 @@ def test_aexit_nulls_api_even_if_close_raises(monkeypatch): asyncio.run(go()) +def test_aexit_keeps_api_for_retry_when_stop_fails(monkeypatch): + """A failed stop leaves the store open, so the Client must keep the + controller: dropping it would leak the store with no way to close it.""" + import simplex_chat.client as client_mod + + stop_results = ["chatCmdError", "chatStopped"] + closed: list[bool] = [False] + + class _FailingStopApi: + @classmethod + async def init(cls, *_a, **_kw): + return cls() + + @property + def initialized(self): + return not closed[0] + + async def start_chat(self): + pass + + async def close(self): + response = stop_results.pop(0) + if response != "chatStopped": + raise ChatCommandError("error stopping chat", {"type": response}) + closed[0] = True + + async def api_get_active_user(self): + return {"userId": 1, "profile": {"displayName": "x"}} + + async def send_chat_cmd(self, _cmd): + return {"type": "cmdOk"} + + monkeypatch.setattr(client_mod, "ChatApi", _FailingStopApi) + + c = Client(profile=Profile(display_name="x"), db=SqliteDb(file_prefix="/tmp/test")) + + async def go(): + with pytest.raises(ChatCommandError, match="error stopping chat"): + async with c: + pass + assert c._api is not None, "controller dropped while its store is still open" + assert closed == [False] + await c.__aexit__(None, None, None) + assert closed == [True] + assert c._api is None + + asyncio.run(go()) + + +@pytest.mark.parametrize("queue_size", [None, 65536]) +def test_bot_passes_queue_size_to_chat_api_init(monkeypatch, queue_size): + import simplex_chat.client as client_mod + + init_args: list[tuple[Any, ...]] = [] + + class StopInit(RuntimeError): + pass + + class FakeChatApi: + @classmethod + async def init(cls, *args): + init_args.append(args) + raise StopInit + + monkeypatch.setattr(client_mod, "ChatApi", FakeChatApi) + db = SqliteDb(file_prefix="/tmp/test") + bot = Bot(profile=BotProfile(display_name="x"), db=db, queue_size=queue_size) + + async def go(): + with pytest.raises(StopInit): + async with bot: + pytest.fail("should not enter the with-block") + + asyncio.run(go()) + assert init_args == [(db, MigrationConfirmation.YES_UP, queue_size)] + + def test_aenter_rolls_back_partial_init_on_post_start_failure(monkeypatch): """If anything in __aenter__ raises after ChatApi.init succeeded — including _post_start — the controller must be closed. Otherwise the with-block isn't @@ -550,7 +658,7 @@ def test_aenter_rolls_back_partial_init_on_post_start_failure(monkeypatch): pytest.fail("should not enter the with-block") asyncio.run(go()) - assert closed == ["stop", "close"], f"controller not cleaned up: {closed}" + assert closed == ["close"], f"controller not cleaned up: {closed}" assert c._api is None, "Client._api should be reset to None after rollback" diff --git a/packages/simplex-chat-python/tests/test_codegen.py b/packages/simplex-chat-python/tests/test_codegen.py index c5842f5d56..20a9400d18 100644 --- a/packages/simplex-chat-python/tests/test_codegen.py +++ b/packages/simplex-chat-python/tests/test_codegen.py @@ -39,3 +39,9 @@ def test_chat_ref_cmd_string_direct(): """Sanity check the codegen fix for ChatRef-bearing commands.""" assert T.ChatRef_cmd_string({"chatType": "direct", "chatId": 7}) == "@7" assert T.ChatRef_cmd_string({"chatType": "group", "chatId": 42}) == "#42" + + +def test_api_connect_cmd_string_renders_incognito(): + link = {"connFullLink": "L"} + assert CC.APIConnect_cmd_string({"userId": 1, "incognito": True, "preparedLink_": link}) == "/_connect 1 incognito=on L" + assert CC.APIConnect_cmd_string({"userId": 1, "incognito": False, "preparedLink_": link}) == "/_connect 1 L" diff --git a/packages/simplex-chat-python/tests/test_core_migrate_init.py b/packages/simplex-chat-python/tests/test_core_migrate_init.py new file mode 100644 index 0000000000..f18b6d34f0 --- /dev/null +++ b/packages/simplex-chat-python/tests/test_core_migrate_init.py @@ -0,0 +1,123 @@ +"""core.chat_migrate_init picks the FFI export by queue_size, with a fake libsimplex.""" + +from __future__ import annotations + +import asyncio +import json +from typing import Any + +import pytest + +from simplex_chat import core +from simplex_chat.core import ChatInitError, MigrationConfirmation + +CTRL = 42 + + +class FakeLib: + """Records calls; each export writes CTRL to the out-param and returns the JSON result.""" + + def __init__(self, result: dict[str, Any]) -> None: + self.result = json.dumps(result) + self.calls: list[tuple[str, tuple[Any, ...]]] = [] + + def _init(self, name: str, args: tuple[Any, ...]) -> str: + *call_args, ctrl_ref = args + self.calls.append((name, tuple(call_args))) + ctrl_ref._obj.value = CTRL + return self.result + + def chat_migrate_init(self, *args: Any) -> str: + return self._init("chat_migrate_init", args) + + @property + def chat_migrate_init_queue(self) -> Any: + lib = self + + class Fn: + argtypes: Any = None + restype: Any = None + + def __call__(self, *args: Any) -> str: + return lib._init("chat_migrate_init_queue", args) + + return Fn() + + +class OldLib(FakeLib): + """A libsimplex released before chat_migrate_init_queue existed.""" + + def __getattribute__(self, name: str) -> Any: + if name == "chat_migrate_init_queue": + raise AttributeError(name) + return super().__getattribute__(name) + + +@pytest.fixture +def fake_lib(monkeypatch: pytest.MonkeyPatch): + def install(result: dict[str, Any]) -> FakeLib: + lib = FakeLib(result) + monkeypatch.setattr(core._native, "lib", lambda: lib) + monkeypatch.setattr(core, "_read_and_free", lambda ptr: ptr) + return lib + + return install + + +def migrate_init(queue_size: int | None = None) -> int: + return asyncio.run( + core.chat_migrate_init("/tmp/db", "key", MigrationConfirmation.YES_UP, queue_size) + ) + + +def test_without_queue_size_uses_chat_migrate_init(fake_lib): + lib = fake_lib({"type": "ok"}) + assert migrate_init() == CTRL + assert lib.calls == [("chat_migrate_init", (b"/tmp/db", b"key", b"yesUp"))] + + +def test_with_queue_size_uses_chat_migrate_init_queue(fake_lib): + lib = fake_lib({"type": "ok"}) + assert migrate_init(65536) == CTRL + assert lib.calls == [("chat_migrate_init_queue", (b"/tmp/db", b"key", b"yesUp", 65536))] + + +def test_invalid_queue_size_result_raises_init_error(fake_lib): + fake_lib({"type": "invalidQueueSize"}) + with pytest.raises(ChatInitError) as e: + migrate_init(0) + assert e.value.db_migration_error == {"type": "invalidQueueSize"} + + +@pytest.mark.parametrize("queue_size", [2**31, -(2**31) - 1]) +def test_queue_size_outside_c_int_is_rejected_before_ffi(fake_lib, queue_size): + lib = fake_lib({"type": "ok"}) + with pytest.raises(ValueError, match="does not fit C int"): + migrate_init(queue_size) + assert lib.calls == [] + + +def test_queue_size_on_old_lib_raises_clear_error(monkeypatch): + lib = OldLib({"type": "ok"}) + monkeypatch.setattr(core._native, "lib", lambda: lib) + with pytest.raises(RuntimeError, match="does not export chat_migrate_init_queue"): + migrate_init(65536) + assert lib.calls == [] + + +def test_setup_signatures_accepts_old_lib(): + class Fn: + argtypes: Any = None + restype: Any = None + + class Lib: + def __getattr__(self, name: str) -> Fn: + if name == "chat_migrate_init_queue": + raise AttributeError(name) + fn = Fn() + setattr(self, name, fn) + return fn + + from simplex_chat import _native + + _native._setup_signatures(Lib()) # type: ignore[arg-type] diff --git a/packages/simplex-chat-python/tests/test_native_cache.py b/packages/simplex-chat-python/tests/test_native_cache.py index c2938ee3e4..6c4f2a923d 100644 --- a/packages/simplex-chat-python/tests/test_native_cache.py +++ b/packages/simplex-chat-python/tests/test_native_cache.py @@ -91,3 +91,13 @@ def test_atomic_install(tmp_path, monkeypatch): _download(target, "sqlite") assert (target / "libsimplex.so").read_text() == "fake-so" assert (target / "libHS-stub.so").read_text() == "fake-hs" + + +def test_libc_on_windows_is_ucrt(monkeypatch): + loaded: list[str | None] = [] + monkeypatch.setattr("sys.platform", "win32") + monkeypatch.setattr("ctypes.CDLL", lambda name: loaded.append(name)) + from simplex_chat import _native + + _native._load_libc() + assert loaded == ["ucrtbase"] diff --git a/packages/simplex-chat-python/tests/test_recv_executor.py b/packages/simplex-chat-python/tests/test_recv_executor.py new file mode 100644 index 0000000000..ef160d5cf9 --- /dev/null +++ b/packages/simplex-chat-python/tests/test_recv_executor.py @@ -0,0 +1,207 @@ +"""ChatApi receives run on a dedicated per-instance thread, not the default pool. + +Uses a fake libsimplex (see tests/test_core_migrate_init.py for the pattern): +`core._native.lib` and `core._read_and_free` are monkeypatched so `chat_recv_msg_wait` +sleeps for a controlled time and returns a scripted result. +""" + +from __future__ import annotations + +import asyncio +import json +import threading +import time +from concurrent.futures import ThreadPoolExecutor +from typing import Any + +import pytest + +from simplex_chat import ChatApi, ChatCommandError + +RECV_SLEEP = 0.3 + + +class FakeRecvLib: + """Fake chat_recv_msg_wait: blocks for `sleep` seconds, then returns a scripted result. + + `events` records "stop" / "recv_start" / "recv_end" / "close_store" in call order, across + all ChatApi instances sharing this fake, so tests can assert ordering between stop, receive + and store-close calls (not just that they happened). + """ + + def __init__( + self, + sleep: float = RECV_SLEEP, + results: list[str] | None = None, + stop_response: str = "chatStopped", + ) -> None: + self.sleep = sleep + self._results = iter(results or []) + self._stop_response = stop_response + self.calls: list[tuple[int, int]] = [] # (ctrl, thread ident) + self.events: list[str] = [] + self._lock = threading.Lock() + + def chat_recv_msg_wait(self, ctrl: int, wait_us: int) -> str: + with self._lock: + self.events.append("recv_start") + time.sleep(self.sleep) + with self._lock: + self.events.append("recv_end") + self.calls.append((ctrl, threading.get_ident())) + return next(self._results, "") + + def chat_send_cmd(self, ctrl: int, cmd: bytes) -> str: + assert cmd == b"/_stop", f"unexpected command {cmd!r}" + with self._lock: + self.events.append("stop") + return json.dumps({"result": {"type": self._stop_response}}) + + def chat_close_store(self, ctrl: int) -> str: + with self._lock: + self.events.append("close_store") + return "" + + +@pytest.fixture +def fake_lib(monkeypatch: pytest.MonkeyPatch): + def install( + sleep: float = RECV_SLEEP, + results: list[str] | None = None, + stop_response: str = "chatStopped", + ) -> FakeRecvLib: + lib = FakeRecvLib(sleep=sleep, results=results, stop_response=stop_response) + monkeypatch.setattr("simplex_chat.core._native.lib", lambda: lib) + monkeypatch.setattr("simplex_chat.core._read_and_free", lambda ptr: ptr) + return lib + + return install + + +def _recv_thread_names() -> list[str]: + return [t.name for t in threading.enumerate() if t.name.startswith("simplex-recv")] + + +async def test_receives_do_not_use_the_default_executor(fake_lib): + fake_lib(sleep=RECV_SLEEP) + loop = asyncio.get_running_loop() + loop.set_default_executor(ThreadPoolExecutor(max_workers=1)) + + apis = [ChatApi(ctrl=i) for i in range(3)] + recv_tasks = [asyncio.create_task(api.recv_chat_event()) for api in apis] + try: + await asyncio.sleep(0.05) # let all three receives claim their own thread + + start = time.monotonic() + await asyncio.to_thread(lambda: None) + elapsed = time.monotonic() - start + + await asyncio.gather(*recv_tasks) + finally: + for api in apis: + await api.close() + + assert elapsed < 0.1 + + +async def test_one_receive_thread_per_chatapi_reused(fake_lib): + lib = fake_lib(sleep=0.02) + api = ChatApi(ctrl=1) + other_api = ChatApi(ctrl=2) + try: + for _ in range(3): + await api.recv_chat_event() + + idents = {ident for ctrl, ident in lib.calls if ctrl == 1} + assert len(idents) == 1 + recv_ident = idents.pop() + thread = next(t for t in threading.enumerate() if t.ident == recv_ident) + assert thread.name.startswith("simplex-recv") + assert thread.ident != threading.get_ident() + + await other_api.recv_chat_event() + other_idents = {ident for ctrl, ident in lib.calls if ctrl == 2} + assert other_idents and other_idents != {thread.ident} + finally: + await api.close() + await other_api.close() + + +def test_no_thread_until_first_receive(): + # A bare ThreadPoolExecutor spawns no worker thread until the first submit, + # so the real assertion is the attribute itself, not threading.enumerate(). + api = ChatApi(ctrl=1) + assert api._recv_executor is None + + +async def test_close_shuts_down_the_executor_without_blocking_the_loop(fake_lib): + fake_lib(sleep=RECV_SLEEP) + api = ChatApi(ctrl=1) + recv_task = asyncio.create_task(api.recv_chat_event()) + await asyncio.sleep(0.05) # let the receive claim its executor thread + assert _recv_thread_names() != [] + + sleep_task = asyncio.create_task(asyncio.sleep(0.01)) + close_task = asyncio.create_task(api.close()) + + await asyncio.wait_for(sleep_task, timeout=0.2) + assert not close_task.done() # shutdown still waiting on the in-flight receive + + await close_task + await recv_task + + assert _recv_thread_names() == [] + + +async def test_close_shuts_down_executor_before_closing_the_store(fake_lib): + lib = fake_lib(sleep=RECV_SLEEP) + api = ChatApi(ctrl=1) + recv_task = asyncio.create_task(api.recv_chat_event()) + try: + await asyncio.sleep(0.05) # ensure the receive is in flight before close() starts + await api.close() + await recv_task + finally: + if not recv_task.done(): + recv_task.cancel() + + # recv_end (executor drained) must precede close_store: a receive must never + # be in flight while the store closes underneath it. + assert lib.events == ["recv_start", "stop", "recv_end", "close_store"] + + +async def test_close_stops_the_chat_before_closing_the_store(fake_lib): + lib = fake_lib() + api = ChatApi(ctrl=1) + await api.close() + assert lib.events == ["stop", "close_store"] + assert not api.initialized + + +async def test_close_does_not_close_the_store_when_stop_fails(fake_lib): + lib = fake_lib(stop_response="chatCmdError") + api = ChatApi(ctrl=1) + with pytest.raises(ChatCommandError, match="error stopping chat"): + await api.close() + assert lib.events == ["stop"] + assert api.initialized + + +async def test_recv_chat_event_after_close_raises_before_touching_executor(fake_lib): + fake_lib() + api = ChatApi(ctrl=1) + await api.close() + with pytest.raises(RuntimeError, match="controller not initialized"): + await api.recv_chat_event() + assert api._recv_executor is None + + +async def test_receive_parses_event_json_and_none_on_timeout(fake_lib): + event: dict[str, Any] = {"type": "chatItemUpdated", "chatItem": {}} + fake_lib(sleep=0.01, results=[json.dumps({"result": event}), ""]) + api = ChatApi(ctrl=1) + try: + assert await api.recv_chat_event() == event + assert await api.recv_chat_event() is None + finally: + await api.close() diff --git a/packages/simplex-chat-python/tests/test_util.py b/packages/simplex-chat-python/tests/test_util.py index 3ea0d87e6d..d5a43eabaa 100644 --- a/packages/simplex-chat-python/tests/test_util.py +++ b/packages/simplex-chat-python/tests/test_util.py @@ -167,6 +167,11 @@ def test_ci_bot_command_no_text(): assert util.ci_bot_command(ci) is None +def test_ci_bot_command_multiline_params(): + ci = {"content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": "/review line1\nline2"}}} + assert util.ci_bot_command(ci) == ("review", "line1\nline2") + + def test_reaction_text_emoji(): r = {"chatReaction": {"reaction": {"type": "emoji", "emoji": "🎉"}}} assert util.reaction_text(r) == "🎉" diff --git a/plans/2026-07-26-p2p-member-keys.md b/plans/2026-07-26-p2p-member-keys.md new file mode 100644 index 0000000000..6bad3829e4 --- /dev/null +++ b/plans/2026-07-26-p2p-member-keys.md @@ -0,0 +1,189 @@ +# p2p group member keys - generation and distribution + +## Goal + +Give every member of a p2p (non-relay) group an Ed25519 signing key, and distribute each member's public key to the other members, so p2p group messages can be signed and verified. New members are keyed at join; existing members are keyed on upgrade and their keys are distributed through the existing profile-update path. + +## Design (agreed) + +- Own key: private in `groups.member_priv_key` (via `GroupKeys.memberPrivKey`), public in the membership's `group_members.member_pub_key`. `groupKeys = Just (GroupKeys {publicGroupKeys = Nothing, memberPrivKey})` marks a p2p member key. +- Distribution: the public key is included in `XInfo` (and in `XContact` at join). `XInfo` is sent by the existing profile-update send (`sendGroupProfileUpdate`); the key is included whenever `XInfo` is sent, and a per-member flag records delivery to version-compatible members. One `XInfo` per send - if the profile is sent because it changed, the key is included in that message rather than a second one. +- Version: a new chat version decides who is marked and who can read the key. A member between version 7 and the new version receives `XInfo` for the profile and ignores the unknown key field. +- No acknowledgement in groups: the flag is set on send. A lost message means the member cannot verify until the next send re-delivers the key; whether an unverifiable claim is hidden or shown is a per-claim decision. + +## Current state (last commit `261d09ba4`) + +Field plumbing is done: `memberKey :: Maybe MemberKey` added to `XInfo` and `XContact`, full encode/decode, all call sites pass `Nothing`/`_`. Four `TODO [member keys]` markers remain at the fill-in points: `Commands.hs:3911` (XContact join), `Internal.hs:2489` (profile-update send `sendGroupProfileUpdate`), `Subscriber.hs:836` (join-confirmation allow), `xInfoMember` (receive/store). + +## Changes + +### 1. Version - `Protocol.hs` + +- Add `groupMemberKeyVersion :: VersionChat = VersionChat 20` with a comment. +- `currentChatVersion = VersionChat 20` (from 19). +- Add changelog line `-- 20 - p2p group member keys for signing (2026-07-26)`. +- No new binary-floor constant: reuse `relayWebCapVersion` (18) as the reliable binary-batch floor for partitioning signed sends (item 7). Binary parsing was added in #6597 at version 17 with no constant; 18 is the first guaranteed. + +### 2. Schema + type + row parsing + +- New migration `M20260726_member_key_sent.hs` (mirror `M20260720_server_roles.hs`): `ALTER TABLE group_members ADD COLUMN user_member_key_sent INTEGER NOT NULL DEFAULT 0`. Update `chat_schema.sql`. +- `GroupMember` (`Types.hs:1119`): add `userMemberKeySent :: Bool` after `memberPubKey`. +- `GroupMemberRow` / `MaybeGroupMemberRow` (`Groups.hs:263`): add `BoolInt` / `Maybe BoolInt` to the last tuple group, next to `member_pub_key`. +- `toGroupMember` / `toMaybeGroupMember`: parse the column. +- Every `SELECT` that builds a `GroupMemberRow` adds `user_member_key_sent` (shared column list - several sites; grep the existing `member_pub_key, relay_link` list). + +### 3. Own key generation + storage (key exists before signing) + +- New store fn `setUserMemberKey :: DB.Connection -> GroupId -> GroupMemberId -> C.PrivateKeyEd25519 -> IO ()`: two writes, both required - the private key to `groups.member_priv_key` (the user's own signing key for this group) and its derived public key to the user's own membership row (`group_members.member_pub_key`), as `createNewGroup:429` does. +- New helper `ensureUserMemberKey :: User -> GroupInfo -> CM GroupInfo`: if `groupKeys` already has a key, return `gInfo` unchanged; for a p2p group with `groupKeys = Nothing`, generate an Ed25519 key, store it via `setUserMemberKey`, and return `gInfo` with `groupKeys = Just (GroupKeys {publicGroupKeys = Nothing, memberPrivKey})`. Idempotent (check-and-set in one transaction so concurrent sends cannot create two keys). +- Generation points: + - Create group - `APINewGroup` (`Commands.hs:2642`): generate the key and pass `Just GroupKeys {publicGroupKeys = Nothing, memberPrivKey}` to `newGroup` (was `Nothing`). `createNewGroup` (`Groups.hs:393-430`) already stores both columns. + - Join - in `joinContact`'s p2p-group branch (item 5): `gInfo' <- ensureUserMemberKey user gInfo`, take the public key from `gInfo'` `groupKeys` for `XContact`. + - Send - call `ensureUserMemberKey` at the top of the send entry (`sendGroupMessages` `:2458`, `sendGroupSignedMessages` `:2464`) and thread the returned `gInfo'` to BOTH `sendGroupProfileUpdate` and `sendGroupMessages_`, so `groupMsgSigning` signs the very first message after generation - the key must not lag one message behind. This is also the lazy path for groups created before this change. + +### 4. `sendGroupProfileUpdate` - one `XInfo` with profile and/or key (`Internal.hs:2468`) + +Key and signing are independent of incognito status: the member key is per group and needed in every p2p group. Only the badge may depend on incognito, and that is handled by the existing profile/badge logic, not the key path. `shouldSendProfileUpdate` gates only the profile part; the key part runs regardless. Restructure `sendGroupProfileUpdate` so a single `XInfo` serves both purposes (never two messages), for non-relay groups, using `gInfo'` from `ensureUserMemberKey`: + +- `profileMembers = if shouldSendProfileUpdate then filter (\`supportsVersion\` memberProfileUpdateVersion) members else []` (unchanged trigger; still skips incognito, scope, asGroup). +- `keyMembers` = members with `supportsVersion groupMemberKeyVersion` and `not (userMemberKeySent m)` - runs regardless of incognito. +- recipients = union of the two. +- Send one `XInfo profile (Just ownKey)` to recipients via `sendGroupMessages_` (`:2500`), which returns the `GroupSndResult` needed for marking - `sendGroupMessage'` (`:2374`) discards it (the `_` at `:2377`), so the current `sendGroupProfileUpdate` send call must change. `profile` and its badge are the existing profile logic (unchanged); `ownKey = MemberKey (C.publicKey memberPrivKey)` from `gInfo'` `groupKeys`. +- After send, from that `GroupSndResult`: set `userMemberKeySent = True` for a key recipient whose `sentTo` delivery result (the third tuple element, `:2495`) is `Right`, or that is in `pending` or `forwarded` (enqueued, stored for delivery on connect, or forwarded). A `sentTo` `Left` (enqueue failure) stays `False`. `updateUserMemberProfileSentAt` only when `shouldSendProfileUpdate`. +- Retry stays but is uncapped: any `False` member is re-included on the next send. No cap is needed because `memberSendAction` (`Internal.hs:2608`) returns `Nothing` for a disabled/deleted/failed/rejected connection, so `addMember` (`:2542`) skips it - re-inclusion re-filters it in memory, it is never actually re-sent. The only un-marked-but-attempted case is a `sentTo` failure on a *ready* connection (a rare enqueue error), which retries next send and fails identically for the content message; a truly broken connection transitions to disabled/failed and is then skipped. So retry is cheap and self-limiting. `user_member_key_sent` is a plain boolean. +- New store fn `setMembersMemberKeySent :: DB.Connection -> [GroupMemberId] -> IO ()`. +- Relay groups keep current behaviour (no key here; key comes from the roster). + +The member list is already in memory and `userMemberKeySent` is a field on the record, so both filters are in-memory with no extra query. + +### 5. Fill the TODO send points + +- `joinContact` (`Commands.hs:3900`): the key belongs only in the `Just (Just gInfo) | not (useRelays' gInfo)` case (p2p group join). Split that out of the current `_` branch: `gInfo' <- ensureUserMemberKey user gInfo`, then `XContact profileToSend (Just ownKey) (Just xContactId) welcomeSharedMsgId msg_`. The `Just Nothing` (unknown group) and `Nothing` (direct contact) cases keep `XContact ... Nothing ...`. `XContact` is `encodeConnInfoPQ` (JSON), so this delivery is **unsigned** - the initial trust-on-first-use key. The membership row exists in `gInfo` here, so `setUserMemberKey` writes `member_pub_key` (#5 confirmed). +- `Subscriber.hs:836` (joiner's allow-reply to the host): `XInfo profileToSend (Just ownKey)`, **signed** with the joiner's key when the host version allows (item 6). `XInfo` is `requiresSignature`, so it is signed like any other `XInfo`; this gives the host a signed confirmation of the joiner's key at join. +- `Subscriber.hs:1626` (host accepting the join): pass the parsed `XContact.memberKey` to `acceptGroupJoinRequestAsync` instead of `Nothing`; it flows to `createJoiningMember` (`Groups.hs:2070`, `:2112`), which stores `member_pub_key` (unsigned TOFU). +- Host key to the joiner: `XGrpLinkMem` (`Protocol.hs:503`, currently `Profile` only) needs a `Maybe MemberKey` field added, like the commit added to `XInfo`/`XContact`. The host already sends it during the join in `sendXGrpLinkMem` (`Subscriber.hs:974`), fired on the joiner's `CON` (`:958`); include the host's key, and store it in `xGrpLinkMem` (`:2760`). This is the host->joiner counterpart of the joiner's `XContact`. Add `XGrpLinkMem` to `requiresSignature` (safe - p2p-only, relay groups never send/receive it). But `sendXGrpLinkMem` currently uses `sendDirectMemberMessage` -> `sendDirectMessage_` -> `createSndMessage` (`:2197`), which hardcodes `Nothing` signing and sends via `deliverMessage` (no `groupMsgSigning`, no mode partition) - so `requiresSignature` alone would not sign it. Switch `sendXGrpLinkMem` to `sendGroupMemberMessages` (`:2228`), which computes `groupMsgSigning` (`:2231`) and uses the mode at `:2232` (item-7 partition site: binary-signed to a v20+ joiner, unsigned JSON to a pre-20 joiner), and run `ensureUserMemberKey` first so the host has a key to sign with. `xGrpLinkMem` (`:2760`) must `verifyGroupSig` against the key delivered in the message (self-certifying, like `:868`), since `withVerifiedMsg` has no stored host key yet. + +### 6. Receive + confirm the key + +The key is confirmed cryptographically wherever the `XInfo` is signed. Two receive points: + +- Handshake allow-reply - `Subscriber.hs:868` (`XInfo _ _`). The joiner's reply can be signed: `encodeSignedConnInfo` already produces a signed connInfo (used by `encodeXMemberConnInfo`), and the peer version is known by `INFO` (`updatePeerChatVRange`), so sign the allow-reply (item 5) with the joiner's key when the host version supports it. `parseChatMessage` here is `parseChatMessage'` with the signature discarded (`Internal.hs:1793`); switch to `parseChatMessage'`, verify the signature against the key in the `XInfo`, then read and confirm the key. This confirms the joiner's key at join. +- Group-message `XInfo` - `xInfoMember` (`Subscriber.hs:2755`), signed via item 7, for ongoing profile/key updates. + +Store/confirm rule at both points, new store fn `setMemberPubKey :: DB.Connection -> GroupMemberId -> C.PublicKeyEd25519 -> IO ()` (the key-only, no-role counterpart of the existing `setGroupMemberKeyRole`): +- `memberPubKey m = Nothing`, `mKey = Just k` -> store `k`. +- `memberPubKey m = Just k0` -> accept only `Nothing` or `Just k0`; a different key is rejected (immutable). + +This is the same pin-or-reject rule as the existing `applyMemberKeyRole` (`Subscriber.hs`, used by the roster): `Nothing` -> pin, `Just k` with `k /= pubKey` -> `Left` reject. Reuse or mirror it. + +Signing is uniform: `XInfo` is `requiresSignature`, so every group `XInfo` (the 836 allow-reply, group profile updates) and `XGrpLinkMem` is signed with the member's key when the recipient version allows (binary). The one unconditionally-unsigned delivery is `XContact` - a JSON connInfo that cannot be signed; the host holds that key as trust-on-first-use, confirmed by the joiner's signed `XInfo` (the 836 reply, then later updates). + +### 7. Signed send - partition recipients by binary capability + +Once `groupKeys = Just`, `groupMsgSigning` (`Internal.hs:2214`) produces a `MsgSigning` for p2p messages, and `createNewSndMessage` (`Store/Messages.hs:236`) stores each `SndMessage` with both `msgBody` (plain encoded message) and `signedMsg_ :: Maybe SignedMsg` (signature over that body). A signed element cannot sit in a JSON batch: `encodeBatchElement (Just sm) body = "/" <> smpEncode (chatBinding, signatures) <> body` (binary), `encodeBatchElement Nothing body = body` (plain JSON), and `encodeBatch` wraps them as `=...` (binary) or `[...]` (JSON) (`Batch.hs:70`, `:130-134`). So the same `SndMessage` yields either form with no re-encoding: keep `signedMsg_` for the signed element, set it to `Nothing` for the unsigned one. + +The send path currently picks one mode for the whole group: `mode = if useRelays' gInfo then BMBinary else BMJson` (`Internal.hs:2232`, `:2549`, `:2676`), so p2p is always `BMJson`. Change, for a p2p group when any message is signed (`any (isJust . signedMsg_) msgs`): + +- Partition the recipients (`toSendSeparate` and `toSendBatched`) by `\`supportsVersion\` relayWebCapVersion` (18, the binary-batch floor). +- Binary-capable members -> `batchSndMessagesJSON BMBinary msgs` (signed `/` elements). +- Binary-incapable members -> `batchSndMessagesJSON BMJson (map (fmap dropSig) msgs)`, `dropSig m = m {signedMsg_ = Nothing}` (unsigned JSON). +- Fold each partition over its own batch (`foldMembers` already runs per list) and concatenate; body references (`VRRef`) are naturally per-partition. + +Relay groups (`BMBinary` for all) and unsigned p2p sends (`BMJson` for all) are unchanged. + +A binary-capable member below `groupMemberKeyVersion` (18-19) receives the signed form, stores it unverified (no key), and can forward it intact via `encodeFwdElement` (`Batch.hs:125`), which preserves `signedMsg_` - which is why the partition is by binary capability, not key possession. A member below 18 receives the unsigned JSON form; in a p2p group `signatureOptional` is true, so it accepts the unsigned message rather than rejecting it. + +Three mode sites to update: `prepareMsgReqs` (`:2549`, main group send), `sendGroupMemberMessages` (`:2232`, member-to-member / introductions), and `:2676`. + +## Revision (2026-07-30): consolidate the send decision, classify delivery + +Items 2, 4, 7 above are the first-cut design - a boolean `user_member_key_sent`, set true on delivery, with the binary/JSON split re-derived in `prepareMsgReqs`. That shipped (commit `261d09ba4` onward) and is the current code. This revision replaces it. Two problems drove it: + +1. **`prepareMsgReqs` re-derives the send mode.** `memberSendAction` (`Internal.hs:2606`) already walks every member - with `useRelays'` and version in hand - to choose `MSASend`; `prepareMsgReqs` then walks the resulting `toSend` again - `partition useBinary` where `useBinary (m,_) = useRelays' gInfo || m supportsVersion relayWebCapVersion` (`:2564`) - recomputing the same decision. One decision, two owners. +2. **The boolean models only the happy path.** A `sentTo` `Left` is never marked, so an errored member is re-selected every send forever, uncounted; a disabled member (a `memberSendAction` skip) likewise. No permanent/transient split, no give-up. + +### A. `MemberSendAction` - total, records mode and skip reason + +```haskell +data SkipReason = SRUnsendable | SRNotApplicable + -- SRUnsendable: disabled / ConnDeleted / failed / GSMemRejected (memberSendAction :2619) + -- SRNotApplicable: relay non-target (:2615), self GCUserMember (:2625), forward with no path (:2633) +data MemberSendAction = MSASend BatchMode Connection | MSAPending | MSAForwarded | MSASkip SkipReason +memberSendAction :: ... -> MemberSendAction -- total, no Maybe +``` + +- Every current `Nothing` becomes `MSASkip r` with the reason from the branch it came from. For a key recipient (a real, non-self member receiving `XInfo`, not `XGrpMsgForward`), only `:2619` → `SRUnsendable` is reachable. +- `MSASend` records the `BatchMode` (`BMBinary` for `useRelays' gInfo || m supportsVersion relayWebCapVersion`, else `BMJson`) - computed where `memberSendAction` already branches on exactly that predicate. +- `addMember` (`:2550`) sorts `MSASend BMBinary` / `MSASend BMJson` into pre-partitioned `toSendBin` / `toSendJson`; `prepareMsgReqs` consumes those and drops its own `partition useBinary`. Problem 1 gone. +- `GroupSndResult` gains `skipped :: [(GroupMember, SkipReason)]` so the key-marking sees skips. The other consumer, `createMemberSndStatuses` (`Commands.hs:4822`), ignores `skipped` and the mode - unchanged. + +### B. Two columns + `KeySendStatus` sum type + +Replace the boolean with two columns (edit the unreleased `M20260727` migration + both `chat_schema.sql`; no new migration): +- `user_member_key_status TEXT` - `NULL` = attempting; `"sent"` = delivered; any other text = terminal error reason. +- `user_member_key_attempts INTEGER NOT NULL DEFAULT 0` - retriable-failure count. + +`GroupMember` field `userMemberKeyStatus :: KeySendStatus` (was `userMemberKeySent :: Bool`), a sum type constructed from the two columns: + +```haskell +data KeySendStatus = KSSent | KSError Text | KSAttempts Int +-- from (status :: Maybe Text, attempts :: Int): +-- (Just "sent", _) -> KSSent +-- (Just reason, _) -> KSError reason -- any non-null, non-"sent" text ("sent" reserved) +-- (Nothing, n) -> KSAttempts n -- still attempting, n prior retriable failures +``` + +Two columns, not one text field, so each marking outcome is one uniform bulk write (C): success touches only `status`, a retry touches only `attempts` (`attempts = attempts + 1`, no per-row value), an error groups by reason. Member creation default `KSAttempts 0` = (`NULL`, `0`). + +Selection: `memberNeedsKey m = m supportsVersion groupMemberKeyVersion && case userMemberKeyStatus m of { KSAttempts n -> n < maxKeySendAttempts; _ -> False }`. Comparing the count to config (E) means no separate "abandoned" state; raising the cap re-includes maxed-out members. + +### C. Marking - classify once, from `GroupSndResult` + +Per key-recipient, one outcome, written as partitioned bulk updates: +- **Delivered** (`sentTo` enqueue `Right`, or `pending`, or `forwarded`) → `status = "sent"`. +- **Skipped** (`skipped`): `SRUnsendable` → `status = ` terminal (a disabled connection is terminal in practice - `APIEnableGroupMember` is effectively never called - so stop re-selecting it); `SRNotApplicable` → untouched. +- **Errored** (`sentTo` `Left`): `terminalKeySend e` → `status = ` (grouped by reason); else → `attempts = attempts + 1`. + +The send is async: this classifies only the synchronous **enqueue** result. `submitPendingMsg` (`Agent.hs:2068`) hands the message to the SND worker, which does the network send and emits `SENT` / `MERR` (`Agent.hs:2196,2274`) - so AUTH / QUOTA / NETWORK / BROKER never reach this point; the agent retries them itself. `temporaryOrHostError` is therefore the wrong classifier here - it triages the async errors that cannot occur, and misjudges the few that can. + +### D. `terminalKeySend` - closed terminal set, default retriable + +```haskell +terminalKeySend :: ChatError -> Bool +terminalKeySend = \case + ChatErrorAgent {agentError} -> case agentError of + CONN SIMPLEX _ -> True -- connection has no send queue (prepareConn :1821) + CONN NOT_FOUND _ -> True -- connection / ratchet gone (getConn :1812) + NO_USER -> True -- user deleted + _ -> False + _ -> False +``` + +Everything else is retriable, bounded by the cap: `CMD PROHIBITED` (ratchet resync, `Agent.hs:1826`), `CRITICAL True` (agent DB lock, `SEDatabaseBusy`), `INACTIVE` (agent suspended), `ChatErrorStore` (chat DB contention), `INTERNAL` (catch-all - ambiguous, so retriable), and oversize (`CMD LARGE` / batch `CEInternalError "large message"` / `CEException "large compressed message"` - rare, a global profile-size problem, self-limiting under the cap; a dedicated `ChatErrorType` constructor for the batch case is a separate cleanup, out of this branch). Inverting to a terminal whitelist is deliberate: at the enqueue phase, mislabeling a transient error permanent abandons a member whose next send would succeed, while mislabeling a permanent error retriable costs only a few capped sends. + +Exhaustive reachability (why the terminal set is these three): the only synchronous producers are `getConn_` (`Agent.hs:1806`), `prepareConn` (`:1814`), and `enqueueMessageB`/`storeSentMsg` (`:2062`). All network/server errors (`SMP`/`BROKER`/`PROXY`/`NTF`/`XFTP`, and `AUTH`/`QUOTA`) are async (MERR) and unreachable here; `AGENT (A_*)` are receive/queue-op side; `CONN DUPLICATE`/`NOT_ACCEPTED`/`NOT_AVAILABLE`, `CMD SYNTAX`/`NO_CONN`/`SIZE`, `NTF`/`XFTP`/`FILE`/`RCP`/`NOTICE`/`CRITICAL False` are other paths. + +### E. Config + +`maxKeySendAttempts :: Int` in the chat config (value immaterial - a small cap like 5; over-retrying a rare ambiguous error is cheap). + +## Implementation status (2026-07-30) + +- Items 1, 3, 5, 6 - implemented as described: versions, key generation, distribution (`XContact` / `XGrpLinkMem` / `XInfo`), receive pin-or-reject. +- Items 2, 4, 7 - the boolean baseline is **replaced by the Revision (A-E)**, which is now implemented and compiles (`cabal build lib:simplex-chat`, both backends' schema + migration updated): + - A - `MemberSendAction` total, records `BatchMode` and `SkipReason`; `sendBatchMode` is the single owner of the binary/JSON decision; `sendGroupSignedMessages_` dedups then classifies with list comprehensions into pre-partitioned `toSendBin`/`toSendJson`; `prepareMsgReqs` reads them (no re-derivation); `GroupSndResult` gains `skipped`. + - B - two columns `user_member_key_status TEXT` / `user_member_key_attempts` (migration `M20260727` + both `chat_schema.sql`), field `userMemberKeyStatus :: KeySendStatus` built by `toKeySendStatus`, store fns `setMembersKeyStatus` / `incMembersKeyAttempts`. + - C - `markKeySends` classifies each key-recipient once from `GroupSndResult` and writes partitioned bulk updates; `memberNeedsKey` selects `KSAttempts n < maxKeySendAttempts`. + - D - `terminalKeySend` = {`CONN SIMPLEX`, `CONN NOT_FOUND`, `NO_USER`}; everything else retriable. + - E - `maxKeySendAttempts = 5`. + +Remaining work: +- Regenerate the client-type mirrors: `userMemberKeyStatus` still shows as `userMemberKeySent: boolean` in the generated `types.ts`, `_types.py`, and `bots/api/TYPES.md` (generated by `bots/src/API/Docs/Generate*.hs`). +- Tests: the branch adds none beyond `ProtocolTests` field plumbing - no coverage of distribution, pin-or-reject, signed send/verify, or the classification. + +## Open decisions + +- Names, to confirm or adjust: columns `user_member_key_status` / `user_member_key_attempts`, field `userMemberKeyStatus :: KeySendStatus`, constructors `KeySendStatus`/`KS*` and `SkipReason`/`SR*`, config `maxKeySendAttempts`. +- Whether a `SRUnsendable` skip is recorded as a terminal `error` (proposed: yes - a disabled connection is terminal in practice). + +Resolved: both key writes required (`groups.member_priv_key` and own-row `member_pub_key`). `XContact` includes the unsigned key at member creation and the signed allow-reply confirms it. Reuse `relayWebCapVersion` (18) as the binary floor. Key change on receipt - reject any change, immutable. Signed send (item 7) - partition by binary capability, and (Revision A) the mode is decided once in `memberSendAction`, not re-derived. Key distribution runs in all p2p groups including incognito. First send after generation is signed. Sign criteria unchanged. Handshake allow-reply signed, confirms the key at join. Status is a two-column `KeySendStatus` sum type (not a boolean); delivery is classified terminal-vs-retriable with a capped attempt counter, terminal set closed (D). diff --git a/plans/2026-07-30-supporter-badges-v3-ux.md b/plans/2026-07-30-supporter-badges-v3-ux.md new file mode 100644 index 0000000000..221dd1159d --- /dev/null +++ b/plans/2026-07-30-supporter-badges-v3-ux.md @@ -0,0 +1,535 @@ +# Supporter Badges v3 + +**Date:** 2026-07-30 +**Status:** draft +**Transport:** service RPC (`plans/2026-07-22-service-rpc-chat.md`, implemented, branch `rpc`): signed requests (`APISendServiceRequest.signKey`), verified signer key delivered to the service (`CEvtServiceRequest.signerKey`), one response per request, per-call timeout, no transport persistence. The client repeats the same signed request after a timeout; every bot operation is idempotent. +**Designs:** `/code/simplex-chat-art/screens/badges-mobile_15-*.jpg`, `_16-*-dark.jpg` + +## Principles + +- At most one badge is shown per profile. A user holds at most two badges per profile: paid (supporter/legend) and investor (2.7). +- The core engine performs all badge automation and emits events; apps render and call APIs only for user actions (2.9). +- No bot push; the client reads state via its own requests. Bot-initiated messaging, if ever added, is an opt-in SimpleX contact. +- Paid time is a months balance on a single-entry ledger; unconsumed months lapse; credentials are issued monthly for all plans; prepaid time is pausable post-MVP (§3, 2.13). +- One current alert per profile, derived from state; a later state supersedes an unacknowledged earlier alert (2.4). +- Renewal reminders on by default with opt-out; no success screens. +- Credentials expire on Sundays (UTC); profile updates are sent on Mondays, including removal updates for expired badges; the user is shown one date — the payment date (2.11). + +## 1. Existing designs + +| Screen | Covers | +|---|---| +| `banner` | chat-list banner: "Support SimpleX — Get badge + files up to 5GB", dismissable | +| `support-simplex` | intro screen → Choose your level | +| `your-level` | tier choice: Supporter $7/month (2GB) vs Legend $70/month (5GB), profile preview with badge | +| `pay` | plan choice: "1 month" vs "Subscribe", Pay button, renewal date | +| `settings` | "Supporter perks" row in Your settings | +| `whats-new` | v7.0 announcement entry | + +These designs cover the acquisition path for iOS/Play builds only. Missing designs: + +- payment method + duration selector (2.1) +- crypto invoice screen (2.1) +- user-picker badge presence (2.3) +- management screen (2.6) +- start-sharing question (2.5) + +Store builds display store-fetched localized prices; catalog prices apply to Stripe/BTCPay only. + +## 2. UX + +### 2.1 Payment method and duration (non-store builds) + +- Store builds: no method UI. One-time 1 month; monthly/annual subscriptions. +- Non-store builds: method selector (Card / Bitcoin / Monero), all multi-month prepaid; no card subscriptions (§6.11). Plan screen copy: "payments don't renew — you choose how many months to pay for". +- Durations 1 / 3 / 12 months; prices 1× / 2× / 6× monthly (§6.12). Duration selector — new design. +- Unavailable options: disabled offers and inactive products are shown as disabled; when no option is available, the purchase screen shows an unavailable notice; the `product_unavailable` error is displayed the same way. +- Crypto payment screen (new design): + - amount, one-off address, QR, copy, open-in-wallet, countdown (BTCPay fixes the fiat rate at invoice creation; the invoice expires in ~15–60 min); + - states: waiting → processing (transaction seen, unconfirmed) → settled; expired → "Invoice expired" + new invoice (new payment row); + - fiat-first display; + - partial payment: the BTCPay marked state is shown; the user is directed to support. +- After settlement the issue response contains a receipt code (§3 recovery); one-time save prompt at checkout ("keep your receipt — it recovers unused months if you lose this device"); receipts are shown in payment history (2.6). +- Stripe: payment link opened in the system browser; "Waiting for payment…" + open-link-again; the link is stored in the payment row. + +### 2.2 Chat-list banner + +Shown after ≥ 3 conversations and not in the first days after install; hidden for ~6 months after dismissal; not shown while a badge is active or a payment is pending; not re-shown on expiry. Exact thresholds set at implementation. + +### 2.3 User picker (design needed) + +Menu opened from the avatar in the chat-list toolbar (`apps/multiplatform/.../chatlist/UserPicker.kt`, `apps/ios/Shared/Views/ChatList/UserPicker.swift`; the profile bar renders `localBadge` — UserPicker.kt:213). + +- Option A (preferred): badge icon on the right of the profile bar, opens the management screen; indicator dot for the current alert. Icon shown while a badge is held or a payment is pending; acquisition entry remains the banner and the Settings row. +- Option B: full option row (state text: "Supporter · renews Aug 21", "ends Oct 22", "⚠ payment problem"), replacing the Settings row; "Your chat profiles" is moved to Settings. + +### 2.4 Alerts + +| Alert | When | Content / actions | +|---|---|---| +| subscription renewal approaching | 3 days before `renews_at`, once per cycle | price + date; OK · Remind me again (1 day before) · Cancel path | +| payment failed / grace | on provider grace/hold state, once per episode | "Payment problem — perks until {date}. Fix payment." → store fix flow | +| subscription ended | at paid-through, once | "Subscription ended {date}." + Resubscribe | +| prepaid ending | 3 days before paid-through, balance 0, not paused, once | "Your support ends {date}. Renew for {price}?" OK · Remind me again (1 day before) | +| support ended | at paid-through, balance 0, no active subscription, not paused, once | "Your support ended {date}." + Renew / Choose level | +| renewal charged | never — visible in charge history and picker badge state | — | +| credential issued | never | — | + +No alerts for paused badges (2.13). An alert is shown in its profile's context (indicator on the profile row in the picker). + +Persistence: no alerts table. On the badge row: `alert_acked_kind` + `alert_acked_episode` (the episode value, e.g. this cycle's `renews_at`) and `alert_snooze_until`. Core emits `CEvtBadgeAlert` when the derived alert differs from the acknowledged one; on OK the client stores the kind + episode; on snooze it sets `alert_snooze_until`, after which the alert is emitted once more. + +Opt-out toggle "Remind me about renewals and expiry" disables renewal-approaching and prepaid-ending; the other alerts cannot be disabled. + +### 2.5 Start-sharing + +- Default: a newly acquired badge is presented immediately; switching the shown badge takes effect immediately. +- Perks require presentation: the recipient verifies file size against the presented proof (`maxXFTPFileSize` on `PeerBadge`); the sender's client disables the perk at paid-through (2.11). This is stated in the "Now / From Monday" question. +- Management-screen toggle: "Show new badges from Monday" — off by default. +- Global setting "Show advanced privacy questions" (Privacy & security): when on, acquisitions that change the presented badge ask "Start showing: Now / From Monday". Later candidates for the same setting: deletion notifications, rejected contact requests, call questions. +- While presentation is delayed: the previous badge remains presented; the management screen shows "starts Monday {date}"; field `use_from`. + +### 2.6 Management screen (design needed) + +All dates shown are paid-through (2.11): + +1. **Acquiring / payment pending** — method-specific pending UI (2.1); old badge unchanged. +2. **Active prepaid** — "Ends {date}" ({N} months left); payment history (last 12; Stripe/crypto payments with receipts, §3); Pause (2.13); Subscribe; Upgrade. +3. **Active subscription** — "Renews {date} · {price}"; Cancel; Upgrade; charge history (last 12: date, amount, method, in the charged currency); reminders toggle. +4. **Cancelled, still active** — "Ends {date}"; Resubscribe. +5. **Payment issue** — banner over state 3; perks remain enabled during the provider grace period. +6. **Paused** — "Paused · {N} months left"; Resume. +7. **Expired** — "Support ended {date}"; Renew / Choose level. +8. **Held badges** — list of held badges (validity, source); radio for the shown one; pending presentation ("starts Monday {date}"). On a switch the client sends a profile update. +9. **Redeem code** entry (2.8). +10. Settings: reminders toggle (2.4); "Show new badges from Monday" (2.5). + +### 2.7 Held badges and switching + +- Perks apply only from the shown badge, verified against the presented proof. Holding legend while showing investor yields 2 GB (`BTLegend` alone yields 5 GB). Switch UI copy: "Sending 5 GB files requires showing the Legend badge". +- Exactly one shown; on a switch the client sends a profile update with a fresh proof (`setUserBadge` + broadcast; the presentation copy is stored in `contact_profiles`). Timing per 2.5. +- When the shown paid badge expires and an investor badge is held: the client switches the shown badge to investor with the Monday update. + +### 2.8 Redeem codes + +The service grants any badge type for 1/3/12 months per code (investor: lifetime); the response contains badge type + granted months. Granted months are recorded in the ledger; credentials are re-issued monthly. UX: "Have a code?" → paste → signed `redeem` (fresh order) → the badge is shown (2.5). Errors: invalid / used / expired, inline. + +### 2.9 Core engine + +Triggers: + +- chat start +- foreground +- network restore +- profile switch +- timers: renewal, expiry, Monday boundaries + +1. resume: settled payments without issuance; subscription renewals after `renews_at`; monthly re-issue while balance > 0 and not paused; invoiced/pending crypto payments; +2. `advance(now)` + `issue(now)` at each `issue`/`status` request (§3); +3. presentation on/after Monday (UTC): fresh proofs for renewed badges and for badges whose `use_from` was reached; removal updates for badges expired unrenewed; +4. alert derivation (2.4), `CEvtBadgeAlert`; +5. sync: store charges and ledger rows received after the `since` cursor (§3). + +Events to the app: `CEvtBadgeChanged`, `CEvtBadgeAlert`. + +API calls (user actions): + +- get status +- get catalog (purchase-screen open, §3 prices) +- start purchase / get invoice +- issue after store payment (the app passes store evidence to core) +- cancel — opens the store sheet; no bot op +- pause / resume +- switch shown badge / set start date +- acknowledge / snooze alert +- reminder and presentation toggles +- redeem code + +On `ASETimeout` core retries the identical signed envelope on the next trigger; never on a poll timer. + +### 2.10 Upgrades + +- supporter → legend (store subscription): native upgrade flow, same subscription group. New badge row (new keys); old row superseded. +- prepaid upgrades: legend invoice = legend price − full supporter price while the supporter badge is unexpired; remaining supporter months are removed by `debit(conversion)` (§3). Both amounts are displayed. +- one-time → subscription (same tier): same badge row, new payment row; `payment_id` is updated to the new payment. +- Presentation per 2.5, including investor → legend. + +### 2.11 Dates + +- Credential expiry = paid-through of the issued month rounded up to Sunday (end of Sunday, UTC) — `sundayAfter` (§3). +- The user is shown one date — paid-through: "Renews {date}" / "Ends {date}" / "Support ended {date}". "Badge valid until" does not appear anywhere. The sender's client disables perks at paid-through; the recipient's client enforces credential expiry + grace. Small print: "your profile badge may remain visible to contacts for a few days after your support ends." +- Monday send-outs: fresh proofs for renewed badges; removal updates (`setUserBadge Nothing`) for badges expired unrenewed. +- Recipient display: a peer badge with expiry E is displayed as active until E + 7 days; the `BSExpiredOld` boundary (31 days) is shifted by the same 7 days. Both in the recipient's `mkBadgeStatus` derivation. + +### 2.12 Multi-profile + +Badges, payments, charges, ledgers are per `user_id`. No badge is presented for incognito profiles. + +### 2.13 Pause (prepaid only, post-MVP) + +Issued credentials are not pausable (expiry is signed); re-issuance is. Pause suspends issuance and lapse, with no duration limit. + +- Prepaid time only (crypto, Stripe one-time, codes). +- Signed RPC op, recorded bot-side and mirrored (`resume` transition, §3). +- While paused: the current credential remains valid to its expiry; no alerts; management screen state 6. +- On resume: issuance is resumed at the next `issue` request. + +## 3. Model + +### Ownership — two databases + +Bot tables (no private keys, no master keys, no UI state): + +- `orders` — `order_key` PK; pinned `product_id`; timestamps. Order state is derived from the ledger + provider. +- `payments` — user-initiated payments, as opposed to charges: bot-assigned `payment_ref`; per-payment `offer_id`, `provider`, `provider_ref`, receipt hash, status +- `charges` +- `badge_ledger` — authoritative +- `issuances` + cached credentials +- `codes` — code hashes, granted type/months, redemption, batch revocation +- webhook dedup + +Client tables: + +- `products`, `offers` — catalog cache +- `payments` +- `charges` — replica +- `badge_ledger` — replica +- `issuances` — replica +- `badges` — keys, credential, UI state + +Rules: + +- The replicated tables (`badge_ledger`, `issuances`, `charges`) have identical row content on both sides: bot-assigned row ids and refs. Ledger and issuance account column: `order_key` on the bot, `badge_id` on the client — 1:1, one key per badge. Charges reference payments on both sides: `payment_ref` on the bot, `bot_payment_ref` on the client. +- Synced via the `since` cursor; all ledger transitions (§ ledger) are executed by the bot only; the client stores received rows unchanged. +- Not replicated: the client's `payments` row records the local act (evidence, invoice URL, status); the bot's `payments` row records the binding (offer, `provider_ref`, receipt hash); `orders` records the pinned product; `badges` has no bot counterpart. +- Payments are product-agnostic on both sides: keyed by `order_key`, without references to product tables. The product row references its current payment; payment history is the join on `order_key`. + +### `products` + `offers` — two-tier catalog + +Tier 1 — `products`. All builds use it for product structure; display names and localization are app resources. + +- `product_id` PK +- `product_type` — `badge` +- `badge_type` — `supporter|legend|investor` +- `active` + +Tier 2 — `offers`: + +- `offer_id` PK +- `product_id` → products +- `plan` — `one_time|monthly|annual` +- `months` — 1/3/12 for one-time; NULL for subscriptions +- `apple_product_id`, `google_product_id` — store offers +- `price`, `currency` — non-store offers +- `state` — `active|deprecated|disabled` + +Rules: + +- Offers are append-only: repricing = new offer row + deprecation of the old. +- Lifecycle: `active` (rendered + accepted) → `deprecated` (hidden; accepted by `order`; window length is operator policy) → `disabled` (`order` rejects with a refresh-catalog error). +- State is checked at invoice creation only; the amount of an existing invoice does not change; `issue` on a settled payment is always honored. +- For store offers the state controls display only; their sale lifecycle is managed in the store consoles. +- `catalog` response: `active` + `deprecated` offers with state; `disabled` omitted. The client renders `active`, retains `deprecated`, may delete offers absent from the response unless referenced by a payment row. +- The duration selector (2.1) is built from active one-time offers; discounts are per-duration prices. +- Store one-time is 1 month only (§6.12). Seeded from app config; server-authoritative on the bot. +- Forward compatibility: entries with unknown `product_type`/`badge_type` are stored, not rendered, no error. New product types require an app release; new offers of known products do not. +- Products with `active = false` are not offered; an in-flight request on one → `product_unavailable`. + +Prices: + +- The amount at invoice creation is authoritative: BTCPay fixes the fiat amount, the Stripe page shows the charge, the final amount is confirmed at checkout. +- Display prices: the unsigned `catalog` op on purchase-screen open (non-store builds; non-blocking — the screen is rendered from the stored catalog and updated from the response), and the full catalog included in every `issue`/`status` response; the client reconciles received rows against stored rows. +- The catalog shipped with the app is the offline fallback; store builds read prices from StoreKit/Play Billing. +- Repricing applies to future purchases only. + +### `payments` — user-initiated acts + +- `payment_id` PK +- `user_id` +- `order_key` — the order paid for; badge orders are rows in `badges` +- `offer_id` → offers +- `bot_payment_ref` — bot-assigned; recorded from the invoice response or sync rows +- `months`, `amount`, `currency` — copied from the offer at purchase +- `provider` — `apple|google|stripe|btc|xmr|code` +- `provider_ref` — Apple original transaction id / Google purchase token / Stripe intent ref / BTCPay invoice id / code hash +- `invoice_url` +- `evidence` — Apple JWS / Google token, stored for repeat `issue` requests +- `receipt_code` — client-side (§ recovery); the bot stores `receipt_hash` +- `status` — `new|invoiced|pending|settled|failed|expired` +- `renews_at` — subscriptions +- `cancelled` — bot-confirmed renewal-off +- `created_at`, `updated_at` + +One row per act: purchase, subscribe, upgrade, resubscribe, each crypto invoice, code redemption. Abandoned attempts remain as history. On settlement the bot runs `advance` then `grant` (§ ledger). `paidThrough` = last ledger row; `badge_expiry` = last issuance. + +### `charges` — provider-initiated billing events + +- `charge_id` PK +- `payment_id` → payments +- `bot_payment_ref` — the synced ref; the client resolves `payment_id` from it +- `provider_charge_ref` — Stripe invoice id / Apple transaction id / Google order id +- `period_start`, `period_end` +- `amount`, `currency` — as reported by the provider +- `charged_at` +- unique (`payment_id`, `provider_charge_ref`) + +Rules: + +- Sources — the first charge of a subscription is also a row: + - Stripe: invoices by subscription + - Apple: Get Transaction History by original transaction id + - Google: per-renewal order ids +- Synced to the client via the `since` cursor. +- For each settled charge the bot records a `grant` of the charge's period length: +1 monthly, +12 annual. +- Charges are money facts referenced by grants, not ledger rows: a grant is not recorded for every charge (webhook replays are rejected by the unique key before any grant; charges from provider history for consumed, refunded, or re-bound periods are recorded without grants), and `charged_at` differs from the accounting time. + +### `badge_ledger` + `issuances` + +**Operations to support:** + +| # | operation | months | credential | +|---|---|---|---| +| O1 | prepaid settlement (Stripe/BTCPay) | +N | — | +| O2 | subscription charge settled | +1 monthly / +12 annual | — | +| O3 | code redemption | +N | — | +| O4 | goodwill grant | +N | — | +| O5 | transfer in | +M | — | +| O6 | refund / chargeback | −balance | issued credential remains valid | +| O7 | upgrade conversion | −balance | — | +| O8 | transfer out | −balance | — | +| O9 | correction (code abuse) | −balance | — | +| O10 | issue a month | −1 | new credential | +| O11 | lapse elapsed months | −k | — | +| O12 | repeat issue, same month | none | cached credential, no rows | +| O13 | lifetime issuance (investor) | none — no ledger | new credential | +| O14 | pause / resume (post-MVP) | 0 | — | + +**Ledger state** — two values; every row contains the state after it; **the last row is the state**: + +- `months` — unused months. +- `start` — the date the unused balance starts. Not changed by grants (while `months > 0`); advanced by one month per `consume` and by the lapsed count per `lapse`. +- `anchor` — the start of the current run of months, set only where a lapsed run restarts. Every month boundary of the run is counted from it, so a run beginning 31 Jan reaches 28 Feb and then returns to 31 Mar; counting from the moving `start` would hold it at the 28th for good. + +Write `monthAfter n = addMonths (m + n) anchor`, where `m` is the whole months from `anchor` to `start`, which always sits on a boundary. Coverage = `[start, monthAfter months)`. `paidThrough = monthAfter months` — read from the last row alone; not a `badges` column. + +**Row:** + +- `entry_id` PK +- `badge_id` +- `op` — `grant(source) | debit(reason) | consume | lapse | resume` +- `delta` — signed months change +- `months` — state: unused months after this row +- `start` — state: balance start after this row +- `anchor` — state: the run's anchor after this row +- ref — bot-assigned payment ref / `charge_id` (grants) +- `created_at` + +Append protocol: lock the badge's ledger → read the last row → compute the next → insert. The client stores rows verbatim from responses (`since` cursor = last `entry_id` held) and can re-verify each row from its predecessor. + +**Transitions** — from the last row `(start, months)`; `advance t` is run before every issue, grant, and debit: + +``` +advance t: -- time bookkeeping only: one lapse row for the fully elapsed months + k = the largest k in [0, months] with monthAfter k <= t + if k > 0: append (lapse, −k, months − k, monthAfter k) -- O11, one row + +issue t: -- run after advance t + requires months > 0 && start <= t && no issuance for [start, monthAfter 1) + sign the credential, expiry sundayAfter (monthAfter 1) + in one transaction: append (consume, −1, months − 1, monthAfter 1) -- O10 + + issuance row for [start, monthAfter 1) + on signing failure: no rows; retried at the next `issue` + +grant t n src: -- O1–O5; t = settlement time, + months == 0 && t > start → append (grant src, +n, n, t), anchor = t -- provider period start for O2 + otherwise → append (grant src, +n, months + n, start) -- same run keeps its anchor + +debit reason: append (debit reason, −months, 0, start) -- O6–O9 + +resume t: append (resume, 0, months, max start t) -- O14; no row is written on pause +``` + +**Issuances** — separate table; no credential is issued for `grant`/`debit`/`lapse` rows, and no ledger rows are written for lifetime (O13) and cached (O12) issuance events: + +- `issuance_id` PK +- `badge_id` +- `period_start`, `period_end` — NULL for lifetime +- `expiry` — `sundayAfter period_end`; NULL for lifetime +- `entry_id` → the `consume` row; NULL for lifetime +- `created_at` + +`badgeExpiry` = `expiry` of the last issuance — the credential's disclosed field. The current credential is stored in the badge row's credential columns; the issuance rows are the history; the O12 check reads the last issuance's period. + +**Properties:** + +1. Each row's `(months, start)` equals the transition applied to its predecessor — client-verifiable per row. +2. `months ≥ 0`; the sum of `delta` equals `months`; `start` is non-decreasing. +3. `consume` rows ↔ period issuances are 1:1 (`issuances.entry_id`) — written in one transaction, after the credential is signed. +4. Re-running the `issue` op (§4) immediately appends nothing; the response is the cached credential or `status`. + +**Example** — buy 3 months Tue Mar 10, 2026; app off Apr 5 – May 20; issue May 20: + +| # | op | delta | months | start | note | +|---|---|---|---|---|---| +| 1 | grant(payment) | +3 | 3 | Mar 10 | paidThrough = Jun 10 | +| 2 | consume | −1 | 2 | Apr 10 | issuance: Mar 10–Apr 10, expiry Sun Apr 12 | +| 3 | lapse | −1 | 1 | May 10 | Apr 10–May 10 passed unissued; recorded May 20 | +| 4 | consume | −1 | 0 | Jun 10 | issuance: May 10–Jun 10, expiry Sun Jun 14 | + +`paidThrough` after every row = Jun 10. ~Jun 7: prepaid-ending alert. Jun 10: sender-side perk disabled. Sun Jun 14: credential expires. Mon Jun 15: removal update. An additional +3 purchase on Jun 5: `months = 0` → row (grant, +3, 3, max(Jun 10, Jun 5)) → paidThrough = Sep 10. + +**Adjustments:** + +| Case | Rows | +|---|---| +| refund / chargeback (Apple `REFUND` notification; Google voided purchases; Stripe disputes) | `debit(refund)`; issued credentials remain valid to expiry | +| goodwill / outage compensation | `grant(goodwill) +N` with reason text | +| upgrade conversion (2.10) | `debit(conversion)` on the supporter badge; discount on the legend invoice | +| overpaid / duplicate crypto invoice | `grant(goodwill) +N` or refund, per support resolution | +| balance transfer after profile loss | `debit(transfer_out)` old badge / `grant(transfer_in) +M` new badge | +| leaked/abused code batch | `debit(correction)` on affected badges | + +### Recovery and transfer + +- Normal path: `order_priv_key` and `master_key` are part of the profile backup; after restore, a signed `status` request resolves the same order. +- Receipts (Stripe and crypto payments): the bot creates a receipt code for every settled payment — a high-entropy, checksummed, human-copyable bearer secret; only its hash is stored bot-side; redeemable once. Contained in the issue response; stored in the app (payment history, profile backups); save prompt at checkout (2.1). +- Stores: no receipt — the new client presents a fresh store receipt → re-bind, capped per payment and period. +- Transfer: a request signed by the new order key contains the receipt → `debit(transfer_out)` on the old badge, `grant(transfer_in)` on the new; the provider binding is reassigned to the new order. Once per receipt; unissued months only; logged. With a receipt used by another party, only unissued months are transferred, once; disputes are sent to support. +- Support evidence when the receipt is lost: the statement-descriptor ref (~22 chars total incl. prefix, e.g. `SIMPLEX 4F7K2`) and, for crypto, txid + amount/time. Discretionary; sufficient only for cancellation-level actions. +- Both app and receipt lost: no recovery. + +### `badges` — one row per order + +- `badge_id` PK +- `user_id` +- `order_key` — unique; the order identity +- `order_priv_key`, `master_key` +- `badge_type` +- `product_id` → products +- `payment_id` → payments — NOT NULL; the current payment. The badge row and its first payment are created in one transaction +- `status` — `acquiring|issued|superseded|failed` +- credential columns — `key_idx`, `signature`, `badge_expiry` (`BadgeRow` conventions; investor: `badge_expiry` NULL) +- `use_from` — presentation start (2.5) +- `paused_at` (2.13) +- `alert_acked_kind` + `alert_acked_episode`, `alert_snooze_until` (2.4) +- `created_at`, `updated_at` + +- A row is created per manual act (purchase, upgrade, resubscribe, redeem); on subscription renewal and prepaid re-issue the credential is updated in place, with charge and ledger rows added. +- Status is derived at load (`mkBadgeStatus`); the current alert is derived (2.4). Months and `paidThrough`: last ledger row. `badge_expiry`: credential columns / last issuance. +- Get-or-create concurrency: per-user lock (controller `TMap`) + one store transaction + partial unique index — one live (`acquiring|issued`) row per (`user_id`, slot), slot = investor vs paid. +- The shown badge is referenced from the user's record (`users.shown_badge_id`) — at most one, structurally; presentation starts at `use_from`; on a switch the client updates the reference and `contact_profiles` via `setUserBadge`. + +### Storage integration + +`contact_profiles` badge columns remain the presentation copy of the shown badge (`setUserBadge`, Store/Profiles.hs:375). `badges` records what the user has; the profile columns record what the user shows (2.6.8). + +## 4. Wire protocol + +`docs/protocol/badges-rpc.schema.json` + `docs/protocol/badges-rpc.md`. Every request except `catalog` is signed with the order key and contains `orderKey`, its public part; the bot verifies `orderKey` equals the transport signer key. + +| op | request | response | +|---|---|---| +| `catalog` | — | catalog | +| `order` (stripe/btc/xmr) | `offerId`, `provider` | invoice: url/address, amount, expiry | +| `issue` | `masterKey`; store evidence (JWS / token) when settling a store payment; `since` | credential + sync, or status | +| `status` | `since` | status or credential + sync | +| `redeem` | `masterKey`, `code`, `since` | credential + sync | +| `pause` / `resume` (post-MVP, 2.13) | — | status | +| `transfer` (post-MVP, §3 recovery) | `receipt` | status | + +- Offer resolution: by `offerId` at `order`; by the store SKU in the evidence at `issue`; by the code at `redeem`. The order's product is pinned at creation. +- On `issue`: `advance(now)`; then: current month issued → cached credential (O12); `issue` precondition met → `issue(now)`; otherwise `status` (§3). +- Store cancellation is performed in store UI and reflected by the next `status` (§6.11). +- New product types are added in the catalog (`product_type`); the ops are unchanged. + +## 5. Providers + +| | invoice step | pending means | settled evidence | renewal | cancel | +|---|---|---|---|---|---| +| Apple | local: `Product.purchase()` after creating the payment row | `pending` (Ask to Buy / SCA), late result via `Transaction.updates` | signed transaction JWS (verified offline by the bot; contains storefront price + currency) | store auto-renews; issue after `renews_at`; history = Get Transaction History | store management sheet | +| Google | local: `launchBillingFlow` | `purchaseState = PENDING` | purchase token (verified + acknowledged by the bot ≤ 3 days) | store auto-renews; per-renewal order ids | Play subscriptions UI | +| Stripe | RPC `order` → invoice (payment link) | `issue` responds "pending" until the webhook is received | none client-side — signer key + bot webhook state | none — prepaid 1/3/12 months; renewed manually | n/a | +| BTCPay (btc/xmr) | RPC `order` → invoice (address/amount), rate fixed, window ~15–60 min | invoice `Processing` | none client-side — invoice `Settled` at the bot | none — prepaid 1/3/12 months; renewed manually | n/a | + +## 6. Decisions + +Resolved: + +1. Banner thresholds — set at implementation. +2. Alerts — one derived alert per profile; two markers on the badge row; no alerts table. +3. Reminders — 3 days before, once; "Remind me again" 1 day before; opt-out covers reminder kinds only. +4. Start-sharing — immediate by default; "from Monday" toggle; interactive question only under "Show advanced privacy questions". +5. Prepaid upgrade pricing — deduct the full supporter price while the supporter badge is unexpired. +6. Charge history — last 12 in UI. +7. Redeem codes — all badge types; the service response contains type + months. +8. Crypto prices — fiat-first; crypto amount fixed at invoice time. +9. Time accounting — single-entry months ledger (§3); unconsumed months lapse; per-charge grants; pause post-MVP. +10. Receipts — every Stripe/crypto payment; recovery is the receipt's only operation; stores re-bind via the store account. +11. No card (Stripe) subscriptions — multi-month prepaid is the non-store model. Re-add path if ever needed: `cancel` op + bot webhook renewal handling; the schema suffices (`renews_at`/`cancelled`, per-charge grants, receipt re-bind, statement descriptor). +12. Durations and pricing — non-store 1/3/12 months at 1×/2×/6× monthly; store annual at 6× monthly; store offering = 1-month one-time + monthly/annual subscriptions, no store multi-month SKUs; all pricing in offers. +13. Expiry rounding — weekly: badges end Sundays (UTC); Monday presentations + removal updates; payment/issuance cadence stays monthly. +14. One user-facing date — paid-through everywhere; the sender's client disables perks at paid-through; credential expiry is internal. + +Open: + +1. User-picker badge presence: icon (preferred) vs row (2.3) — needs design. +2. Scope of "Show advanced privacy questions" beyond badges — separate initiative. +3. Support tooling at launch: refund clawback is mandatory; transfer is manual (support acts on a presented receipt) until the op is implemented. + +## 7. MVP + +Non-store payments at MVP are multi-month prepaid purchases: one flow (order → pay → issue → grant) for Stripe, BTC, XMR. Subscriptions exist only on stores. The ledger, charges, and receipts are included in the MVP. + +### Launch set + +- Tiers: supporter + legend + investor (redeem codes, 2.8). Store: monthly and annual subscriptions + one-time 1 month. Stripe/BTC/XMR: one-time 1/3/12 months (§6.12). +- Redeem codes for all badge types (2.8) — the reward mechanism; code generation is operator tooling, required at MVP. Held badges with switching (2.7) — a switch takes effect immediately at MVP (`use_from` deferred). +- Ops: `catalog` | `order` | `issue` | `status` | `redeem`. Receipts in issue responses; the full catalog in `issue`/`status` responses. +- Store cancellation: + - the Cancel button opens the store management sheet; the engine sends `status` on return; + - the client renders cancelled-active from local renewal state (StoreKit 2 `RenewalInfo.willAutoRenew`, Play Billing `Purchase.isAutoRenewing`); + - the bot reads cancellation from the provider on every `issue`/`status` (App Store Server API `autoRenewStatus`, Play `subscriptionsv2`) and from store notifications. +- Bot-side provider notifications, required: + - Stripe webhooks + - BTCPay webhooks + - App Store Server Notifications V2 + - Play RTDN + - covering: cancellations, grace/on-hold, refunds/voided purchases + - On a notification the bot updates its records and re-reads provider state; credentials are never issued from a notification payload; nothing is pushed to the client. +- Tables (§3 ownership): + - `products`, `offers` + - `payments` + - `charges` + - `badge_ledger` + - `issuances` + - `badges` — without `use_from`, `paused_at` +- Engine per 2.9: issue requests, monthly re-issue, `advance`/`issue` recording, Monday presentation incl. removal updates, alert derivation. +- Alerts: the full 2.4 set (renewal approaching: store subscriptions only); opt-out toggle. +- Receipts for every Stripe/crypto payment with the checkout save prompt; `transfer` op deferred — support executes recovery manually against a presented receipt. +- Entry points: chat-list banner, Settings "Supporter perks" row, whats-new. Management screen without the Paused state; `charges` recorded from the first release, history UI deferred. +- New designs: + - management screen + - method + duration selector + - crypto invoice screen +- Multi-profile; incognito profiles never present a badge. + +### Required in the first release (or earlier) + +1. +7 day recipient display grace (2.11) — in the earliest release, before purchases. +2. Week-boundary expiry + Monday presentation incl. removal updates (2.11). +3. Order-key-per-badge identity (§4). +4. Apple subscription group containing all subscription SKUs (both tiers × monthly/annual). +5. Refund handling: stop issuance + `debit(refund)`. +6. Stripe statement descriptor with short payment ref. +7. Receipts from the first payment. + +### Deferred + +| Deferred | Returns | Re-add path | +|---|---|---| +| `transfer` op | phase 2 | receipts exist from the first release; support transfers manually until then | +| pause / resume | phase 2 | ledger `resume` transition + `paused_at` column | +| charge history UI | phase 2 | `charges` recorded from the first release | +| upgrades (2.10) | phase 2 | store subscription group in place (item 4 above); bot SKU-change mapping + conversion debits | +| start-sharing question + "Show advanced privacy questions" (`use_from`) | phase 3 | immediate presentation and switching at MVP | +| user-picker badge presence | phase 2 | the Settings row is the entry point until then | diff --git a/plans/2026-07-31-badges-core-implementation.md b/plans/2026-07-31-badges-core-implementation.md new file mode 100644 index 0000000000..f36e1f8718 --- /dev/null +++ b/plans/2026-07-31-badges-core-implementation.md @@ -0,0 +1,410 @@ +# Supporter Badges v3 — core implementation plan + +**Date:** 2026-07-31 +**Product plan:** `plans/2026-07-30-supporter-badges-v3-ux.md` (referenced below as UX §n) +**Transport:** service RPC (`plans/2026-07-22-service-rpc-chat.md`, implemented, branch `rpc`) +**Scope:** MVP launch set (UX §7): commands `getBadgeCatalog | getBadgeInvoice | purchaseBadge | issueBadge` implemented; `upgradeBadgeSubscription | pauseBadge` and the `receipt` payment are defined in the protocol, post-MVP; no `use_from`, `paused_at`. + +## 1. Client schema + +`src/Simplex/Chat/Store/SQLite/Migrations/M20260915_user_badges.hs` — SQLite only; the Postgres variant is written when the schema is final; registered in the migrations list and cabal at delivery step 2. + +- Table mapping to UX §3: + - `badges` → `badge_purchases` + - `issuances` → `badge_issuances` + - `products` + `offers` → `badge_prices` + `badge_offers` + - `badge_ledger` → `badge_ledger` + - `payments` and `charges` — unprefixed, product-agnostic (UX §3 ownership) +- The payment row is inserted first, the badge row in the same transaction. +- Payment columns beyond the UX §3 list (`invoice_*` client-side; `grace_until` and `exception` on both sides, sent in `paymentState`): + - `invoice_address` — the crypto screen address (UX 2.1) + - `invoice_crypto_amount` — the fixed crypto amount (UX 2.1) + - `invoice_expires_at` — the countdown and the expired state (UX 2.1) + - `grace_until` — the provider grace/hold deadline (UX 2.4 payment failed; 2.6 state 5) + - `exception` — the provider exception state: partial or over payment (UX 2.1) +- The badge–user match for `users.shown_badge_id` is enforced in code. +- `badge_ledger` has one definition — the `badgeLedgerTable` constant in the client migration; the service schema re-uses it literally. +- Settings (reminders opt-out; "Show new badges from Monday", post-MVP) are app settings, not schema. + +### `badge_ledger` + +A verbatim replica of the service ledger: the service is the only author; rows arrive in statements; the last row is the balance; each row is verifiable from its predecessor. + +| column | meaning | +|---|---| +| `entry_id` | local autoincrement; PK; never on the wire | +| `entry_uuid` | service-assigned UUIDv7; sent both ways; UNIQUE index | +| `badge_purchase_id` | FK → `badge_purchases` | +| `change_months` | months added or removed: `+3` credit, `−1` debit | +| `balance_months` | unused months after this entry | +| `balance_start_ts` | start of the unused balance, after this entry | +| `balance_badge_type` | the type of the unused balance, after this entry | +| `was_paused_since` | set on the entry ending a pause: paused from this time until this entry | +| `service_created_at` | service time of the entry | +| `created_at` | local time the row was stored | +| `entry_type` | `credit` \| `debit` | +| `entry_credit_type` | the `type` tag of the `credit` value — any string, known or not | +| `entry_debit_type` | the `type` tag of the `debit` value — any string, known or not | +| `entry_type_unknown` | client only, added via ALTER TABLE after the shared table: 1 when the tag is beyond the client's version; the typed reference columns stay NULL | +| `entry_type_value` | client only, added via ALTER TABLE: the credit/debit object JSON, verbatim; present only for unknown tags; re-decoded after upgrade | +| `invoice_id` | field of `credit(payment)`; FK → `payments` | +| `charge_id` | field of `credit(charge)`; FK → `charges` | +| `from_purchase_id` | field of `credit(transferIn)`; FK → `badge_purchases`, nullable — the source may not exist locally | +| `to_purchase_id` | field of `debit(upgrade)` / `debit(transferOut)`; FK → `badge_purchases` | + +| kind | type | meaning | when | +|---|---|---|---| +| credit | `payment` | months credited: a settled non-recurring payment — card, crypto, code, store one-time | MVP | +| credit | `charge` | months credited: one subscription charge, the first included | MVP | +| credit | `support` | months credited by support — compensation, gift | MVP | +| credit | `transferIn` | months arriving via transfer | post-MVP | +| credit | `opening` | balance restated: the ledger is reset to the stated amount, no relation to the previous row | MVP | +| debit | `refund` | balance removed: refund or chargeback | MVP | +| debit | `upgrade` | balance converted into an upgrade's credit | post-MVP | +| debit | `transferOut` | balance transferred out | post-MVP | +| debit | `support` | balance removed by support — leaked code batch | MVP | +| debit | `badge` | one month spent on the badge — a credential issued | MVP | +| debit | `lapse` | elapsed unissued months removed | MVP | + +``` +entry = credit — creditType: payment {invoiceId} | charge {chargeId} | support | transferIn {fromPurchaseKey} | opening + | debit — debitType: refund | upgrade {toPurchaseKey} | transferOut {toPurchaseKey} | support | badge | lapse +``` + +The protocol entry (`StatementEntry`, §4): `entryId`, `changeMonths`, `balanceMonths`, `balanceStartTs`, `balanceBadgeType`, `wasPausedSince`?, `createdAt`, `entryType` — the same sum with wire references. + +## 2. Badge RPC service schema + +`plans/2026-07-31-badges-service-schema.sql` — SQLite, like the client (the Postgres variant is written when the schema is final); the bot schema, extended with the purchase/ledger layer. + +## 3. Client types + +Domain types — `src/Simplex/Chat/Badges/Types.hs`. Records: + +- `BadgePurchase` +- `BadgePayment` +- `BadgeLedgerEntry` +- `BadgeCharge` +- `BadgeIssuance` +- `BadgeAlert` +- `UserBadgeState` + +Id newtypes: + +- `BadgePriceId` +- `BadgeOfferId` +- `InvoiceId` + +Enums: + +- `BadgePlan` +- `BadgeItemStatus` +- `BadgeProvider` +- `BadgePaymentStatus` +- `BadgePurchaseStatus` +- `BadgeAlertKind` + +Tagged sums: + +- `BadgePaymentMethod` — `apple` | `google` | `card {provider}` | `crypto {currency}`; the last two map to the wire `ServicePaymentMethod` +- `BadgePurchasePayment` — `apple {paymentId, jws}` | `google {paymentId, token}` | `code {code}`; the `APIPurchaseBadge` argument, mapped to the wire `ServicePayment` +- `OfferDiscount` — `discount_type` with `free_months` / `discount` (§2) +- `LedgerEntryType` — `entry_type` with `credit_type` / `debit_type` (§2) +- `LedgerCreditType` +- `LedgerDebitType` + +Reused from `Simplex.Chat.Badges`: + +- `BadgeType` +- `BadgeInfo` +- `BadgeRequest` +- `BadgeCredential` +- `BadgeMasterKey` + +Protocol types — `src/Simplex/Chat/Badges/Service.hs`, one constructor/field per JTD member: + +- `BadgeServiceRequest` +- `BadgeServiceCommand` +- `ServicePaymentMethod` +- `ServiceInvoice` +- `CardProvider` +- `CryptoCurrency` +- `CurrencyAmount` +- `ServicePayment` +- `BadgeUpgrade` +- `BadgeServiceResponse` +- `ServicePaymentDestination` +- `BadgeServiceErrorCode` +- `BadgeCatalog` +- `BadgePrice` — the store type as well +- `BadgeOffer` — the store type as well +- `BadgeStatement` +- `BadgeBalance` +- `StatementEntry` +- `StatementEntryType` +- `StatementCreditType` +- `StatementDebitType` + +Instances — added at implementation; enums follow the `BadgeType` conventions (Badges.hs:114); the unions encode the JTD discriminator `type`: + +- `TextEncoding` +- JSON +- `ToField` / `FromField` + +## 4. RPC protocol + +`docs/protocol/badges-rpc.schema.json` — the JTD schema (definitions `request` and `response`); `docs/protocol/badges-rpc.md` — the protocol description (identity, idempotency, op semantics, statement, errors). + +A request is an envelope: `version`; `purchaseKey`? (optional for `getBadgeCatalog` — signed, the response adds the purchase's `statement`; required for other commands, which are signed with it); `request` — the command, discriminated on `type`. Responses discriminate on `type`; response types are badge-namespaced, fields inside are plain. + +| request `type` | request fields (beyond `purchaseKey`, `version`) | response `type` | response fields | +|---|---|---|---| +| `getBadgeCatalog` (signature optional) | — | `badgeCatalog` | `catalog`
`badgeStatement`? (for signed requests) | +| `getBadgeInvoice` | `priceId`
`offerId`? (absent for 1 month at the badge price)
`badgeInfo {badgeType, badgeExpiry?, badgeExtra}`
`paymentVia` — `card`: `provider`; `crypto`: `currency`
`upgrade`? — `fromPurchaseKey`, `receipt`, `receiptSignature`, `balance` | `badgeInvoice` | `invoice` — `invoiceId`, `price`, `discount`?, `credit`?, `amount` (= price − discount − credit), `currency`, `expiresAt`, `paymentTo` — `card`: `provider`, `url`; `crypto`: `currency`, `address`, `cryptoAmount`
`badgeType`
`months` | +| `purchaseBadge` | `badgeRequest` — `masterKey`, `badgeInfo`
`payment` — `apple`: `jws`; `google`: `token`; `invoice`: `invoiceId`; `code`: `code`; `receipt`: `receipt` (transfer, post-MVP)
`upgrade`? — `fromPurchaseKey`, `receipt`, `receiptSignature`, `balance` | `badgeCredential` | `credential`
`receipt`? (not provided for lifetime badges)
`statement` | +| `upgradeBadgeSubscription` | `badgeRequest`
`payment` — `apple`: `jws`; `google`: `token`
`balance` | `badgeCredential` | `credential`?
`statement` | +| `issueBadge` | `badgeRequest`
`balance` | `badgeCredential` | `credential`? (absent when the balance is exhausted)
`statement` | +| `pauseBadge` (post-MVP) | — | `badgeCredential` | `credential`?
`statement` | +| any, on failure | — | `error` | `code` (incl. `payment_pending`, `code_invalid` / `code_used` / `code_expired`)
`message`?
`retryAfter`? | + +`statement` — record: `entries` — ledger entries; `previousEntryId`? — matches the client's asserted entryId, absent for the full ledger. + +`balance` — record: `lastEntry` — the client's last ledger entry. + +`catalog` — record: `prices`, `offers`. + +| item | fields | +|---|---| +| price | `priceId` — UUID
`badgeType` — `investor` is never priced
`monthPrice`
`currency` — `usd`
`status` — `active` \| `deprecated` \| `disabled`; disabled is not sent
`createdAt` | +| offer | `offerId` — UUID
`priceId`? — absent applies to any price
`months`
`discount` — `freeMonths`: `freeMonths` \| `discount`: `discount` (percent)
`status`
`createdAt` | + +Prices and offers have one type each across the protocol and the store: the UUID is the primary key, `createdAt` gives the sequence, and `payments` and `badge_purchases` reference them by UUID. + +Offer price: `freeMonths` → `(months − freeMonths) × monthPrice`; `discount` → `months × monthPrice × (100 − discount) / 100`, floored. + +Every offer states a discount; a plain 1-month purchase has no offer — its price is `monthPrice`, and `getBadgeInvoice` omits `offerId`. The duration selector is the fixed 1-month entry plus the offers. + +`getBadgeInvoice` pins the price by `priceId`, and the badge type and currency with it; the service validates `badgeInfo.badgeType` against the price. Repricing appends a price and deprecates the old one: a deprecated price is honored at invoice creation, a disabled one rejected. Deprecated prices and offers are sent so that a catalog refresh cannot remove what the client pinned; disabled ones are omitted, since the service rejects them. An offer pinned to a price dies with it; unpinned offers survive repricing. + +Rendering is app-driven: tiers and durations come from app resources, and one without a price is shown disabled. Store SKUs are absent from the catalog — store builds read prices from the store and SKUs from app config, and make no `getBadgeCatalog` requests, `issueBadge` on schedule and on wake keeping the ledger current. The service holds the SKUs for webhook mapping. + +## 5. Commands and events + +`ChatCommand` (Controller.hs; parsers in `chatCommandP`) — the UX 2.9 user actions. UX 2.9 actions without a command: + +- reminder and presentation toggles — app settings (§1) +- pause / resume and the start-sharing date — post-MVP (Scope) +- cancel — the Cancel button opens the store management sheet; on its close the management screen re-reads state via `APIGetBadgeState` (UX §7) + +```haskell +| APIGetBadgeState UserId -- /_badge state +| APIGetBadgeCatalog UserId -- /_badge catalog ; unsigned getBadgeCatalog +| APIGetBadgeInvoice {userId :: UserId, priceId :: BadgePriceId, offerId :: Maybe BadgeOfferId, paymentVia :: BadgePaymentMethod} -- /_badge invoice [] ; via — apple | google | card | crypto +| APIPurchaseBadge {userId :: UserId, payment :: BadgePurchasePayment} -- /_badge purchase +| APISwitchShownBadge {userId :: UserId, badgePurchaseId :: Int64} -- /_badge shown +| APIAckBadgeAlert {userId :: UserId, kind :: BadgeAlertKind, episode :: Text, snooze :: Bool} -- /_badge ack +``` + +| command | UX | called when | +|---|---|---| +| `APIGetBadgeState` | 2.2, 2.3, 2.6, 2.1, §7 | app start — the initial model load; a badge screen is opened or regains focus | +| `APIGetBadgeCatalog` | §3 prices | the purchase screen is opened (non-store builds); non-blocking | +| `APIGetBadgeInvoice` | 2.1 | the user taps Pay on the selected duration | +| `APIPurchaseBadge` | 2.1, 2.8, §5 | the store purchase flow returns evidence; the user submits a code (2.6.9) | +| `APISwitchShownBadge` | 2.6.8, 2.7 | the user selects the shown badge | +| `APIAckBadgeAlert` | 2.4 | the user taps OK or "Remind me again" on an alert | + +Purchase is two commands because the store purchase runs in the app between them: StoreKit and Play Billing are app-platform APIs, which core cannot call, and the store may deliver the result late (`pending` / Ask to Buy — via `Transaction.updates`, including after a restart). `APIGetBadgeInvoice` obtains the invoice for a card or crypto payment; after payment `APIPurchaseBadge` presents the store evidence, and the worker sends `purchaseBadge` — verification, the grant, and the first issuance in one round trip. Issuance has no command — `issueBadge` is core-driven (UX 2.9 engine), from the balance only: settlement credits months, issuance debits them (UX §3 ledger), so the first and every repeat issuance are the same command. + +- `APIGetBadgeInvoice` starts every purchase: core loads or creates the live purchase row for the badge type's slot (per-user lock + `idx_badge_purchases_live`) — a `failed` row of the same slot is reused — creates the payment row, and points the badge row's `payment_id` at it (UX §3: the current payment). For `card` and `crypto` core sends `getBadgeInvoice` with the pinned `priceId` and the `offerId` of the chosen duration, and responds with the invoice — the Stripe link or the crypto screen data (UX 2.1). For `apple` and `google` core writes the rows, generates the invoice id itself, and sends nothing, since prices come from the store and SKUs from app config (§4); the app states the store because core is the same on both platforms. The invoice fields are stored on the payment row (§1), so pending-payment screens re-render after a restart; after invoice expiry a new `APIGetBadgeInvoice` creates a new invoice and payment row (UX 2.1). +- The app passes the invoice id to the store as the account token — Apple `Product.PurchaseOption.appAccountToken` (a UUID, echoed in the signed transaction), Google `BillingFlowParams.setObfuscatedAccountId` — so the store transaction states which payment it settles, and the service reads it from the verified store payload at `purchaseBadge`. +- `APIPurchaseBadge` completes a store purchase — the only payment whose result is delivered to the app: the store hands the app the evidence, and only that evidence ties the store transaction to the purchase, because the store flow knows neither purchase keys nor the service. Core records it on the payment row; the worker sends `purchaseBadge` — the service verifies, records the credit, and issues in one round trip (§6). Card and crypto payments need no completion command and carry no evidence: the service records their settlement from the provider webhook (UX §7 notifications); the worker's next `issueBadge` returns the credential, or `payment_pending` until the webhook arrives (§4). +- `APIPurchaseBadge` with a `code` sends the same `purchaseBadge` under the user lock, and differs only in the order of the writes: keys are generated first, and the badge and payment rows (`provider = code`, `price_id` and `offer_id` NULL) are written on success in one transaction, directly `issued`, because the badge type — and with it the slot — is stated in the response (UX 2.8). A live row of the granted slot is superseded (at most two badges per profile, UX 2.7); its unconsumed months stay on its purchase — purchases are unlinkable, so the service cannot move them; recovery per UX §3 (the `receipt` payment, post-MVP). On a timeout the error is surfaced to the user; a code consumed by a lost response is restored by support (codes tooling, delivery 7). +- `APIGetBadgeState` loads the badge state into the app model at start (and on profile switch); events only update the model afterward, so without the initial read it would hold nothing at first render — the 2.2 banner is rendered from it. It reads stored state and sends nothing itself. The same call re-reads state when a badge screen is opened or regains focus, and signals the worker (§6); reconciliation results follow as `CEvtBadgeChanged`. Screen re-focus covers the returns that fire no core trigger: the store cancellation sheet close — UX §7 "the engine sends `status` on return"; the in-app sheet fires no foreground trigger — and return to a pending-payment screen after payment (UX 2.1), which on desktop produces no foreground event either. + +`ChatResponse`: + +```haskell +| CRBadgeState {user :: User, badgeState :: UserBadgeState} +| CRBadgeCatalog {user :: User, prices :: [BadgePrice], offers :: [BadgeOffer]} +| CRBadgeInvoice {user :: User, payment :: BadgePayment} +``` + +- `CRBadgeState` — the state the badge surfaces render (banner 2.2; picker 2.3; management screen 2.6); the response of every command except `APIGetBadgeInvoice` and `APIGetBadgeCatalog`. Worker results follow as `CEvtBadgeChanged`. +- `CRBadgeCatalog` — the refreshed catalog for the open purchase screen (UX §3 prices). +- `CRBadgeInvoice` — the purchase continuation (`APIGetBadgeInvoice`): `payment` holds the invoice fields — the Stripe link, or the crypto address, amount, and expiry (UX 2.1); for a store payment, the locally generated invoice id, which the app passes to the store as the account token. +- Errors: `ChatErrorType` gains `CEBadgeServiceError {badgeError :: BadgeServiceErrorCode, message :: Maybe Text, retryAfter :: Maybe Int}` — the inline code-redemption errors (UX 2.8) and the purchase-screen unavailable notice (UX 2.1). + +`ChatEvent`: + +```haskell +| CEvtBadgeChanged {user :: User, badgeState :: UserBadgeState} +| CEvtBadgeAlert {user :: User, alert :: BadgeAlert} +``` + +- `CEvtBadgeChanged` — emitted by the worker on any state change it applies (UX 2.9 events); open badge surfaces re-render from it. +- `CEvtBadgeAlert` — the derived current alert (UX 2.4); the app displays it and answers with `APIAckBadgeAlert`. + +The reminders opt-out is an app setting: core emits `CEvtBadgeAlert` for all kinds; the app suppresses display of the two reminder kinds (`BARenewalApproaching` and `BAPrepaidEnding`) when reminders are off, and ignores them in `UserBadgeState.alert` for the picker indicator (UX 2.3). + +## 6. BadgeManager + +Runs in core. Controller state; the worker is the agent `Worker` framework the controller already uses (`getAgentWorker` / `hasWorkToDo'` / `cancelWorker`; the `TMap k Worker` fields, Controller.hs:304): + +```haskell +data BadgeManager = BadgeManager + { badgeWorkers :: TMap UserId Worker, -- agent Worker: doWork TMVar, restart on crash + badgeLocks :: TMap UserId Lock, -- one signed badge op per user in flight + badgeReads :: TMap UserId UTCTime, -- read requests from APIGetBadgeState (step B) + badgeBoundaries :: TMap UserId UTCTime, -- next boundary per user + badgeTimerAsync :: TVar (Maybe (Async ())) -- the timer thread; sleeps to the earliest boundary + } +``` + +One worker per user because badge state is per profile (UX 2.12): the worker serializes that profile's RPC ops and row writes, and profiles do not delay one another. The worker holds no task queue: a trigger only signals it, and each pass derives the work from stored state (the reconcile step below). Ops are idempotent (§4), so lost and duplicated signals are harmless. + +Lifecycle: + +1. start — the first signal for a user creates the worker via `getAgentWorker`; the timer thread is started at chat start (`badgeTimerAsync`). +2. signal — every trigger performs `hasWorkToDo'`: the UX 2.9 triggers (chat start, foreground, and network restore signal every user with a badge or payment row; profile switch — the switched-to user), the timer thread, and the commands — `APIGetBadgeState`, which also records the request time in `badgeReads`; `APIPurchaseBadge` after the evidence is recorded, and again for presentation after a code (step E); `APISwitchShownBadge` for presentation. +3. pass — the loop takes the signal and runs the flowchart below once per live badge row (the paid and the investor slots, §5). +4. re-run — a signal that arrives during a pass stays in `doWork`; the loop runs one more pass, so state changed mid-pass is picked up. +5. idle — between passes the loop blocks on `doWork`. +6. stop — workers are stopped with `cancelWorker` at chat stop and on user deletion; the timer thread with them. + +The worker and the commands that send signed requests themselves (`APIGetBadgeInvoice` — `getBadgeInvoice`; `APIPurchaseBadge` with a code — `purchaseBadge`) take the user's lock in `badgeLocks`, so one signed command per user is in flight; the same lock guards get-or-create of the badge row (§5). The unsigned, purchase-independent `getBadgeCatalog` (`APIGetBadgeCatalog`) is sent outside the lock. `badgeLocks` follows the `withEntityLock` discipline (Library/Internal.hs:127) — `chatLock` is waited for first — so the step E broadcast creates no new lock order. + +There is no read command in the protocol: every response carries the `statement`, so state advances with the work. A purchase whose balance has lapsed sends nothing, and service-side credits (support, transfer-in post-MVP) would stay unseen; the signed `getBadgeCatalog` is the check — its response adds `badgeStatement` for the signing key, and it is the same request that prices a new purchase (§4). The pass reads the `badgeReads` entry of `APIGetBadgeState`: a request newer than the last response, a lapsed balance, and no due command → signed `getBadgeCatalog`, then the entry is cleared. It is also sent after a restore (UX §3 recovery). + +Timer — each pass reports the user's next boundary, the earliest of: + +- `renews_at` − 3d — renewal reminder (UX 2.4) +- paid-through − 3d — prepaid ending (UX 2.4) +- paid-through — ended alerts (UX 2.4); sender-side perk cutoff (UX 2.11) +- `alert_snooze_until` — the snoozed alert is emitted once more (UX 2.4) +- next Monday 00:00 UTC — presentation and removal updates (UX 2.11) + +Each pass writes its user's next boundary into `badgeBoundaries`; the timer thread sleeps until the earliest entry and signals the workers whose boundaries elapsed. + +```mermaid +flowchart TD + T[trigger: UX 2.9, timer, command] --> S[hasWorkToDo'] + S --> W{pass running?} + W -- yes --> H[signal held -> one more pass] + W -- no --> A[load badges + payments + last ledger rows] + A --> B[per live badge, reconcile:\nstore evidence unverified -> purchaseBadge\npayment in status new -> getBadgeInvoice re-sent\ninvoiced/pending non-store -> issueBadge\nsubscription past renews_at -> issueBadge\nbalance > 0, month unissued -> issueBadge\nlapsed balance, read requested -> signed getBadgeCatalog] + B --> C[request under the user lock, signed with the purchase key\non ASETimeout the same envelope is retried at the next signal] + C --> D[apply response:\ncatalog -> badge_prices / badge_offers\nstatement: entries -> badge_ledger verbatim, an opening entry restates the balance;\npayments and charges -> the local rows\nverify credential -> update badge_purchases credential columns\nerror: terminal code -> badge failed; transient -> hold the command until retryAfter elapses] + D --> E[presentation:\non/after Monday 00:00 UTC, or immediately on acquisition/switch:\nshown badge credential -> setUserBadge + broadcast, fresh proof per send, incognito connections skipped\nexpired unrenewed -> setUserBadge Nothing + broadcast\npaid expired + investor held -> switch shown to investor] + E --> F[derive alert from state\nif differs from acked/snoozed -> CEvtBadgeAlert] + F --> G[emit CEvtBadgeChanged if state changed\nwrite next boundary to badgeBoundaries] + G --> I[block on doWork] +``` + +Non-store purchase: + +```mermaid +sequenceDiagram + participant UI as app + participant C as core (BadgeManager) + participant B as bot + participant P as Stripe/BTCPay + UI->>C: APIGetBadgeInvoice priceId offerId card|crypto + C->>C: load or create the badge row; create the payment row (lock + unique index) + C->>B: getBadgeInvoice (signed; priceId, offerId, badgeInfo, paymentVia) + B->>P: create intent / invoice + B-->>C: badgeInvoice (invoiceId, price, discount, amount, expiresAt, paymentTo) + C-->>UI: CRBadgeInvoice (url / address, cryptoAmount, expiresAt) + UI->>UI: browser (card) or the in-app invoice screen (crypto); user pays + P-->>B: webhook: settled + B->>B: credit the ledger + UI->>C: APIGetBadgeState (the screen regains focus) + C->>B: issueBadge (signed; badgeRequest, balance) + B->>B: debit the month; issue + B-->>C: badgeCredential (credential, receipt, statement) + C->>C: store rows; verify credential; update badge; presentation + C-->>UI: CEvtBadgeChanged +``` + +Store purchase: + +```mermaid +sequenceDiagram + participant UI as app + participant S as StoreKit / Play Billing + participant C as core (BadgeManager) + participant B as bot + participant PR as store server API + UI->>C: APIGetBadgeInvoice priceId offerId apple|google + C->>C: load or create the badge row; create the payment row with a local invoice id (lock + unique index) + C-->>UI: CRBadgeInvoice (invoice id) + UI->>S: purchase(sku from app config, accountToken = invoice id) + S-->>UI: evidence (JWS / purchase token) + UI->>C: APIPurchaseBadge paymentId evidence + C->>B: purchaseBadge (signed; badgeRequest, payment: apple jws / google token) + B->>B: verify the JWS offline (Apple) + B->>PR: verify + acknowledge the purchase token (Google) + B->>B: credit the ledger; debit the month; issue + B-->>C: badgeCredential (credential, receipt, statement) + C->>C: store rows; verify credential; update badge; presentation + C-->>UI: CEvtBadgeChanged +``` + +Badge row status: + +```mermaid +stateDiagram-v2 + [*] --> acquiring: APIGetBadgeInvoice + [*] --> issued: APIPurchaseBadge with a code, created on success + acquiring --> issued: credential verified and stored + acquiring --> superseded: a new row takes the slot + acquiring --> failed: terminal error (step D) + issued --> issued: renewal / re-issue (credential updated in place) + issued --> superseded: a new row takes the slot (a code; upgrade post-MVP) + failed --> acquiring: get-or-create reuses the slot's failed row; payment_id repointed +``` + +## 7. UX coverage + +Each UX plan point and its implementation home: + +| UX | implementation | +|---|---| +| 2.1 method + duration selector, prices | app UI; `APIGetBadgeCatalog`; `badge_prices` / `badge_offers` (§1); the 1-month entry is priced by `monthPrice` (§4) | +| 2.1 unavailable options | a tier or duration with no price is shown disabled (§4); `offer_disabled` / `product_unavailable` | +| 2.1 crypto and Stripe screens | `APIGetBadgeInvoice` → `CRBadgeInvoice`; invoice columns (§1); payment statuses from the statement's payments; partial/over payment in `payments.exception` | +| 2.1 receipt save prompt | `receipt` in the `badgeCredential` response → `payments.receipt_code`; the prompt is app UI | +| 2.2 banner | app UI over `CRBadgeState` / `CEvtBadgeChanged` | +| 2.3 user picker | post-MVP (UX §7 Deferred); state and `alert` in place; the Settings row is the MVP entry point | +| 2.4 alerts | `BadgeAlertKind`; `alert_acked_kind` / `alert_acked_episode` / `alert_snooze_until` / `grace_until` (§1); worker step F; `CEvtBadgeAlert`; `APIAckBadgeAlert` | +| 2.5 start-sharing | post-MVP (`use_from`, Scope); immediate presentation in worker step E | +| 2.6 management screen | `UserBadgeState`; the commands table (§5) | +| 2.7 held badges, switching | `users.shown_badge_id`; `APISwitchShownBadge`; the investor fallback in step E | +| 2.8 redeem codes | `APIPurchaseBadge` with a code → `purchaseBadge`; payment `provider = code` | +| 2.9 triggers | worker lifecycle item 2 (§6) | +| 2.9 engine | the worker flowchart (§6) | +| 2.9 API calls | the commands table (§5) | +| 2.10 upgrades | post-MVP (UX §7 Deferred); the `superseded` status in place | +| 2.11 dates | bot `sundayAfter` (delivery 7); Monday in step E and the timer; `mkBadgeStatus` grace (delivery 1) | +| 2.12 multi-profile | one worker per user; the incognito skip in step E | +| 2.13 pause | post-MVP; `pauseBadge`, and `issueBadge` resumes (§4) | +| §3 ownership, tables | the §1 mapping; the service schema (§2) | +| §3 catalog rules | `badge_prices` / `badge_offers`; reconciliation (delivery 4) | +| §3 payments, charges | §1; the statement's payments and charges (§4) | +| §3 ledger, issuances | replicas (§1); the `balance` assertion and the `opening` restatement (§4); service transitions (delivery 7); tests (delivery 8) | +| §3 recovery | `payments.receipt_code`; signed `getBadgeCatalog` after restore (worker); capped store re-bind at `purchaseBadge` (delivery 7); the `receipt` payment post-MVP | +| §4 wire protocol | `Badges/Service.hs`; `docs/protocol` | +| §5 providers | delivery 7; the §5 command bullets | +| §6 decisions 11–14 | 11 — `renews_at` / `cancelled` / charges kept; 12 — catalog seed (delivery 2); 13 — `sundayAfter`; 14 — `paidThrough` in `UserBadgeState` | +| §7 MVP set | Scope; the delivery order | + +## Delivery order + +1. `mkBadgeStatus`: the +7-day recipient display grace and the shifted `BSExpiredOld` boundary (UX 2.11) — released before purchases (UX §7). +2. Register migration `M20260915_user_badges` (migrations list + cabal + regenerated `chat_schema.sql` / `chat_lint.sql`); the Postgres variant of the migration; the catalog seed from app config (UX §3 prices); store functions (`Store/Badges.hs`): get-or-create with lock; last-ledger-row reads; verbatim replica inserts. +3. Instances for the types in `Badges.hs` and `Badges/Service.hs` (§3) + roundtrip tests. +4. RPC codec: `docs/protocol/badges-rpc.schema.json` packets ↔ `Badges/Service.hs` types; catalog reconciliation on every `badgeCatalog` response. +5. BadgeManager §6: worker, locks, timer; reconcile/apply/presentation/alert steps; events. +6. Commands §5 + parsers + `View.hs` rendering. +7. Bot: schema §2; ledger transitions (UX §3); providers (Apple offline JWS, Google verify+acknowledge, Stripe intents+webhooks, BTCPay invoices+webhooks); codes tooling; notifications endpoints; receipt generation and hashing on settlement (UX §3 recovery); the capped store re-bind on `purchase` (UX §3 recovery); store setup — one Apple subscription group for all subscription SKUs and the Stripe statement descriptor with the short payment ref (UX §7). +8. Tests: ledger properties 1–4 (UX §3); the `balance` assertion and the `opening` restatement; `purchaseBadge` and `issueBadge` idempotency; replica equality service↔client; price and offer lifecycle at `getBadgeInvoice`; alert derivation incl. supersession; Monday presentation incl. removal updates; provider sandbox flows. diff --git a/plans/2026-07-31-badges-service-schema.sql b/plans/2026-07-31-badges-service-schema.sql new file mode 100644 index 0000000000..a98e31c6b7 --- /dev/null +++ b/plans/2026-07-31-badges-service-schema.sql @@ -0,0 +1,146 @@ +CREATE TABLE badge_prices( + price_id TEXT NOT NULL PRIMARY KEY, + badge_type TEXT NOT NULL, + month_price INTEGER NOT NULL, + currency TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE badge_offers( + offer_id TEXT NOT NULL PRIMARY KEY, + price_id TEXT REFERENCES badge_prices, + months INTEGER NOT NULL, + discount_type TEXT NOT NULL, + free_months INTEGER, + discount INTEGER, + status TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE store_skus( + store_sku_id INTEGER PRIMARY KEY AUTOINCREMENT, + provider TEXT NOT NULL, + sku TEXT NOT NULL, + badge_type TEXT NOT NULL, + plan TEXT NOT NULL, + months INTEGER, + created_at TEXT NOT NULL +); + +CREATE UNIQUE INDEX idx_store_skus_provider_sku ON store_skus(provider, sku); + +CREATE TABLE badge_purchases( + badge_purchase_id INTEGER PRIMARY KEY AUTOINCREMENT, + purchase_key BLOB NOT NULL, + badge_type TEXT NOT NULL, + price_id TEXT REFERENCES badge_prices, + offer_id TEXT REFERENCES badge_offers, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(purchase_key) +); + +CREATE TABLE payments( + payment_id INTEGER PRIMARY KEY AUTOINCREMENT, + badge_purchase_id INTEGER NOT NULL REFERENCES badge_purchases, + badge_type TEXT NOT NULL, + price_id TEXT REFERENCES badge_prices, + offer_id TEXT REFERENCES badge_offers, + invoice_uuid TEXT, + provider TEXT NOT NULL, + provider_ref TEXT, + months INTEGER, + amount INTEGER, + currency TEXT, + status TEXT NOT NULL, + exception TEXT, + receipt_hash BLOB, + renews_at TEXT, + grace_until TEXT, + cancelled INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE UNIQUE INDEX idx_payments_invoice_uuid ON payments(invoice_uuid); + +CREATE INDEX idx_payments_purchase ON payments(badge_purchase_id); + +CREATE INDEX idx_payments_provider_ref ON payments(provider, provider_ref); + +CREATE TABLE charges( + charge_id INTEGER PRIMARY KEY, + payment_id INTEGER NOT NULL REFERENCES payments, + provider_charge_ref TEXT NOT NULL, + period_start TEXT NOT NULL, + period_end TEXT NOT NULL, + amount INTEGER NOT NULL, + currency TEXT NOT NULL, + charged_at TEXT NOT NULL, + UNIQUE(payment_id, provider_charge_ref) +); + +-- badgeLedgerTable constant (M20260915_user_badges), re-used literally +CREATE TABLE badge_ledger( + entry_id INTEGER PRIMARY KEY AUTOINCREMENT, + entry_uuid TEXT NOT NULL, + badge_purchase_id INTEGER NOT NULL REFERENCES badge_purchases ON DELETE CASCADE, + change_months INTEGER NOT NULL, + balance_months INTEGER NOT NULL, + balance_start_ts TEXT NOT NULL, + balance_badge_type TEXT NOT NULL, + was_paused_since TEXT, + service_created_at TEXT NOT NULL, + created_at TEXT NOT NULL, + entry_type TEXT NOT NULL, + entry_credit_type TEXT, + entry_debit_type TEXT, + invoice_id INTEGER REFERENCES payments, + charge_id INTEGER REFERENCES charges, + from_purchase_id INTEGER REFERENCES badge_purchases, + to_purchase_id INTEGER REFERENCES badge_purchases +); + +CREATE UNIQUE INDEX idx_badge_ledger_uuid ON badge_ledger(entry_uuid); + +CREATE INDEX idx_badge_ledger_purchase ON badge_ledger(badge_purchase_id, entry_id); + +CREATE INDEX idx_badge_ledger_invoice ON badge_ledger(invoice_id); + +CREATE INDEX idx_badge_ledger_charge ON badge_ledger(charge_id); + +CREATE INDEX idx_badge_ledger_from_purchase ON badge_ledger(from_purchase_id); + +CREATE INDEX idx_badge_ledger_to_purchase ON badge_ledger(to_purchase_id); + +CREATE TABLE issuances( + issuance_id INTEGER PRIMARY KEY, + badge_purchase_id INTEGER NOT NULL REFERENCES badge_purchases, + period_start TEXT, + period_end TEXT, + expiry TEXT, + entry_id INTEGER REFERENCES badge_ledger, + credential BLOB NOT NULL, + created_at TEXT NOT NULL, + UNIQUE(badge_purchase_id, period_start) +); + +CREATE TABLE codes( + code_hash BLOB NOT NULL PRIMARY KEY, + badge_type TEXT NOT NULL, + months INTEGER, + batch TEXT NOT NULL, + redeemed_purchase_id INTEGER REFERENCES badge_purchases, + redeemed_at TEXT, + revoked_at TEXT, + created_at TEXT NOT NULL +); + +CREATE TABLE provider_events( + provider TEXT NOT NULL, + event_id TEXT NOT NULL, + received_at TEXT NOT NULL, + processed_at TEXT, + PRIMARY KEY(provider, event_id) +); diff --git a/plans/2026-08-04-badges-mvp-scope.md b/plans/2026-08-04-badges-mvp-scope.md new file mode 100644 index 0000000000..26a4fc3081 --- /dev/null +++ b/plans/2026-08-04-badges-mvp-scope.md @@ -0,0 +1,83 @@ +# Supporter Badges v3 — MVP scope + +**Date:** 2026-08-04 +**Product plan:** `plans/2026-07-30-supporter-badges-v3-ux.md` — UX §n +**Core plan:** `plans/2026-07-31-badges-core-implementation.md` — §n +**Protocol:** `docs/protocol/badges-rpc.md`, `docs/protocol/badges-rpc.schema.json` +**Service schema:** `plans/2026-07-31-badges-service-schema.sql` + +Order: one path end to end first, then the paths that need external systems. Each milestone ends with passing tests. + +## 1. Badge service + +`apps/simplex-badge-service` — a bot on core as a library, after `apps/simplex-directory-service`. + +- `BadgeService/Options.hs`, `Service.hs` — the RPC handler; `Store.hs`, `Store/SQLite/Migrations.hs` — the schema (§2), with `badgeLedgerTable` imported from the client migration. +- Request handling: envelope version check, signer key against `purchaseKey`, dispatch on the command. +- Ledger writes: one transaction per command; the statement is read back from the written entries. +- Credential signing: the badge key set and `sundayAfter` expiry (UX 2.11). +- Codes: hashes, batch, redemption (UX 2.8). +- HTTP listener for provider webhooks — `warp`, as in `tests/NameResolver.hs`, or simplexmq `getHTTP2Server`. Stripe signature verification reads the raw body. + +## 2. Core badge API + +§5 and §6, minus alerts. + +- Commands: `APIGetBadgeCatalog`, `APIGetBadgeInvoice`, `APIPurchaseBadge`, `APIGetBadgeState`, `APISwitchShownBadge`. +- Responses and events: `CRBadgeState`, `CRBadgeCatalog`, `CRBadgeInvoice`, `CEvtBadgeChanged`. +- Store functions (`Store/Badges.hs`): get-or-create under the user lock, last-entry reads, verbatim replica writes. +- `BadgeManager`: worker, locks, reconcile, apply, presentation. The timer and `CEvtBadgeAlert` follow later. +- JSON instances for `Badges/Service.hs` and `Badges/Store.hs` — `taggedObjectJSON`, with roundtrip tests. +- Register `M20260915_user_badges` in the migrations list and cabal; regenerate `chat_schema.sql` and `chat_lint.sql`. + +## 3. Store integration — iOS, Android + +- App Store Connect: badge products, one subscription group, sandbox testers. +- Play Console: SKUs, an internal track build, licence testers. +- Swift and Kotlin: read prices and SKUs, run the purchase with the invoice id as the account token (`appAccountToken`, `setObfuscatedAccountId`), pass the evidence to `APIPurchaseBadge`, handle late results (`Transaction.updates`, `queryPurchasesAsync`). +- Store SKUs are held in app config and in the service `store_skus` table. + +Console setup starts on day one — its latency is external. + +## 4. Provider integration + +Direct from the service; `http-client-tls` is available through simplexmq. + +| provider | outbound | inbound | +|---|---|---| +| Stripe | checkout session or payment intent | webhook: settled, refunded, subscription charge | +| BTCPay | invoice with address and amount | webhook: settled, partial, expired | +| Apple | — | server notifications v2 | +| Google | Publisher API verify and acknowledge | RTDN | + +Every inbound event is recorded in `provider_events` before processing. + +## 5. Tests + +`tests/Bots/BadgeTests.hs`, after `tests/Bots/DirectoryTests.hs`: the service in-process, chat clients as apps. + +- Provider mock — a Warp app on a free port with a mutable registry, after `tests/NameResolver.hs`: responses and webhook delivery are driven by the test. +- Coverage: code redemption; invoice, settlement, issuance; store evidence; renewal charges; replica equality service to client; idempotency of every command; price and offer lifecycle at `getBadgeInvoice`. + +## 6. Ledger algebra + +After the end-to-end path works. + +- Every entry type against every prior state; the resulting balance, its start, and its badge type. +- Properties: the balance never falls below zero; an `opening` entry restates it absolutely; issuance debits exactly one month; lapse debits only elapsed unissued months. +- The client's balance function against the service ledger, over generated histories. + +## Milestones + +| # | end state | needs | +|---|---|---| +| 1 | code redemption end to end: `purchaseBadge` with a code returns a credential, the client stores the replica and shows the badge | §1 service skeleton, §2 core API, §5 harness | +| 2 | card and crypto: invoice, mocked settlement, issuance, renewal reminder date | §4 Stripe and BTCPay, webhook listener | +| 3 | store purchase on both platforms | §3, console setup | +| 4 | subscriptions: renewal charges, cancellation, grace | charges wire identifier, §4 notifications | +| 5 | ledger algebra, unit tested | §6 | + +## Settle before milestone 4 + +- The wire identifier for charges — the service's local integer cannot be resolved by the client. +- Ledger unification: whether entries reference invoices, charges, and purchases by UUID and key rather than by local id. diff --git a/plans/2026-08-27-badges-mvp-streams.md b/plans/2026-08-27-badges-mvp-streams.md new file mode 100644 index 0000000000..444ad73f91 --- /dev/null +++ b/plans/2026-08-27-badges-mvp-streams.md @@ -0,0 +1,194 @@ +# Supporter badges — beta slice and stream plan + +**2026-08-27** · protocol `docs/protocol/badges-rpc.{md,schema.json}` · model and copy rules `plans/2026-07-30-supporter-badges-v3-ux.md` · core API `plans/2026-07-31-badges-core-implementation.md`. The product plan's §4 wire protocol was superseded before implementation; `badges-rpc.md` is the protocol. + +--- + +## 1. The slice + +Beta ships one route to a badge: **buy a code in a browser, redeem it in the app.** The app asks no purchase questions — tier, duration and method are the site's. + +Deferred, still in the protocol: in-app invoices, store purchase, subscriptions, upgrades, transfers, pause, alerts. + +**First, no dependencies:** divert the apps' purchase path so nothing reaches a store charge — it takes real money today and issues no badge. The views and the store code stay in place, uncalled. + +Accepted: a code is a bearer instrument. Whoever sees it can spend it, and this slice has no revocation. + +## 2. State on `badges` + +Other branches may carry work this does not see. + +- **Written** — credential signing, verification, proofs, status, presentation; protocol types, RPC docs, JTD schema; client and service schema incl. this plan's tables; app banner, settings entry, what's-new +- **Scaffold** — badge service answers every request `unsupported_version` +- **Not written** — JSON instances for most protocol types; core client badge API, store, worker; checkout site and providers; app redeem view (title-only stub), badge-state screen, browser hand-off +- **To divert** — the app's tier/duration screens, which today lead to a store purchase + +Registering the client migration needs `STRICT` on all ten SQLite tables; Postgres must not have it. + +--- + +## 3. Contracts between streams + +**The code** — `SXB-` + 20 Crockford chars in four groups, one of them a check character. Normalisation upper-cases and folds `I`/`L`→`1`, `O`→`0`, identically both sides. Only `SHA-256` of the normalised code is stored anywhere. Site codes are `HMAC(secret, orderId)`; operator codes are random. + +**`redeemBadgeCode`** over service RPC — signed with an Ed25519 key generated per code and reused by a retry of it. Carries the badge master key and the code, nothing else. Returns the signed credential and a **statement**, or `code_invalid` | `code_used` | `code_expired` | `rate_limited` | `internal`. + +A *statement* is an extract of the badge's ledger — the months balance, as a list of entries. The ledger is authored by the service alone; the client keeps a verbatim replica and reads its balance from the last entry. + +**The catalog** — one source in the service, one total function. The site renders those totals; the browser never multiplies a price by a month count. The app does not read it this release. + +The code is the only thing crossing site → app. The site never sees a purchase key, credential or ledger; the app never sees an order. + +--- + +## 4. Stream 1 — codes for badges + +### 4.1 Redemption becomes its own command + +`purchaseBadge {badgeRequest, payment:{type:"code"}}` asks the caller for a tier and expiry it cannot know and the service must override. + +- `redeemBadgeCode {masterKey, code}` → `badgeCredential {credential, receipt?, statement}` +- `code` leaves the payment union; `purchaseBadge` keeps the rest — `apple`, `google`, `invoice`, `receipt` — and stays unimplemented +- the tier is stated by the credential, which the client verifies +- client command `APIRedeemBadgeCode {userId, code}` + +One thing here fails quietly. The request envelope carries an optional `purchaseKey`, and the service decides per command whether that key must already name a purchase — most require it and answer `unknown_purchase_key` otherwise. `redeemBadgeCode` is the exception: it *creates* the purchase, so on a first redemption the key is always unknown. Miss the special case and the lookup falls through to the default, every first redemption is refused, and nothing fails to compile because it is a runtime lookup. Worth its own test. + +### 4.2 Schema + +The tables, in layers: + +- **money** — `invoices` (an amount owed), `payments` (an amount paid), `subscription_charges`. Generic: they carry no idea what was bought. +- **catalog** — `badge_prices` (tier → price per month), `badge_offers` (duration discounts). +- **what an invoice bought** — `badge_invoices` for a purchase, `badge_code_invoices` for a voucher. Same shape, except the second names no purchase: at the time of sale none exists, and the buyer may never be the redeemer. +- **the badge** — `badge_purchases` is the anchor: keys, tier, status, and what funded it — `payment_id` for an in-app purchase, or a code. The code column is added separately on each side, each pointing at its own code table. Its balance is `badge_ledger`, its credentials `badge_issuances`, and `users.shown_badge_id` names the one on show. +- **the voucher** — `badge_codes`, service only: the hash, the tier and months it is worth, and `code_payment_status` — paid, unpaid or free — so a minted code is told from a sold one without joining invoices. Never a badge itself. +- **the attempt** — `badge_code_redemptions`, client only. It holds the code in plaintext, because the client sends the code and not its hash, and the keys the redemption is signed with, so a retry can be the same signer (§4.3). + +The beta path: the site writes an invoice and what it bought, then a code. The app redeems that code, which creates the purchase and its first issuance — and, from milestone D, its ledger. + +New here are `badge_code_redemptions` (client) and `badge_codes`, `badge_code_invoices` (service); all are in the migration modules already. Every schema change touches four files — the shared block, the client-only section and the service migrations each exist twice, SQLite and Postgres, unlinked by the build. + +### 4.3 Retry is safe + +A redemption is signed with a key the client generates for it. If that key is generated fresh on every attempt, a timeout is unrecoverable: the service may already have redeemed the code, and the retry arrives as a *different* signer, so it reads as someone else presenting a spent code — `code_used`. The user has paid and cannot get the badge. + +The fix is for a retry to be the **same** signer: + +- the service's replay keys on **(code hash, verified signer)**: the same code from the same key returns the credential it already issued and writes nothing +- so the client writes the signing keys into a `badge_code_redemptions` row **before** sending, found by the code, and a retry reads them back + +That row is a stash for the in-flight attempt. Its fate depends on the outcome: + +| outcome | the row | +|---|---| +| success | kept, and pointed at by the new purchase | +| terminal error — `code_invalid`, `code_used`, `code_expired` | deleted: the code will never work, so the keys are dead | +| timeout | **kept** — this is the case it exists for | + +The keys need a row of their own because **code redemption cannot create its `badge_purchases` row up front.** That row's badge-type columns are `NOT NULL` and a code carries no tier, so there is nowhere to put them until the service answers. An in-app purchase has no such problem — the tier was picked on screen, so it creates its purchase in `acquiring` immediately. + +### 4.4 Service + +Store layer, ledger transitions, credential signing, code minting and classification, RPC dispatcher, then the redeem and issue handlers. + +**Nothing is written until the credential is signed.** Look the code up, compute the ledger changes in memory, sign — and only then open one transaction that writes the purchase, the ledger rows, the issuance and the redemption together. Signing is the step most likely to fail for reasons unrelated to the request. If the code were marked redeemed first, a signing failure would leave it spent with no credential behind it: dead, and revivable only by an operator. Signing first means a failure touches nothing and the user can simply try again. + +**Errors say as little as possible.** An unknown code and a malformed one both answer `code_invalid`, so someone guessing learns nothing from the difference. A code already redeemed answers `code_used` — but only to a *different* key; the key that redeemed it gets its credential back (§4.3). + +### 4.5 Core client + +Badge store, commands and events, re-issue worker, redeem path. + +- verify the credential against the configured issuer keys **before** writing anything +- copy the statement's entries into the ledger replica exactly as received — never compute a balance locally, never edit or invent an entry; one author means client and service cannot disagree +- on success, one transaction: write the purchase and its issuance, copy the ledger entries, complete the code row, retire the profile's previous badge of the same kind, and point the profile at the new one — then release the lock and present to contacts +- split `addUserBadge` first — it verifies, stores and broadcasts under the global chat lock in one function and raises command errors; the redeem path needs a per-user lock and a service error code + +A profile shows one badge at a time and holds at most two: a paid one and an investor one. Redeeming a code fills that kind's place, and the purchase that was there moves to `superseded`. Its unspent months stay with it — purchases are unlinkable, so nothing can move a balance between them. That matters for **Add more months** (§4.6): a second code starts a new balance rather than topping up the old one. + +### 4.6 Apps + +- *Support SimpleX* — **Get the code** (opens the site; absent on store builds) and **Redeem the code** (everywhere) +- *Redeem code* — formats as typed, folds ambiguous characters, verifies the check character before sending, one message per service error +- *Supporter perks* — the badge, that it is shown, the date support **ends**, **Add more months**; plus the ended state +- **Diverted** — tier and duration screens leave the flow, kept compiled and uncalled; store product loading and the catalog command lose their last callers +- **Copy** — one date, the paid-through date from the ledger, never the credential's expiry; *ends*, never *renews* + +### 4.7 Issuing without selling + +Compensation codes need no new mechanism: minted by the operator, random rather than derived, printed once, stored as hashes. + +Investor badges are not a special case: an operator mints a code of that tier and it is redeemed by the same path as any other. `code_payment_status` records that it was minted rather than sold; the redemption path never reads it. + +A badge that never expires is a separate question, and deferred — the ledger holds a month count, so "forever" has no representation. Until it does, a long finite term serves: the count is a byte, so twenty years is expressible. + +### 4.8 Order of work + +**The ledger is stubbed until last** — redemption issues one credential and writes no ledger rows, and the statement comes back empty. Everything before D is a working badge without accounting. + +**First, independent:** divert the apps' purchase path (§1). + +**A — core, redeemable from the CLI.** Migration registered with `STRICT` and both dumps regenerated; code format in the shared library; `redeemBadgeCode` types, JSON, schema and docs; service store, signing, dispatcher and redeem handler; a minimal mint command. + +`APIRedeemBadgeCode` lands here **with its `chatCommandP` parser and `View.hs` rendering**, so a code can be redeemed from the terminal with no app involved. That is what proves the round trip — RPC delivery, signing, credential verification, the profile update — before any UI exists to confuse the picture, and it stays the fastest way to reproduce a redemption afterwards. + +Tests land here too, not later: `tests/Bots/BadgeServiceTests.hs` already starts the service in-process and hands a chat client its address, so a mint-then-redeem case is an extension of the existing harness rather than new scaffolding. Cover the code redeeming into a badge, and an unknown code answering `code_invalid`. That spec sits under `xdescribe''`, which skips when `CI` is set — so it must be run locally; CI green is not evidence for it. + +*Done when* a code minted by that command and redeemed from the terminal puts a badge on the profile and contacts see it, and those tests pass locally. + +**B — apps.** Support screen with its two actions, redeem screen, badge-state screen, copy. Both platforms in step. *Done when* the same works by pasting a code into the app. + +**C — unhappy paths.** Retry idempotency — keys stashed before sending, replay keyed on (code hash, signer). Error mapping for `code_invalid`, `code_used`, `code_expired` and a locally failed verification. + +**D — ledger.** Transitions, credit on redemption, debit on issue, lapse; statement in the response; client replica; monthly re-issue worker. *Done when* a three-month code re-issues at the month boundary and both ledgers match row for row. Property tests here: balance never below zero, issuance debits exactly one month, lapse removes only elapsed unissued months. + +### 4.9 Done means + +- an operator-minted code redeems on desktop, Android and iOS and shows on the profile and in member lists +- the same code twice from one profile returns the same badge and consumes nothing +- a timeout then a retry issues exactly one badge, no operator involved +- every service error renders inline, as does a locally failed credential verification +- an operator-minted code redeems by the same path as one bought on the site, whatever its tier +- client and service ledger rows match row for row; a crossed month boundary re-issues without a restart +- redeeming a code writes no payment row + +--- + +## 5. Stream 2 — selling codes on the web + +Takes money and produces codes: a page, card and crypto payment, and whatever server side that needs. Its only output that stream 1 consumes is a code. + +**What it owes stream 1** + +- codes in §3's format and normalisation, with only their `SHA-256` stored +- prices from the service catalog — one source and one total function, so the site and the app cannot disagree +- `badge_code_invoices` filled with what each invoice bought; the table is already in the schema + +**Its own to settle.** Hosting and deployment, endpoints, which providers, how the browser learns an order settled, how long a code stays retrievable after purchase, and what reference support resolves against. None of it reaches stream 1. + +**Before the tier page ships:** it advertises a storage-duration perk — confirm that perk exists (§6) or drop the claim. + +--- + +## 6. Stream 3 — perks + +What an active badge changes for the user: XFTP file size and storage duration, granted against the badge proof the sender presents. + +Integration points, in dependency order: + +1. a presentation context binding a proof to the operation it authorises, so a proof lifted from a profile cannot authorise an upload *(client)* +2. recipient-side size verification against the presented proof *(client)* +3. carrying the proof to the file server on upload *(simplexmq)* +4. issuer keys in file-server config, so it can verify one *(simplexmq)* +5. per-file size and retention derived from the verified badge type *(simplexmq)* + +1 and 2 are client-side and independent of the rest. Target values: supporter 2GB and 7 days, legend 5GB and 21 days. + +--- + +## 7. Ledger — for information + +The months accounting, and a mechanism inside stream 1 rather than a stream of its own: the service authors every entry, the client keeps a verbatim replica and reads the balance from the last one. + +Beta needs three operations — credit on redemption, debit on issue, lapse on elapsed months — which is milestone D. The rest of the ledger's operations arrive with the features that need them. diff --git a/plans/2026-09-01-badges-renewal-alerts.md b/plans/2026-09-01-badges-renewal-alerts.md new file mode 100644 index 0000000000..2934bc3cc6 --- /dev/null +++ b/plans/2026-09-01-badges-renewal-alerts.md @@ -0,0 +1,151 @@ +# Badge renewal and alerts — code-redeemed badges + +Continues `plans/2026-08-27-badges-mvp-streams.md` stream 1. Model `plans/2026-07-30-supporter-badges-v3-ux.md` §2.4, §2.9, §2.11, §3; engine sketch `plans/2026-07-31-badges-core-implementation.md` §6. Protocol `docs/protocol/badges-rpc.{md,schema.json}`. + +Those two older plans predate the code. Where they disagree with what is built, what is built wins — see §7. + +--- + +## 1. What this fixes + +A code says how many months it is worth. Nothing reads it. `getBadgeCode` does not select `months`, and `redeemCode` hardcodes `addMonths 1 now`, so a twelve-month code buys one month and nothing renews it. + +The ledger is the mechanism that fixes it: a redemption credits the months, each issuance debits one, and the client asks for the next one when it comes due. + +**Scope.** Badges redeemed by code. No subscriptions, invoices, store payments, upgrades, transfers or pause — those tables and error paths stay untouched and unwritten. No app UI. + +**Not in scope, deliberately.** Investor badges (the type stays, unused) and lifetime badges (being removed in `stable`, merged here later). Every badge in this slice has a balance and an expiry. + +--- + +## 2. The ledger, minimally + +Three operations, against the tables as built. + +| op | entry_type | change | writes | +|---|---|---|---| +| grant, on redemption | `credit` / `payment` | +N months | one row | +| issue a month | `debit` / `badge` | −1 | one row + one `badge_issuances` row, same transaction | +| lapse elapsed months | `debit` / `lapse` | −k | one row | + +Every row carries the state after it: `balance_months`, `balance_start_ts`, `balance_badge_type`. **The last row is the state** — nothing derives a balance by summing, on either side. + +- coverage is `[balance_start_ts, addMonths balance_months balance_start_ts)` +- `paidThrough` = `addMonths balance_months balance_start_ts`, read from the last row alone +- credential expiry = `endOfSundayAfter (addMonths 1 balance_start_ts)` — **anchored on the balance, not on `now`**, or the schedule drifts a little further from the purchase every month + +`advance t` runs before every grant and issue: lapse the fully elapsed unissued months in one row, if any. Issue requires `balance_months > 0`, `balance_start_ts <= t`, and no issuance already covering `[balance_start_ts, +1 month)` — that last check is what makes a repeated `issueBadge` return the stored credential and write nothing. + +The columns this slice never writes stay NULL: `payment_id`, `charge_id`, `from_purchase_id`, `to_purchase_id`, `was_paused_since`. A code grant has no invoice, which the wire type already allows (`payment {invoiceId?}`, absent for codes) and the storage type does not — see §7. + +`entry_uuid` is the service's identity for a row and the client's replication key. The client stores rows **verbatim** and never authors one; `entry_type_unknown` / `entry_type_value` keep a row a newer service wrote. + +--- + +## 3. Service + +- `getBadgeCode` selects `months` again; redemption grants that many. +- Redemption becomes: `advance` → grant → issue, and the response's `statement` carries the rows it wrote instead of being empty. +- `issueBadge` (`BSCIssueBadge`, already in the protocol) gets its handler: `advance`, then issue if a month is due, then reply. The `balance` the client asserts names its last held entry; the service returns the entries after it, or the whole ledger with an `opening` restatement when it names nothing the service holds. +- A repeat within an issued period replies with the **stored** credential and writes nothing. Not a re-sign: the same period signed twice yields a different signature and churns the client's credential for nothing. +- An exhausted balance is not an error: the reply carries no `credential` and a statement that shows why. +- Signing stays before every write, as in redemption. A signing failure writes nothing and the month is still due. + +--- + +## 4. Client worker + +One worker per user, on the agent `Worker` framework already used for delivery (`getAgentWorker` / `hasWorkToDo'` / `cancelWorker`, and the `TMap _ Worker` fields on `ChatController`). Per user because badge state is per profile and one profile must not stall another. + +The worker holds no queue. A trigger only signals it; each pass reads stored state and derives the work. Signals are therefore free to be lost or duplicated. + +**Pass**, per badge: is a month due, or has one lapsed, or is an alert derivable? If a request is needed, take the user's badge lock, send it signed with the purchase key, apply the response, then release. Applying a response means: copy the statement's entries verbatim, verify the credential, store the issuance, update the purchase, and re-present the badge to contacts. + +**Triggers.** Chat start, chat activate, and redemption. Network restore and profile switch belong here too and cost nothing to add, but no caller sends them in this slice. Redemption has no immediate follow-up work — the round trip returns the credential and the statement, and the redeem command already stores the rows, sets the badge and broadcasts — but scheduling is something only a pass produces, so a purchase created without a signal has no wake-up until the next start or activate. Any later path that creates a purchase signals for the same reason. + +**Scheduling.** No timer thread and no boundary map. Each pass ends by scheduling its own next wake-up, in the shape of `rescheduleWork` (`simplexmq` `NtfSubSupervisor.hs:478`): clear `doWork`, fork a sleeper that signals it at the next boundary, then block as usual. Two boundaries only, both day-granularity — the next month falling due, and `paidThrough`. + +Unlike its original, this sleeper is **tracked per user and replaced**, following `deleteTimedItem` (`Internal.hs:1687`): cancel the previous before forking, cancel with the worker at chat stop, and re-check `waitChatStartedAndActivated` on waking. Badge horizons are a month where NtfSubSupervisor's are minutes, so untracked sleepers would accumulate — one per activate — instead of retiring. + +**Expiry.** When the balance is exhausted and the last period ends, the shown badge is cleared and the profile update broadcast — the removal update of UX 2.11. This is the visible half of "the badge expired". + +**Locking.** Add a `ChatLockEntity` constructor for the badge user and use `withEntityLock`, rather than the separate `badgeLocks` map §6 sketches. Same discipline, one lock map, and the `chatLock` ordering comes for free. + +Nothing here is load-bearing for correctness: a pass derives its work from stored state, so chat start alone gives correct behaviour. It is what lets a client left running renew without a restart, and with `badgeGraceInterval` at 7 days a renewal hours late is invisible to contacts. + +Timeouts are retried as the identical signed envelope at the next signal, never on a poll timer. + +--- + +## 5. Alerts + +One alert: `BASupportEnded`, at `paidThrough` with the balance exhausted, once. + +`BAPrepaidEnding` — the 3-days-out warning — is **not** implemented, because a user cannot act on it. Topping up before the current period ends needs the service to credit a balance without issuing a credential, which is a change to the redemption path that beta does not carry; today a prepaid badge can only be continued after it has elapsed. Record that with a `TODO [badges]` in the alert derivation, where the missing branch is. The other three `BadgeAlertKind` constructors need subscriptions and stay unemitted. + +Derived from state at the end of each pass, not stored as pending: compare the derived alert with `alert_acked_kind` / `alert_acked_episode` on the purchase, emit if they differ. The episode is the value that makes this occurrence distinct — `paidThrough`. Acknowledging writes the pair; snoozing sets `alert_snooze_until`, after which the alert is emitted once more. All three columns already exist. + +--- + +## 6. Command and event surface + +None of this exists — the names in §6 of the older plan have no code behind them. The minimum that makes the slice observable and testable from the terminal: + +- `APIGetBadgeState` → the user's badges, balance, `paidThrough`, and current alert +- `APIAckBadgeAlert` — acknowledge or snooze +- `CEvtBadgeChanged` — state changed, including a renewal that arrived without a command +- `CEvtBadgeAlert` — an alert became derivable + +Each needs its `chatCommandP` parser, `View.hs` rendering, and registration in `bots/src/API/Docs/{Commands,Responses}.hs`, which `tests/APIDocs.hs` enforces. + +--- + +## 7. Types to correct first + +Declared during design, never exercised — `Badges.Types`' `BadgePurchase`, `BadgeIssuance`, `CTPayment` and `CTCharge` have no users, so none of this costs a migration or a call site. (`Simplex.Chat.Badges` has a different `BadgePurchase`, the payment-proof sum, which `Badges.Types` hides; that one is in use and unaffected.) + +- **Add a `code` credit type, on the wire and in storage.** The wire currently models a code grant as `payment` with `invoiceId` absent. Storing it as anything else would mean the client rewriting a row it is meant to replicate verbatim, so both sides gain the constructor together. It also makes the absence structural: a code's own invoice belongs to the buyer and lives in `badge_code_invoices` on the service, and the redeemer's ledger must never reference it. Cheap now, expensive once stream 2 ships. +- `CTPayment.invoiceId` becomes `InvoiceId`, not `Int64` — `invoices.invoice_id` is `TEXT`. +- `CTCharge.chargeId` becomes `Text`, matching `subscription_charges.charge_id` and its wire twin `SCCharge`, corrected in milestone A. Unused in this slice; changed so the twins stop disagreeing. +- `BadgePurchase` gains a funding sum — payment or code — rather than a mandatory `Int64` `paymentId`. Exactly one is set and the schema cannot say so, so the type should. +- `BadgeIssuance` loses its `Maybe` period, expiry and entry id. Lifetime is gone, the columns are `NOT NULL`, and every issuance is written beside exactly one `consume` row. +- `UserBadgeState`'s subscription fields stay: `renewsAt` is `Nothing` and `willRenew` is `False` until subscriptions exist. + +Out of scope but worth knowing: `badge_ledger.payment_id` references a **payment** while the wire's `payment` credit names an **invoice**. Both are NULL for a code grant, so this slice never has to resolve it. + +## 8. Order of work + +**A — ledger on the service.** The injectable clock (§9), reading `months`, the three transitions, redemption grants and issues, the `issueBadge` handler, the statement in both responses. Done when a three-month code redeems and a second `issueBadge` a month later returns a second credential, asserted against the service's own rows. + +**B — client replica.** Store the statement verbatim, read the balance from the last row, resolve §7's types. Done when the client's rows equal the service's row for row after a redemption. + +**C — worker and renewal.** Worker, lock, self-scheduling, the pass, re-presentation. Done when a badge whose month has elapsed renews with no command, and one whose balance is exhausted loses its shown badge and broadcasts the removal. + +**D — alerts and surface.** The ended alert, ack and snooze, the four commands and events. Done when the terminal shows it at `paidThrough` and acknowledging silences it. + +## 9. Testing time + +Nothing about the badge service is mocked: the harness already runs the real one in-process, so a fake would be less faithful and no faster. What is mocked is the clock. + +**An injectable `now`** — `IO UTCTime` in config, defaulting to `getCurrentTime`. No badge code calls the clock directly: the transitions already take `now` as a parameter, and so do their two callers, the service handler and the worker pass. In tests both sides read one source — real time plus a test-controlled offset — so a test can redeem a twelve-month code, jump the offset a month, signal the worker, and assert, twelve times over, in milliseconds and against the real service, real signing and real rows. + +The offset tracks real time rather than freezing it, which is what keeps the sleeper honest: `rescheduleWork` computes `actionTs - now` in shifted time and still sleeps the right real duration. + +This is preferred over making the issuance period configurable. The period is baked into the credential's signed expiry and interacts with the Sunday rounding, so shortening it to seconds means disabling the rounding too — two knobs, and production arithmetic that no test exercises. A clock offset leaves every production computation exactly as shipped and only lies about the date. + +It also reaches what waiting cannot: eight months offline, a boundary on the 31st, a leap day. Those hold the bugs. + +Real elapsed time is then needed for one thing only — that the sleeper wakes the worker at all. With the offset set a second short of a boundary that is a one-second test. + +Tests land with each step, extending `tests/Bots/BadgeServiceTests.hs`. + +## 10. Done means + +- a twelve-month code yields twelve monthly credentials, one per month, and a thirteenth request yields none +- a second request inside an issued month returns the credential already stored, unchanged +- re-issue happens without a command, from the worker's own wake-up alone +- an app offline across several months lapses exactly the elapsed ones and issues the current one +- redeeming the same code twice still yields one badge and one set of ledger rows +- client and service ledgers match row for row, and the client authored none of them +- the ended alert fires once, survives a restart, and stays silent once acknowledged +- an expired badge disappears from contacts' view without the user acting diff --git a/plans/2026-09-04-badge-binding-and-file-limits.md b/plans/2026-09-04-badge-binding-and-file-limits.md new file mode 100644 index 0000000000..72890d8ac7 --- /dev/null +++ b/plans/2026-09-04-badge-binding-and-file-limits.md @@ -0,0 +1,264 @@ +# Badge proofs bound to the conversation, and file size limits decided in core + +**Date:** 2026-09-04 +**Branch:** ep/p2p-group-signing. The work depends on member keys and signed profile messages, which this branch adds. + +## Summary + +A badge is a credential issued to a user who supports SimpleX Chat. The user shows it to others by putting a proof in their profile. This plan binds every proof to the place where it is shown: a proof in a group profile to the sender's identity in that group, a proof attached to a file to the conversation and to that file. Verification checks the binding. + +The same badge raises the size limit for files the user sends. Today each app decides whether a received file is within the limit, using the sender's profile as it is at the moment of display. After this plan, the core library decides once, when the file invitation arrives, from a proof in the invitation; stores the decision with the file; and the apps read it. A second proof arrives with the file description, the record that says where the file's chunks are stored, and is checked before the download starts. + +The changes: + +1. Three new presentation headers: one for a profile shown in a chat, one for a file invitation, one for a file description. The random header stays valid where there is no chat yet, and for the profile in direct chats until the direct binding reaches the handshake. +2. In p2p groups a badge is accepted only from a message signed by the member. The member connection handshake is signed in both directions, and the profile sent in it is stored, so the badge appears when two members connect. +3. In channels a badge is accepted from any profile message, because member keys there come from the roster, the member list signed by the channel owner. +4. Files above the default limit include a proof in the invitation and a proof in the description, in every chat type. The core library verifies both. The decision is stored on the file and shown by the apps from one field. +5. Forwarding a file above the forwarder's limit is refused with an alert before the forwarding sheet opens, and again, for the chosen destination, before anything is uploaded. +6. A received file keeps its two proofs, so a file re-sent to a new member as part of history keeps them; the sender's own files get fresh proofs from the credential. + +Two new columns on `files`, and a new table `file_badge_proofs` holding the invitation proof and the description proof of a file, kept for history. One new function in simplexmq, the hash of the fields shared by all descriptions of one upload. + +## Terms + +**Core library.** The Haskell library shared by all apps. The apps display what it decides. + +**simplexmq.** The library below the core library that transfers messages and files. The core library calls it and never changes its formats without a change there. + +**P2p group.** A group whose members connect to each other directly. A new member is introduced to each existing member by the admin who admitted them, and until the two connect, the new member's messages reach the existing member forwarded by an admin. + +**Channel.** A group whose messages go through relay servers. Members do not connect to each other. The channel owner signs the member list, the roster, which establishes each member's key. + +**Introduction.** The messages by which an admin tells one member about another: `XGrpMemNew` to the existing members, `XGrpMemIntro` and `XGrpMemFwd` to the two members being connected. They include the member's profile and public key. + +**Handshake.** The exchange when two members connect directly. Each side sends `XGrpMemInfo` with its group profile. + +**File description.** The record the sender sends after an upload completes. It lists where each chunk of the file is stored and the keys to download and decrypt them. A file cannot be downloaded without it. It is sent in parts, in `XMsgFileDescr` messages. + +**History.** The recent items the host sends to a member who has just joined. A file item is re-sent as a new invitation together with its description. + +**Badge credential.** The secret record issued to the user: an issuer key index, a master key, a BBS signature, and the badge information (type, expiry, extra). Stored in the user's own profile row. Type `BadgeCredential` in `Badges.hs`. + +**Badge proof.** A BBS proof generated from the credential for one presentation. It discloses the badge information and hides the master key. Different proofs from one credential cannot be linked. Type `BadgeProof`. In a profile it is sent as `Profile.badge`. + +**Badge status.** What a receiver concludes about a proof: `BadgeStatus` (`Badges.hs:114`) — `BSActive`, `BSExpired`, `BSExpiredOld`, `BSFailed`, `BSUnknownKey` — computed by `mkBadgeStatus`, which treats a badge as active for seven days past its expiry. + +**Presentation header.** A byte string that is an input to proof generation and to proof verification. A proof verifies only with the header it was generated with. Type `ProofPresHeader` in `Badges.hs`. `PHTest` is a random nonce. + +**Chat binding.** The byte string that identifies the sender in one conversation, produced by `encodeChatBinding` (`Protocol.hs:444`). Message signatures and shared contact cards are computed over it. For a direct chat it is `encodeChatBinding CBDirect adHash`, where `adHash` is the hash of the connection's ratchet data, which both sides obtain with `getConnectionRatchetAdHash`. For a p2p group it is `encodeChatBinding CBGroup (smpEncode (memberId, memberKey))`. For a channel it is `encodeChatBinding CBGroup (smpEncode (publicGroupId, memberId))`. `groupBindingData` (`Internal.hs:2261`) computes the inner part for groups. + +**Member key.** The Ed25519 key a member holds for one group. It is created when first needed — at group creation on this branch, or by `createUserMemberKey` before the first signed message — and the public key is sent in introductions and in `XInfo`. + +**Default limit.** `maxFileSize`, 1GB. A supporter badge raises it to 2GB, a legend badge to 5GB (`maxXFTPFileSize`, `Badges.hs:201`). The three sizes are `FileSizeLimits` in `ChatConfig`, `defaultFileSizeLimits` in production and lowered in tests. The default limit is also the size above which a proof is required. + +## 1. Presentation headers + +File: `src/Simplex/Chat/Badges.hs`. + +Extend `ProofPresHeader`: + +```haskell +data ProofPresHeader + = PHTest ByteString + | PHChat ByteString + | PHFileInv ByteString String Integer + | PHFileDescr ByteString String Integer ByteString (Maybe UTCTime) + | PHUnknown Char ByteString +``` + +- `PHChat` holds the chat binding. +- `PHFileInv` holds the chat binding and the file size from the invitation. +- `PHFileDescr` holds the same two values, then the shared description hash (section 8) and the file expiration. + +The file name is not part of either header: `validateFileInvitation` replaces it with a name valid on the local file system, and history re-sends the stored name. + +One constructor serves every chat type, because the chat binding already encodes the type of chat in its first byte. Each constructor gets a tag character in `ProofPresHeaderTag` and an encoding in the `StrEncoding` instance, in the same style as `PHTest`. The file expiration is optional, because a server may grant none; it is encoded as `strEncode` of the time, or one fixed byte when absent. The badge's own expiry is a time and is encoded with `strEncode` in the disclosed messages (`badgeInfoMessages`, `Badges.hs:296`). + +`verifyBadgeWith` today verifies a proof with whatever header the proof contains. After this change the receiver first checks that the header names the sender as the receiver knows them, and only then runs BBS verification with that header. `proofPresHeaderAccepted` is removed. What the receiver knows is already held by existing types, so no new type is added: + +- A contact request, link data, and the profile in a direct chat: the header must be `PHTest`. +- A file in a direct chat: the receiver has the contact's connection and obtains its ratchet hash from the agent, as `newContentMessage` does for a contact card (`Subscriber.hs:1883`). The binding in the header must equal `encodeChatBinding CBDirect adHash`. +- A profile or a file in a group: the receiver has the `GroupInfo` and the sender's `GroupMember`. The binding in the header must equal `groupBindingData` for that member — for a channel the group's public id and the member id; for a p2p group the member id and a key that passes the key check. + +**The key check.** A p2p binding contains the sender's member key. The receiver may know that member's key from the introduction or from a signed message, or may not know it yet. If the receiver knows a key and it differs from the key in the header, the proof fails. Otherwise the key in the header is used for this verification and never stored; keys are stored only by the introduction and by `storeMemberKey`. + +The file headers are checked the same way and then further: `PHFileInv` must also name the file size as received; `PHFileDescr` must also hold the hash of the received description and the expiration received with it. + +`PHUnknown` fails every check. A proof from a released client, which presents `PHTest` in groups, fails in groups; no badge has been issued yet, so nothing in use is affected. A released client that receives one of the new headers verifies it, because its `proofPresHeaderAccepted` admits unknown tags and BBS verification runs with the header bytes as sent. No protocol version change is needed. + +`groupBindingData` moves from `Internal.hs` to `Protocol.hs`, beside `encodeChatBinding`, because the store modules import `Protocol` and not `Internal`. Module order fixes where the check is computed: `Badges.hs` is imported by `Types.hs`, which `Protocol.hs` imports, so the header check in `Badges.hs` takes plain values — the expected binding for a channel or a direct chat, or the member id and the stored key for a p2p group — and the callers in the store compute them from `GroupInfo` and `GroupMember` with `groupBindingData`. `profileBadgeVerified` is in `Types.hs` today and cannot call `groupBindingData`; it moves to `Store/Shared.hs`, beside the other badge-verifying store code. + +`SimplexDomainProof` (`Names.hs:37`) also uses `ProofPresHeader`, as an opaque value. Its verification is unchanged. + +## 2. Presenting the profile badge + +File: `src/Simplex/Chat/Library/Internal.hs`, `presentUserBadge` (`:2178`). + +The function generates the proof for an outgoing profile. It takes a new argument, `Maybe GroupInfo`. With `Nothing` it generates `PHTest` as today. With `Just gInfo` it generates `PHChat` from the group's chat binding and the user's own member key in that group, calling `createUserMemberKey` first when the group has no key yet. + +Call sites that send a profile into a group pass the group: `Commands.hs:3953` (join via group link, the group case), `:4291` (the owner's profile to a relay); `Subscriber.hs:480` (the group case), `:611`, `:799`, `:813`, `:941`, `:1220`, `:3271`; `Internal.hs:2539` (`sendGroupProfileUpdate`). All other call sites send a direct profile and pass `Nothing`. + +The profile in a direct chat keeps `PHTest`; moving it to `PHChat` is a later change. + +## 3. Accepting the profile badge + +A received badge is verified today at seven places in the store layer, each verifying the proof with no knowledge of the sender: `profileBadgeVerified` (`Types.hs:834`), `createContact_` (`Store/Shared.hs:420`), `createJoiningMember` (`Store/Groups.hs:2089`), `createNewMemberProfile_` (`Store/Groups.hs:2459`), two contact request sites (`Store/ContactRequest.hs:169, 236`), and `linkDataBadge` (`Internal.hs:2194`). + +The direct sites keep verifying with `PHTest`. The group sites gain the `GroupInfo` and the sender's `GroupMember` where they do not have them already: `updateMemberProfile` and `updateContactMemberProfile` (`Store/Groups.hs:3430, 3453`) have the member and gain the group, and pass both to `profileBadgeVerified`; `createNewMemberProfile_` gains both from `createNewGroupMember`. A badge from a message that was not verified with the member's key is not verified at all: the caller removes it from the profile before storing, so the store function sees no badge. + +Where a badge is accepted in a p2p group: + +- `xInfoMember` (`Subscriber.hs:2738`): only when the `XInfo` was verified with the member's key. `RcvMessage.msgSigned` is `MSSVerified` when a stored key verified it. When the same message delivers the key, `storeMemberKey` has verified the signature with that key, and the badge is kept on the same basis. Otherwise the badge is removed from the profile before `processMemberProfileUpdate`. +- `xGrpLinkMem` (`:2744`): the host's profile to the joiner, signed on this branch. +- The member connection handshake, section 4. + +Where a badge is dropped in a p2p group: `createJoiningMember` and `createNewMemberProfile_`. The profile is stored without the badge, and the member's badge arrives at the handshake. + +In a channel a member's profile arrives in three ways: in `XMember` when the member joins, which the member signs and the owner verifies with the roster key (`verifyKey`, `Subscriber.hs:1656`) before `createJoiningMember`; in the introduction from a relay, stored by `createNewMemberProfile_`; and in `XInfo`. A badge in any of them is kept and verified, because member keys in a channel are established by the roster, which the owner signs, and `xGrpMemNew` rejects a relay that asserts a different key (`Subscriber.hs:3127-3134`). + +## 4. The member connection handshake + +When two p2p members connect, each sends `XGrpMemInfo` with its group profile. It is sent from two places: the reply on the member connection (`Subscriber.hs:816`) and the join of the member connection and of the direct connection to the same member (`:3272`, both joined with the same message at `:3282-3283`). The four receiving sites — `:590, 620` on the direct connection, `:810, 823` on the member connection — each have a "TODO update member profile" comment. + +- **Sign the join side.** `xGrpMemFwd` sends `encodeConnInfo $ XGrpMemInfo ...` (`:3272`), plain JSON. Change it to `encodeSignedConnInfo` with `groupMsgSigning` when the agreed version is at least `relayWebCapVersion`. The agreed version is computed three lines below, as `chatV`; move that computation above the send. Call `createUserMemberKey` before signing, here and at the reply site, as every other signing site does. +- **Parse the signature on CONF.** The member CONF site parses with `parseChatMessage` (`:745`), which discards the signature. Change it to `parseChatMessage'`, as INFO already does (`:823`). +- **Verify the signature.** At `:810` and `:823` verify the signed `XGrpMemInfo` with the member's stored key. `XGrpMemInfo` names no key, and the handshake follows the introduction, which stored the key. A member with no stored key is not verified. +- **Store the profile.** At `:810` and `:823` call `processMemberProfileUpdate` with the profile, with the badge removed when the signature did not verify. +- `:590` and `:620` stay as they are. The profile there is the same group profile, received over the direct connection to the member. The contact for a member shares the member's profile row (`createIntroToMemberContact`, `Store/Groups.hs:2684-2685`), so storing it once, on the member connection, updates both. + +## 5. The file size limit at send + +`checkSndFile` (`Commands.hs:3973`) compares the file size with the sender's limit and is called from the two content send paths only, with `Nothing` for an incognito send (`:4773`, `:4858`). `APIUploadStandaloneFile` (`:3628`) checks the hard limit and never the badge. + +The comparison stays where it is, with one change: the limit at send counts a badge as active until one day after its expiry, instead of the seven days `maxXFTPFileSize` allows a receiver. `maxSndXFTPFileSize` in `Badges.hs` computes the send limit with that rule, from `FileSizeLimits` and the current time, and the apps use the same rule for the limit they show on the compose screen (section 11), so the compose screen never offers a size the send refuses. + +Standalone uploads do not apply badge limits. `APIUploadStandaloneFile` keeps the hard limit. + +## 6. The file invitation proof + +**Type.** `FileInvitation` (`Types.hs:1555`) gains `fileBadge :: Maybe BadgeProof`. The JSON instance omits absent fields, so a released client ignores it. + +**Generation.** In `xftpSndFileTransfer_` (`Internal.hs:438`), when the file is above the default limit and the send is not incognito, generate a proof with `PHFileInv` from the chat binding and the file size, and set it in the invitation. `sndFileChatBinding` computes the binding from `ContactOrGroup`: `CBDirect` with the ratchet hash of the contact's connection, obtained from the agent as `shareChatBinding` does (`Commands.hs:4685`); `CBChannel` with the public group id for content sent as the group; the member binding otherwise. `CGGroup` gains `ShowGroupAsSender`, which both send paths already hold. + +A file sent as the group is bound to the channel because a relay forwards it as `FwdChannel` with no author, so the receiver has no member to rebind to. The receiver picks the same case from the item's `showGroupAsSender`. + +**Verification.** A file invitation arrives at three places: `processFileInvitation` (`Subscriber.hs:1957`), for a file in a content message in a direct chat or a group, called with a closure that creates the transfer; `processGroupFileInvitation'` (`:2437`), for the older `XFile` event in a group; and `processFileInvitation'` (`:2422`) for `XFile` in a direct chat. The proof is checked against the file size and against the sender: the connection's ratchet hash for a contact, the group and member for a member. The result is the decision below, passed to `createRcvFileTransfer` or `createRcvGroupFileTransfer`, which gain it as an argument and write it. + +**The decision.** A file is either allowed or prohibited; when prohibited, the apps need the limit that applied and why. + +```haskell +data FileProhibited = FileProhibited {maxSize :: Int64, badgeStatus :: Maybe BadgeStatus} +``` + +`Nothing` when allowed; `Just` when prohibited: + +- Above the default limit, and the invitation has no proof: the default limit and `Nothing`. +- Above the default limit, and the proof fails the header check or BBS verification: the default limit and `BSFailed`; `BSUnknownKey` when the issuer key index is not configured. +- Above the default limit, and the proof verifies but the badge has expired beyond the grace: the default limit and the expiry status. +- Above the limit that a verified, active badge allows: that limit and `BSActive`. + +The sender's profile badge plays no part. An invitation without a proof gets the default limit in every chat type. + +**Storage.** Two new columns on `files`: `file_max_size INTEGER`, the limit that applied, and `file_badge_status TEXT`, the badge status, both NULL when the file is allowed. A file is prohibited when `file_max_size` is set; `file_badge_status` is NULL when the invitation had no proof. `BadgeStatus` gains `TextEncoding` and field instances for the column, as `MsgSigStatus` has (`Types/Shared.hs:137`). `createRcvFileTransfer` and `createRcvGroupFileTransfer` (`Store/Files.hs:448, 469`) write both. Sent and local files, and rows from before this change, hold NULL. + +A proof that verifies is stored in `file_badge_proofs` (section 12) with kind `inv`, for history (section 9); a proof that failed is not stored, as `files.file_badge_status` records that it failed. A sent file stores its own proof the same way. + +**The chat item.** `CIFile` (`Messages.hs:684`) gains `fileProhibited :: Maybe FileProhibited`. `MaybeCIFIleRow` (`Store/Messages.hs:2279`) gains the column, the three queries that select the file columns gain `f.file_max_size, f.file_badge_status`, and the two `maybeCIFile` constructors and the five other `CIFile` constructions (`Internal.hs:449`, `Subscriber.hs:2001, 2456, 2472`, `Commands.hs:5009`) set it. + +**Accepting a file.** `acceptFileReceive` (`Internal.hs:746`) fails with `CEFileSize` when the file is prohibited. The apps stop a tap before that, from the same field. + +## 7. The file description proof + +**Type.** `XMsgFileDescr` (`Protocol.hs:454`) gains `fileBadge :: Maybe BadgeProof`, encoded with `.=?` like `fileExpires`. + +**Generation.** The description proof is generated when the upload completes, which may be hours after the send, from the credential as it is then. If the badge expired meanwhile, the proof still verifies at receivers, which allow seven days past expiry; if the user hid the badge, the credential row is still there; if the badge was renewed, the new credential is used. Only a credential deleted outright leaves the file without a description proof, and receivers then prohibit it. Nothing is copied at send. + +In the `SFDONE` handler (`Subscriber.hs:209`), when the file is above the default limit, the user holds a credential, and the send was not incognito — the handler has the chat item and its contact or group, so the same condition as at the invitation — generate one proof with `PHFileDescr`: the values of the invitation header, `sharedDescriptionHash` of any one recipient description, and `fileExpires`. `sendFileDescriptions` (`:283`) sets it on the last part for each recipient, in the direct branch (`:237`) and the group branch (`:252`) alike; the parts are split in `splitText` (`:297`). + +**Verification.** When the file is allowed and above the default limit: + +1. The part that completes the description must have a proof. `processFDMessage` (`Subscriber.hs:1940`) receives every part and calls `receiveViaCompleteFD` when the description is complete and the file was accepted. It verifies the proof on the completing part, before that call, and on success stores it in `file_badge_proofs` with kind `descr`. On failure it records the same decision as a failed invitation proof — the default limit and the badge status on `files` — so the file is refused by `acceptFileReceive` and the apps show the reason from one field. A file that requires a badge is received from the description message: `validateFileInvitation` stores its description as incomplete, so a complete description in an invitation cannot start a download unverified. +2. Check the header: binding, name, size as at the invitation; the hash equal to `sharedDescriptionHash` of the parsed description; the expiration equal to `fileExpires` from the message. +3. If the expiration is present and in the past, fail. +4. If the expiration is absent, accept. Older servers grant no expiration. This is tightened once servers are upgraded. + +The error set on failure is a new `FileError` value, so the apps can name the reason. + +## 8. The shared description hash + +File: `Simplex.FileTransfer.Description` in simplexmq. + +`sndFileToDescrs`, in the agent's `Simplex.FileTransfer.Agent`, builds one description per recipient from one set of values. The values common to the sender's description and every recipient's are `size`, `digest`, `key`, `nonce`, `chunkSize`, and for each chunk `chunkNo`, `chunkSize` and `digest`. `party` differs between sender and recipient, `replicas` differ per recipient, and `redirect` is absent from the sender's. + +Add `sharedDescriptionHash :: FileDescription p -> ByteString`: SHA-512 over a fixed encoding of those values in that order. Defining it beside the type keeps it in step with the format. + +Because the hash ignores replicas, one proof is valid for every recipient's description, including one re-sent later as history. In a channel the sender sends descriptions to the relays (`getGroupRelayMembers`, `Subscriber.hs:260`), which forward them to the members; whichever description a member receives, the hash is the same. + +## 9. History + +`sendHistory` (`Internal.hs:1366-1481`) re-sends a file item to a new member as a new invitation built from the stored name and size (`invCompleteDescr`, `:1445`) with the description in `XMsgFileDescr` parts (`:1481`). Content is not signed, so nothing from the original messages survives. + +Both proofs are read from `file_badge_proofs` by kind. For a file received from another member they are re-sent unchanged: they are bound to the original sender, and history names that sender (`fwdSender`, `:1466`), so the new member verifies them against that member's binding. For the host's own files the stored proofs are re-sent too, and are re-made from the credential over the stored headers only when the badge that made them is past the send grace and the current badge is active — never for a signed item, whose original bytes are forwarded. + +`fileExpired` (`:1439-1443`) decides which files history re-sends by the item's age against `rcvFilesTTL`, two days, and ignores the granted expiration stored with the file. That check should use the stored `fileExpires`; it is noted here because it bounds when the stored proofs are read. + +## 10. Forwarding + +Forwarding a file uploads it again from the local copy, so the forwarder's own limit applies. The forward plan (`APIPlanForwardChatItems`, `Commands.hs:1004-1050`) runs before the destination is chosen; it checks whether the file was received and exists, and nothing about size. A too-large forward fails at send, after the user has chosen the recipient. + +Two checks, both before any upload: + +- **In the apps, before the sheet.** The app has the file size and the user's own badge, so it decides without calling core, with the same send rule the compose screen uses (section 11). The per-item Forward action, which opens the sheet directly today — `forwardedChatItems = [chatItem]` (`ChatView.swift:2407`), `SharedContent.Forward(listOf(cItem), cInfo)` (`ChatView.kt:625`) — shows the alert instead when the file is above that limit. Multi-select (`ChatView.swift:1515`, `ChatView.kt:347`) makes the same check on the selected items before calling the plan, and shows the same alert with the count when any file is above it. The plan command is unchanged. +- **In the sheet.** When a forwarded item has a file above the default limit, the sheet where the destination is chosen disables every chat in which the user is incognito — a contact with `contactConnIncognito`, a group with `memberIncognito` on the membership — because the badge is not presented there and the file cannot be sent. +- **In the forward command**, `APIForwardChatItems` (`Commands.hs:1052`), once the destination is known: each file is checked against the destination's limit — the default for an incognito membership, as `checkSndFile` decides today — before any item is created or upload started. A file above it fails the command with a new `ChatErrorType` value naming the count, which the apps show as the same alert. + +## 11. The apps + +The receive decision is computed in eleven places from the sender's profile: `getMaxFileSize(protocol, senderProfile)` (`FileUtils.swift:280`, `Utils.kt:477`) and `fileSizeValid(file, senderProfile)` (`CIFileView.swift:236`, `CIFileView.kt:241`), used in `CIFileView`, `CIImageView`, `CIVideoView`, `ChatView.swift:2323`, `ChatItemView.kt:452`, with a second copy of the check in each video view (`receiveFileIfValidSize`). + +- `CIFile` gains `fileProhibited` in `ChatTypes.swift:4681` and `ChatModel.kt:4249`, with `FileProhibited` decoded from core. +- `fileSizeValid` becomes a check that `fileProhibited` is absent and takes no profile. The alert is worded by `badgeStatus` — no badge, unverified, unknown key, expired, or above the badge's limit — with `maxSize` as the figure. +- `getMaxFileSize` loses the profile argument for received files. The compose screen keeps computing the sender's own limit from the user's own badge (`ComposeView.swift:1272`, `ComposeView.kt:1423`), with the one-day rule of section 5 instead of the seven-day status; `ShareModel.swift:448, 539` and `ComposeView.kt:118` start passing the profile, so they stop showing 1GB to a badge holder. +- `ciSenderProfile` and the `senderProfile` parameters are removed from the file, image and video views and their call sites in `FramedItemView` and `ChatPreviewView`. +- `FileError` gains the new value in `ChatTypes.swift` and `ChatModel.kt`, with a message for it. +- The generated API mirrors — `bots/api/TYPES.md`, `types.ts`, `_types.py` — are regenerated for `CIFile`, `FileProhibited` and `FileInvitation`. + +## 12. Schema and fixtures + +Migration `M20260904_file_badges`, SQLite and Postgres: + +```sql +ALTER TABLE files ADD COLUMN file_max_size INTEGER; +ALTER TABLE files ADD COLUMN file_badge_status TEXT; + +CREATE TABLE file_badge_proofs( + badge_proof_id INTEGER PRIMARY KEY AUTOINCREMENT, + file_id INTEGER NOT NULL REFERENCES files ON DELETE CASCADE, + proof_kind TEXT NOT NULL, + badge_proof BLOB NOT NULL, + badge_pres_header BLOB NOT NULL, + badge_key_idx INTEGER NOT NULL, + badge_type TEXT NOT NULL, + badge_expiry TEXT NOT NULL, + badge_extra TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +) STRICT; + +CREATE UNIQUE INDEX idx_file_badge_proofs_file_id_kind ON file_badge_proofs(file_id, proof_kind); +``` + +The six proof columns are the fields of `BadgeProof` — the proof, the presentation header, the issuer key index, and the disclosed type, expiry and extra — with a conversion of its own. A file is one direction, so it has at most two proofs; `proof_kind` is `inv` or `descr`, and the unique index makes each a single upsert. The row references the file with `ON DELETE CASCADE`, so it is removed with the file, which is how file rows are removed today — by cascade from chat items, contacts and groups rather than by one function. Postgres uses `BYTEA`, `BIGINT` and `GENERATED ALWAYS AS IDENTITY`. Register in both `Migrations.hs` lists and in `simplex-chat.cabal`. Update both `chat_schema.sql` files and `chat_query_plans.txt`; `SchemaDump.hs` compares them. + +## 13. Tests + +- `BadgeTests.hs`: each header encodes and decodes; a proof generated with one header fails with another; the key check accepts an unknown key, accepts an equal key, rejects a different one. +- `ChatTests/Profiles.hs`, beside the seven badge tests: a badge in a p2p group appears at the other member after the connection handshake and not before; a proof presented under another member's binding is rejected; a badge in a channel appears on presentation. Existing tests that assert a badge at introduction time are updated. +- `ChatTests/Files.hs`, beside `testXFTPGroupFileTransfer`: a file above the default limit from a badge holder is received in a group and in a direct chat; an invitation whose proof was made for another member is refused; a description with a changed hash fails before download; a file above the limit received as history is received by the new member; a forward into an incognito membership above the default limit fails the command before any upload. +- `ProtocolTests.hs`: the new fields in `FileInvitation` and `XMsgFileDescr`. + +## Out of scope + +- Moving the direct chat profile proof to `PHChat`. +- Requiring an expiration in the description proof, once servers grant one. diff --git a/plans/2026-09-07-badge-ledger-types.md b/plans/2026-09-07-badge-ledger-types.md new file mode 100644 index 0000000000..5f830713a6 --- /dev/null +++ b/plans/2026-09-07-badge-ledger-types.md @@ -0,0 +1,100 @@ +# Badge ledger: one entry type + +This branch introduced seven types to describe ledger rows. Three hold nothing the wire's own `StatementEntry` does not already carry. Two describe a plan the caller can hold directly. One pairs a balance with a row id used for a single equality test. One holds three timestamps that two consecutive entries already determine. `StatementEntry` becomes the single representation, and nothing replaces the rest. + +| introduced | what it holds beyond `StatementEntry` | +| --- | --- | +| `LedgerBalance` | nothing — its four fields are `StatementEntry`'s | +| `ServiceLedgerEntry` | nothing — `statementEntry` exists only to rename `entryUuid`, flatten the balance, and fill in a `wasPausedSince` the service never sets | +| `LedgerRow` | nothing — a change, a balance and a type, all fields of `StatementEntry` | +| `LedgerPlan` | the rows to write, and which is the issuance | +| `SignedPlan` | the same, with the credential | +| `LedgerTip` | a numeric row id beside a balance | +| `BadgePeriod` | three timestamps, all derivable from two consecutive entries | + +## The entry is the state + +The ledger's central property is that the last row *is* the balance. `StatementEntry` already carries it — `balanceMonths`, `balanceStartTs`, `balanceAnchorTs`, `balanceBadgeType` — alongside the change that produced it and the type of operation that made it. So an operation is a function from the last entry to the next. The three keep the shapes they have today, with `LedgerBalance` replaced by `StatementEntry`, a uuid added, and the credit type moving in from `ledgerPlan` because a grant now writes its own entry type: + +```haskell +lapseEntry :: UTCTime -> Text -> StatementEntry -> Maybe StatementEntry -- was advanceBalance +issueEntry :: UTCTime -> Text -> StatementEntry -> Maybe StatementEntry -- was issueMonth +grantEntry :: UTCTime -> Text -> Int -> StatementCreditType -> StatementEntry -> StatementEntry -- was grantMonths +``` + +Each sets its own `changeMonths` and `entryType` — `SEDebit SDLapse`, `SEDebit SDBadge`, the caller's credit — so the count and the type it is recorded under travel together and cannot be mismatched. `Nothing` means the operation does not apply: nothing has elapsed, or no month is due. `grantEntry` is total, because a grant always applies. + +The arithmetic inside them does not change. `entryId` and `createdAt` are supplied and never read, and `wasPausedSince` stays `Nothing` as the service sets it today. The price is that uuid generation moves from the store to the caller — which is also what lets an entry be identified before it is written, and be the value the credential is stored against. + +The client is already here: `getBadgeLedgerLastEntry` returns a `StatementEntry`, because that is what the wire sends and what `badge_ledger` stores. This makes the service match rather than inventing a shape of its own. + +## No plan, just composition + +`ledgerPlan` goes too. A request lapses first, as its own entry, and the caller chains the rest, keeping whichever came back: + +```haskell +let lapsed = lapseEntry now uuid1 tip + current = fromMaybe tip lapsed + issued = issueEntry now uuid2 current + rows = catMaybes [lapsed, issued] +``` + +A redemption is the same chain with a grant between the two: `lapseEntry`, `grantEntry`, `issueEntry`. It grants the months and issues the first of them at once, which is what `ledgerPlan` does today. + +Do not skip the lapse before a grant. `grantEntry` restarts the run only when the balance is empty, so elapsed months still on the books get added to instead: three months bought on 10 January and another code redeemed on 10 June gives five months running from January, every one already spent, where lapsing first gives two running from June. + +The caller generates one uuid per entry it might write. These two are the only callers, so a function to compose them would serve two sites that differ by one step. + +This disposes of `LedgerPlan` and `SignedPlan` without replacing them: the rows are a list the caller already holds, and the credential belongs to `issued`, a local in scope rather than something to find by position or by type. + +Two store functions simplify with them. `getLedgerEntries` returns `[StatementEntry]`, so `credentialResponse`'s `map statementEntry` disappears. And `appendLedgerPlan` loses both its `TVar ChaChaDRG` and its `now`, since the entries now carry their own ids and timestamps — it gains only the issuance's predecessor, which the caller has as `current`. + +## The period is derived + +`BadgePeriod` carried what an issuance writes to `badge_issuances`. Given the issuance entry and the one before it, all three values are already there: + +- `periodStart` is the previous entry's `balanceStartTs` +- `periodEnd` is the issuance entry's own `balanceStartTs`, because issuing moves the start to the period end +- the expiry is a pure function of `periodEnd` + +The client already derives the two bounds this way in `getIssuedPeriod`, and takes the expiry from the credential, which is the service's own computation arriving back. On the service, compute the expiry at both points it is needed — signing, then writing the issuance row — from the same entry through the same function. + +The predecessor is not always one of the written rows — when nothing lapsed, the entry before the issue is the tip. Composing at the call site names it anyway: it is `current`, the value the issue was computed from, so the writer has it without having to look for it. + +## The tip is just an entry + +`LedgerTip` is the newest row's id and balance. The id has one use — checking that no row was written while the plan was being signed — and that only asks whether it is still the same row, which `StatementEntry`'s uuid answers. So `getLedgerTip` returns a `StatementEntry`. + +Reading a row as an entry means decoding its type, which `entryTypeFromColumns` does only partially — but totally over `code`, `badge` and `lapse`, which is everything the service writes. So `Nothing` still means no rows. + +## What stays shared + +The client authors no entries — it stores what the service sends and reads the last one back. So `lapseEntry`, `issueEntry` and `grantEntry` are the service's alone after this change, though `Ledger.hs` stays one module and the client simply imports less of it. + +The rest of the module changes only in its argument: `paidThrough`, `elapsedMonths`, `monthsFromAnchor` and `monthAfter` take a `StatementEntry` where they took a `LedgerBalance`. `addMonths`, the tag functions and the column helpers are untouched; the week-boundary function is shifted a day and renamed by the worker plan, not here. `elapsedMonths` need no longer be exported, since `lapseEntry` is the only caller. The client keeps `paidThrough` and gains the check below. + +The client's remaining use, `ledgerPlan` inside `badgeWorkDue`, is removed by the worker plan. + +## Verifying what the service sends + +The client authors nothing, so it takes the service's arithmetic on trust — while holding everything needed to check it. + +**Where.** In `applyBadgeStatement`, as the rows are stored — the one place holding both the arriving entries and the stored tip, in one transaction. The tip is always read, and `previousEntryId` selects nothing: it is the predecessor of the statement's first entry alone, and a replayed first entry is not rewritten, so its verdict is discarded either way. A statement that resets the ledger legitimately does so with an `opening` credit, which is checked without a predecessor. + +**What.** Re-run the operation the entry claims and compare, rather than restate its arithmetic as rules. Each operation is a total function of a predecessor and a timestamp, and the entry carries the timestamp it was computed with. The predecessor is the previous entry as received, the tip for the first, or `emptyEntry` when there is no tip — what `redeemCode` grants onto, so a ledger's first credit is not a rule of its own. An `opening` credit is the exception: it resets the ledger to the amount it states with no relation to the entry before it (badges-rpc.md), so it is checked against nothing but itself — the months it credits, and the purchase's badge type. + +Re-running is what catches over-lapsing: writing off three months when one elapsed adds up against its predecessor, and it empties `balanceMonths` while leaving `paidThrough` untouched, so the badge stops renewing while the ledger still reads as paid up. Two values cannot be re-derived and are bounded instead — a credit's months, of which only the sign is checkable, and `createdAt`, which every recompute is anchored on. A `createdAt` before its predecessor's is the service against its own clock and is marked bad; one beyond the client's clock is two clocks disagreeing, so the row goes unjudged rather than accused. + +**What happens when it fails: store the row and mark it.** Not refuse. Perks do not depend on the ledger — the credential is signed independently and a receiver verifies that signature — so rejecting a statement would strand a badge the service considers paid while proving nothing. The ledger is the user's record of what was spent, and the useful response to arithmetic that does not add up is to keep it and be able to point at the line. + +**The column.** `balance_checked`, per entry — `1` when re-running reproduced the row, `0` when something contradicted it, and null when nothing was re-run: an unknown tag, a debit declared but unimplemented, or a timestamp this client's clock cannot corroborate. Marking an unrebuildable type `0` would report a newer service's correct row as broken, so those are held only to what is true of any operation — the months add up, the balance is not negative, coverage does not move backwards. Not `verified`, which already means signature verification on profiles and would read as the same thing. + +The check belongs in `Ledger.hs`, beside the arithmetic it verifies — `monthsFromAnchor` is internal there and would otherwise have to be exported. The column ships with the rest of the ledger schema, keeping it out of a migration of its own. + +## The tests move with the types + +The eight ledger-transition tests in `BadgeTests.hs` and their helpers move to `StatementEntry`. Assertions on `balanceMonths`, `balanceStartTs` and `paidThrough` survive as they are. The ones on periods do not: `pass` no longer returns a `BadgePeriod`, so `testTwelveMonths` and `testLapseAfterGap` state their period bounds as consecutive `balanceStartTs` values instead. + +## Not in scope + +The wire format does not change: `StatementEntry` is what the service already sends and the client already stores, and every column `badge_ledger` has today is still written. The only schema change is `balance_checked`, on the client's `badge_ledger` alone — the service has nothing to check, since it is the side that computes. diff --git a/plans/2026-09-07-badge-worker-simplification.md b/plans/2026-09-07-badge-worker-simplification.md new file mode 100644 index 0000000000..d4de387489 --- /dev/null +++ b/plans/2026-09-07-badge-worker-simplification.md @@ -0,0 +1,168 @@ +# Badge worker: one thread, one sleep + +Today each user has two threads: a worker, and a sleeper that outlives each pass and signals it. That is where the `Weak ThreadId`, the swap-and-kill in `scheduleBadgeWake`, and the sleeper half of `stopBadgeWorkers` come from. The next wake is the earliest of four separately-derived times — a retry after a failed request, the snooze expiry, the stall floor, and the ledger's next renewal — and behind the first sit a per-purchase `(elapsed, delay)` map and three functions to advance it. + +This assumes one badge per user. Most of what collapses here collapses because the maps keyed by purchase have a single key, and because `updateUserBadges` and `updateBadgePurchase` become one function, `updateUserBadge`. + +```haskell +data BadgeWorker = BadgeWorker + { badgeWorkerAsync :: Async (), + badgeWork :: TMVar () + } + +badgeWorkers :: TMap UserId (SessionVar BadgeWorker) +badgeSeq :: TVar Int +``` + +The loop, which the rest of this fills in: + +```haskell +runBadgeWorker :: User -> TMVar () -> CM () +runBadgeWorker user badgeWork = do + emitted <- newTVarIO Nothing + ri <- asks $ badgeRetryInterval . config + forever $ do + at_ <- withRetryInterval ri $ \_ loop -> do + now <- badgeNow + let done = pure $ Just $ badgeStalledInterval `addUTCTime` now + updateUserBadge user emitted now `catchAllErrors` retryBadgeError loop done + now <- badgeNow + liftIO $ waitBadgeWake badgeWork now at_ +``` + +The clock is read twice because a retry sequence can run for hours, so the wait needs a fresh reading rather than the one the pass started from. + +## Waiting + +`registerDelay` makes the deadline an STM value, so the wait is a single transaction over the timer and the signal — no second thread, and the transaction reports which of the two woke it: + +```haskell +waitBadgeWake :: TMVar () -> UTCTime -> Maybe UTCTime -> IO () +waitBadgeWake badgeWork now = \case + Nothing -> atomically $ takeTMVar badgeWork + Just at -> waitFor $ diffToMicroseconds $ diffUTCTime at now + where + waitFor time + | time <= 0 = pure () + | otherwise = do + let maxWait = min time $ fromIntegral (maxBound :: Int) + timer <- registerDelay $ fromIntegral maxWait + signalled <- atomically $ do + w <- tryTakeTMVar badgeWork + fired <- readTVar timer + unless (isJust w || fired) retry + pure $ isJust w + unless signalled $ waitFor $ time - maxWait +``` + +This is `threadDelay'` with an escape hatch, and it keeps that function's names so it reads as one. Nothing survives the wait, so there is no handle to keep and nothing to kill. `Nothing` means sleep until signalled. + +`registerDelay` takes an `Int`, so the loop is needed for the same reason `threadDelay'` has one: `maxBound :: Int` is ~292,000 years on 64-bit and ~36 minutes on 32-bit. A month is one sleep everywhere except legacy armv7a, which re-arms — as it already does today. Counting down the remaining time rather than re-reading the clock keeps this correct when a test has shifted `badgeCurrentTime`. + +Taking the signal and reading the timer in one transaction means a signal is never consumed and discarded. + +## Retrying + +`withRetryInterval` holds the backoff in its own recursion — no map, no `TVar`, nothing persisted: + +```haskell +retryBadgeError :: CM a -> CM a -> ChatError -> CM a +retryBadgeError loop done e = eToView e >> if badgeErrorRetry e then loop else done +``` + +Same shape as `retryOnError` (`FileTransfer/Agent.hs:260`), except that it classifies with `badgeErrorRetry` rather than `temporaryOrHostError`, which does not cover `AGENT (A_SERVICE ASETimeout)` — the likeliest renewal failure, and using it would leave the retry inert for the common case. + +`updateUserBadge` returns the next wake, as `updateBadgePurchase` does today. Retry-or-stop is a property of the error rather than a return value, so no result type is needed and `BadgeRetry` collapses to `badgeErrorRetry :: ChatError -> Bool`. + +A service error is the exception, since it is returned rather than thrown: `requestBadgeIssue` becomes `Either (Maybe UTCTime) LedgerBalance`, where `Left` means do not retry and wake then. That is where a `retryAfter` hint lands — `withRetryInterval` owns the sleeping, so a wait the service names becomes a wake time — and where a terminal code lands as the stall floor. Floor the hint at `initialInterval`, since a service answering `0` would otherwise spin the worker, but do not cap it: a service that names a long wait is denying issuance, which it can already do by refusing, and `paidThrough` remains a wake candidate regardless of what it says. + +**The stall floor is a day.** A terminal failure is not retried, and the ledger boundary is a whole term away. The client's own state cannot change without the service, so the only thing that can make the next attempt succeed is the service being repaired — a day is slow enough not to press a service already failing, and fast enough to recover well inside the badge's 8-14 days of headroom. + +**The loop cannot die.** Catching everything except cancellation means no exception ends it, and a failure that reaches the top returns the stall floor as its wake, so a persistent fault is one attempt a day rather than a hot loop. That replaces the `Worker`'s rate-limited restart. + +## When renewal is due + +Renewal is driven by the **credential's expiry**, not the ledger's period end. A badge whose period has ended but whose credential is still valid needs nothing done — the holder keeps their perks, and the service is not asked early. + +The expiry rounding moves one day later, from the Monday after the period to the Tuesday: `endOfSundayAfter`'s `8 - dayOfWeek` becomes `9 - dayOfWeek`. Periods ending anywhere in one Monday-to-Sunday week still share a single expiry, so the anonymity set is unchanged; it now falls on a weekday in every timezone, where Monday 00:00 UTC is Sunday evening in the Americas and puts a failed renewal on a weekend for support. The function is renamed for the day it now returns, and the range in `testSundayExpiry` widens from 1-7 days to 2-8. + +Renewal splits into two steps, normally a day apart: + +- **Request**, on the Monday — while the held credential is still valid, so a failure has a day of slack before anything is visible. +- **Present**, on the Tuesday — as the old credential lapses, so the profile broadcast does not correlate with the request that produced it. + +Neither needs a marker. Both derive from the shown credential's expiry, which the profile already stores, and from whether a newer issuance exists, which `presentIssuedBadge` already compares: + +- **Request** when the shown credential expires within a day, months remain, and the newest issuance is still the one shown — so nothing has been requested yet. A successful request moves the newest expiry a month out, and the condition stops holding by itself. +- **Present** when the newest issuance differs from what is shown and the shown credential has expired — or when nothing is shown at all, which is the state a crash between the issuance write and the profile write leaves behind, and which `testPresentationCatchesUp` covers. + +Ordering falls out of that: presenting cannot precede requesting, because nothing differs until the request succeeds. + +`badgeBoundary` becomes the next of three moments: `shownExpiry - 1 day`, `shownExpiry`, and `paidThrough`. The ledger's `balanceStartTs` goes, since renewal is no longer month-aligned — but `paidThrough` stays, and for a different reason from the other two. The credential's expiry window is what covers renewal: the client renews around it to join the anonymity set, and the recipients' grace period keeps the badge honoured while that happens. `paidThrough` is when entitlement itself ends. The worker has to be there for it, to retire the badge and raise the alert that tells the user to buy again; waiting for the credential to expire would leave them wearing a badge they have stopped paying for. + +Missing a week is safe. A worker whose first run is the Wednesday finds both conditions true and does both in one pass: that renewal loses its anonymity benefit, and nothing else changes, which is the same property every other wake here has. + +## The pass + +```haskell +updateUserBadge :: User -> TVar (Maybe BadgeOccurrence) -> UTCTime -> CM (Maybe UTCTime) +updateUserBadge user emitted now = do + (p, balance) <- ... + retired <- retireExpiredBadge user p now balance + balance' <- + if retired + then pure balance + else do + b <- if requestDue p balance now then requestBadgeIssue ... else pure balance + when (presentDue p now) $ presentIssuedBadge user p now + pure b + emitBadgeAlert user emitted p now balance' + pure $ earliestTime [badgeBoundary now p balance', snoozeAt p now] +``` + +Retiring ends the renewal half of the pass. It means `paidThrough <= now`, so every funded month has already passed and a request could only write lapse rows — it cannot issue, because `advanceBalance` consumes the balance before `issueMonth` sees it. Reconciling the ledger for a badge that is over is not worth a round trip; a later redemption reconciles it anyway by asserting the last entry. + +The guard is on `retired` rather than on the purchase's `shown` field because `p` was read before retirement and its `shown` is stale within the pass. `presentIssuedBadge` has its own `not shown` check, which covers later passes but not this one. + +The alert stays outside the guard: support-ended fires exactly when the balance is exhausted, which is the pass that retires. + +Retirement comes first because it needs no service and reads only stored state, and because an unbounded retry does not return while a failure lasts — anything after the request is unreachable meanwhile. Moving it ahead of the request is behaviour-preserving: `paidThrough` is invariant under issue and lapse, only a grant moves it, and `BSCIssueBadge` never produces one, so it gives the same answer either side. + +## Starting and stopping + +Chat start, `/_app activate`, `APIGetBadgeState` and a redemption can each ask for the worker at the same moment. Exactly one thread must be started, and every caller must come away holding it. + +`getAgentWorker'` manages that by doing the lookup and the create in a single STM transaction, which works only because creating a `Worker` allocates a few TVars and nothing else. Starting a thread is IO and cannot happen inside a transaction, so for us the lookup and the create come apart, and two callers can both find nothing. + +`SessionVar` closes the gap by putting a `TMVar` in the map instead of the value — the map holds the promise of a worker rather than a worker. `getSessVar` either inserts an empty one and returns `Left`, meaning you are the creator, or finds an existing one and returns `Right`. Exactly one caller gets `Left`. + +```haskell +withGetSessVar' badgeSeq userId badgeWorkers now startWorker signalExisting + where + startWorker v = do + badgeWork <- newTMVarIO () -- full: a new worker has work to do + a <- async $ runBadgeWorker user badgeWork + let w = BadgeWorker {badgeWorkerAsync = a, badgeWork} + w <$ atomically (putTMVar (sessionVar v) w) + signalExisting v = do + w <- atomically $ readTMVar $ sessionVar v + w <$ atomically (void $ tryPutTMVar (badgeWork w) ()) +``` + +`readTMVar` blocks the other callers until the creator fills the var, so they signal the one worker rather than starting a second. The hazard is the creator dying between those two steps, leaving an empty var everyone waits on forever; `withGetSessVar'` wraps the creating branch in `bracketOnError` and drops it from the map so the next caller creates a fresh one. + +Shutdown follows `closeAgentClient`, which stops a `TMap k (SessionVar (Async ()))` the same way: swap the map out, then for each var fork a thread that waits on `readTMVar` and `uninterruptibleCancel`s what it finds. Waiting rather than skipping is what catches a worker created after the swap; the fork is so shutdown does not block on it. + +## What goes away + +| removed | replaced by | +| --- | --- | +| `Worker`, `getAgentWorker'`, `cancelWorker`, restart accounting | `Async`, `badgeWork`, `SessionVar` | +| `scheduleBadgeWake`, `Weak ThreadId`, `killWeakThread`, sleeper cleanup | `waitBadgeWake` | +| `BadgeAttempt`, `nextBadgeAttempt`, `badgeAttemptDelay` | `withRetryInterval`'s own recursion | +| `BadgeMemory` and its two maps | one `TVar (Maybe BadgeOccurrence)`, once a user has one badge | +| `BadgeRetry`'s two constructors | `badgeErrorRetry :: ChatError -> Bool` | + +Wake candidates go from four to three. The one that goes is the retry, because `withRetryInterval` sleeps between attempts rather than returning a time; the next request or present day, the snooze expiry, and the stall floor — or a wait the service named — all remain. + +Unchanged: everything derived from stored state so a wake early, late or missed changes only timing; the alert-occurrence memory; `badgeCurrentTime`; `RetryInterval` in config, both as the existing pattern and so a test can shorten it. Signalling `badgeWork` replaces `startBadgeWork` at the same call sites. diff --git a/plans/2026-09-12-fix-passcode-submit-split-screen.md b/plans/2026-09-12-fix-passcode-submit-split-screen.md new file mode 100644 index 0000000000..f5051e6721 --- /dev/null +++ b/plans/2026-09-12-fix-passcode-submit-split-screen.md @@ -0,0 +1,147 @@ +# Fix Submit button not visible in passcode view in split screen + +## Problem + +On Android in **split screen**, the "Enter Passcode" view shows the title and the +keypad but **no Cancel/Submit buttons** — the app cannot be unlocked with a passcode +without leaving split screen. + +The same `PasscodeView` also backs `SetAppPasscodeView`, so "New Passcode" / +"Confirm passcode" (Privacy & security → passcode, and the passcode onboarding in +`AppLock.setPasscode`) are affected identically. + +Desktop is affected too whenever the window is short (below ~590dp of height at a +typical width), since desktop always uses the vertical layout. + +## Cause + +Two independent defects, both invisible at full screen. + +### 1. The keypad consumes the space the buttons need + +`PasscodeView.VerticalLayout` is a `Column` of three children — title, keypad, +buttons `Row` — and the keypad's size is derived from the constraints it is offered +(`PasswordEntry.kt`): + +```kotlin +val s = if (appPlatform.isAndroid) minOf(maxWidth, maxHeight) / 4 - 1.dp else ... +``` + +`Column` measures unweighted children in order, each with `maxHeight` reduced by what +the previous ones took, so the keypad — the **second** child — is offered *all* +remaining height and a 4-row grid then occupies exactly that. The buttons `Row` is +measured last, from what is left, and `Modifier.heightIn(min = 70.dp)` cannot rescue +it: `heightIn` constrains its target into the incoming constraints, so a 70dp minimum +against a 1dp maximum yields 1dp. + +At full screen the bug is dormant because `maxWidth` is the smaller term there — the +keypad is sized by width and the leftover height goes to the buttons. It appears as +soon as the remaining height drops below the width, which every split-screen half does. + +Measured with a Compose harness that renders these composables at fixed window sizes +(411dp wide, density 1, `Surface(Modifier.fillMaxSize())` as in `LocalAuthentication.kt`): + +| window | keypad `maxHeight` | key size | buttons Row height | Submit | +|---|---|---|---|---| +| 411×914 (full screen) | 805 | 101.75 | 70 @ y=763 | 108×40 ✓ | +| 411×520 | 411 | 101.75 | **0** | 108×0 | +| 411×480 | 371 | 91.75 | **0** | 108×0 | +| 411×445 (split half) | 336 | 83.0 | **1** @ y=444 | 108×**1** | +| 411×400 | 291 | 71.75 | **0** | 108×0 | + +`HorizontalLayout` has the same defect in the width axis: its keypad is 4 keys wide +and sized `minOf(maxWidth, maxHeight) / 3.5f`, so the keypad's *width* grows with the +available *height*, and the Cancel/Submit `Column` — the **second** child of the `Row` +— gets the remainder. At 411×365 it is offered `maxWidth = 24dp` and Submit measures +**0×0**; at 411×445 it is offered **0dp** and Submit is placed at **x=441**, outside a +411dp-wide window. + +This branch matters in split screen because `windowOrientation()` reads +`Configuration.orientation`, which is computed from the *activity window* bounds, not +the device: a split half of a tall phone is `PORTRAIT` (~411×445) while a half of a +16:9 phone is `LANDSCAPE` (~411×365). Both layouts are reachable, and both were broken. + +### 2. No window insets + +`MainActivity` calls `enableEdgeToEdge()`, and this screen never applied insets — at +full screen `SpaceEvenly` happened to leave the buttons ~80dp clear of the navigation +bar. Once the buttons are laid out correctly in a split half, they land at the window +edge: with fix 1 alone, Submit occupies y=390…430 of a 445dp window, and a 48dp +3-button navigation bar covers y=397…445 — **33 of its 40dp**, so the button is drawn +but not reliably tappable in the bottom split window. + +## Fix + +`Modifier.weight(1f, fill = false)` on the keypad in the vertical layout and on the +keypad column in the horizontal layout (`PasscodeView.kt`), which requires a `modifier` +parameter on `PasscodeEntry` (`PasswordEntry.kt`): + +```kotlin +PasscodeEntry(passcode, true, Modifier.weight(1f, fill = false)) +``` + +`Column`/`Row` measure **weighted children after all unweighted ones**, so the title +and the buttons are measured first at their natural size and the keypad receives what +is left — the inverse of the current order, with no size constant to keep in sync. +`fill = false` keeps the keypad at its own size rather than stretching it, and because +a weighted child makes the container expand to the incoming bounded maximum, the +`SpaceEvenly` / `SpaceBetween` distribution at full screen is unchanged. + +Plus `Modifier.systemBarsPadding()` on both layout roots. + +Measured after the fix: + +| window | branch | key size | Submit | +|---|---|---|---| +| 411×445, 24dp status bar (top split half) | vertical | 59.5 | 108×40 @ y=390…430 ✓ | +| 411×445, 48dp nav bar (bottom split half) | vertical | 53.5 | 108×40 @ y=342…382, clear of the bar ✓ | +| 411×365, 48dp nav bar | horizontal | 29.3 | 108×40 @ x=263…371 ✓ | +| 411×914 (full screen) | vertical | **101.75 (unchanged)** | 108×40, column y=24…866 | + +The insets go on the `Column`/`Row` themselves rather than a wrapping `Box`: Material +`Surface` lays its content out with `propagateMinConstraints = true`, so the layout root +receives the window size as a *minimum*. A plain `Box` in between drops that minimum +(its own default is `false`), the `Column` becomes wrap-content, `SpaceEvenly` has no +slack left to distribute, and the whole screen top-aligns — measured as the buttons +moving from y=763 to y=544 at full screen. Keeping the padding on the root preserves +the propagation. + +## Scope / non-goals + +- Both files are `commonMain`, so the fix covers Android and desktop; on desktop + `systemBarsPadding()` resolves to zero insets and only the `weight` change has an + effect (short desktop windows). +- The keypad necessarily gets smaller in a small window: 53.5–59.5dp keys in a portrait + split half, 29.3dp in the 411×365 horizontal case, where the title, reason and + passcode text consume ~170dp before the keypad is measured. Making the passcode + screen itself more compact below some height (smaller title, dropping the reason + line) is a separate design change and is not attempted here. +- `systemBarsPadding()` also shrinks the keypad in full-screen landscape (79.6 → 59.0dp). + That is the same 72dp the bars always occupied — previously the keypad's bottom row + extended ~8dp under the navigation bar — but it is a visible change on a screen that + did not show the reported bug. +- Not changed: `windowOrientation()` branching on `Configuration.orientation`. Selecting + the layout from the measured aspect ratio (as iOS does) would be a better fit for + resizable windows, but it changes behaviour on every device rather than fixing this bug. + +## iOS + +Not affected, and for a structural reason worth recording: iOS picks its layout from the +*measured* geometry rather than an orientation flag — +`if g.size.width < g.size.height * 2 / 3` (`PasscodeView.swift`) — so a short, wide +window (the geometry that breaks Android's vertical layout) selects iOS's horizontal +layout, whose keypad is sized from height (`s = height / 5`) with the buttons column +explicitly bounded to `height / 5 * 3 * 0.97`. The vertical layout's width-driven keypad +(`s = width / 3`) is only ever used when the window is at least 1.5× taller than wide, +where it fits by construction. No iOS change required. + +## Verification + +- Compose measurement harness at the window sizes tabulated above, before and after, + for both layout branches and for 24dp/48dp/72dp inset combinations. +- Android arm64 debug APK (`bash ~/build/android.sh`) — manual check of Submit in both + the top and the bottom split-screen half, and no visual change at full screen. +- Linux x86_64 AppImage (`bash ~/build/linux.sh`) — manual check with the window resized + short, confirming the desktop vertical layout keeps Cancel/Submit visible. +- Also exercise "New Passcode" / "Confirm passcode" (`SetAppPasscodeView`) in split + screen, which share `PasscodeView`. diff --git a/plans/2026-09-13-group-keys-sum-type.md b/plans/2026-09-13-group-keys-sum-type.md new file mode 100644 index 0000000000..5d8e8e98ab --- /dev/null +++ b/plans/2026-09-13-group-keys-sum-type.md @@ -0,0 +1,252 @@ +# Group keys as a sum type + +Branch: `master`, on top of `core: refactor groups`. + +## Summary + +`GroupInfo.groupKeys` is removed, and the user's private keys leave every API response and event. + +Group keys become a sum type with one constructor per kind of group, each holding the user's member key. + +A group and its keys come from one query. Every read of keys is a read of the group. + +The member key is written at every group insert, and generated at the first read of a row created before this change. `createUserMemberKey` is removed. + +## Terms + +- **p2p group** — `use_relays = 0`. The user's member key signs messages. +- **public group** — `use_relays = 1`. Identified by `public_group_id` and the group root key. +- **member key** — `groups.member_priv_key`, the user's own key in the group. +- **root key** — the group's identity key. The owner holds it as `GRKPrivate`; everyone else holds `GRKPublic`. +- **relay request** — a group row a relay creates on `XGrpRelayInv`, before it fetches the group link. +- **prepared channel** — a public group prepared from a link, before `APIConnectPreparedGroup` stores the root key. + +## 1. The type + +`Simplex/Chat/Types.hs`. + +```haskell +data GroupKeys + = GKGroup + { memberPrivKey :: C.PrivateKeyEd25519 + } + | GKPublicGroup + { groupRootKey :: GroupRootKey, + memberPrivKey :: C.PrivateKeyEd25519 + } + | GKRelayRequest + { memberPrivKey :: C.PrivateKeyEd25519 + } + | GKPreparedPublicGroup + { memberPrivKey :: C.PrivateKeyEd25519 + } + deriving (Eq, Show) + +isPublicGroup :: GroupKeys -> Bool + +data GroupInfoKeys = GIK GroupInfo GroupKeys +``` + +`GroupInfoKeys` is the group read with its keys. A function that signs takes it in place of `GroupInfo`; the name `gInfo` denotes whichever of the two a scope holds. + +`PublicGroupKeys` is removed. `GroupRootKey` is unchanged, and its JSON instance is removed with those of `GroupKeys` and `PublicGroupKeys`. + +`GroupInfo` loses `groupKeys` and keeps every other field, `rosterVersion` included. Its `deriveJSON` then emits public fields only. + +`RequestEntity` becomes `REBusinessChat GroupInfoKeys GroupMember`. + +`PreparedChatEntity` becomes `PCEGroup {groupInfo :: GroupInfoKeys, hostMember}`. + +`ReceivedGroupInvitation` gains `groupKeys :: GroupKeys`. + +## 2. Reading + +`Simplex/Chat/Store/Shared.hs`. + +`StoreCxt` gains the generator, named `drg` because `random` collides with `ChatController.random` wherever both records are in scope. + +```haskell +data StoreCxt = StoreCxt {vr :: VersionRangeChat, badgeKeys :: Map Int BBSPublicKey, drg :: TVar ChaChaDRG} +``` + +```haskell +storeCxt :: ChatController -> StoreCxt +``` + +`toGroupInfo` returns `(GroupInfo, GroupKeysRow)`; `toGroupInfo_` returns the group alone. + +```haskell +mkGroupKeys :: DB.Connection -> StoreCxt -> GroupInfo -> GroupKeysRow -> ExceptT StoreError IO GroupKeys +``` + +The member key is taken from the row, or generated and stored. The constructor follows: + +| `use_relays` | `public_group_id` | root key | result | +| --- | --- | --- | --- | +| 0 | — | — | `GKGroup` | +| 1 | present | present | `GKPublicGroup` | +| 1 | present | absent | `GKPreparedPublicGroup` | +| 1 | absent | — | `GKRelayRequest` | + +### Reads + +| function | returns | +| --- | --- | +| `getGroupInfoRow` | `(GroupInfo, GroupKeysRow)` | +| `getGroupInfoKeys` | `GroupInfoKeys` | +| `getGroupInfo` | `GroupInfo`, as `fst <$> getGroupInfoRow` | +| `getGroupKeys_` | `(Group, GroupKeys)` | +| `getGroup` | `Group`, as `fst <$> getGroupKeys_` | + +All five issue one `groupInfoQuery`. `getGroupKeys_` and `getGroup` add the member query. + +`getGroupInfoKeys` returns the group with `membership.memberPubKey` set from the member key it materialized, so the pair agrees on a row created before this change. + +A site that needs keys switches its existing read to `getGroupInfoKeys` or `getGroupKeys_`. + +### Reads that return keys with their entity + +| function | returns | +| --- | --- | +| `getConnectionEntityKeys` | `(ConnectionEntity, Maybe GroupKeysRow)` | +| `getConnectionEntity` | `ConnectionEntity`, as `fst <$> getConnectionEntityKeys` | +| `getGroupInvitation` | `ReceivedGroupInvitation`, with `groupKeys` | +| `createGroupInvitation` | `(GroupInfoKeys, GroupMemberId)` | +| `createBusinessRequestGroup` | `(GroupInfoKeys, GroupMember)` | +| `updatePreparedRelayedGroup` | `GroupInfoKeys` | +| `getRelayServedGroups` | `[GroupInfoKeys]` | +| `getAcceptedBusinessChat` | `Maybe (GroupInfo, GroupKeysRow)` | +| `getGroupAndRegLink` (directory service) | `(GroupInfoKeys, GroupReg, Maybe GroupLink)` | + +### Reads that discard the keys + +`toGroupInfo_` builds the group for `getBaseGroupDetails`, `getRelayInactiveGroups` and `toGroupInfoRegLink`. + +## 3. Message handling + +`getUserEntity` reads the entity with `getConnectionEntityKeys` and builds the keys from the row with `mkGroupKeys` in the same transaction; the first message on a row created before this change writes the member key. `processAgentMessageConn` takes `Maybe GroupKeys` and passes `GroupInfoKeys` to `processGroupMessage`. + +Handlers that send take `GroupInfoKeys`: `xGrpInfo`, `xGrpRosterAck`, `xGrpRosterRequest`, `xGrpLinkAcpt`, `xGrpMemNew`, `xGrpMemRole`, `xGrpMemDel`, `xGrpLeave`, `xGrpMsgForward`, `applyAtRosterVersion`, `bFileChunkGroup`, `receiveRosterChunk`, `rosterCompletion`, `sendRosterAck`. `updatePublicGroupData` in `Internal.hs` takes `GroupInfo` and `GroupKeys` and returns the updated `GroupInfo`. + +An entity of a group connection without keys raises `CEInternalError`. + +## 4. Writing the member key + +One statement writes `groups.member_priv_key` after this change, in `setUserMemberKey`: + +```sql +UPDATE groups +SET member_priv_key = COALESCE(member_priv_key, ?), updated_at = ? +WHERE group_id = ? +RETURNING member_priv_key +``` + +`group_members.member_pub_key` for the membership row is set from the returned key. + +```haskell +setUserMemberKey :: DB.Connection -> GroupId -> GroupMemberId -> C.PrivateKeyEd25519 -> ExceptT StoreError IO C.PrivateKeyEd25519 +``` + +### Inserts + +Every insert writes a member key. `createRelayRequestGroup` generates one and passes its public half to `createContactMemberInv_`, closing the TODO it held. + +`createNewGroup` takes a non-optional `GroupKeys` and derives `use_relays` from `isPublicGroup`. + +### Updates that stop writing the member key + +`updateGroupMemberKeys` is replaced by `setGroupRootKey`, which writes `root_pub_key` alone. `updateRelayGroupKeys` keeps `group_type`, `group_link` and `public_group_id`, and calls it. + +### Callers that stop generating keys + +| caller | change | +| --- | --- | +| `APIConnectPreparedGroup` | writes the root key alone | +| `createRelayLink` | signs the relay link with the stored member key | + +A relay's member key and its relay-link root key are the same key. Members read it from the link as `FixedLinkData.rootKey`. + +The owner keeps the split: the root key authorises owner keys through `OwnerAuth`, and the member key signs messages and shares. + +### `createUserMemberKey` + +Removed, with its six calls. Each caller takes `GroupKeys` from its own read. + +## 5. The error + +```haskell +| SEGroupNotFound {groupId :: GroupId} +``` + +A relay request and a prepared channel read as their own constructors, so every read returns them. + +## 6. Consumers of the keys + +`groupBindingData` reads `publicGroupId` from `groupProfile.publicGroup`, so it takes `GroupInfo`. Everything that verifies — `verifyGroupSig`, `withVerifiedMsg`, `xInfoMember`, `storeMemberKey`, `verifyKey`, `rcvGroupChatBinding` — takes `GroupInfo` alone. The receive path is unchanged. + +`sndGroupChatBinding` asserts the user's own member key, which `membership.memberPubKey` holds as the public half. + +```haskell +groupMemberKey :: GroupKeys -> MemberKey +``` + +| site | uses | +| --- | --- | +| `groupLinkData` | root key as `GRKPrivate`, member key | +| `groupMsgSigning` | member key | +| `groupMemberKey` | member key | +| `encodeXMemberConnInfo` | member key | +| `APIShareChatMsgContent` | root key as the owner test, member key to sign | + +### Functions that take `GroupInfoKeys` in place of `GroupInfo` + +`Internal.hs`: `acceptGroupJoinRequestAsync`, `acceptBusinessJoinRequestAsync`, `groupLinkData`, `setGroupLinkData`, `setGroupLinkData'`, `setGroupLinkDataAsync`, `introduceToModerators`, `introduceToAll`, `introduceToRemaining`, `introduceMember`, `introduceInChannel`, `serveRoster`, `sendInlineBlobChunks`, `sendRelayCapIfNeeded`, `sendGroupMemberMessages`, `sendGroupMessage`, `sendGroupMessage'`, `sendRoster`, `broadcastRoster`, `sendGroupRosterToRelay`, `sendGroupMessages`, `sendGroupSignedMessages`, `sendGroupProfileUpdate`, `sendGroupMessages_`, `groupMsgSigning`, `encodeXMemberConnInfo`, `allowAgentConnectionAsync` (as `Maybe GroupInfoKeys`). + +`Commands.hs`: `delEventSigned`, `changeRoleInvitedMems`, `deleteMemsSend`, `deletePendingMember`, `blockMembers`, `sendGroupContentMessages`, `sendGroupContentMessages_`, `getCommandGroupChatItems`, `delGroupChatItemsForMembers`, `sendGrpInvitation`, `connectToRelay`, `leaveChannelRelay`, `leaveGroupSendMsg`, `runUpdateGroupProfile`. `changeRoleCurrentMems` takes `Group` and `GroupKeys`. `newGroup` takes `GroupKeys` and loses its `Bool`. + +`joinContact` takes `Maybe (Maybe GroupInfoKeys)` and `Maybe MemberId`. + +`Subscriber.hs`: `processGroupMessage` and every handler under it that sends; `acceptJoin`, `getLinkDataCreateRelayLink`. + +Directory service: `updateGroupLinkData` and the `withGroupRegLink` callbacks. + +`saveConnInfo` returns `Maybe GroupInfoKeys`. + +## 7. Queries + +The query count is unchanged. Every site that needs keys takes them from a read it already performs: + +| site | before | after | +| --- | --- | --- | +| commands holding a group | `getGroupInfo` | `getGroupInfoKeys` | +| commands holding a group and members | `getGroup` | `getGroupKeys_` | +| `processAgentMessageConn` | `getConnectionEntity` | `getConnectionEntityKeys` | +| `APIJoinGroup` | `getGroupInvitation` | same, with `groupKeys` in the record | +| `APIConnectPreparedGroup` | `getGroupInfo` | `getGroupInfoRow` | +| business request | `getGroupInfo` | `getGroupInfoRow` | +| directory service link update | `getGroupLink` | `getGroupAndRegLink` | + +## 8. Schema + +The schema is unchanged. The columns keep their meaning: + +- `groups.member_priv_key` — written at insert, or at the first read of a row created before this change. +- `groups.root_priv_key` — the owner's root key. +- `groups.root_pub_key` — every other member's copy of the root key. +- `group_profiles.public_group_id` — the public group identity. + +## 9. Tests + +`testGroupMemberKeyGenerated` (`tests/ChatTests/Groups.hs`): a p2p group whose member key columns are NULL on both sides. The first send stores a key, the profile update carries and is signed by that key, the peer stores it from the update and verifies the next signed event with it, `member_pub_key` of the membership is the public half of `member_priv_key`, and a second send leaves the key unchanged. + +Covered by the existing suites: relay request and prepared channel flows (`chat relay tests`), relay link signing (`chat relay tests`), `GroupInfo` JSON (`Bot API docs`, once the generated files are writable). + +## Open + +`bots/api/TYPES.md`, `packages/simplex-chat-client/types/typescript/src/types.ts` and `packages/simplex-chat-python/src/simplex_chat/types/_types.py` still declare `GroupKeys`. The `Bot API docs` test regenerates them once they are writable; they are owned by root. + +## Out of scope + +- The sum type in API responses and events. +- Moving relay request data out of the `groups` row. +- `groupSummary.publicMemberCount` moving into `GKPublicGroup`, since both apps decode `GroupSummary`. diff --git a/scripts/simplex-chat-reproduce-builds.sh b/scripts/simplex-chat-reproduce-builds.sh index d0f121c6ea..5d18b226a8 100755 --- a/scripts/simplex-chat-reproduce-builds.sh +++ b/scripts/simplex-chat-reproduce-builds.sh @@ -148,8 +148,9 @@ for os_pair in ${oses}; do -t "${container_name}" \ sh -c 'rm -rf ./dist-newstyle ./apps/multiplatform' - # Also restore git to previous state + # Also restore git to previous state + re-initialize submodules git reset --hard && git clean -dfx + git submodule update --init --recursive # Stop containers, delete images docker stop "${container_name}" diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 7c7a253042..366b80917d 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: 7.1.0.4 +version: 7.1.0.7 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat @@ -40,6 +40,10 @@ library Simplex.Chat.AppSettings Simplex.Chat.Badges Simplex.Chat.Badges.CLI + Simplex.Chat.Badges.Code + Simplex.Chat.Badges.Ledger + Simplex.Chat.Badges.Service + Simplex.Chat.Badges.Types Simplex.Chat.Names Simplex.Chat.Store.Wallets Simplex.Chat.Wallet @@ -65,6 +69,8 @@ library Simplex.Chat.Operators.Presets Simplex.Chat.Options Simplex.Chat.Options.DB + Simplex.Chat.PaymentService + Simplex.Chat.PaymentService.Types Simplex.Chat.ProfileGenerator Simplex.Chat.Protocol Simplex.Chat.Remote @@ -77,6 +83,7 @@ library Simplex.Chat.Stats Simplex.Chat.Store Simplex.Chat.Store.AppSettings + Simplex.Chat.Store.Badges Simplex.Chat.Store.Connections Simplex.Chat.Store.ContactRequest Simplex.Chat.Store.Delivery @@ -101,6 +108,7 @@ library exposed-modules: Simplex.Chat.Bot Simplex.Chat.Bot.KnownContacts + Simplex.Chat.Bot.Store Simplex.Chat.Core Simplex.Chat.Help Simplex.Chat.Terminal @@ -158,7 +166,9 @@ library Simplex.Chat.Store.Postgres.Migrations.M20260813_auto_accept_group_invitations Simplex.Chat.Store.Postgres.Migrations.M20260822_forward_link Simplex.Chat.Store.Postgres.Migrations.M20260828_file_expiry - Simplex.Chat.Store.Postgres.Migrations.M20260908_wallet_seeds + Simplex.Chat.Store.Postgres.Migrations.M20260904_file_badges + Simplex.Chat.Store.Postgres.Migrations.M20260915_user_badges + Simplex.Chat.Store.Postgres.Migrations.M20260921_wallet_seeds else exposed-modules: Simplex.Chat.Archive @@ -332,7 +342,9 @@ library Simplex.Chat.Store.SQLite.Migrations.M20260813_auto_accept_group_invitations Simplex.Chat.Store.SQLite.Migrations.M20260822_forward_link Simplex.Chat.Store.SQLite.Migrations.M20260828_file_expiry - Simplex.Chat.Store.SQLite.Migrations.M20260908_wallet_seeds + Simplex.Chat.Store.SQLite.Migrations.M20260904_file_badges + Simplex.Chat.Store.SQLite.Migrations.M20260915_user_badges + Simplex.Chat.Store.SQLite.Migrations.M20260921_wallet_seeds other-modules: Paths_simplex_chat hs-source-dirs: @@ -406,6 +418,58 @@ library , template-haskell ==2.16.* , text >=1.2.4.0 && <1.3 +executable simplex-badge-service + if flag(client_library) + buildable: False + main-is: Main.hs + hs-source-dirs: + apps/simplex-badge-service + apps/simplex-badge-service/src + default-extensions: + StrictData + other-modules: + BadgeService.Options + BadgeService.Service + BadgeService.Store + BadgeService.Store.Migrate + 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 -Werror=name-shadowing -threaded -rtsopts + build-depends: + aeson ==2.2.* + , attoparsec ==0.14.* + , base >=4.7 && <5 + , containers ==0.6.* + , crypton ==0.34.* + , directory ==1.3.* + , mtl >=2.3.1 && <3.0 + , optparse-applicative >=0.15 && <0.17 + , simple-logger ==0.1.* + , simplex-chat + , simplexmq >=6.3 + , stm ==2.5.* + , time ==1.12.* + default-language: Haskell2010 + if flag(client_postgres) + other-modules: + BadgeService.Store.Postgres.Migrations + build-depends: + postgresql-simple ==0.7.* + , raw-strings-qq ==1.1.* + cpp-options: -DdbPostgres + else + other-modules: + BadgeService.Store.SQLite.Migrations + build-depends: + sqlcipher-simple ==0.4.* + if impl(ghc >= 9.6.2) + build-depends: + bytestring ==0.11.* + , text >=2.0.1 && <2.2 + if impl(ghc < 9.6.2) + build-depends: + bytestring ==0.10.* + , text >=1.2.4.0 && <1.3 + executable simplex-bot if flag(client_library) buildable: False @@ -537,7 +601,6 @@ executable simplex-directory-service 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 -Werror=name-shadowing -threaded -rtsopts build-depends: @@ -632,6 +695,11 @@ test-suite simplex-chat-test API.Docs.Syntax.Types API.Docs.Types API.TypeInfo + BadgeService.Options + BadgeService.Service + BadgeService.Store + BadgeService.Store.Migrate + Bots.BadgeServiceTests Broadcast.Bot Broadcast.Options Directory.BlockedWords @@ -643,7 +711,6 @@ test-suite simplex-chat-test Directory.Service Directory.Store Directory.Store.Migrate - Directory.Util Paths_simplex_chat if flag(client_postgres) other-modules: @@ -658,6 +725,7 @@ test-suite simplex-chat-test hs-source-dirs: bots/src tests + apps/simplex-badge-service/src apps/simplex-broadcast-bot/src apps/simplex-directory-service/src default-extensions: @@ -700,6 +768,7 @@ test-suite simplex-chat-test default-language: Haskell2010 if flag(client_postgres) other-modules: + BadgeService.Store.Postgres.Migrations Directory.Store.Postgres.Migrations build-depends: postgresql-simple ==0.7.* @@ -707,6 +776,7 @@ test-suite simplex-chat-test cpp-options: -DdbPostgres else other-modules: + BadgeService.Store.SQLite.Migrations Directory.Store.SQLite.Migrations build-depends: sqlcipher-simple ==0.4.* diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 3eb2045317..3e6fb7e229 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -28,7 +28,7 @@ import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe, mapMaybe) import Data.Text (Text) import Data.Time.Clock (getCurrentTime, nominalDay) -import Simplex.Chat.Badges (badgeServerCredential) +import Simplex.Chat.Badges (badgeServerCredential, defaultFileSizeLimits) import Simplex.Chat.Controller import Simplex.Chat.Library.Commands import Simplex.Chat.Operators @@ -53,6 +53,7 @@ import Simplex.Messaging.Agent.Store.Shared (MigrationConfig (..), MigrationConf import Simplex.Messaging.Client (defaultNetworkConfig) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.Entitlement (entitlementIssuerKeys) +import Simplex.Messaging.Encoding.String (strDecode) import Simplex.Messaging.Protocol (ProtoServerWithAuth (..), ProtocolType (..), SProtocolType (..), SubscriptionMode (..), UserProtocol) import qualified Simplex.Messaging.TMap as TM import qualified UnliftIO.Exception as E @@ -68,6 +69,9 @@ defaultChatConfig = }, chatVRange = supportedChatVRange, badgePublicKeys = M.mapKeys fromIntegral entitlementIssuerKeys, + badgeServiceAddress = Just $ either error id $ strDecode "https://smp5.simplex.im/a#ooSNWlEZTO2RPE0Ff5ZoybAs5zEhWLMlQrXesnhaZHM", + badgeCurrentTime = getCurrentTime, + badgeRetryInterval = RetryInterval {initialInterval = 30_000000, increaseAfter = 0, maxInterval = 3600_000000}, confirmMigrations = MCConsole, -- this property should NOT use operator = Nothing -- non-operator servers can be passed via options @@ -102,10 +106,12 @@ defaultChatConfig = shortLinkPresetServers = allPresetServers, presetDomains = [".simplex.im", ".simplexonflux.com"], tbqSize = 1024, + maxChats = 5000, fileChunkSize = 15780, -- do not change xftpDescrPartSize = 14000, inlineFiles = defaultInlineFilesConfig, autoAcceptFileSize = 0, + fileSizeLimits = defaultFileSizeLimits, showReactions = False, showFullLinks = False, showReceipts = False, @@ -146,11 +152,11 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agentConfig = aCfg, presetServers, inlineFiles, deviceNameForRemote, confirmMigrations} - ChatOpts {coreOptions = CoreChatOpts {smpServers, xftpServers, simpleNetCfg, logLevel, logConnections, logServerHosts, logFile, tbqSize, deviceName, webPreviewConfig, highlyAvailable, yesToUpMigrations}, optFilesFolder, optTempDirectory, showReactions, showFullLinks, allowInstantFiles, autoAcceptFileSize} + ChatOpts {coreOptions = CoreChatOpts {smpServers, xftpServers, simpleNetCfg, logLevel, logConnections, logServerHosts, logFile, tbqSize, maxChats, deviceName, webPreviewConfig, highlyAvailable, yesToUpMigrations}, optFilesFolder, optTempDirectory, showReactions, showFullLinks, allowInstantFiles, autoAcceptFileSize} backgroundMode = do let inlineFiles' = if allowInstantFiles || autoAcceptFileSize > 0 then inlineFiles else inlineFiles {sendChunks = 0, receiveInstant = False} confirmMigrations' = if confirmMigrations == MCConsole && yesToUpMigrations then MCYesUp else confirmMigrations - config = cfg {logLevel, showReactions, showFullLinks, tbqSize, subscriptionEvents = logConnections, hostEvents = logServerHosts, presetServers = presetServers', inlineFiles = inlineFiles', autoAcceptFileSize, webPreviewConfig, highlyAvailable, confirmMigrations = confirmMigrations'} + config = cfg {logLevel, showReactions, showFullLinks, tbqSize, maxChats, subscriptionEvents = logConnections, hostEvents = logServerHosts, presetServers = presetServers', inlineFiles = inlineFiles', autoAcceptFileSize, webPreviewConfig, highlyAvailable, confirmMigrations = confirmMigrations'} randomPresetServers <- chooseRandomServers presetServers' let rndSrvs = L.toList randomPresetServers operatorWithId (i, op) = (\o -> o {operatorId = DBEntityId i}) <$> pOperator op @@ -188,6 +194,8 @@ newChatController deliveryTaskWorkers <- TM.emptyIO deliveryJobWorkers <- TM.emptyIO relayRequestWorkers <- TM.emptyIO + badgeWorkers <- TM.emptyIO + badgeSeq <- newTVarIO 0 relayGroupLinkChecksAsync <- newTVarIO Nothing webPreviewState <- forM webPreviewConfig $ \_ -> newWebPreviewState chatRelayTests <- TM.emptyIO @@ -234,6 +242,8 @@ newChatController deliveryTaskWorkers, deliveryJobWorkers, relayRequestWorkers, + badgeWorkers, + badgeSeq, relayGroupLinkChecksAsync, webPreviewState, chatRelayTests, diff --git a/src/Simplex/Chat/Badges.hs b/src/Simplex/Chat/Badges.hs index e926d34d0a..62683788d5 100644 --- a/src/Simplex/Chat/Badges.hs +++ b/src/Simplex/Chat/Badges.hs @@ -25,7 +25,11 @@ module Simplex.Chat.Badges BBSPublicKeyStr (..), localBadgeInfo, localBadgeStatus, + FileSizeLimits (..), + defaultFileSizeLimits, maxXFTPFileSize, + maxSndXFTPFileSize, + badgeSndGraceInterval, badgeServerCredential, maxFileSizeSupporter, maxFileSizeLegend, @@ -46,6 +50,10 @@ module Simplex.Chat.Badges verifyBadge_, mkBadgeStatus, BadgeRow, + BadgeProofKind (..), + BadgeProofRow, + badgeProofToRow, + rowToBadgeProof, badgeToRow, localBadgeToRow, rowToBadge, @@ -66,11 +74,13 @@ import Data.String import Data.Text (Text) import Data.Text.Encoding (encodeUtf8) import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime, nominalDay) +import Data.Time.Clock.System (systemToUTCTime, utcToSystemTime) import Simplex.FileTransfer.Description (gb, maxFileSize) import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..), fromTextField_) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.BBS import Simplex.Messaging.Crypto.Entitlement (Entitlement (Entitlement), EntitlementCredential (EntitlementCredential), MasterKey (MasterKey), entitlementBBSHeader) +import Simplex.Messaging.Encoding (Encoding (..)) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON) #if defined(dbPostgres) @@ -114,6 +124,35 @@ instance FromJSON BadgeType where data BadgeStatus = BSActive | BSExpired | BSExpiredOld | BSFailed | BSUnknownKey deriving (Eq, Show) +instance TextEncoding BadgeStatus where + textEncode = \case + BSActive -> "active" + BSExpired -> "expired" + BSExpiredOld -> "expired_old" + BSFailed -> "failed" + BSUnknownKey -> "unknown_key" + textDecode = \case + "active" -> Just BSActive + "expired" -> Just BSExpired + "expired_old" -> Just BSExpiredOld + "failed" -> Just BSFailed + "unknown_key" -> Just BSUnknownKey + _ -> Nothing + +-- Badge proof kind - a file has at most one proof of each kind + +data BadgeProofKind = BPKInvitation | BPKDescription + deriving (Eq, Show) + +instance TextEncoding BadgeProofKind where + textEncode = \case + BPKInvitation -> "inv" + BPKDescription -> "descr" + textDecode = \case + "inv" -> Just BPKInvitation + "descr" -> Just BPKDescription + _ -> Nothing + -- Disclosed badge content (BBS messages 1, 2, 3) data BadgeInfo = BadgeInfo @@ -186,10 +225,10 @@ localBadgeStatus = \case ShownBadge _ st -> st -- XFTP file size limit raised by an active badge: a legend badge to 5GB, any other to 2GB, otherwise the default. -maxFileSizeSupporter :: Int64 +maxFileSizeSupporter :: Integer maxFileSizeSupporter = gb 2 -maxFileSizeLegend :: Int64 +maxFileSizeLegend :: Integer maxFileSizeLegend = gb 5 badgeServerCredential :: Maybe LocalBadge -> Maybe EntitlementCredential @@ -198,31 +237,62 @@ badgeServerCredential = \case Just $ EntitlementCredential (fromIntegral idx) (MasterKey mk) (Entitlement badgeExpiry (textEncode badgeType) badgeExtra) sig _ -> Nothing -maxXFTPFileSize :: Maybe LocalBadge -> Int64 -maxXFTPFileSize = \case - Just b | localBadgeStatus b == BSActive -> case badgeType (localBadgeInfo b) of - BTLegend -> maxFileSizeLegend - _ -> maxFileSizeSupporter - _ -> maxFileSize +data FileSizeLimits = FileSizeLimits + { noBadge :: Integer, + supporter :: Integer, + legend :: Integer + } + deriving (Eq, Show) + +defaultFileSizeLimits :: FileSizeLimits +defaultFileSizeLimits = FileSizeLimits {noBadge = toInteger maxFileSize, supporter = maxFileSizeSupporter, legend = maxFileSizeLegend} + +-- a badge raises the size limit at send for this long after its expiry, shorter than badgeGraceInterval so the receiver still accepts the size +badgeSndGraceInterval :: NominalDiffTime +badgeSndGraceInterval = nominalDay + +badgeFileSize :: FileSizeLimits -> LocalBadge -> Integer +badgeFileSize FileSizeLimits {supporter, legend} b = case badgeType (localBadgeInfo b) of + BTLegend -> legend + _ -> supporter + +maxXFTPFileSize :: FileSizeLimits -> Maybe LocalBadge -> Integer +maxXFTPFileSize lims = \case + Just b | localBadgeStatus b == BSActive -> badgeFileSize lims b + _ -> noBadge lims + +maxSndXFTPFileSize :: FileSizeLimits -> UTCTime -> Maybe LocalBadge -> Integer +maxSndXFTPFileSize lims now = \case + Just b | localBadgeStatus b == BSActive && addUTCTime badgeSndGraceInterval (badgeExpiry (localBadgeInfo b)) >= now -> badgeFileSize lims b + _ -> noBadge lims -- Presentation header: a tag char + payload. PHTest is unbound - a fresh random nonce per -- presentation, not bound to any context; the 'T' tag marks it so master rejects it. -- PHUnknown is the forward-compat catch-all for tags this version does not interpret. -data ProofPresHeaderTag = PHTestTag | PHUnknownTag Char +data ProofPresHeaderTag = PHTestTag | PHChatTag | PHFileInvTag | PHFileDescrTag | PHUnknownTag Char instance StrEncoding ProofPresHeaderTag where strEncode = B.singleton . \case PHTestTag -> 'T' + PHChatTag -> 'C' + PHFileInvTag -> 'F' + PHFileDescrTag -> 'D' PHUnknownTag c -> c strP = tag <$> A.anyChar where tag = \case 'T' -> PHTestTag + 'C' -> PHChatTag + 'F' -> PHFileInvTag + 'D' -> PHFileDescrTag c -> PHUnknownTag c data ProofPresHeader = PHTest ByteString + | PHChat ByteString + | PHFileInv {chatBinding :: ByteString, fileSize :: Int64} + | PHFileDescr {chatBinding :: ByteString, fileSize :: Int64, descrHash :: ByteString, fileExpires :: Maybe UTCTime} | PHUnknown Char ByteString deriving (Eq, Show) deriving (ToJSON, FromJSON) via (StrJSON "ProofPresHeader" ProofPresHeader) @@ -230,16 +300,31 @@ data ProofPresHeader instance StrEncoding ProofPresHeader where strEncode = \case PHTest nonce -> strEncode PHTestTag <> nonce + PHChat binding -> strEncode PHChatTag <> binding + PHFileInv {chatBinding, fileSize} -> + strEncode PHFileInvTag <> smpEncode (chatBinding, fileSize) + PHFileDescr {chatBinding, fileSize, descrHash, fileExpires} -> + strEncode PHFileDescrTag <> smpEncode (chatBinding, fileSize, descrHash, utcToSystemTime <$> fileExpires) PHUnknown c b -> strEncode (PHUnknownTag c) <> b strP = strP >>= \case PHTestTag -> PHTest <$> A.takeByteString + PHChatTag -> PHChat <$> A.takeByteString + PHFileInvTag -> do + (chatBinding, fileSize) <- smpP + pure PHFileInv {chatBinding, fileSize} + PHFileDescrTag -> do + (chatBinding, fileSize, descrHash, expires_) <- smpP + pure PHFileDescr {chatBinding, fileSize, descrHash, fileExpires = systemToUTCTime <$> expires_} PHUnknownTag c -> PHUnknown c <$> A.takeByteString -- v6.5.x accepts both; v7 will reject PHTest/PHUnknown proofPresHeaderAccepted :: ProofPresHeader -> Bool proofPresHeaderAccepted = \case PHTest _ -> True + PHChat _ -> True + PHFileInv {} -> True + PHFileDescr {} -> True PHUnknown _ _ -> True -- Payment proof @@ -348,6 +433,26 @@ instance FromField BadgeType where fromField = fromTextField_ textDecode instance ToField BadgeType where toField = toField . textEncode +instance FromField BadgeStatus where fromField = fromTextField_ textDecode + +instance ToField BadgeStatus where toField = toField . textEncode + +instance FromField BadgeProofKind where fromField = fromTextField_ textDecode + +instance ToField BadgeProofKind where toField = toField . textEncode + +-- (proof, pres_header, key_idx, type, expiry, extra) - the fields of BadgeProof as stored in file_badge_proofs +type BadgeProofRow = (Binary ByteString, Binary ByteString, Int, Text, UTCTime, Text) + +badgeProofToRow :: BadgeProof -> BadgeProofRow +badgeProofToRow (BadgeProof idx (BBSPresHeader ph) (BBSProof p) BadgeInfo {badgeType, badgeExpiry, badgeExtra}) = + (Binary p, Binary ph, idx, textEncode badgeType, badgeExpiry, badgeExtra) + +rowToBadgeProof :: BadgeProofRow -> Maybe BadgeProof +rowToBadgeProof (Binary p, Binary ph, idx, type_, badgeExpiry, badgeExtra) = do + badgeType <- textDecode type_ + pure $ BadgeProof idx (BBSPresHeader ph) (BBSProof p) BadgeInfo {badgeType, badgeExpiry, badgeExtra} + -- (proof, pres_header, expiry, type, verified, extra, master_key, signature, key_idx) - binary columns wrapped in Binary (BLOB/bytea) type BadgeRow = (Maybe (Binary ByteString), Maybe (Binary ByteString), Maybe UTCTime, Maybe Text, Maybe BoolInt, Maybe Text, Maybe (Binary ByteString), Maybe (Binary ByteString), Maybe Int) diff --git a/src/Simplex/Chat/Badges/Code.hs b/src/Simplex/Chat/Badges/Code.hs new file mode 100644 index 0000000000..5098b24a21 --- /dev/null +++ b/src/Simplex/Chat/Badges/Code.hs @@ -0,0 +1,118 @@ +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE OverloadedStrings #-} + +-- | Badge redemption codes, shared by the client, the badge service and the checkout site. +-- +-- A code is "SB-" and 20 Crockford base32 characters in four groups of five: +-- 19 payload characters and a final check character. +-- +-- Reading folds the characters the alphabet omits so that a code copied by hand still +-- verifies: it is case-insensitive and maps 'I' and 'L' to '1' and 'O' to '0'. +-- +-- The check character is Luhn mod N with N = 32 over the payload values, which keeps it +-- inside the same 32-character alphabet. It detects every single-character substitution +-- and every transposition of adjacent characters except '0' next to 'Z' - the values 0 and +-- N-1, which is Luhn's one blind spot at any base. +-- +-- 'BadgeCode' is only constructed by 'parseBadgeCode' and 'randomBadgeCode', so a code +-- whose check character fails cannot be hashed, looked up or sent. +module Simplex.Chat.Badges.Code + ( BadgeCode, + parseBadgeCode, + randomBadgeCode, + badgeCodeText, + badgeCodeHash, + formatBadgeCode, + ) +where + +import Control.Concurrent.STM +import Crypto.Random (ChaChaDRG) +import Data.ByteString.Char8 (ByteString) +import qualified Data.ByteString.Char8 as B +import Data.Char (isAlphaNum, toUpper) +import Data.List (elemIndex) +import Data.Text (Text) +import qualified Data.Text as T +import Data.Text.Encoding (encodeUtf8) +import qualified Simplex.Messaging.Crypto as C + +-- | A code that has passed its check character, in canonical form: 'codePrefix' followed by +-- 20 upper-case alphabet characters, without separators. +newtype BadgeCode = BadgeCode Text + deriving (Eq, Show) + +-- Crockford base32: the digits and the upper-case letters except I, L, O and U. +alphabet :: String +alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" + +base :: Int +base = 32 + +codeLength :: Int +codeLength = 20 + +groupLength :: Int +groupLength = 5 + +codePrefix :: Text +codePrefix = "SB" + +-- | The Crockford value of a character, folding the omitted characters onto the digits they +-- are mistaken for. +charValue :: Char -> Maybe Int +charValue c = case toUpper c of + 'I' -> Just 1 + 'L' -> Just 1 + 'O' -> Just 0 + u -> elemIndex u alphabet + +valueChar :: Int -> Char +valueChar v = alphabet !! v + +-- | Luhn mod N (N = 32): the value that makes the whole code sum to zero modulo the base. +checkValue :: [Int] -> Int +checkValue payload = (base - total `mod` base) `mod` base + where + -- doubling every second value from the right, as the check character sits to the right of the payload + total = fst $ foldr step (0, 2) payload + step v (sum', factor) = + let addend = factor * v + in (sum' + addend `div` base + addend `mod` base, if factor == 2 then 1 else 2) + +-- | Read a code as typed: any case, separators optional, ambiguous characters folded. +-- 'Nothing' for anything not well-formed, a failed check character included. +parseBadgeCode :: Text -> Maybe BadgeCode +parseBadgeCode t = do + body <- T.stripPrefix codePrefix $ T.toUpper $ T.filter isAlphaNum t + vs <- mapM charValue $ T.unpack body + let (payload, checkChar) = splitAt (codeLength - 1) vs + if T.length body == codeLength && checkChar == [checkValue payload] + -- rebuilt from the values, not from body: that is what folds I/L/O into the canonical form + then Just $ BadgeCode $ codePrefix <> T.pack (map valueChar vs) + else Nothing + +-- | A new code from the CSPRNG. 256 is a multiple of the base, so a byte reduces without bias. +randomBadgeCode :: TVar ChaChaDRG -> IO BadgeCode +randomBadgeCode drg = do + bs <- atomically $ C.randomBytes (codeLength - 1) drg + let payload = map ((`mod` base) . fromEnum) $ B.unpack bs + vs = payload <> [checkValue payload] + pure $ BadgeCode $ codePrefix <> T.pack (map valueChar vs) + +-- | The canonical form: the only representation of a code that is hashed or sent. +badgeCodeText :: BadgeCode -> Text +badgeCodeText (BadgeCode t) = t + +-- | The only thing about a code stored service-side: SHA-256 over the ASCII bytes of the +-- canonical form, prefix included. +badgeCodeHash :: BadgeCode -> ByteString +badgeCodeHash = C.sha256Hash . encodeUtf8 . badgeCodeText + +-- | The code as it is shown and printed, in four groups of five. +formatBadgeCode :: BadgeCode -> Text +formatBadgeCode (BadgeCode t) = T.intercalate "-" $ codePrefix : groups (T.drop (T.length codePrefix) t) + where + groups s + | T.null s = [] + | otherwise = let (g, rest) = T.splitAt groupLength s in g : groups rest diff --git a/src/Simplex/Chat/Badges/Ledger.hs b/src/Simplex/Chat/Badges/Ledger.hs new file mode 100644 index 0000000000..2f31773d2d --- /dev/null +++ b/src/Simplex/Chat/Badges/Ledger.hs @@ -0,0 +1,240 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module Simplex.Chat.Badges.Ledger + ( emptyEntry, + lapseEntry, + grantEntry, + issueEntry, + paidThrough, + balanceChecked, + addMonths, + endOfMondayAfter, + entryTypeColumns, + entryTypeFromColumns, + creditTypeTag, + debitTypeTag, + ) +where + +import Data.Maybe (fromMaybe) +import Data.Text (Text) +import Data.Time.Calendar (addDays, addGregorianMonthsClip, toGregorian) +import Data.Time.Calendar.WeekDate (toWeekDate) +import Data.Time.Clock (NominalDiffTime, UTCTime (..), addUTCTime) +import Simplex.Chat.Badges (BadgeType) +import Simplex.Chat.Badges.Service (StatementCreditType (..), StatementDebitType (..), StatementEntry (..), StatementEntryType (..)) + +-- The calendar difference overshoots by at most one month, so one comparison settles it. +monthsBetween :: UTCTime -> UTCTime -> Integer +monthsBetween from to + | addMonths months from <= to = max 0 months + | otherwise = max 0 (months - 1) + where + (fy, fm, _) = toGregorian (utctDay from) + (ty, tm, _) = toGregorian (utctDay to) + months = (ty - fy) * 12 + toInteger (tm - fm) + +monthsFromAnchor :: StatementEntry -> Integer +monthsFromAnchor e = monthsBetween (balanceAnchorTs e) (balanceStartTs e) + +-- | The start of the month that follows n more months of this run. +monthAfter :: StatementEntry -> Int -> UTCTime +monthAfter e n = addMonths (monthsFromAnchor e + toInteger n) (balanceAnchorTs e) + +paidThrough :: StatementEntry -> UTCTime +paidThrough e = monthAfter e (balanceMonths e) + +-- Counted from the anchor: 31 Jan plus a month clips to 28 Feb, and counting on from there would +-- retire the next month three days early. +elapsedMonths :: UTCTime -> StatementEntry -> Int +elapsedMonths t e = fromInteger $ max 0 $ min (toInteger $ balanceMonths e) elapsed + where + elapsed = monthsBetween (balanceAnchorTs e) t - monthsFromAnchor e + +-- | The seed for a purchase with no ledger yet: no months, and a run starting now. +emptyEntry :: UTCTime -> BadgeType -> StatementEntry +emptyEntry t badgeType = + StatementEntry + { -- this entry is never stored, and every operation puts its own id on the entry it returns + entryId = "", + changeMonths = 0, + balanceMonths = 0, + balanceStartTs = t, + balanceAnchorTs = t, + balanceBadgeType = badgeType, + wasPausedSince = Nothing, + createdAt = t, + entryType = SECredit SCOpening + } + +-- | Writes off the months that have passed. +lapseEntry :: UTCTime -> Text -> StatementEntry -> Maybe StatementEntry +lapseEntry t entryId e@StatementEntry {balanceMonths} + | k == 0 = Nothing + | otherwise = + Just + e + { entryId, + createdAt = t, + changeMonths = negate k, + balanceMonths = balanceMonths - k, + balanceStartTs = monthAfter e k, + entryType = SEDebit SDLapse + } + where + k = elapsedMonths t e + +-- | New months start where the current coverage ends, or at t if it has already lapsed - so they +-- are neither spent on the month still running nor backdated over a gap. +grantEntry :: UTCTime -> Text -> Int -> StatementCreditType -> StatementEntry -> StatementEntry +grantEntry t entryId n credit e@StatementEntry {balanceMonths, balanceStartTs} + -- only a lapsed run restarts; topping up before coverage ends continues the run on its anchor, + -- so buying a month at a time keeps the same day of month as buying a year at once + | lapsed = credited {balanceMonths = n, balanceStartTs = t, balanceAnchorTs = t} + | otherwise = credited {balanceMonths = balanceMonths + n} + where + lapsed = balanceMonths == 0 && t > balanceStartTs + credited = e {entryId, createdAt = t, changeMonths = n, entryType = SECredit credit} + +-- | The period issued runs from the previous entry's balanceStartTs to this one's. +issueEntry :: UTCTime -> Text -> StatementEntry -> Maybe StatementEntry +issueEntry t entryId e@StatementEntry {balanceMonths, balanceStartTs} + | balanceMonths <= 0 || balanceStartTs > t = Nothing + | otherwise = + Just + e + { entryId, + createdAt = t, + changeMonths = -1, + balanceMonths = balanceMonths - 1, + balanceStartTs = monthAfter e 1, + entryType = SEDebit SDBadge + } + +-- Generous because postdating only writes off a month by crossing a month boundary, which takes +-- days, while a device clock a few minutes slow would otherwise leave every row unverified. +maxCreatedAtSkew :: NominalDiffTime +maxCreatedAtSkew = 60 * 60 + +-- | Each entry is checked by re-running the operation it claims: checking only that its numbers +-- follow from the previous entry would pass a lapse of three months where one elapsed. So 'True' +-- means the service ran these functions, not that it ran the right one. 'Nothing' is "not re-run": +-- no operation rebuilds that type, or its timestamp is not credible. +balanceChecked :: UTCTime -> BadgeType -> Maybe StatementEntry -> [StatementEntry] -> [(StatementEntry, Maybe Bool)] +balanceChecked _ _ _ [] = [] +balanceChecked now badgeType tip entries@(first : _) = zipWith withVerdict (opening : entries) entries + where + -- the purchase's own type, not the statement's: on the seed path nothing else contradicts it + opening = fromMaybe (emptyEntry (createdAt first) badgeType) tip + withVerdict prev e = (e, entryChecked now badgeType prev e) + +entryChecked :: UTCTime -> BadgeType -> StatementEntry -> StatementEntry -> Maybe Bool +entryChecked now badgeType prev e + -- the recompute runs on createdAt, so a stamp our own clock contradicts makes every verdict + -- below meaningless - which is not the same as the row being wrong, and is not marked as it + | postdated = Nothing + | backdated = Just False + | otherwise = case entryType e of + SEDebit SDLapse -> maybe (Just False) matches $ lapseEntry t "" prev + SEDebit SDBadge -> maybe (Just False) matches $ issueEntry t "" prev + SEDebit SDRefund -> uncontradicted + SEDebit SDUpgrade {} -> uncontradicted + SEDebit SDTransferOut {} -> uncontradicted + SEDebit SDSupport -> uncontradicted + SEDebit SDUnknown {} -> uncontradicted + -- an opening credit resets the ledger to the amount it states, with no relation to the entry + -- before it (badges-rpc.md), so it is checked against nothing but itself + SECredit SCOpening -> Just restated + SECredit SCUnknown {} -> uncontradicted + SECredit c + -- grantEntry is given the row's month count, so the check agrees with whatever it claims - + -- including a negative count, which shortens what the user paid for. + -- TODO [badges] a purchase made in the app knows the months it bought; check them here. + | changeMonths e < 0 -> Just False + | otherwise -> matches $ grantEntry t "" (changeMonths e) c prev + where + t = createdAt e + postdated = t > addUTCTime maxCreatedAtSkew now + -- two of the service's own stamps, so no allowance and no doubt about whose clock is wrong. + -- Equal is not behind: a service pass writes its lapse and its issue with one clock reading + backdated = t < createdAt prev + matches = Just . sameBalance e + restated = balanceMonths e == changeMonths e && balanceMonths e >= 0 && balanceBadgeType e == badgeType + uncontradicted + | balanceMonths e /= balanceMonths prev + changeMonths e = Just False + | balanceMonths e < 0 = Just False + | balanceStartTs e < balanceStartTs prev = Just False + | otherwise = Nothing + +sameBalance :: StatementEntry -> StatementEntry -> Bool +sameBalance a b = + balanceMonths a == balanceMonths b + && balanceStartTs a == balanceStartTs b + && balanceAnchorTs a == balanceAnchorTs b + && balanceBadgeType a == balanceBadgeType b + && changeMonths a == changeMonths b + +-- | The tag stored is the string the service sent, so a type this version does not know is kept +-- as received and can be read once it does. +entryTypeColumns :: StatementEntryType -> (Text, Maybe Text, Maybe Text) +entryTypeColumns = \case + SECredit c -> ("credit", Just $ creditTypeTag c, Nothing) + SEDebit d -> ("debit", Nothing, Just $ debitTypeTag d) + +creditTypeTag :: StatementCreditType -> Text +creditTypeTag = \case + SCPayment _ -> "payment" + SCCode -> "code" + SCCharge _ -> "charge" + SCSupport -> "support" + SCTransferIn _ -> "transferIn" + SCOpening -> "opening" + SCUnknown {tag} -> tag + +debitTypeTag :: StatementDebitType -> Text +debitTypeTag = \case + SDRefund -> "refund" + SDUpgrade _ -> "upgrade" + SDTransferOut _ -> "transferOut" + SDSupport -> "support" + SDBadge -> "badge" + SDLapse -> "lapse" + SDUnknown {tag} -> tag + +-- | Only the types a tag alone rebuilds, which is those whose constructor has no fields; the rest +-- answer Nothing rather than a type with an invented payload. The client also stores each type's +-- JSON and reads that first, so this is its fallback; the service has no such column. +-- TODO [badges] take the reference columns and rebuild payment, charge, transferIn, upgrade and +-- transferOut, without which the service cannot re-emit a statement carrying one. +entryTypeFromColumns :: Text -> Maybe Text -> Maybe Text -> Maybe StatementEntryType +entryTypeFromColumns entryType credit_ debit_ = case (entryType, credit_, debit_) of + ("credit", Just t, _) -> SECredit <$> creditType t + ("debit", _, Just t) -> SEDebit <$> debitType t + _ -> Nothing + where + creditType = \case + "code" -> Just SCCode + "support" -> Just SCSupport + "opening" -> Just SCOpening + _ -> Nothing + debitType = \case + "badge" -> Just SDBadge + "lapse" -> Just SDLapse + "refund" -> Just SDRefund + "support" -> Just SDSupport + _ -> Nothing + +addMonths :: Integer -> UTCTime -> UTCTime +addMonths n (UTCTime d t) = UTCTime (addGregorianMonthsClip n d) t + +-- Every badge in a week expires together, revealing nothing about when it was bought. +-- The end of a Monday is the next Tuesday at 00:00, so this returns a Tuesday and 9 is right. +-- Returning a Monday instead would put the expiry on Sunday evening in the Americas, leaving a +-- renewal that failed there waiting for weekend support. +endOfMondayAfter :: UTCTime -> UTCTime +endOfMondayAfter (UTCTime d _) = + let (_, _, dayOfWeek) = toWeekDate d -- 1 Monday .. 7 Sunday + in UTCTime (addDays (toInteger (9 - dayOfWeek)) d) 0 diff --git a/src/Simplex/Chat/Badges/Service.hs b/src/Simplex/Chat/Badges/Service.hs new file mode 100644 index 0000000000..d876dbb059 --- /dev/null +++ b/src/Simplex/Chat/Badges/Service.hs @@ -0,0 +1,328 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE PatternSynonyms #-} +{-# LANGUAGE TemplateHaskell #-} + +module Simplex.Chat.Badges.Service + ( BadgeServiceRequest (..), + BadgeServiceCommand (..), + BadgeServiceVersion, + VersionBadgeService, + VersionRangeBadgeService, + pattern VersionBadgeService, + initialBadgeServiceVersion, + currentBadgeServiceVersion, + supportedBadgeServiceVRange, + BadgeUpgrade (..), + BadgeServiceResponse (..), + BadgeServiceErrorCode (..), + BadgeCatalog (..), + BadgePrice (..), + BadgeOffer (..), + BadgeStatement (..), + BadgeBalance (..), + StatementEntry (..), + StatementEntryType (..), + StatementCreditType (..), + StatementDebitType (..), + ) where + +import Control.Applicative ((<|>)) +import Data.Aeson (FromJSON (..), ToJSON (..), (.:)) +import qualified Data.Aeson as J +import qualified Data.Aeson.Encoding as JE +import qualified Data.Aeson.TH as JQ +import qualified Data.Aeson.Types as JT +import Data.Text (Text) +import Data.Time.Clock (UTCTime) +import Data.Word (Word8, Word16, Word32) +import Simplex.Chat.Badges +import Simplex.Chat.Badges.Types +import Simplex.Chat.PaymentService +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, taggedObjectJSON) +import Simplex.Messaging.Version (VersionRange, VersionScope, mkVersionRange) +import Simplex.Messaging.Version.Internal (Version (..)) + +data BadgeServiceVersion + +instance VersionScope BadgeServiceVersion + +type VersionBadgeService = Version BadgeServiceVersion + +pattern VersionBadgeService :: Word16 -> VersionBadgeService +pattern VersionBadgeService v = Version v + +type VersionRangeBadgeService = VersionRange BadgeServiceVersion + +initialBadgeServiceVersion :: VersionBadgeService +initialBadgeServiceVersion = VersionBadgeService 1 + +currentBadgeServiceVersion :: VersionBadgeService +currentBadgeServiceVersion = VersionBadgeService 1 + +-- the service is deployed ahead of app releases, so it answers within the client's version +supportedBadgeServiceVRange :: VersionRangeBadgeService +supportedBadgeServiceVRange = mkVersionRange initialBadgeServiceVersion currentBadgeServiceVersion + +data BadgeServiceRequest = BadgeServiceRequest + { version :: VersionBadgeService, + purchaseKey :: Maybe C.PublicKeyEd25519, -- optional for BSCGetBadgeCatalog, required for other commands + request :: BadgeServiceCommand + } + +data BadgeServiceCommand + = BSCGetBadgeCatalog + | BSCGetBadgeInvoice + { priceId :: BadgePriceId, + offerId :: Maybe BadgeOfferId, -- absent for 1 month at the badge price + badgeInfo :: BadgeInfo, + paymentVia :: ServicePaymentMethod, + upgrade :: Maybe BadgeUpgrade -- upgrade non-store badge + } + | BSCRedeemBadgeCode + { masterKey :: BadgeMasterKey, + code :: Text -- no badgeRequest: a code carries no tier for the client to state + } + | BSCPurchaseBadge + { badgeRequest :: BadgeRequest, + payment :: ServicePayment, + upgrade :: Maybe BadgeUpgrade -- upgrade store 1-time badge + } + | BSCUpgradeBadgeSubscription + { badgeRequest :: BadgeRequest, + payment :: ServicePayment, -- store payments only + balance :: BadgeBalance + } + | BSCIssueBadge + { balance :: BadgeBalance -- no badgeRequest: the service holds the key, the tier and the expiry + } + | BSCPauseBadge + +data BadgeUpgrade = BadgeUpgrade + { fromPurchaseKey :: C.PublicKeyEd25519, + receipt :: Text, + receiptSignature :: C.Signature 'C.Ed25519, + balance :: BadgeBalance + } + +data BadgeServiceResponse + = BSPBadgeCatalog + { catalog :: BadgeCatalog, + badgeStatement :: Maybe BadgeStatement -- for signed getBadgeCatalog + } + | BSPBadgeInvoice + { invoice :: ServiceInvoice, + badgeType :: BadgeType, + months :: Word8 + } + | BSPBadgeCredential + { credential :: Maybe BadgeCredential, -- Nothing when no balance to issueBadge or no current credential for pause + receipt :: Maybe Text, -- not provided for lifetime badges + statement :: BadgeStatement + } + | BSPError + { code :: BadgeServiceErrorCode, + message :: Maybe Text, + retryAfter :: Maybe Word32 + } + +data BadgeCatalog = BadgeCatalog + { prices :: [BadgePrice], + offers :: [BadgeOffer] + } + deriving (Show) + +data BadgePrice = BadgePrice + { priceId :: BadgePriceId, + badgeType :: BadgeType, + monthPrice :: CurrencyAmount, + currency :: Text, + status :: BadgeItemStatus, + createdAt :: UTCTime + } + deriving (Show) + +data BadgeOffer = BadgeOffer + { offerId :: BadgeOfferId, + priceId :: Maybe BadgePriceId, -- absent applies to any price + months :: Word8, + discount :: OfferDiscount, + status :: BadgeItemStatus, + createdAt :: UTCTime + } + deriving (Show) + +data BadgeStatement = BadgeStatement + { entries :: [StatementEntry], + previousEntryId :: Maybe Text -- matches the client's asserted entryId, absent for the full ledger + } + deriving (Show) + +data BadgeBalance = BadgeBalance + { lastEntry :: StatementEntry + } + deriving (Show) + +data StatementEntry = StatementEntry + { entryId :: Text, + changeMonths :: Int, + balanceMonths :: Int, + balanceStartTs :: UTCTime, + -- the start of the current run of months; every month boundary in it is counted from here, + -- so that the day of month survives a short month + balanceAnchorTs :: UTCTime, + balanceBadgeType :: BadgeType, + wasPausedSince :: Maybe UTCTime, + createdAt :: UTCTime, + entryType :: StatementEntryType + } + deriving (Show) + +data StatementEntryType = SECredit {credit :: StatementCreditType} | SEDebit {debit :: StatementDebitType} + deriving (Show) + +data StatementCreditType + = SCPayment {invoiceId :: Maybe InvoiceId} -- absent for store payments + | SCCode -- a redeemed code; its own invoice belongs to the buyer, not to the redeemer + | SCCharge {chargeId :: Text} + | SCSupport + | SCTransferIn {fromPurchaseKey :: C.PublicKeyEd25519} + | SCOpening + | SCUnknown {tag :: Text, json :: J.Object} + deriving (Show) + +data StatementDebitType + = SDRefund + | SDUpgrade {toPurchaseKey :: C.PublicKeyEd25519} + | SDTransferOut {toPurchaseKey :: C.PublicKeyEd25519} + | SDSupport + | SDBadge + | SDLapse + | SDUnknown {tag :: Text, json :: J.Object} + deriving (Show) + +data BadgeServiceErrorCode + = BSEBadRequest + | BSEUnsupportedVersion + | BSEUnknownPurchaseKey + | BSEUnknownOfferId + | BSEOfferDisabled + | BSEOfferMismatch + | BSEProductUnavailable + | BSEPaymentNotEntitled + | BSEPaymentPending + | BSEProviderUnavailable + | BSERateLimited + | BSECodeInvalid + | BSECodeUsed + | BSECodeExpired + | BSEReceiptInvalid + | BSEReceiptUsed + | BSEInternal + | BSEUnknown Text -- forwards-compatible: service is deployed ahead of clients + deriving (Eq, Show) + +instance TextEncoding BadgeServiceErrorCode where + textEncode = \case + BSEBadRequest -> "bad_request" + BSEUnsupportedVersion -> "unsupported_version" + BSEUnknownPurchaseKey -> "unknown_purchase_key" + BSEUnknownOfferId -> "unknown_offer_id" + BSEOfferDisabled -> "offer_disabled" + BSEOfferMismatch -> "offer_mismatch" + BSEProductUnavailable -> "product_unavailable" + BSEPaymentNotEntitled -> "payment_not_entitled" + BSEPaymentPending -> "payment_pending" + BSEProviderUnavailable -> "provider_unavailable" + BSERateLimited -> "rate_limited" + BSECodeInvalid -> "code_invalid" + BSECodeUsed -> "code_used" + BSECodeExpired -> "code_expired" + BSEReceiptInvalid -> "receipt_invalid" + BSEReceiptUsed -> "receipt_used" + BSEInternal -> "internal" + BSEUnknown t -> t + textDecode s = Just $ case s of + "bad_request" -> BSEBadRequest + "unsupported_version" -> BSEUnsupportedVersion + "unknown_purchase_key" -> BSEUnknownPurchaseKey + "unknown_offer_id" -> BSEUnknownOfferId + "offer_disabled" -> BSEOfferDisabled + "offer_mismatch" -> BSEOfferMismatch + "product_unavailable" -> BSEProductUnavailable + "payment_not_entitled" -> BSEPaymentNotEntitled + "payment_pending" -> BSEPaymentPending + "provider_unavailable" -> BSEProviderUnavailable + "rate_limited" -> BSERateLimited + "code_invalid" -> BSECodeInvalid + "code_used" -> BSECodeUsed + "code_expired" -> BSECodeExpired + "receipt_invalid" -> BSEReceiptInvalid + "receipt_used" -> BSEReceiptUsed + "internal" -> BSEInternal + t -> BSEUnknown t + +instance ToJSON BadgeServiceErrorCode where + toJSON = textToJSON + toEncoding = textToEncoding + +instance FromJSON BadgeServiceErrorCode where + parseJSON = textParseJSON "BadgeServiceErrorCode" + +$(pure []) + +instance FromJSON StatementCreditType where + parseJSON v@(J.Object j) = + $(JQ.mkParseJSON (taggedObjectJSON $ dropPrefix "SC") ''StatementCreditType) v + <|> SCUnknown <$> j .: "type" <*> pure j + parseJSON invalid = + JT.prependFailure "bad StatementCreditType, " (JT.typeMismatch "Object" invalid) + +instance ToJSON StatementCreditType where + toJSON = \case + SCUnknown _ j -> J.Object j + v -> $(JQ.mkToJSON (taggedObjectJSON $ dropPrefix "SC") ''StatementCreditType) v + toEncoding = \case + SCUnknown _ j -> JE.value $ J.Object j + v -> $(JQ.mkToEncoding (taggedObjectJSON $ dropPrefix "SC") ''StatementCreditType) v + +instance FromJSON StatementDebitType where + parseJSON v@(J.Object j) = + $(JQ.mkParseJSON (taggedObjectJSON $ dropPrefix "SD") ''StatementDebitType) v + <|> SDUnknown <$> j .: "type" <*> pure j + parseJSON invalid = + JT.prependFailure "bad StatementDebitType, " (JT.typeMismatch "Object" invalid) + +instance ToJSON StatementDebitType where + toJSON = \case + SDUnknown _ j -> J.Object j + v -> $(JQ.mkToJSON (taggedObjectJSON $ dropPrefix "SD") ''StatementDebitType) v + toEncoding = \case + SDUnknown _ j -> JE.value $ J.Object j + v -> $(JQ.mkToEncoding (taggedObjectJSON $ dropPrefix "SD") ''StatementDebitType) v + +$(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "SE") ''StatementEntryType) + +$(JQ.deriveJSON defaultJSON ''StatementEntry) + +$(JQ.deriveJSON defaultJSON ''BadgeStatement) + +$(JQ.deriveJSON defaultJSON ''BadgeBalance) + +$(JQ.deriveJSON defaultJSON ''BadgeUpgrade) + +$(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "BSC") ''BadgeServiceCommand) + +$(JQ.deriveJSON defaultJSON ''BadgeServiceRequest) + +$(JQ.deriveJSON defaultJSON ''BadgePrice) + +$(JQ.deriveJSON defaultJSON ''BadgeOffer) + +$(JQ.deriveJSON defaultJSON ''BadgeCatalog) + +$(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "BSP") ''BadgeServiceResponse) diff --git a/src/Simplex/Chat/Badges/Types.hs b/src/Simplex/Chat/Badges/Types.hs new file mode 100644 index 0000000000..cc205458c7 --- /dev/null +++ b/src/Simplex/Chat/Badges/Types.hs @@ -0,0 +1,276 @@ +{-# LANGUAGE CPP #-} +{-# LANGUAGE DerivingStrategies #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE GeneralizedNewtypeDeriving #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TemplateHaskell #-} + +module Simplex.Chat.Badges.Types + ( BadgePriceId (..), + BadgeOfferId (..), + BadgePlan (..), + BadgeItemStatus (..), + OfferDiscount (..), + BadgePurchaseStatus (..), + BadgeCodePaymentStatus (..), + LedgerEntryType (..), + LedgerCreditType (..), + LedgerDebitType (..), + BadgeAlertKind (..), + BadgeFunding (..), + BadgePurchase (..), + BadgeLedgerEntry (..), + BadgeCharge (..), + BadgeIssuance (..), + BadgeAlert (..), + BadgeAlertPrice (..), + BadgeState (..), + ) where + +import Data.Aeson (FromJSON, ToJSON) +import qualified Data.Aeson as J +import qualified Data.Aeson.TH as JQ +import Data.Int (Int64) +import Data.Text (Text) +import Data.Time.Clock (UTCTime) +import Data.Word (Word8) +import Simplex.Chat.Badges hiding (BadgePurchase (..)) +import Simplex.Chat.PaymentService.Types (InvoiceId, PaymentId, StoredPayment) +import Simplex.Chat.Types (BoolDef (..)) +import Simplex.Messaging.Agent.Protocol (UserId) +import Simplex.Messaging.Agent.Store.DB (fromTextField_) +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, taggedObjectJSON) +#if defined(dbPostgres) +import Database.PostgreSQL.Simple.FromField (FromField (..)) +import Database.PostgreSQL.Simple.ToField (ToField (..)) +#else +import Database.SQLite.Simple.FromField (FromField (..)) +import Database.SQLite.Simple.ToField (ToField (..)) +#endif + +-- confirmed +newtype BadgePriceId = BadgePriceId Text + deriving newtype (Eq, Show, ToJSON, FromJSON) + +-- confirmed +newtype BadgeOfferId = BadgeOfferId Text + deriving newtype (Eq, Show, ToJSON, FromJSON) + +-- unconfirmed draft +data BadgePlan = BPOneTime | BPMonthly | BPAnnual + deriving (Eq, Show) + +-- confirmed +data BadgeItemStatus = BISActive | BISDeprecated | BISDisabled -- disabled is not sent + deriving (Eq, Show) + +-- confirmed +data OfferDiscount + = ODFreeMonths {freeMonths :: Word8} + | ODDiscount {discount :: Word8} -- percent + deriving (Eq, Show) + +-- unconfirmed draft +data BadgePurchaseStatus = PSAcquiring | PSIssued | PSSuperseded | PSFailed + deriving (Eq, Show) + +-- unconfirmed draft +data BadgeCodePaymentStatus = CPSPaid | CPSUnpaid | CPSFree + deriving (Eq, Show) + +-- confirmed +data LedgerEntryType = LECredit {credit :: LedgerCreditType} | LEDebit {debit :: LedgerDebitType} + deriving (Eq, Show) + +-- confirmed +data LedgerCreditType + = CTPayment {invoiceId :: InvoiceId} + | CTCode + | CTCharge {chargeId :: Text} + | CTSupport + | CTTransferIn {fromPurchaseId :: Maybe Int64} + | CTOpening + | CTUnknown {tag :: Text, json :: J.Object} + deriving (Eq, Show) + +-- confirmed +data LedgerDebitType + = DTRefund + | DTUpgrade {toPurchaseId :: Int64} + | DTTransferOut {toPurchaseId :: Int64} + | DTSupport + | DTBadge + | DTLapse + | DTUnknown {tag :: Text, json :: J.Object} + deriving (Eq, Show) + +-- unconfirmed draft +data BadgeAlertKind = BARenewalApproaching | BAPaymentIssue | BASubscriptionEnded | BAPrepaidEnding | BASupportEnded + deriving (Eq, Show) + +instance TextEncoding BadgeAlertKind where + textEncode = \case + BARenewalApproaching -> "renewal_approaching" + BAPaymentIssue -> "payment_issue" + BASubscriptionEnded -> "subscription_ended" + BAPrepaidEnding -> "prepaid_ending" + BASupportEnded -> "support_ended" + textDecode = \case + "renewal_approaching" -> Just BARenewalApproaching + "payment_issue" -> Just BAPaymentIssue + "subscription_ended" -> Just BASubscriptionEnded + "prepaid_ending" -> Just BAPrepaidEnding + "support_ended" -> Just BASupportEnded + _ -> Nothing + +instance FromField BadgeAlertKind where fromField = fromTextField_ textDecode + +instance ToField BadgeAlertKind where toField = toField . textEncode + +-- exactly one of these funds a purchase; the schema cannot say so, both columns being nullable +data BadgeFunding + = BFPayment {paymentId :: PaymentId} + | BFCodeRedemption {redemptionId :: Int64} + deriving (Eq, Show) + +-- to review +data BadgePurchase = BadgePurchase + { badgePurchaseId :: Int64, + userId :: UserId, + purchaseKey :: C.PublicKeyEd25519, + purchasePrivKey :: C.PrivateKeyEd25519, + masterKey :: BadgeMasterKey, + badgeType :: BadgeType, + priceId :: Maybe BadgePriceId, + offerId :: Maybe BadgeOfferId, + funding :: BadgeFunding, + status :: BadgePurchaseStatus, + credential :: Maybe BadgeCredential, + alertAcked :: Maybe (BadgeAlertKind, Text), + alertSnoozeUntil :: Maybe UTCTime, + createdAt :: UTCTime, + updatedAt :: UTCTime + } + +-- confirmed +data BadgeLedgerEntry = BadgeLedgerEntry + { entryId :: Int64, + entryUuid :: Text, + badgePurchaseId :: Int64, + changeMonths :: Int, + balanceMonths :: Int, + balanceStartTs :: UTCTime, + balanceAnchorTs :: UTCTime, + balanceBadgeType :: BadgeType, + wasPausedSince :: Maybe UTCTime, + serviceCreatedAt :: UTCTime, + createdAt :: UTCTime, + entryType :: LedgerEntryType + } + deriving (Show) + +-- unconfirmed draft +data BadgeCharge = BadgeCharge + { chargeId :: Int64, + paymentId :: Int64, + invoiceUuid :: InvoiceId, + providerChargeRef :: Text, + periodStart :: UTCTime, + periodEnd :: UTCTime, + amount :: Int64, + currency :: Text, + chargedAt :: UTCTime + } + deriving (Show) + +-- every issuance covers one month and is written beside exactly one debit(badge) row +data BadgeIssuance = BadgeIssuance + { issuanceId :: Text, + badgePurchaseId :: Int64, + badgeType :: BadgeType, + periodStart :: UTCTime, + periodEnd :: UTCTime, + expiry :: UTCTime, + entryId :: Int64, + credential :: BadgeCredential, + createdAt :: UTCTime + } + deriving (Show) + +-- unconfirmed draft +data BadgeAlert = BadgeAlert + { kind :: BadgeAlertKind, + episode :: Text, + date :: UTCTime, + price :: Maybe BadgeAlertPrice + } + deriving (Show) + +data BadgeAlertPrice = BadgeAlertPrice + { amount :: Int64, + currency :: Text + } + deriving (Show) + +-- | The user's badge as the badge surfaces render it. The private purchase key is deliberately +-- absent: this travels to the UI and over remote control, and it is a secret that stays in core. +data BadgeState = BadgeState + { badgePurchaseId :: Int64, + purchaseKey :: C.PublicKeyEd25519, -- the purchase's identifier on the service + badgeType :: BadgeType, + shown :: BoolDef, + monthsLeft :: Int, + paidThrough :: UTCTime, + -- payments returns here with the payment types, which this slice neither writes nor encodes + renewsAt :: Maybe UTCTime, + willRenew :: Bool, + alert :: Maybe BadgeAlert + } + deriving (Show) + +instance TextEncoding BadgePurchaseStatus where + textEncode = \case + PSAcquiring -> "acquiring" + PSIssued -> "issued" + PSSuperseded -> "superseded" + PSFailed -> "failed" + textDecode = \case + "acquiring" -> Just PSAcquiring + "issued" -> Just PSIssued + "superseded" -> Just PSSuperseded + "failed" -> Just PSFailed + _ -> Nothing + +instance FromField BadgePurchaseStatus where fromField = fromTextField_ textDecode + +instance ToField BadgePurchaseStatus where toField = toField . textEncode + +instance TextEncoding BadgeCodePaymentStatus where + textEncode = \case + CPSPaid -> "paid" + CPSUnpaid -> "unpaid" + CPSFree -> "free" + textDecode = \case + "paid" -> Just CPSPaid + "unpaid" -> Just CPSUnpaid + "free" -> Just CPSFree + _ -> Nothing + +instance FromField BadgeCodePaymentStatus where fromField = fromTextField_ textDecode + +instance ToField BadgeCodePaymentStatus where toField = toField . textEncode + +$(JQ.deriveJSON (enumJSON $ dropPrefix "BIS") ''BadgeItemStatus) + +$(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "OD") ''OfferDiscount) + +$(JQ.deriveJSON (enumJSON $ dropPrefix "BA") ''BadgeAlertKind) + +$(JQ.deriveJSON defaultJSON ''BadgeAlertPrice) + +$(JQ.deriveJSON defaultJSON ''BadgeAlert) + +$(JQ.deriveJSON defaultJSON ''BadgeState) diff --git a/src/Simplex/Chat/Bot.hs b/src/Simplex/Chat/Bot.hs index dfb7418f3f..3b1c4c6da0 100644 --- a/src/Simplex/Chat/Bot.hs +++ b/src/Simplex/Chat/Bot.hs @@ -47,16 +47,17 @@ chatBotRepl welcome answer _user cc = do contactConnected Contact {localDisplayName} = putStrLn $ T.unpack localDisplayName <> " connected" initializeBotAddress :: ChatController -> IO () -initializeBotAddress = initializeBotAddress' True +initializeBotAddress = initializeBotAddress' True Nothing True -initializeBotAddress' :: Bool -> ChatController -> IO () -initializeBotAddress' logAddress cc = do +-- pqRatchet_ selects the address type when creating: Just True (IKUsePQ) is required for service RPC, Nothing is the legacy non-DR contact address. +initializeBotAddress' :: Bool -> Maybe Bool -> Bool -> ChatController -> IO () +initializeBotAddress' logAddress pqRatchet_ doAutoAccept cc = do sendChatCmd cc ShowMyAddress >>= \case Right (CRUserContactLink _ UserContactLink {connLinkContact}) -> showBotAddress connLinkContact Left (ChatErrorStore SEUserContactLinkNotFound) -> do when logAddress $ putStrLn "No bot address, creating..." -- TODO [short links] create short link by default - sendChatCmd cc (CreateMyAddress Nothing) >>= \case + sendChatCmd cc (CreateMyAddress pqRatchet_) >>= \case Right (CRUserContactLinkCreated _ ccLink) -> showBotAddress ccLink _ -> putStrLn "can't create bot address" >> exitFailure _ -> putStrLn "unexpected response" >> exitFailure @@ -65,7 +66,8 @@ initializeBotAddress' logAddress cc = do when logAddress $ do putStrLn $ "Bot's contact address is: " <> B.unpack (maybe (strEncode uri) strEncode shortUri) when (isJust shortUri) $ putStrLn $ "Full contact address for old clients: " <> B.unpack (strEncode uri) - let settings = AddressSettings {businessAddress = False, autoAccept = Just AutoAccept {acceptIncognito = False}, autoReply = Nothing} + let aa = if doAutoAccept then Just AutoAccept {acceptIncognito = False} else Nothing + settings = AddressSettings {businessAddress = False, autoAccept = aa, autoReply = Nothing} void $ sendChatCmd cc $ SetAddressSettings Nothing settings sendMessage :: ChatController -> Contact -> Text -> IO () diff --git a/apps/simplex-directory-service/src/Directory/Util.hs b/src/Simplex/Chat/Bot/Store.hs similarity index 87% rename from apps/simplex-directory-service/src/Directory/Util.hs rename to src/Simplex/Chat/Bot/Store.hs index 52d376a945..bfa5b04ebc 100644 --- a/apps/simplex-directory-service/src/Directory/Util.hs +++ b/src/Simplex/Chat/Bot/Store.hs @@ -3,7 +3,11 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} -module Directory.Util where +module Simplex.Chat.Bot.Store + ( withDB, + withDB', + ) +where import Control.Logger.Simple import Control.Monad.Except @@ -15,10 +19,6 @@ import Simplex.Messaging.Agent.Store.Common (withTransaction) import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Util (catchAll) -storeCxt :: ChatController -> StoreCxt -storeCxt ChatController {config} = mkStoreCxt config -{-# INLINE storeCxt #-} - withDB' :: Text -> ChatController -> (DB.Connection -> IO a) -> IO (Either String a) withDB' cxt cc a = withDB cxt cc $ ExceptT . fmap Right . a diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index d0fe2ee1c9..ae873662e0 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -84,7 +84,9 @@ import Simplex.Messaging.Agent.Store.DB (SQLError) import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Client (HostMode (..), SMPProxyFallback (..), SMPProxyMode (..), SMPWebPortServers (..), SocksMode (..)) import qualified Simplex.Messaging.Crypto as C -import Simplex.Chat.Badges (BadgeCredential) +import Simplex.Chat.Badges (BadgeCredential, FileSizeLimits, LocalBadge) +import Simplex.Chat.Badges.Service (BadgeServiceErrorCode, StatementEntry) +import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeAlertKind, BadgeState (..)) import Simplex.Messaging.Crypto.BBS (BBSPublicKey) import Simplex.Messaging.Crypto.File (CryptoFile (..)) import qualified Simplex.Messaging.Crypto.File as CF @@ -93,10 +95,11 @@ import Simplex.Messaging.Encoding.String import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), NtfTknStatus) import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, parseAll, parseString, sumTypeJSON) import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType (..), MsgId, NMsgMeta (..), NtfServer, ProtocolType (..), QueueId, SMPMsgMeta (..), SubscriptionMode (..), XFTPServer) +import Simplex.Messaging.Session (SessionVar) import Simplex.Messaging.TMap (TMap) import Simplex.Messaging.Transport (TLS, TransportPeer (..), simplexMQVersion) import Simplex.Messaging.Transport.Client (SocksProxyWithAuth, TransportHost) -import Simplex.Messaging.Util (AnyError (..), catchAllErrors, (<$$>)) +import Simplex.Messaging.Util (AnyError (..), catchAllErrors, catchOwn', (<$$>)) import Simplex.RemoteControl.Client import Simplex.RemoteControl.Invitation (RCSignedInvitation, RCVerifiedInvitation) import Simplex.RemoteControl.Types @@ -144,15 +147,23 @@ data ChatConfig = ChatConfig chatVRange :: VersionRangeChat, -- issuer public keys by index: credentials and proofs name the key that signed them, for rotation badgePublicKeys :: Map Int BBSPublicKey, + -- Nothing until the badge service is deployed + badgeServiceAddress :: Maybe (ConnectTarget 'CMContact), + -- the only clock badge code reads, so tests can shift it; production arithmetic is unchanged + badgeCurrentTime :: IO UTCTime, + -- how long a badge worker waits before repeating a renewal that failed for a passing reason + badgeRetryInterval :: RetryInterval, confirmMigrations :: MigrationConfirmation, presetServers :: PresetServers, shortLinkPresetServers :: NonEmpty SMPServer, presetDomains :: [HostName], tbqSize :: Natural, + maxChats :: Int, fileChunkSize :: Integer, xftpDescrPartSize :: Int, inlineFiles :: InlineFilesConfig, autoAcceptFileSize :: Integer, + fileSizeLimits :: FileSizeLimits, showReactions :: Bool, showFullLinks :: Bool, showReceipts :: Bool, @@ -218,9 +229,9 @@ newWebPreviewState = do -- | Builds the read-only context threaded through store functions from chat config. -- The single construction point, so new store-wide config (e.g. server keys) is added in one place. -mkStoreCxt :: ChatConfig -> StoreCxt -mkStoreCxt ChatConfig {chatVRange, badgePublicKeys} = StoreCxt chatVRange badgePublicKeys -{-# INLINE mkStoreCxt #-} +storeCxt :: ChatController -> StoreCxt +storeCxt ChatController {config = ChatConfig {chatVRange, badgePublicKeys}, random} = StoreCxt chatVRange badgePublicKeys random +{-# INLINE storeCxt #-} data RandomAgentServers = RandomAgentServers { smpServers :: NonEmpty (ServerCfg 'PSMP), @@ -277,6 +288,12 @@ defaultInlineFilesConfig = data ChatDatabase = ChatDatabase {chatStore :: DBStore, agentStore :: DBStore} +-- | Signalling badgeWork wakes the worker from its own wait; a full var means it has work to do. +data BadgeWorker = BadgeWorker + { badgeWorkerAsync :: Async (), + badgeWork :: TMVar () + } + data ChatController = ChatController { currentUser :: TVar (Maybe User), randomPresetServers :: NonEmpty PresetOperator, @@ -309,6 +326,9 @@ data ChatController = ChatController deliveryTaskWorkers :: TMap DeliveryWorkerKey Worker, deliveryJobWorkers :: TMap DeliveryWorkerKey Worker, relayRequestWorkers :: TMap Int Worker, -- single global worker with key 1 is used to fit into existing worker management framework + -- one badge worker per user: badge state is per profile, and one profile must not stall another + badgeWorkers :: TMap UserId (SessionVar BadgeWorker), + badgeSeq :: TVar Int, relayGroupLinkChecksAsync :: TVar (Maybe (Async ())), webPreviewState :: Maybe WebPreviewState, chatRelayTests :: TMap ConnId RelayTest, @@ -381,7 +401,7 @@ data ChatCommand | APISaveAppSettings AppSettings | APIGetAppSettings (Maybe AppSettings) | APIGetChatTags UserId - | APIGetChats {userId :: UserId, pendingConnections :: Bool, pagination :: PaginationByTime, query :: ChatListQuery} + | APIGetChats {userId :: UserId, pendingConnections :: Bool, pagination :: Maybe PaginationByTime, query :: ChatListQuery} | APIGetChat {chatRef :: ChatRef, contentTag :: Maybe MsgContentTag, chatPagination :: ChatPagination, search :: Maybe Text} | APIGetChatContentTypes ChatRef | APIGetChatItems {chatPagination :: ChatPagination, search :: Maybe Text} @@ -646,6 +666,12 @@ data ChatCommand | UpdateProfileImage (Maybe ImageData) -- UserId (not used in UI) | UpdateProfileImageFromFile FilePath -- set profile image from a .png/.jpg/.jpeg file | AddBadge BadgeCredential -- attach an issued badge credential (testing; credential from `simplex-chat badge sign`) + | APIRedeemBadgeCode {userId :: UserId, code :: Text} -- redeem a badge code with the configured badge service + | APIGetBadgeState {userId :: UserId} -- the user's badges, their balances and any current alert + | APIGetBadgeLedger {userId :: UserId, badgePurchaseId :: Int64} -- the purchase's ledger, oldest first + -- episode is last because it is free text: it is the value that makes one occurrence of an + -- alert distinct from the next, and the app returns whatever it was given + | APIAckBadgeAlert {userId :: UserId, badgePurchaseId :: Int64, alertKind :: BadgeAlertKind, snooze :: Bool, episode :: Text} | ShowProfileImage | SetUserFeature AChatFeature FeatureAllowed -- UserId (not used in UI) | SetContactFeature AChatFeature ContactName (Maybe FeatureAllowed) @@ -663,10 +689,10 @@ data ChatCommand | DeleteRemoteHost RemoteHostId -- Unregister remote host and remove its data | StoreRemoteFile {remoteHostId :: RemoteHostId, storeEncrypted :: Maybe Bool, localPath :: FilePath} | GetRemoteFile {remoteHostId :: RemoteHostId, file :: RemoteFile} - | ConnectRemoteCtrl RCSignedInvitation -- Connect new or existing controller via OOB data + | ConnectRemoteCtrl {remoteInvitation :: RCSignedInvitation} -- Connect new or existing controller via OOB data | FindKnownRemoteCtrl -- Start listening for announcements from all existing controllers | ConfirmRemoteCtrl RemoteCtrlId -- Confirm the connection with found controller - | VerifyRemoteCtrlSession Text -- Verify remote controller session + | VerifyRemoteCtrlSession {sessionCode :: Text} -- Verify remote controller session | ListRemoteCtrls | StopRemoteCtrl -- Stop listening for announcements or terminate an active session | DeleteRemoteCtrl RemoteCtrlId -- Remove all local data associated with a remote controller session @@ -850,6 +876,9 @@ data ChatResponse | CRContactRequestRejected {user :: User, contactRequest :: UserContactRequest, contact_ :: Maybe Contact} | CRServiceResponse {user :: User, responseData :: J.Object} | CRServiceReplyAccepted {user :: User, connectionId :: AgentConnId} + | CRBadgeRedeemed {user :: User, redeemedBadge :: LocalBadge, newBadge :: Bool, badgeState :: Maybe BadgeState} + | CRBadgeState {user :: User, badgeState :: Maybe BadgeState} + | CRBadgeLedger {user :: User, badgeLedger :: [StatementEntry]} | CRWallet {user :: User, accountIndexes_ :: Maybe [AccountIndex]} | CRWalletMnemonic {user :: User, mnemonic :: Text} | CRWalletAddress {user :: User, walletAddress :: WalletAddress} @@ -884,7 +913,7 @@ data ChatResponse | CRAcceptingContactRequest {user :: User, contact :: Contact} | CRContactAlreadyExists {user :: User, contact :: Contact} | CRLeftMemberUser {user :: User, groupInfo :: GroupInfo} - | CRGroupDeletedUser {user :: User, groupInfo :: GroupInfo, msgSigned :: Bool} + | CRGroupDeletedUser {user :: User, groupInfo :: GroupInfo, msgSigned :: Bool, localDeletion :: Bool} | CRForwardPlan {user :: User, itemsCount :: Int, chatItemIds :: [ChatItemId], forwardConfirmation :: Maybe ForwardConfirmation} | CRChatMsgContent {user :: User, msgContent :: MsgContent} | CRRcvFileAccepted {user :: User, chatItem :: AChatItem} @@ -971,6 +1000,8 @@ data ChatEvent | CEvtReceivedContactRequest {user :: User, contactRequest :: UserContactRequest, chat_ :: Maybe AChat} | CEvtServiceRequest {user :: User, requestId :: AgentInvId, signerKey :: Maybe C.PublicKeyEd25519, requestData :: J.Object} | CEvtServiceReplySent {connectionId :: AgentConnId} + | CEvtBadgeChanged {user :: User, badgeState :: Maybe BadgeState} -- badge state changed, including a renewal that arrived without a command + | CEvtBadgeAlert {user :: User, badgeAlert :: BadgeAlert} | CEvtContactRequestRejected {user :: User, contact :: Contact, rejectionReason :: Maybe ContactRejectionReason} | CEvtAcceptingContactRequest {user :: User, contact :: Contact} -- there is the same command response | CEvtAcceptingBusinessRequest {user :: User, groupInfo :: GroupInfo} @@ -1460,6 +1491,16 @@ data SimplexDomainError | SDEUnknownDomain -- the resolved link's profile has no name, or a different name deriving (Eq, Show) +data BadgeRedeemError + = BREInvalidCode -- format or check character + | BREServiceNotConfigured + | BREBadgeActive + | BREServiceError {serviceError :: BadgeServiceErrorCode} + | BREInvalidResponse {message :: String} + | BREUnknownKeyIndex + | BRECredentialNotVerified + deriving (Eq, Show) + data ChatErrorType = CENoActiveUser | CENoConnectionUser {agentConnId :: AgentConnId} @@ -1533,6 +1574,7 @@ data ChatErrorType | CEAgentVersion | CEAgentNoSubResult {agentConnId :: AgentConnId} | CECommandError {message :: String} + | CEBadgeRedeemError {badgeRedeemError :: BadgeRedeemError} | CEServerProtocol {serverProtocol :: AProtocolType} | CEAgentCommandError {message :: String} | CEInvalidFileDescription {message :: String} @@ -1766,12 +1808,12 @@ withFastStore = withStorePriority True withStorePriority :: Bool -> (DB.Connection -> ExceptT StoreError IO a) -> CM a withStorePriority priority action = do ChatController {chatStore} <- ask - liftIOEither $ withTransactionPriority chatStore priority (runExceptT . withExceptT ChatErrorStore . action) `E.catch` handleDBErrors + liftIOEither $ withTransactionPriority chatStore priority (runExceptT . withExceptT ChatErrorStore . action) `catchOwn'` handleDBErrors withStoreBatch :: Traversable t => (DB.Connection -> t (IO (Either ChatError a))) -> CM' (t (Either ChatError a)) withStoreBatch actions = do ChatController {chatStore} <- ask - liftIO $ withTransaction chatStore $ mapM (`E.catch` handleDBErrors) . actions + liftIO $ withTransaction chatStore $ mapM (`catchOwn'` handleDBErrors) . actions handleDBErrors :: E.SomeException -> IO (Either ChatError a) handleDBErrors e = pure $ Left $ ChatErrorStore $ case E.fromException e of @@ -1817,6 +1859,8 @@ $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "FC") ''ForwardConfirmation) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "SDE") ''SimplexDomainError) +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "BRE") ''BadgeRedeemError) + $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CE") ''ChatErrorType) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RHE") ''RemoteHostError) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index ba1ec6323b..11d03a5ff9 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -51,12 +51,19 @@ import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Time (NominalDiffTime, addUTCTime, defaultTimeLocale, formatTime) -import Data.Time.Clock (UTCTime, getCurrentTime, nominalDay) +import Data.Word (Word32) +import Data.Time.Clock (UTCTime, diffUTCTime, getCurrentTime, nominalDay) import Data.Type.Equality import qualified Data.UUID as UUID import qualified Data.UUID.V4 as V4 import Simplex.Chat.Library.Subscriber -import Simplex.Chat.Badges (BadgeCredential (..), LocalBadge (..), badgeServerCredential, maxXFTPFileSize, mkBadgeStatus, verifyCredential) +import Crypto.Random (ChaChaDRG) +import Simplex.Messaging.Session (SessionVar (..), withGetSessVar') +import Simplex.Chat.Badges (BadgeCredential (..), BadgeInfo (..), BadgeMasterKey, BadgeType, LocalBadge (..), badgeServerCredential, mkBadgeStatus, maxSndXFTPFileSize, verifyCredential) +import qualified Simplex.Chat.Badges.Ledger as L +import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeAlertKind (..), BadgeState (..)) +import Simplex.Chat.Badges.Code (badgeCodeText, parseBadgeCode) +import Simplex.Chat.Badges.Service (BadgeBalance (..), BadgeServiceCommand (..), BadgeServiceErrorCode (..), BadgeServiceRequest (..), BadgeServiceResponse (..), BadgeStatement (..), StatementDebitType (..), StatementEntry (..), StatementEntryType (..), currentBadgeServiceVersion) import Simplex.Chat.Names (SimplexDomainProof (..), SimplexDomainClaim (..), claimDomain, mkDomainClaim) import Simplex.Chat.Store.Wallets (WalletSeed (..), accountHeldByOther, bindAccount, createWalletSeed, deleteWalletSeed, getUserAccounts, getWalletSeed, resolveAccount) import Simplex.Chat.Wallet (AccountIndex, AccountKey, WalletAddress (..), WalletError (..), accountSecret, checkAccountIndex, deriveAccountKey, entropyFromMnemonic, newSeedEntropy, renderAccountPath, seedMaster, seedMnemonic) @@ -67,7 +74,7 @@ import Simplex.Chat.Delivery (DeliveryJobScope (..), DeliveryJobSpec (..), Deliv import Simplex.Chat.Files import Simplex.Chat.Markdown import Simplex.Chat.Messages -import Simplex.Chat.Messages.Batch (encodeBatchElement) +import Simplex.Chat.Messages.Batch (BatchMode, encodeBatchElement) import Simplex.Chat.Messages.CIContent import Simplex.Chat.Messages.CIContent.Events import Simplex.Chat.Operators @@ -80,6 +87,7 @@ import Simplex.Chat.Library.Internal import Simplex.Chat.Stats import Simplex.Chat.Store import Simplex.Chat.Store.AppSettings +import Simplex.Chat.Store.Badges import Simplex.Chat.Store.ContactRequest import Simplex.Chat.Store.Connections import Simplex.Chat.Store.Delivery @@ -100,6 +108,7 @@ import Simplex.FileTransfer.Description (FileDescriptionURI (..), maxFileSizeHar import Simplex.Messaging.Agent import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..), allRoles) import Simplex.Messaging.Agent.Protocol +import Simplex.Messaging.Agent.RetryInterval (RetryInterval (..), withRetryInterval) import Simplex.Messaging.Agent.Store.Entity import Simplex.Messaging.Agent.Store.Interface (execSQL) import Simplex.Messaging.Agent.Store.Shared (upMigration) @@ -114,7 +123,7 @@ import Simplex.Messaging.Crypto.Ratchet (E2ERatchetParamsUri (..), InitialKeys ( import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (base64P) -import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..), ErrorType (NAME), MsgFlags (..), NameRecord (..), NtfServer, ProtoServerWithAuth (..), ProtocolServer, ProtocolType (..), ProtocolTypeI (..), SProtocolType (..), SubscriptionMode (..), UserProtocol, userProtocol) +import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..), ErrorType (NAME), MsgFlags (..), NameRecord (..), NameRegistration (..), NameResponse (..), 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 @@ -126,23 +135,24 @@ import Simplex.RemoteControl.Types (RCCtrlAddress (..)) import System.Exit (ExitCode, exitSuccess) import System.FilePath (takeExtension, takeFileName, ()) import System.IO (Handle, IOMode (..)) +import System.Mem.Weak (deRefWeak) import System.Random (randomRIO) import System.Timeout (timeout) import UnliftIO.Async -import UnliftIO.Concurrent (forkIO, threadDelay) +import UnliftIO.Concurrent (forkIO, killThread, threadDelay) import UnliftIO.Directory import qualified UnliftIO.Exception as E import UnliftIO.IO (hClose) import UnliftIO.STM #if defined(dbPostgres) import Data.Bifunctor (bimap, first, second) -import Simplex.Messaging.Agent.Client (SubInfo (..), getAgentQueuesInfo, getAgentWorkersDetails, getAgentWorkersSummary, temporaryOrHostError) +import Simplex.Messaging.Agent.Client (SubInfo (..), cancelWorker, getAgentQueuesInfo, getAgentWorkersDetails, getAgentWorkersSummary, temporaryOrHostError) #else import Data.Bifunctor (bimap, first, second) import qualified Data.ByteArray as BA import qualified Database.SQLite.Simple as SQL import Simplex.Chat.Archive -import Simplex.Messaging.Agent.Client (SubInfo (..), agentClientStore, getAgentQueuesInfo, getAgentWorkersDetails, getAgentWorkersSummary, temporaryOrHostError) +import Simplex.Messaging.Agent.Client (SubInfo (..), agentClientStore, cancelWorker, getAgentQueuesInfo, getAgentWorkersDetails, getAgentWorkersSummary, temporaryOrHostError) import Simplex.Messaging.Agent.Store.Common (withConnection) import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..)) #endif @@ -167,7 +177,7 @@ checkProfileImageSize = mapM_ $ \(ImageData t) -> in when (size > maxProfileImageSize) $ throwCmdError $ "Profile image is too large " <> show size checkProfileSize :: Profile -> CM () -checkProfileSize p = checkInfoSize "Profile" (XInfo p) +checkProfileSize p = checkInfoSize "Profile" (XInfo p Nothing) checkGroupProfileSize :: GroupProfile -> CM () checkGroupProfileSize p = checkInfoSize "Group profile" (XGrpInfo p) @@ -253,6 +263,7 @@ startChatController mainApp enableSndFiles serviceRequests = do startDeliveryWorkers startRelayRequestWorker_ startCleanupManager + mapM_ startBadgeWork users void $ forkIO $ mapM_ startExpireCIs users startRelayChecks users startWebPreview users @@ -349,11 +360,20 @@ restoreCalls = do atomically $ writeTVar calls callsMap stopChatController :: ChatController -> IO () -stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, expireCIFlags, remoteHostSessions, remoteCtrlSession} = do +stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, expireCIFlags, remoteHostSessions, remoteCtrlSession, cleanupManagerAsync, relayGroupLinkChecksAsync, webPreviewState, expireCIThreads, timedItemThreads, deliveryTaskWorkers, deliveryJobWorkers, relayRequestWorkers, badgeWorkers} = do readTVarIO remoteHostSessions >>= mapM_ (cancelRemoteHost False . snd) atomically (stateTVar remoteCtrlSession (,Nothing)) >>= mapM_ (cancelRemoteCtrl False . snd) disconnectAgentClient smpAgent - readTVarIO s >>= mapM_ (\(a1, a2) -> forkIO $ uninterruptibleCancel a1 >> mapM_ uninterruptibleCancel a2) + readTVarIO s >>= mapM_ (\(a1, a2) -> uninterruptibleCancel a1 >> mapM_ uninterruptibleCancel a2) + cancelAsync cleanupManagerAsync + cancelAsync relayGroupLinkChecksAsync + forM_ webPreviewState $ \WebPreviewState {webPreviewWorkerAsync} -> cancelAsync webPreviewWorkerAsync + clearMap expireCIThreads >>= mapM_ (mapM_ uninterruptibleCancel) + clearMap timedItemThreads >>= mapM_ (readTVarIO >=> mapM_ (deRefWeak >=> mapM_ killThread)) + clearMap deliveryTaskWorkers >>= mapM_ cancelWorker + clearMap deliveryJobWorkers >>= mapM_ cancelWorker + clearMap relayRequestWorkers >>= mapM_ cancelWorker + stopBadgeWorkers badgeWorkers closeFiles sndFiles closeFiles rcvFiles atomically $ do @@ -361,6 +381,10 @@ stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, forM_ keys $ \k -> TM.insert k False expireCIFlags writeTVar s Nothing where + cancelAsync :: TVar (Maybe (Async ())) -> IO () + cancelAsync a = atomically (swapTVar a Nothing) >>= mapM_ uninterruptibleCancel + clearMap :: TM.TMap k a -> IO (Map k a) + clearMap m = atomically $ swapTVar m M.empty closeFiles :: TVar (Map Int64 Handle) -> IO () closeFiles files = do fs <- readTVarIO files @@ -581,6 +605,7 @@ processChatCommand cxt nm = \case void . forkIO $ subscribeUsers True users void . forkIO $ startFilesToReceive users setAllExpireCIFlags True + mapM_ startBadgeWork users ok_ APISuspendChat t -> do chatWriteVar chatActivated False @@ -655,7 +680,9 @@ processChatCommand cxt nm = \case tags <- withFastStore' (`getUserChatTags` user) pure $ CRChatTags user tags APIGetChats {userId, pendingConnections, pagination, query} -> withUserId' userId $ \user -> do - (errs, previews) <- partitionEithers <$> withFastStore' (\db -> getChatPreviews db cxt user pendingConnections pagination query) + ChatConfig {maxChats} <- asks config + let pagination' = fromMaybe (PTLast maxChats) pagination + (errs, previews) <- partitionEithers <$> withFastStore' (\db -> getChatPreviews db cxt user pendingConnections pagination' query) unless (null errs) $ toView $ CEvtChatErrors (map ChatErrorStore errs) pure $ CRApiChats user previews APIGetChat (ChatRef cType cId scope_) contentFilter pagination search -> withUser $ \user -> case cType of @@ -729,8 +756,8 @@ processChatCommand cxt nm = \case Nothing -> pure () withGroupLock "sendMessage" chatId $ do (gInfo, cmrs) <- withFastStore $ \db -> do - g <- getGroupInfo db cxt user chatId - (g,) <$> mapM (composedMessageReqMentions db user g) cms + gik@(GIK g _) <- getGroupInfoKeys db cxt user chatId + (gik,) <$> mapM (composedMessageReqMentions db user g) cms sendGroupContentMessages user gInfo gsScope asGroup live itemTTL sign cmrs APICreateChatTag (ChatTagData emoji text) -> withUser $ \user -> withFastStore' $ \db -> do _ <- createChatTag db user emoji text @@ -759,7 +786,7 @@ processChatCommand cxt nm = \case createNoteFolderContentItems user folderId (L.map composedMessageReq cms) APIReportMessage gId reportedItemId reportReason reportText -> withUser $ \user -> withGroupLock "reportMessage" gId $ do - gInfo <- withFastStore $ \db -> getGroupInfo db cxt user gId + gInfo <- withFastStore $ \db -> getGroupInfoKeys db cxt user gId let mc = MCReport reportText reportReason cm = ComposedMessage {fileSource = Nothing, quotedItemId = Just reportedItemId, msgContent = mc, mentions = M.empty} sendGroupContentMessages user gInfo (Just $ GCSMemberSupport Nothing) False False Nothing False [composedMessageReq cm] @@ -794,7 +821,7 @@ processChatCommand cxt nm = \case _ -> throwChatError CEInvalidChatItemUpdate CChatItem SMDRcv _ -> throwChatError CEInvalidChatItemUpdate CTGroup -> withGroupLock "updateChatItem" chatId $ do - gInfo@GroupInfo {groupId, membership} <- withFastStore $ \db -> getGroupInfo db cxt user chatId + g@(GIK gInfo@GroupInfo {groupId, membership} _) <- withFastStore $ \db -> getGroupInfoKeys db cxt user chatId when (isNothing scope) $ assertUserGroupRole gInfo GRAuthor let (_, ft_) = msgContentTexts mc if prohibitedSimplexLinks gInfo membership mc ft_ @@ -816,7 +843,7 @@ processChatCommand cxt nm = \case mentions' = M.map (\CIMention {memberId} -> MsgMention {memberId}) ciMentions event = XMsgUpdate itemSharedMId mc mentions' (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive) msgScope (Just showGroupAsSender) reuseSign = case msgVerified of Just (MVSigned _) -> True; _ -> False - SndMessage {msgId} <- sendGroupMessage user gInfo scope recipients reuseSign event + SndMessage {msgId} <- sendGroupMessage user g scope recipients reuseSign event ci' <- withFastStore' $ \db -> do currentTs <- liftIO getCurrentTime when changed $ @@ -862,7 +889,7 @@ processChatCommand cxt nm = \case else markDirectCIsDeleted user ct items =<< liftIO getCurrentTime pure $ CRChatItemsDeleted user deletions True False CTGroup -> withGroupLock "deleteChatItem" chatId $ do - (gInfo, items) <- getCommandGroupChatItems user chatId itemIds + (g@(GIK gInfo _), items) <- getCommandGroupChatItems user chatId itemIds -- TODO [knocking] check scope for all items? chatScopeInfo <- mapM (getChatScopeInfo cxt user) scope deletions <- case mode of @@ -875,14 +902,14 @@ processChatCommand cxt nm = \case recipients <- getGroupRecipients cxt user gInfo chatScopeInfo groupKnockingVersion assertDeletable items assertUserGroupRole gInfo GRObserver -- can still delete messages sent earlier - let signedEvents = L.nonEmpty $ mapMaybe (delEventSigned gInfo chatScopeInfo False) items - mapM_ (sendGroupSignedMessages user gInfo Nothing False recipients) signedEvents + let signedEvents = L.nonEmpty $ mapMaybe (delEventSigned g chatScopeInfo False) items + mapM_ (sendGroupSignedMessages user g Nothing False recipients) signedEvents delGroupChatItems user gInfo chatScopeInfo items False CIDMHistory -> do unless (publicGroupEditor gInfo (membership gInfo)) $ throwChatError CEInvalidChatItemDelete recipients <- getGroupRecipients cxt user gInfo chatScopeInfo groupKnockingVersion - let signedEvents = L.nonEmpty $ mapMaybe (delEventSigned gInfo chatScopeInfo True) items - mapM_ (sendGroupSignedMessages user gInfo Nothing False recipients) signedEvents + let signedEvents = L.nonEmpty $ mapMaybe (delEventSigned g chatScopeInfo True) items + mapM_ (sendGroupSignedMessages user g Nothing False recipients) signedEvents delGroupChatItems user gInfo chatScopeInfo items False pure $ CRChatItemsDeleted user deletions True False CTLocal -> do @@ -905,20 +932,20 @@ processChatCommand cxt nm = \case itemsMsgIds :: [CChatItem c] -> [SharedMsgId] itemsMsgIds = mapMaybe (\(CChatItem _ ChatItem {meta = CIMeta {itemSharedMsgId}}) -> itemSharedMsgId) -- history delete always signs (attributable owner action); self-delete signs iff the target was held signed (deniability) - delEventSigned :: GroupInfo -> Maybe GroupChatScopeInfo -> Bool -> CChatItem 'CTGroup -> Maybe (Maybe MsgSigning, ChatMsgEvent 'Json) - delEventSigned gInfo chatScopeInfo onlyHistory (CChatItem _ ChatItem {meta = CIMeta {itemSharedMsgId, msgVerified}}) = + delEventSigned :: GroupInfoKeys -> Maybe GroupChatScopeInfo -> Bool -> CChatItem 'CTGroup -> Maybe (Maybe MsgSigning, ChatMsgEvent 'Json) + delEventSigned g@(GIK gInfo _) chatScopeInfo onlyHistory (CChatItem _ ChatItem {meta = CIMeta {itemSharedMsgId, msgVerified}}) = delEvent <$> itemSharedMsgId where delEvent msgId = let evt = XMsgDel msgId Nothing (toMsgScope gInfo <$> chatScopeInfo) onlyHistory - in (groupMsgSigning (onlyHistory || itemSigned) gInfo evt, evt) + in (groupMsgSigning (onlyHistory || itemSigned) g evt, evt) itemSigned = case msgVerified of Just (MVSigned _) -> True; _ -> False APIDeleteMemberChatItem gId itemIds -> withUser $ \user -> withGroupLock "deleteChatItem" gId $ do - (gInfo, items) <- getCommandGroupChatItems user gId itemIds + (g@(GIK gInfo _), items) <- getCommandGroupChatItems user gId itemIds -- TODO [knocking] check scope is Nothing for all items? (prohibit moderation in support chats?) ms <- withFastStore' $ \db -> getGroupMembers db cxt user gInfo let recipients = filter memberCurrent ms - deletions <- delGroupChatItemsForMembers user gInfo Nothing recipients items + deletions <- delGroupChatItemsForMembers user g Nothing recipients items pure $ CRChatItemsDeleted user deletions True False APIArchiveReceivedReports gId -> withUser $ \user -> withFastStore $ \db -> do g <- getGroupInfo db cxt user gId @@ -926,7 +953,7 @@ processChatCommand cxt nm = \case ciIds <- liftIO $ markReceivedGroupReportsDeleted db user g deleteTs pure $ CRGroupChatItemsDeleted user g ciIds True (Just $ membership g) APIDeleteReceivedReports gId itemIds mode -> withUser $ \user -> withGroupLock "deleteReports" gId $ do - (gInfo, items) <- getCommandGroupChatItems user gId itemIds + (g@(GIK gInfo _), items) <- getCommandGroupChatItems user gId itemIds unless (all isRcvReport items) $ throwCmdError "some items are not received reports" -- TODO [knocking] scope can be different for each item if reports are from different members -- TODO (currently we pass Nothing as scope which is wrong) @@ -937,7 +964,7 @@ processChatCommand cxt nm = \case CIDMBroadcast -> do ms <- withFastStore' $ \db -> getGroupModerators db cxt user gInfo let recipients = filter memberCurrent ms - delGroupChatItemsForMembers user gInfo Nothing recipients items + delGroupChatItemsForMembers user g Nothing recipients items pure $ CRChatItemsDeleted user deletions True False where isRcvReport = \case @@ -966,9 +993,9 @@ processChatCommand cxt nm = \case CTGroup -> withGroupLock "chatItemReaction" chatId $ do -- TODO [knocking] check chat item scope? - (g@GroupInfo {membership}, CChatItem md ci) <- withFastStore $ \db -> do - g <- getGroupInfo db cxt user chatId - (g,) <$> getGroupCIWithReactions db user g itemId + (gik@(GIK g@GroupInfo {membership} _), CChatItem md ci) <- withFastStore $ \db -> do + gik@(GIK g _) <- getGroupInfoKeys db cxt user chatId + (gik,) <$> getGroupCIWithReactions db user g itemId chatScopeInfo <- mapM (getChatScopeInfo cxt user) scope recipients <- getGroupRecipients cxt user g chatScopeInfo groupKnockingVersion case ci of @@ -980,7 +1007,7 @@ processChatCommand cxt nm = \case let itemMemberId = memberId' <$> chatItemMember g ci rs <- withFastStore' $ \db -> getGroupReactions db g membership itemMemberId itemSharedMId True checkReactionAllowed rs - SndMessage {msgId} <- sendGroupMessage user g scope recipients False (XMsgReact itemSharedMId itemMemberId (toMsgScope g <$> chatScopeInfo) reaction add) + SndMessage {msgId} <- sendGroupMessage user gik scope recipients False (XMsgReact itemSharedMId itemMemberId (toMsgScope g <$> chatScopeInfo) reaction add) createdAt <- liftIO getCurrentTime reactions <- withFastStore' $ \db -> do setGroupReaction db g membership itemMemberId itemSharedMId True reaction add msgId createdAt @@ -1065,7 +1092,7 @@ processChatCommand cxt nm = \case case L.nonEmpty cmrs of Just cmrs' -> withGroupLock "forwardChatItem, to group" toChatId $ do - gInfo <- withFastStore $ \db -> getGroupInfo db cxt user toChatId + gInfo <- withFastStore $ \db -> getGroupInfoKeys db cxt user toChatId sendGroupContentMessages user gInfo toScope sendAsGroup False itemTTL False cmrs' Nothing -> pure $ CRNewChatItems user [] CTLocal -> do @@ -1095,7 +1122,7 @@ processChatCommand cxt nm = \case | otherwise = displayName -- TODO [knocking] from scope? CTGroup -> withGroupLock "forwardChatItem, from group" fromChatId $ do - (gInfo, items) <- getCommandGroupChatItems user fromChatId itemIds + (GIK gInfo _, items) <- getCommandGroupChatItems user fromChatId itemIds catMaybes <$> mapM (\ci -> ciComposeMsgReq gInfo ci <$$> prepareMsgReq ci) items where ciComposeMsgReq :: GroupInfo -> CChatItem 'CTGroup -> (MsgContent, Maybe CryptoFile) -> ComposedMessageReq @@ -1202,16 +1229,16 @@ processChatCommand cxt nm = \case let ext = takeExtension fileName pure $ prefix <> formattedDate <> ext APIShareChatMsgContent (ChatRef CTGroup groupId _) toSendRef -> withUser $ \user -> do - GroupInfo {groupProfile = gp@GroupProfile {publicGroup}, membership = GroupMember {memberId, memberRole}, groupKeys} <- - withFastStore $ \db -> getGroupInfo db cxt user groupId + GIK GroupInfo {groupProfile = gp@GroupProfile {publicGroup}, membership = GroupMember {memberId, memberRole}} gks <- + withFastStore $ \db -> getGroupInfoKeys db cxt user groupId case publicGroup of Nothing -> throwCmdError "not a public group" Just PublicGroupProfile {groupLink} -> do - let signingKeys = case (memberRole, groupKeys) of - (GROwner, Just gk@GroupKeys {groupRootKey = GRKPrivate _}) -> Just gk + let signingKeys = case (memberRole, gks) of + (GROwner, GKPublicGroup {groupRootKey = GRKPrivate _, memberPrivKey}) -> Just memberPrivKey _ -> Nothing ownerSig <- - pure signingKeys $>>= \GroupKeys {memberPrivKey} -> + pure signingKeys $>>= \memberPrivKey -> mkLinkOwnerSig memberPrivKey groupLink (Just memberId) <$$> shareChatBinding user toSendRef let text = safeDecodeUtf8 $ strEncode groupLink pure $ CRChatMsgContent user MCChat {text, chatLink = MCLGroup groupLink gp, ownerSig} @@ -1354,7 +1381,7 @@ processChatCommand cxt nm = \case withFastStore' $ \db -> deletePendingContactConnection db userId chatId pure $ CRContactConnectionDeleted user conn CTGroup | isNothing scope -> do - gInfo@GroupInfo {membership} <- withFastStore $ \db -> getGroupInfo db cxt user chatId + g@(GIK gInfo@GroupInfo {membership} _) <- withFastStore $ \db -> getGroupInfoKeys db cxt user chatId let isOwner = memberRole' membership == GROwner canDelete = isOwner || not (memberCurrent membership) unless canDelete $ throwChatError $ CEGroupUserRole gInfo GROwner @@ -1367,7 +1394,7 @@ processChatCommand cxt nm = \case let doSendDel = memberActive membership && isOwner msgSigned <- if doSendDel - then (\SndMessage {signedMsg_} -> isJust signedMsg_) <$> sendGroupMessage' user gInfo recipients XGrpDel + then (\SndMessage {signedMsg_} -> isJust signedMsg_) <$> sendGroupMessage' user g recipients XGrpDel else pure False deleteGroupLinkIfExists user gInfo deleteMembersConnections' user members doSendDel @@ -1376,7 +1403,7 @@ processChatCommand cxt nm = \case withFastStore' $ \db -> cleanupHostGroupLinkConn db user gInfo withFastStore' $ \db -> deleteGroupMembers db user gInfo withFastStore' $ \db -> deleteGroup db user gInfo - pure $ CRGroupDeletedUser user gInfo msgSigned + pure $ CRGroupDeletedUser user gInfo msgSigned (not doSendDel) where getRecipients gInfo | useRelays' gInfo = do @@ -1467,26 +1494,8 @@ processChatCommand cxt nm = \case liftIO $ deleteContactRequest db user connReqId pure ct_ pure $ CRContactRequestRejected user cReq ct_ - APISendServiceRequest userId sendTarget requestTimeout signKey request -> withUserId userId $ \user -> do - cReq <- resolveServiceTarget user sendTarget - respData <- withAgent $ \a -> sendServiceRequestAsync a (aUserId user) cReq requestTimeout (C.unStored <$> signKey) (LB.toStrict $ J.encode request) - resp <- either (const $ throwCmdError "invalid service response") pure $ J.eitherDecodeStrict' respData - pure $ CRServiceResponse user resp - where - resolveServiceTarget user = \case - CTFullContact cReq -> pure cReq - CTShortContact (CTLink sLnk) -> resolveShortLink sLnk - CTShortContact (CTName SimplexNameInfo {nameType, nameDomain}) -> case nameType of - NTContact -> resolveDomain nameDomain - _ -> throwCmdError "service request target must be a contact" - CTDomain d -> resolveDomain d - where - resolveDomain d = do - nr <- withAgent $ \a -> resolveSimplexName a nm (aUserId user) d - case firstNameLink CCTContact (nrSimplexContact nr) of - Just sLnk -> resolveShortLink sLnk - Nothing -> throwChatError $ CESimplexDomainNotReady d SDENoValidLink - resolveShortLink sLnk = (\(_, _, cReq) -> cReq) <$> getShortLinkConnReq nm user sLnk + APISendServiceRequest userId sendTarget requestTimeout signKey request -> withUserId userId $ \user -> + CRServiceResponse user <$> sendServiceRequestTo nm user sendTarget requestTimeout (C.unStored <$> signKey) request APISendServiceResponse userId requestId responseData -> withUserId userId $ \user -> do let AgentInvId invId = requestId connId <- withAgent $ \a -> sendServiceReplyAsync a "" (aUserId user) invId (LB.toStrict $ J.encode responseData) @@ -1630,7 +1639,7 @@ processChatCommand cxt nm = \case UserContactLink {shortLinkDataSet, connLinkContact = CCLink _ sl_} <- withFastStore (`getUserAddress` user) case sl_ of Just sl | shortLinkDataSet -> do - NameRecord {nrSimplexContact} <- withAgent $ \a -> resolveSimplexName a nm (aUserId user) domain + NameRecord {nrSimplexContact} <- resolveNameRecord user nm domain unless (nameResolvesTo sl nrSimplexContact) $ throwChatError $ CESimplexDomainNotReady domain SDENoValidLink pure $ Just (CLShort sl) _ -> throwCmdError "create the address short link and add it to name" @@ -2319,7 +2328,7 @@ processChatCommand cxt nm = \case pure $ CRStartedConnectionToContact user ct' customUserProfile CVRConnectedContact ct' -> pure $ CRContactAlreadyExists user ct' APIConnectPreparedGroup {groupId, incognito, ownerContact, msgContent_} -> withUser $ \user -> do - gInfo <- withFastStore $ \db -> getGroupInfo db cxt user groupId + g@(GIK gInfo _) <- withFastStore $ \db -> getGroupInfoKeys db cxt user groupId case gInfo of GroupInfo {preparedGroup = Nothing} -> throwCmdError "group doesn't have link to connect" GroupInfo {useRelays = BoolDef True, preparedGroup = Just PreparedGroup {connLinkToConnect}} -> do @@ -2339,10 +2348,8 @@ processChatCommand cxt nm = \case -- set group link info and incognito profile, generate and store membership keys incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing let cReqHash = contactCReqHash $ CRContactUri crData {crScheme = SSSimplex} e2e - gVar <- asks random - (_, memberPrivKey) <- liftIO $ atomically $ C.generateKeyPair gVar - gInfo' <- withFastStore $ \db -> do - gInfo' <- updatePreparedRelayedGroup db cxt user gInfo mainCReq cReqHash incognitoProfile rootKey memberPrivKey publicMemberCount_ + g'@(GIK gInfo' _) <- withFastStore $ \db -> do + g'@(GIK gInfo' _) <- updatePreparedRelayedGroup db cxt user gInfo mainCReq cReqHash incognitoProfile rootKey publicMemberCount_ -- Pre-emptively create owner members with trusted keys from link data forM_ owners $ \OwnerAuth {ownerId, ownerKey} -> do let ctId_ = case ownerContact of @@ -2350,9 +2357,9 @@ processChatCommand cxt nm = \case | memberId == MemberId ownerId -> Just contactId _ -> Nothing void $ createLinkOwnerMember db cxt user gInfo' ctId_ (MemberId ownerId) ownerKey - pure gInfo' + pure g' rs <- withGroupLock "connectPreparedGroup" groupId $ - mapConcurrently (connectToRelay user gInfo') relays + mapConcurrently (connectToRelay user g') relays let relayFailed = \case (_, _, Left _) -> True; _ -> False (failed, succeeded) = partition relayFailed rs if null succeeded @@ -2394,7 +2401,7 @@ processChatCommand cxt nm = \case smId <- getSharedMsgId withFastStore' $ \db -> setRequestSharedMsgIdForGroup db groupId smId pure (smId, mc) - r <- connectViaContact user (Just $ PCEGroup gInfo hostMember) incognito connLinkToConnect welcomeSharedMsgId msg_ `catchAllErrors` \e -> do + r <- connectViaContact user (Just $ PCEGroup g hostMember) incognito connLinkToConnect welcomeSharedMsgId msg_ `catchAllErrors` \e -> do -- get updated group info, in case connection was started (connLinkPreparedConnection) - in UI it would lock ability to change -- user or incognito profile for group or business chat, in case server received request while client got network error gInfo' <- withFastStore $ \db -> getGroupInfo db cxt user groupId @@ -2445,7 +2452,7 @@ processChatCommand cxt nm = \case claim <- maybe (throwCmdError "group has no name to verify") pure $ publicGroupAccess >>= groupDomainClaim -- checks the profile link, not the link we joined through (which may have rotated) (verified, reason) <- - tryAllErrors (withAgent $ \a -> resolveSimplexName a nm (aUserId user) (claimDomain claim)) >>= \case + tryAllErrors (resolveNameRecord user nm (claimDomain claim)) >>= \case Right NameRecord {nrSimplexChannel} | nameResolvesTo groupLink nrSimplexChannel -> pure (True, Nothing) | otherwise -> pure (False, Just "the name does not resolve to the link in the group profile") @@ -2482,8 +2489,7 @@ processChatCommand cxt nm = \case Left e -> throwError $ ChatErrorStore e Right _ -> throwError $ ChatErrorStore SEDuplicateContactLink subMode <- chatReadVar subscriptionMode - gVar <- asks random - rootKey@(rootPubKey, rootPrivKey) <- liftIO $ atomically $ C.generateKeyPair gVar + rootKey@(rootPubKey, rootPrivKey) <- atomically . C.generateKeyPair =<< asks random let entityId = C.sha256Hash $ C.pubKeyBytes rootPubKey -- TODO [address DR] remove this option and switch to IKUsePQ True let (pqInitKeys, useDR) = case pqRatchet_ of @@ -2726,14 +2732,15 @@ processChatCommand cxt nm = \case APINewGroup userId incognito gProfile -> withUserId userId $ \user -> do g <- asks random memberId <- liftIO $ MemberId <$> encodedRandomBytes g 12 - gInfo <- newGroup user incognito gProfile False memberId Nothing Nothing + (_, memberPrivKey) <- atomically $ C.generateKeyPair g + gInfo <- newGroup user incognito gProfile memberId GKGroup {memberPrivKey} Nothing createNewGroupItems user gInfo pure $ CRGroupCreated user gInfo NewGroup incognito gProfile -> withUser $ \User {userId} -> processChatCommand cxt nm $ APINewGroup userId incognito gProfile APINewPublicGroup userId incognito relayIds groupProfile -> withUserId userId $ \user -> do (gProfile', memberId, groupKeys, setupLink) <- prepareGroupLink user - gInfo <- newGroup user incognito gProfile' True memberId (Just groupKeys) (Just 1) + gInfo <- newGroup user incognito gProfile' memberId groupKeys (Just 1) (gLink, results) <- setupLink gInfo `catchAllErrors` \e -> do deleteInProgressGroup user gInfo throwError e @@ -2762,7 +2769,7 @@ processChatCommand cxt nm = \case groupLinkId <- GroupLinkId <$> drgRandomBytes 16 subMode <- chatReadVar subscriptionMode -- generate root key pair; entity ID = sha256(rootPubKey) — see docs/rfcs/2026-03-28-group-identity-binding.md - rootKey@(rootPubKey, rootPrivKey) <- liftIO $ atomically $ C.generateKeyPair gVar + rootKey@(rootPubKey, rootPrivKey) <- atomically $ C.generateKeyPair gVar let entityId = C.sha256Hash $ C.pubKeyBytes rootPubKey crClientData = encodeJSON $ CRDataGroup groupLinkId -- prepare link with entityId as linkEntityId (no server request) @@ -2780,7 +2787,7 @@ processChatCommand cxt nm = \case userLinkData = UserContactLinkData UserContactData {direct = False, owners = [ownerAuth], relays = [], userData, ratchetKeys = Nothing} -- create connection with prepared link (single network call) connId <- withAgent $ \a -> createConnectionForLink a nm (aUserId user) True ccLink preparedParams userLinkData subMode - let groupKeys = GroupKeys {publicGroupId = B64UrlByteString entityId, groupRootKey = GRKPrivate rootPrivKey, memberPrivKey} + let groupKeys = GKPublicGroup {groupRootKey = GRKPrivate rootPrivKey, memberPrivKey} setupLink gInfo = do -- TODO [relays] starting role should be communicated in protocol from owner to relays subRole <- asks $ channelSubscriberRole . config @@ -2830,7 +2837,7 @@ processChatCommand cxt nm = \case _ -> False APIAddMember groupId contactId memRole -> withUser $ \user -> withGroupLock "addMember" groupId $ do -- TODO for large groups: no need to load all members to determine if contact is a member - (group, contact) <- withFastStore $ \db -> (,) <$> getGroup db cxt user groupId <*> getContact db cxt user contactId + ((group, gks), contact) <- withFastStore $ \db -> (,) <$> getGroupKeys_ db cxt user groupId <*> getContact db cxt user contactId let Group gInfo members = group Contact {localDisplayName = cName} = contact when (useRelays' gInfo) $ throwCmdError "can't invite contact to channel" @@ -2840,7 +2847,7 @@ processChatCommand cxt nm = \case when (contactConnIncognito contact) $ throwChatError CEContactIncognitoCantInvite -- [incognito] forbid to invite contacts if user joined the group using an incognito profile when (incognitoMembership gInfo) $ throwChatError CEGroupIncognitoCantInvite - let sendInvitation = sendGrpInvitation user contact gInfo + let sendInvitation = sendGrpInvitation user contact (GIK gInfo gks) case contactMember contact members of Nothing -> do gVar <- asks random @@ -2863,13 +2870,13 @@ processChatCommand cxt nm = \case (invitation, ct) <- withFastStore $ \db -> do inv@ReceivedGroupInvitation {fromMember} <- getGroupInvitation db cxt user groupId (inv,) <$> getContactViaMember db cxt user fromMember - let ReceivedGroupInvitation {fromMember, connRequest, groupInfo = g@GroupInfo {membership, chatSettings}} = invitation + let ReceivedGroupInvitation {fromMember, connRequest, groupInfo = g@GroupInfo {membership, chatSettings}, groupKeys = gks} = invitation GroupMember {memberId = membershipMemId} = membership Contact {activeConn} = ct case activeConn of Just Connection {peerChatVRange} -> do subMode <- chatReadVar subscriptionMode - dm <- encodeConnInfo $ XGrpAcpt membershipMemId + dm <- encodeConnInfo $ XGrpAcpt membershipMemId (Just $ groupMemberKey gks) agentConnId <- case memberConn fromMember of Nothing -> do agentConnId <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True connRequest PQSupportOff @@ -2892,7 +2899,7 @@ processChatCommand cxt nm = \case pure $ CRUserAcceptedGroupSent user g {membership = membership {memberStatus = GSMemAccepted}} Nothing Nothing -> throwChatError $ CEContactNotActive ct APIAcceptMember groupId gmId role -> withUser $ \user@User {userId} -> do - (gInfo, m) <- withFastStore $ \db -> (,) <$> getGroupInfo db cxt user groupId <*> getGroupMemberById db cxt user gmId + (g@(GIK gInfo _), m) <- withFastStore $ \db -> (,) <$> getGroupInfoKeys db cxt user groupId <*> getGroupMemberById db cxt user gmId assertUserGroupRole gInfo $ max GRModerator role case memberStatus m of GSMemPendingApproval | memberCategory m == GCInviteeMember -> do -- only host can approve @@ -2901,14 +2908,14 @@ processChatCommand cxt nm = \case Just mConn -> case memberAdmission >>= review of Just MCAll -> do - introduceToModerators cxt user gInfo m + introduceToModerators cxt user g m withFastStore' $ \db -> updateGroupMemberStatus db userId m GSMemPendingReview let m' = m {memberStatus = GSMemPendingReview} pure $ CRMemberAccepted user gInfo m' Nothing -> do let msg = XGrpLinkAcpt GAAccepted role (memberId' m) void $ sendDirectMemberMessage mConn msg groupId - introduceToRemaining cxt user gInfo m {memberRole = role} + introduceToRemaining cxt user g m {memberRole = role} when (groupFeatureAllowed SGFHistory gInfo) $ sendHistory user gInfo m (m', gInfo') <- withFastStore' $ \db -> do m' <- updateGroupMemberAccepted db user m GSMemConnected role @@ -2926,13 +2933,13 @@ processChatCommand cxt nm = \case modMs <- withFastStore' $ \db -> getGroupModerators db cxt user gInfo let rcpModMs' = filter memberCurrent modMs msg = XGrpLinkAcpt GAAccepted role (memberId' m) - void $ sendGroupMessage user gInfo scope ([m] <> rcpModMs') False msg + void $ sendGroupMessage user g scope ([m] <> rcpModMs') False msg when (maxVersion (memberChatVRange m) < groupKnockingVersion) $ forM_ (memberConn m) $ \mConn -> do let msg2 = XMsgNew $ mcSimple (MCText acceptedToGroupMessage) void $ sendDirectMemberMessage mConn msg2 groupId when (memberCategory m == GCInviteeMember) $ do - introduceToRemaining cxt user gInfo m {memberRole = role} + introduceToRemaining cxt user g m {memberRole = role} when (groupFeatureAllowed SGFHistory gInfo) $ sendHistory user gInfo m (m', gInfo') <- withFastStore' $ \db -> do m' <- updateGroupMemberAccepted db user m newMemberStatus role @@ -2964,7 +2971,7 @@ processChatCommand cxt nm = \case APIMembersRole groupId memberIds newRole -> withUser $ \user -> withGroupLock "memberRole" groupId $ do -- TODO [relays] possible optimization is to read only required members + relays - g@(Group gInfo members) <- withFastStore $ \db -> getGroup db cxt user groupId + (g@(Group gInfo members), gks) <- withFastStore $ \db -> getGroupKeys_ db cxt user groupId when (selfSelected gInfo) $ throwCmdError "can't change role for self" let (invitedMems, currentMems, unchangedMems, maxRole, anyAdmin, anyPending, anyPrivilegedTarget, anyRelay, anyRosterChange, finalPrivilegedCount) = selectMembers members when (length invitedMems + length currentMems + length unchangedMems /= length memberIds) $ throwChatError CEGroupMemberNotFound @@ -2980,11 +2987,11 @@ processChatCommand cxt nm = \case throwCmdError "only the group owner can change moderator and admin roles" when (useRelays' gInfo && isRosterRole newRole && finalPrivilegedCount > maxGroupRosterSize) $ throwCmdError $ "the number of members, moderators and admins would exceed the limit of " <> show maxGroupRosterSize - (errs1, changed1) <- changeRoleInvitedMems user gInfo invitedMems + (errs1, changed1) <- changeRoleInvitedMems user (GIK gInfo gks) invitedMems let doBumpRoster = useRelays' gInfo && memberRole' (membership gInfo) == GROwner && anyRosterChange -- roster (with the change projected in) before the delta, so a relay stores the blob at this version before forwarding the delta - rosterVer <- if doBumpRoster then Just <$> broadcastRoster user gInfo (RDRoleChanged newRole currentMems) else pure Nothing - (errs2, changed2, acis, msgSigned) <- changeRoleCurrentMems user g rosterVer currentMems + rosterVer <- if doBumpRoster then Just <$> broadcastRoster user (GIK gInfo gks) (RDRoleChanged newRole currentMems) else pure Nothing + (errs2, changed2, acis, msgSigned) <- changeRoleCurrentMems user g gks rosterVer currentMems unless (null acis) $ toView $ CEvtNewChatItems user acis let errs = errs1 <> errs2 unless (null errs) $ toView $ CEvtChatErrors errs @@ -3011,8 +3018,8 @@ processChatCommand cxt nm = \case -- a current member's role actually changes here; it alters the roster iff the old or new role is on it | otherwise -> (invited, m : current, unchanged, maxRole', anyAdmin', anyPending', anyPrivTarget', anyRelay', anyRosterChange || isRosterRole newRole || isRosterRole memberRole, privCount') | otherwise = (invited, current, unchanged, maxRole, anyAdmin, anyPending, anyPrivTarget, anyRelay, anyRosterChange, if isRosterRole memberRole then privCount + 1 else privCount) - changeRoleInvitedMems :: User -> GroupInfo -> [GroupMember] -> CM ([ChatError], [GroupMember]) - changeRoleInvitedMems user gInfo memsToChange = do + changeRoleInvitedMems :: User -> GroupInfoKeys -> [GroupMember] -> CM ([ChatError], [GroupMember]) + changeRoleInvitedMems user gInfo@(GIK g _) memsToChange = do -- not batched, as we need to send different invitations to different connections anyway mems_ <- forM memsToChange $ \m -> (Right <$> changeRole m) `catchAllErrors` (pure . Left) pure $ partitionEithers mems_ @@ -3024,15 +3031,15 @@ processChatCommand cxt nm = \case sendGrpInvitation user ct gInfo (m :: GroupMember) {memberRole = newRole} cReq withFastStore' $ \db -> updateGroupMemberRole db user m newRole pure (m :: GroupMember) {memberRole = newRole} - _ -> throwChatError $ CEGroupCantResendInvitation gInfo cName - changeRoleCurrentMems :: User -> Group -> Maybe VersionRoster -> [GroupMember] -> CM ([ChatError], [GroupMember], [AChatItem], Bool) - changeRoleCurrentMems user (Group gInfo members) rosterVer memsToChange = case L.nonEmpty memsToChange of + _ -> throwChatError $ CEGroupCantResendInvitation g cName + changeRoleCurrentMems :: User -> Group -> GroupKeys -> Maybe VersionRoster -> [GroupMember] -> CM ([ChatError], [GroupMember], [AChatItem], Bool) + changeRoleCurrentMems user (Group gInfo members) gks rosterVer memsToChange = case L.nonEmpty memsToChange of Nothing -> pure ([], [], [], False) Just memsToChange' -> do let mKey m = if isJust rosterVer then MemberKey <$> memberPubKey m else Nothing events = L.map (\m@GroupMember {memberId} -> XGrpMemRole memberId newRole (mKey m) rosterVer) memsToChange' recipients = filter memberCurrent members - (msgs_, _gsr) <- sendGroupMessages user gInfo Nothing False recipients False events + (msgs_, _gsr) <- sendGroupMessages user (GIK gInfo gks) Nothing False recipients False events let signed = any (either (const False) (\SndMessage {signedMsg_} -> isJust signedMsg_)) msgs_ itemsData = zipWith (fmap . sndItemData) memsToChange (L.toList msgs_) cis_ <- saveSndChatItems user (CDGroupSnd gInfo Nothing) False itemsData Nothing False @@ -3052,7 +3059,7 @@ processChatCommand cxt nm = \case APIBlockMembersForAll groupId memberIds blockFlag -> withUser $ \user -> withGroupLock "blockForAll" groupId $ do -- TODO [relays] possible optimization is to read only required members + relays - Group gInfo members <- withFastStore $ \db -> getGroup db cxt user groupId + (Group gInfo members, gks) <- withFastStore $ \db -> getGroupKeys_ db cxt user groupId when (selfSelected gInfo) $ throwCmdError "can't block/unblock self" -- TODO [relays] consider sending restriction to all members (remove filtering), as we do in delivery jobs let (blockMems, remainingMems, maxRole, anyAdmin, anyPending) = selectMembers members @@ -3060,7 +3067,7 @@ processChatCommand cxt nm = \case when (length memberIds > 1 && anyAdmin) $ throwCmdError "can't block/unblock multiple members when admins selected" when anyPending $ throwCmdError "can't block/unblock members pending approval" assertUserGroupRole gInfo $ max GRModerator maxRole - blockMembers user gInfo blockMems remainingMems + blockMembers user (GIK gInfo gks) blockMems remainingMems where selfSelected GroupInfo {membership} = elem (groupMemberId' membership) memberIds selectMembers :: [GroupMember] -> ([GroupMember], [GroupMember], GroupMemberRole, Bool, Bool) @@ -3073,14 +3080,14 @@ processChatCommand cxt nm = \case anyPending' = anyPending || memberPending m in (m : block, remaining, maxRole', anyAdmin', anyPending') | otherwise = (block, m : remaining, maxRole, anyAdmin, anyPending) - blockMembers :: User -> GroupInfo -> [GroupMember] -> [GroupMember] -> CM ChatResponse - blockMembers user gInfo blockMems remainingMems = case L.nonEmpty blockMems of + blockMembers :: User -> GroupInfoKeys -> [GroupMember] -> [GroupMember] -> CM ChatResponse + blockMembers user g@(GIK gInfo _) blockMems remainingMems = case L.nonEmpty blockMems of Nothing -> throwCmdError "no members to block/unblock" Just blockMems' -> do let mrs = if blockFlag then MRSBlocked else MRSUnrestricted events = L.map (\GroupMember {memberId} -> XGrpMemRestrict memberId MemberRestrictions {restriction = mrs}) blockMems' recipients = filter memberCurrent remainingMems - (msgs_, _gsr) <- sendGroupMessages_ user gInfo recipients False events + (msgs_, _gsr) <- sendGroupMessages_ user g recipients False events let msgSigned = any (either (const False) (\SndMessage {signedMsg_} -> isJust signedMsg_)) msgs_ itemsData = zipWith (fmap . sndItemData) blockMems (L.toList msgs_) cis_ <- saveSndChatItems user (CDGroupSnd gInfo Nothing) False itemsData Nothing False @@ -3101,7 +3108,7 @@ processChatCommand cxt nm = \case APIRemoveMembers {groupId, groupMemberIds, withMessages} -> withUser $ \user -> withGroupLock "removeMembers" groupId $ do -- TODO [relays] possible optimization is to read only required members + relays - Group gInfo members <- withFastStore $ \db -> getGroup db cxt user groupId + (Group gInfo members, gks) <- withFastStore $ \db -> getGroupKeys_ db cxt user groupId let (count, invitedMems, pendingApprvMems, pendingRvwMems, currentMems, maxRole, anyAdmin, anyPrivilegedRemoved, anyRosterRemoved) = selectMembers gmIds members gmIds = S.fromList $ L.toList groupMemberIds memCount = length groupMemberIds @@ -3114,13 +3121,13 @@ processChatCommand cxt nm = \case let recipients = filter memberCurrent members let doBumpRoster = useRelays' gInfo && memberRole' (membership gInfo) == GROwner && anyRosterRemoved -- roster (excluding the removed members) before the delta, so a relay stores the blob at this version before forwarding the delta - rosterVer <- if doBumpRoster then Just <$> broadcastRoster user gInfo (RDRemoved currentMems) else pure Nothing - (errs2, deleted2, acis2, signed2) <- deleteMemsSend user gInfo Nothing rosterVer recipients currentMems + rosterVer <- if doBumpRoster then Just <$> broadcastRoster user (GIK gInfo gks) (RDRemoved currentMems) else pure Nothing + (errs2, deleted2, acis2, signed2) <- deleteMemsSend user (GIK gInfo gks) Nothing rosterVer recipients currentMems (errs3, deleted3, acis3, signed3) <- - foldM (\acc m -> deletePendingMember acc user gInfo [m] m) ([], [], [], False) pendingApprvMems + foldM (\acc m -> deletePendingMember acc user (GIK gInfo gks) [m] m) ([], [], [], False) pendingApprvMems let moderators = filter (\GroupMember {memberRole} -> memberRole >= GRModerator) members (errs4, deleted4, acis4, signed4) <- - foldM (\acc m -> deletePendingMember acc user gInfo (m : moderators) m) ([], [], [], False) pendingRvwMems + foldM (\acc m -> deletePendingMember acc user (GIK gInfo gks) (m : moderators) m) ([], [], [], False) pendingRvwMems let acis = acis2 <> acis3 <> acis4 errs = errs1 <> errs2 <> errs3 <> errs4 deleted = deleted1 <> deleted2 <> deleted3 <> deleted4 @@ -3128,7 +3135,7 @@ processChatCommand cxt nm = \case -- Read group info with updated membersRequireAttention and publicMemberCount gInfo' <- if useRelays' gInfo - then updatePublicGroupData user gInfo + then updatePublicGroupData user gInfo gks else withFastStore $ \db -> getGroupInfo db cxt user groupId let acis' = map (updateACIGroupInfo gInfo') acis unless (null acis') $ toView $ CEvtNewChatItems user acis' @@ -3162,18 +3169,18 @@ processChatCommand cxt nm = \case delMember db m = do deleteGroupMember db user m pure m {memberStatus = GSMemRemoved} - deletePendingMember :: ([ChatError], [GroupMember], [AChatItem], Bool) -> User -> GroupInfo -> [GroupMember] -> GroupMember -> CM ([ChatError], [GroupMember], [AChatItem], Bool) + deletePendingMember :: ([ChatError], [GroupMember], [AChatItem], Bool) -> User -> GroupInfoKeys -> [GroupMember] -> GroupMember -> CM ([ChatError], [GroupMember], [AChatItem], Bool) deletePendingMember (accErrs, accDeleted, accACIs, accSigned) user gInfo recipients m = do (m', scopeInfo) <- mkMemberSupportChatInfo m (errs, deleted, acis, signed) <- deleteMemsSend user gInfo (Just scopeInfo) Nothing recipients [m'] pure (errs <> accErrs, deleted <> accDeleted, acis <> accACIs, accSigned || signed) - deleteMemsSend :: User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe VersionRoster -> [GroupMember] -> [GroupMember] -> CM ([ChatError], [GroupMember], [AChatItem], Bool) - deleteMemsSend user gInfo chatScopeInfo rosterVer recipients memsToDelete = case L.nonEmpty memsToDelete of + deleteMemsSend :: User -> GroupInfoKeys -> Maybe GroupChatScopeInfo -> Maybe VersionRoster -> [GroupMember] -> [GroupMember] -> CM ([ChatError], [GroupMember], [AChatItem], Bool) + deleteMemsSend user g@(GIK gInfo _) chatScopeInfo rosterVer recipients memsToDelete = case L.nonEmpty memsToDelete of Nothing -> pure ([], [], [], False) Just memsToDelete' -> do let chatScope = toChatScope <$> chatScopeInfo events = L.map (\GroupMember {memberId} -> XGrpMemDel memberId withMessages rosterVer) memsToDelete' - (msgs_, _gsr) <- sendGroupMessages user gInfo chatScope False recipients False events + (msgs_, _gsr) <- sendGroupMessages user g chatScope False recipients False events let signed = any (either (const False) (\SndMessage {signedMsg_} -> isJust signedMsg_)) msgs_ itemsData_ = zipWith (fmap . sndItemData) memsToDelete (L.toList msgs_) skipUnwantedItem = \case @@ -3210,14 +3217,14 @@ processChatCommand cxt nm = \case | groupFeatureUserAllowed SGFFullDelete gInfo = deleteGroupMembersCIs user gInfo ms | otherwise = markGroupMembersCIsDeleted user gInfo ms membership APILeaveGroup groupId -> withUser $ \user@User {userId} -> do - gInfo@GroupInfo {membership} <- withFastStore $ \db -> getGroupInfo db cxt user groupId + g@(GIK gInfo@GroupInfo {membership} _) <- withFastStore $ \db -> getGroupInfoKeys db cxt user groupId filesInfo <- withFastStore' $ \db -> getGroupFileInfo db user gInfo withGroupLock "leaveGroup" groupId $ do cancelFilesInProgress user filesInfo msg <- if useRelays' gInfo && isRelay membership - then leaveChannelRelay gInfo - else leaveGroupSendMsg user gInfo + then leaveChannelRelay g + else leaveGroupSendMsg user g (gInfo', scopeInfo) <- mkLocalGroupChatScope gInfo ci <- saveSndChatItem user (CDGroupSnd gInfo' scopeInfo) msg (CISndGroupEvent SGEUserLeft) toView $ CEvtNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat gInfo' scopeInfo) ci] @@ -3232,9 +3239,9 @@ processChatCommand cxt nm = \case pure $ CRLeftMemberUser user gInfo' {membership = membership {memberStatus = GSMemLeft}, relayOwnStatus = relayOwnStatus'} where -- Relay leaving channel: create delivery job for cursor-based sending and async connection cleanup. - leaveChannelRelay gInfo = do + leaveChannelRelay g@(GIK gInfo _) = do msg@SndMessage {msgBody, signedMsg_} <- - liftEither . runIdentity =<< lift (createSndMessages $ Identity (GroupId groupId, groupMsgSigning False gInfo XGrpLeave, XGrpLeave)) + liftEither . runIdentity =<< lift (createSndMessages $ Identity (GroupId groupId, groupMsgSigning False g XGrpLeave, XGrpLeave)) let body = encodeBatchElement signedMsg_ msgBody withFastStore' $ \db -> do deleteGroupDeliveryTasks db gInfo @@ -3242,9 +3249,9 @@ processChatCommand cxt nm = \case createMsgDeliveryJob db gInfo (DJSGroup {jobSpec = DJRelayRemoved}) [] body lift . void $ getDeliveryJobWorker True (groupId, DWSGroup) pure msg - leaveGroupSendMsg user gInfo = do + leaveGroupSendMsg user g@(GIK gInfo _) = do (members, recipients) <- getRecipients user gInfo - msg <- sendGroupMessage' user gInfo recipients XGrpLeave + msg <- sendGroupMessage' user g recipients XGrpLeave deleteMembersConnections' user members True pure msg getRecipients user gInfo @@ -3304,7 +3311,7 @@ processChatCommand cxt nm = \case ct_ <- forM cName_ $ \cName -> withFastStore $ \db -> getContactByName db cxt user cName processChatCommand cxt nm $ APIListGroups userId (contactId' <$> ct_) search_ APIUpdateGroupProfile groupId p' -> withUser $ \user -> do - gInfo <- withFastStore $ \db -> getGroupInfo db cxt user groupId + gInfo <- withFastStore $ \db -> getGroupInfoKeys db cxt user groupId runUpdateGroupProfile user gInfo p' False UpdateGroupNames gName GroupProfile {displayName, fullName, shortDescr} -> updateGroupProfileByName gName $ \p -> p {displayName, fullName, shortDescr} @@ -3315,13 +3322,13 @@ processChatCommand cxt nm = \case ShowGroupDescription gName -> withUser $ \user -> CRGroupDescription user <$> withFastStore (\db -> getGroupInfoByName db cxt user gName) APISetPublicGroupAccess gId access@PublicGroupAccess {groupDomainClaim = newClaim} -> withUser $ \user -> do - gInfo@GroupInfo {groupProfile = p@GroupProfile {publicGroup}} <- withStore $ \db -> getGroupInfo db cxt user gId + gInfo@(GIK GroupInfo {groupProfile = p@GroupProfile {publicGroup}} _) <- withStore $ \db -> getGroupInfoKeys db cxt user gId case publicGroup of Just pg@PublicGroupProfile {groupLink, publicGroupAccess = existingAccess} -> do let domainChanged = (claimDomain <$> newClaim) /= (claimDomain <$> (existingAccess >>= groupDomainClaim)) forM_ (claimDomain <$> newClaim) $ \newDomain -> when domainChanged $ do - NameRecord {nrSimplexChannel} <- withAgent $ \a -> resolveSimplexName a nm (aUserId user) newDomain + NameRecord {nrSimplexChannel} <- resolveNameRecord user nm newDomain unless (nameResolvesTo groupLink nrSimplexChannel) $ throwChatError $ CESimplexDomainNotReady newDomain SDENoValidLink runUpdateGroupProfile user gInfo p {publicGroup = Just pg {publicGroupAccess = Just access}} (isJust newClaim && domainChanged) Nothing -> throwChatError $ CECommandError "not a public group" @@ -3358,11 +3365,11 @@ processChatCommand cxt nm = \case gLnk <- withFastStore $ \db -> getGroupLink db user gInfo pure $ CRGroupLink user gInfo gLnk APIAddGroupShortLink groupId -> withUser $ \user -> do - (gInfo, gLink) <- withFastStore $ \db -> do - gInfo <- getGroupInfo db cxt user groupId + (g@(GIK gInfo _), gLink) <- withFastStore $ \db -> do + g@(GIK gInfo _) <- getGroupInfoKeys db cxt user groupId gLink <- getGroupLink db user gInfo - pure (gInfo, gLink) - gLink' <- setGroupLinkData nm user gInfo gLink + pure (g, gLink) + gLink' <- setGroupLinkData nm user g gLink pure $ CRGroupLink user gInfo gLink' APICreateMemberContact gId gMemberId -> withUser $ \user -> do (g, m) <- withFastStore $ \db -> (,) <$> getGroupInfo db cxt user gId <*> getGroupMember db cxt user gId gMemberId @@ -3434,7 +3441,7 @@ processChatCommand cxt nm = \case joinPreparedConn subMode conn = do -- [incognito] send membership incognito profile p <- presentUserBadge user (incognitoMembershipProfile gInfo) $ userProfileDirect user (fromLocalProfile <$> incognitoMembershipProfile gInfo) Nothing True - dm <- encodeConnInfo $ XInfo p + dm <- encodeConnInfo $ XInfo p Nothing sqSecured <- withAgent $ \a -> joinConnection a nm (aUserId user) (aConnId conn) True cReq dm PQSupportOff subMode let newStatus = if sqSecured then ConnSndReady else ConnJoined void $ withFastStore' $ \db -> updateConnectionStatusFromTo db conn ConnPrepared newStatus @@ -3466,7 +3473,8 @@ processChatCommand cxt nm = \case folderId <- withFastStore (`getUserNoteFolderId` user) processChatCommand cxt nm $ APIClearChat (ChatRef CTLocal folderId Nothing) LastChats count_ -> withUser' $ \user -> do - let count = fromMaybe 5000 count_ + ChatConfig {maxChats} <- asks config + let count = fromMaybe maxChats count_ (errs, previews) <- partitionEithers <$> withFastStore' (\db -> getChatPreviews db cxt user False (PTLast count) clqNoFilters) unless (null errs) $ toView $ CEvtChatErrors (map ChatErrorStore errs) pure $ CRChats previews @@ -3547,10 +3555,10 @@ processChatCommand cxt nm = \case void . sendDirectContactMessage user contact $ XFileCancel sharedMsgId pure $ CRSndFileCancelled user (Just aci) ftm fts (Just (ChatRef CTGroup groupId scope), Just aci) -> do - (gInfo, sharedMsgId) <- withFastStore $ \db -> (,) <$> getGroupInfo db cxt user groupId <*> getSharedMsgIdByFileId db userId fileId + (g@(GIK gInfo _), sharedMsgId) <- withFastStore $ \db -> (,) <$> getGroupInfoKeys db cxt user groupId <*> getSharedMsgIdByFileId db userId fileId chatScopeInfo <- mapM (getChatScopeInfo cxt user) scope recipients <- getGroupRecipients cxt user gInfo chatScopeInfo groupKnockingVersion - void . sendGroupMessage user gInfo scope recipients False $ XFileCancel sharedMsgId + void . sendGroupMessage user g scope recipients False $ XFileCancel sharedMsgId pure $ CRSndFileCancelled user (Just aci) ftm fts (Just _, _) -> throwChatError $ CEFileInternal "invalid chat ref for file transfer" where @@ -3587,6 +3595,20 @@ processChatCommand cxt nm = \case pure $ CRFileTransferStatus user fileStatus ShowProfile -> withUser $ \user@User {profile} -> pure $ CRUserProfile user (fromLocalProfile profile) AddBadge cred -> withUser $ \user -> addUserBadge user cred >> ok user + APIRedeemBadgeCode userId codeText -> withUserId userId $ \user -> redeemBadgeCode nm user codeText + APIGetBadgeState userId -> withUserId' userId $ \user -> do + -- the read also signals the worker, whose results follow as CEvtBadgeChanged + lift $ startBadgeWork user + CRBadgeState user <$> getUserBadgeState user + APIGetBadgeLedger userId badgePurchaseId -> withUserId userId $ \user -> + CRBadgeLedger user <$> withStore' (\db -> getBadgeLedger db user badgePurchaseId) + APIAckBadgeAlert userId badgePurchaseId alertKind snooze episode -> withUserId userId $ \user -> do + now <- badgeNow + let snoozeUntil = if snooze then Just (addUTCTime nominalDay now) else Nothing + withStore' $ \db -> setBadgeAlertAcked db user badgePurchaseId alertKind episode snoozeUntil + -- after the write, so the pass it signals arms a wake for the snooze rather than raising again + lift $ startBadgeWork user + CRBadgeState user <$> getUserBadgeState user SetBotCommands commands -> withUser $ \user@User {profile} -> do let LocalProfile {preferences} = profile prefs = Just (fromMaybe emptyChatPrefs preferences :: Preferences) {commands = Just commands} @@ -3661,7 +3683,7 @@ processChatCommand cxt nm = \case fsFilePath <- lift $ toFSFilePath filePath fileSize <- liftIO $ CF.getFileContentsSize file {filePath = fsFilePath} when (fileSize > toInteger maxFileSizeHard) $ throwChatError $ CEFileSize filePath - (_, _, fileTransferMeta) <- xftpSndFileTransfer_ user file fileSize 1 Nothing + (_, _, fileTransferMeta) <- xftpSndFileTransfer_ user file fileSize 1 Nothing Nothing pure CRSndStandaloneFileCreated {user, fileTransferMeta} APIStandaloneFileInfo FileDescriptionURI {clientData} -> pure . CRStandaloneFileInfo $ clientData >>= J.decodeStrict . encodeUtf8 APIDownloadStandaloneFile userId uri file -> withUserId userId $ \user -> do @@ -3689,6 +3711,7 @@ processChatCommand cxt nm = \case CLUserContact ucId -> "UserContact " <> tshow ucId CLContactRequest crId -> "ContactRequest " <> tshow crId CLFile fId -> "File " <> tshow fId + CLBadgeUser uId -> "BadgeUser " <> tshow uId DebugEvent event -> toView event >> ok_ GetAgentSubsTotal userId -> withUserId userId $ \user -> do users <- withStore' $ \db -> getUsers db @@ -3836,7 +3859,7 @@ processChatCommand cxt nm = \case joinPreparedConn conn incognitoProfile joinPreparedConn conn incognitoProfile = do profileToSend <- presentUserBadge user incognitoProfile $ userProfileDirect user incognitoProfile Nothing True - dm <- encodeConnInfoPQ pqSup' $ XInfo profileToSend + dm <- encodeConnInfoPQ pqSup' $ XInfo profileToSend Nothing sqSecured <- withAgent $ \a -> joinConnection a nm (aUserId user) (aConnId conn) True cReq dm pqSup' subMode let newStatus = if sqSecured then ConnSndReady else ConnJoined conn' <- withFastStore' $ \db -> updateConnectionStatusFromTo db conn ConnPrepared newStatus @@ -3883,7 +3906,7 @@ processChatCommand cxt nm = \case -- relay-group joins (only via connectToRelay) carry the target relay member in preparedEntity_; -- its memberId binds the join signature so a sibling relay can't replay it relayMemberId_ = case preparedEntity_ of - Just (PCEGroup gInfo m) | useRelays' gInfo -> Just (memberId' m) + Just (PCEGroup (GIK gInfo _) m) | useRelays' gInfo -> Just (memberId' m) _ -> Nothing joinPreparedConn' xContactId_ conn@Connection {customUserProfileId} gInfo_ = do when (incognito /= isJust customUserProfileId) $ throwCmdError "incognito mode is different from prepared connection" @@ -3900,7 +3923,7 @@ processChatCommand cxt nm = \case xContactId <- mkXContactId xContactId_ -- [incognito] generate profile to send, or use membership profile for relay groups incognitoProfile_ <- case gInfo_ of - Just (Just gInfo) | useRelays' gInfo -> pure $ ExistingIncognito <$> incognitoMembershipProfile gInfo + Just (Just (GIK gInfo _)) | useRelays' gInfo -> pure $ ExistingIncognito <$> incognitoMembershipProfile gInfo _ -> if incognito then Just . NewIncognito <$> liftIO generateRandomProfile else pure Nothing let incognitoProfile = fromIncognitoProfile <$> incognitoProfile_ subMode <- chatReadVar subscriptionMode @@ -3934,8 +3957,8 @@ processChatCommand cxt nm = \case ct' <- withStore $ \db -> getContact db cxt user contactId pure $ CRSentInvitationToContact user ct' incognitoProfile _ -> throwCmdError "contact already has connection" - connectToRelay :: User -> GroupInfo -> ShortLinkContact -> CM (ShortLinkContact, GroupMember, Either ChatError ()) - connectToRelay user gInfo relayLink = do + connectToRelay :: User -> GroupInfoKeys -> ShortLinkContact -> CM (ShortLinkContact, GroupMember, Either ChatError ()) + connectToRelay user g@(GIK gInfo _) relayLink = do gVar <- asks random -- Save relayLink to re-use relay member record on retry (check by relayLink) relayMember <- withFastStore $ \db -> getCreateRelayForMember db cxt gVar user gInfo relayLink @@ -3948,7 +3971,7 @@ processChatCommand cxt nm = \case pure $ MemberId entityId _ -> throwChatError $ CEException "relay link: no relay link data or entity id" let relayLinkToConnect = CCLink cReq (Just relayLink) - void $ connectViaContact user (Just $ PCEGroup gInfo (relayMember {memberId = relayMemberId})) (incognitoMembership gInfo) relayLinkToConnect Nothing Nothing + void $ connectViaContact user (Just $ PCEGroup g (relayMember {memberId = relayMemberId})) (incognitoMembership gInfo) relayLinkToConnect Nothing Nothing relayMember' <- withFastStore $ \db -> getGroupMember db cxt user (groupId' gInfo) (groupMemberId' relayMember) pure (relayLink, relayMember', r) syncSubscriberRelays :: User -> GroupInfo -> [ShortLinkContact] -> CM () @@ -3981,18 +4004,21 @@ processChatCommand cxt 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) -> Maybe (Maybe GroupInfo) -> Maybe MemberId -> PQSupport -> CM Connection + joinContact :: User -> Connection -> ConnReqContact -> Maybe Profile -> XContactId -> Maybe SharedMsgId -> Maybe (SharedMsgId, MsgContent) -> Maybe (Maybe GroupInfoKeys) -> Maybe MemberId -> PQSupport -> CM Connection joinContact user conn cReq incognitoProfile xContactId welcomeSharedMsgId msg_ gInfo_ relayMemberId_ pqSup = do -- gInfo_ is Maybe (Maybe GroupInfo), where Just Nothing means "some unknown group", e.g. when joining via link without profile profileToSend <- presentUserBadge user incognitoProfile $ case gInfo_ of - Just gInfo_' -> userProfileInGroup' user gInfo_' incognitoProfile + Just gInfo_' -> userProfileInGroup' user ((\(GIK g _) -> g) <$> gInfo_') incognitoProfile Nothing -> userProfileDirect user incognitoProfile Nothing True dm <- case gInfo_ of - Just (Just gInfo) | useRelays' gInfo -> case relayMemberId_ of - Just relayMemberId -> encodeXMemberConnInfo gInfo relayMemberId profileToSend - Nothing -> throwChatError $ CEInternalError "relay group join without target relay memberId" - _ -> encodeConnInfoPQ pqSup $ XContact profileToSend (Just xContactId) welcomeSharedMsgId msg_ + Just (Just gInfo@(GIK g gks)) + | useRelays' g -> case relayMemberId_ of + Just relayMemberId -> encodeXMemberConnInfo gInfo relayMemberId profileToSend + Nothing -> throwChatError $ CEInternalError "relay group join without target relay memberId" + | otherwise -> encodeConnInfoPQ pqSup $ XContact profileToSend (Just $ groupMemberKey gks) (Just xContactId) welcomeSharedMsgId msg_ + _ -> + encodeConnInfoPQ pqSup $ XContact profileToSend Nothing (Just xContactId) welcomeSharedMsgId msg_ subMode <- chatReadVar subscriptionMode void $ withAgent $ \a -> joinConnection a nm (aUserId user) (aConnId conn) True cReq dm pqSup subMode withFastStore' $ \db -> updateConnectionStatusFromTo db conn ConnPrepared ConnJoined @@ -4005,7 +4031,9 @@ processChatCommand cxt nm = \case fsFilePath <- lift $ toFSFilePath f unlessM (doesFileExist fsFilePath) . throwChatError $ CEFileNotFound f fileSize <- liftIO $ CF.getFileContentsSize $ CryptoFile fsFilePath cfArgs - when (fromInteger fileSize > maxXFTPFileSize sndBadge) $ throwChatError $ CEFileSize f + lims <- asks $ fileSizeLimits . config + now <- liftIO getCurrentTime + when (fileSize > maxSndXFTPFileSize lims now sndBadge) $ throwChatError $ CEFileSize f pure fileSize updateProfile :: User -> Profile -> CM ChatResponse updateProfile user p' = updateProfile_ user p' True $ withFastStore $ \db -> updateUserProfile db user p' @@ -4067,7 +4095,7 @@ processChatCommand cxt nm = \case ctSndEvent :: ChangedProfileContact -> CM (ConnOrGroupId, Maybe MsgSigning, ChatMsgEvent 'Json) ctSndEvent ChangedProfileContact {mergedProfile', conn = Connection {connId}} = do p'' <- presentUserBadge user' Nothing mergedProfile' - pure (ConnectionId connId, Nothing, XInfo p'') + pure (ConnectionId connId, Nothing, XInfo p'' Nothing) ctMsgReq :: ChangedProfileContact -> Either ChatError SndMessage -> Either ChatError ChatMsgReq ctMsgReq ChangedProfileContact {conn} = fmap $ \SndMessage {msgId, msgBody} -> @@ -4100,11 +4128,11 @@ processChatCommand cxt nm = \case when (mergedProfile' /= mergedProfile) $ withContactLock "updateContactPrefs" (contactId' ct) $ do p <- presentUserBadge user incognitoProfile mergedProfile' - void (sendDirectContactMessage user ct' $ XInfo p) `catchAllErrors` eToView + void (sendDirectContactMessage user ct' $ XInfo p Nothing) `catchAllErrors` eToView lift . when (directOrUsed ct') $ createSndFeatureItems user ct ct' pure $ CRContactPrefsUpdated user ct ct' - runUpdateGroupProfile :: User -> GroupInfo -> GroupProfile -> Bool -> CM ChatResponse - runUpdateGroupProfile user gInfo@GroupInfo {businessChat, groupProfile = p@GroupProfile {displayName = n}} p'@GroupProfile {displayName = n', image = img', memberAdmission = ma'} domainVerified = do + runUpdateGroupProfile :: User -> GroupInfoKeys -> GroupProfile -> Bool -> CM ChatResponse + runUpdateGroupProfile user (GIK gInfo@GroupInfo {businessChat, groupProfile = p@GroupProfile {displayName = n}} gks) p'@GroupProfile {displayName = n', image = img', memberAdmission = ma'} domainVerified = do assertUserGroupRole gInfo GROwner when (n /= n') $ checkValidName n' checkProfileImageSize img' @@ -4124,14 +4152,14 @@ processChatCommand cxt nm = \case withStore $ \db -> getGroupMemberByMemberId db cxt user gInfo' businessId let p'' = p' {displayName, fullName, shortDescr, image} :: GroupProfile recipients = filter memberCurrentOrPending oldMs - void $ sendGroupMessage user gInfo' Nothing recipients False (XGrpInfo p'') + void $ sendGroupMessage user (GIK gInfo' gks) Nothing recipients False (XGrpInfo p'') let ps' = fromMaybe defaultBusinessGroupPrefs $ groupPreferences p' recipients = filter memberCurrentOrPending newMs - sendGroupMessage user gInfo' Nothing recipients False $ XGrpPrefs ps' + sendGroupMessage user (GIK gInfo' gks) Nothing recipients False $ XGrpPrefs ps' Nothing -> do - void $ setGroupLinkData' nm user gInfo' + void $ setGroupLinkData' nm user (GIK gInfo' gks) recipients <- getRecipients - sendGroupMessage user gInfo' Nothing recipients False (XGrpInfo p') + sendGroupMessage user (GIK gInfo' gks) Nothing recipients False (XGrpInfo p') where getRecipients | useRelays' gInfo' = withFastStore' $ \db -> getGroupRelayMembers db cxt user gInfo' @@ -4155,13 +4183,13 @@ processChatCommand cxt nm = \case when (memberStatus membership == GSMemInvited) $ throwChatError (CEGroupNotJoined g) when (memberRemoved membership) $ throwChatError CEGroupMemberUserRemoved unless (memberActive membership) $ throwChatError CEGroupMemberNotActive - delGroupChatItemsForMembers :: User -> GroupInfo -> Maybe GroupChatScopeInfo -> [GroupMember] -> [CChatItem 'CTGroup] -> CM [ChatItemDeletion] - delGroupChatItemsForMembers user gInfo chatScopeInfo ms items = do + delGroupChatItemsForMembers :: User -> GroupInfoKeys -> Maybe GroupChatScopeInfo -> [GroupMember] -> [CChatItem 'CTGroup] -> CM [ChatItemDeletion] + delGroupChatItemsForMembers user g@(GIK gInfo _) chatScopeInfo ms items = do assertDeletable gInfo items assertUserGroupRole gInfo GRModerator let msgMemIds = itemsMsgMemIds gInfo items -- moderation deletes always sign (attributable; avoids the catch-up-moderator divergence) - signedEvents = L.nonEmpty $ map (\(msgId, memId) -> let evt = XMsgDel msgId memId (toMsgScope gInfo <$> chatScopeInfo) False in (groupMsgSigning True gInfo evt, evt)) msgMemIds + signedEvents = L.nonEmpty $ map (\(msgId, memId) -> let evt = XMsgDel msgId memId (toMsgScope gInfo <$> chatScopeInfo) False in (groupMsgSigning True g evt, evt)) msgMemIds mapM_ (sendGroupSignedMessages_ gInfo ms) signedEvents delGroupChatItems user gInfo chatScopeInfo items True where @@ -4199,10 +4227,10 @@ processChatCommand cxt nm = \case updateGroupProfileByName = updateGroupProfileByName_ Nothing updateGroupProfileByName_ :: Maybe GroupFeature -> GroupName -> (GroupProfile -> GroupProfile) -> CM ChatResponse updateGroupProfileByName_ feature_ gName update = withUser $ \user -> do - gInfo@GroupInfo {groupProfile = p} <- withStore $ \db -> - getGroupIdByName db user gName >>= getGroupInfo db cxt user + gInfo@(GIK g@GroupInfo {groupProfile = p} _) <- withStore $ \db -> + getGroupIdByName db user gName >>= getGroupInfoKeys db cxt user forM_ feature_ $ \feature -> do - let channel = useRelays' gInfo + let channel = useRelays' g applicable = if channel then groupFeatureInChannel feature else groupFeatureInRegularGroup feature unless applicable $ throwCmdError $ T.unpack (groupFeatureNameText feature) <> " is not available in " <> (if channel then "channels" else "groups") @@ -4260,28 +4288,30 @@ processChatCommand cxt nm = \case groupId <- getGroupIdByName db user gName groupMemberId <- getGroupMemberIdByName db user groupId groupMemberName pure (groupId, groupMemberId) - newGroup :: User -> IncognitoEnabled -> GroupProfile -> Bool -> MemberId -> Maybe GroupKeys -> Maybe Int64 -> CM GroupInfo - newGroup user incognito gProfile@GroupProfile {displayName, image, memberAdmission} useRelays memberId groupKeys_ publicMemberCount_ = do + newGroup :: User -> IncognitoEnabled -> GroupProfile -> MemberId -> GroupKeys -> Maybe Int64 -> CM GroupInfo + newGroup user incognito gProfile@GroupProfile {displayName, image, memberAdmission, publicGroup} memberId groupKeys publicMemberCount_ = do checkValidName displayName checkProfileImageSize image checkGroupProfileSize gProfile - when (useRelays && isJust (memberAdmission >>= review)) $ throwCmdError "Admission review is not supported in channels" + when (isPublicGroup groupKeys && isJust (memberAdmission >>= review)) $ throwCmdError "Admission review is not supported in channels" + when (not (isPublicGroup groupKeys) && isJust publicGroup) $ throwCmdError "publicGroup is not allowed in groups" -- [incognito] generate incognito profile for group membership incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing - withFastStore $ \db -> createNewGroup db cxt user gProfile incognitoProfile useRelays memberId groupKeys_ publicMemberCount_ + withFastStore $ \db -> createNewGroup db cxt user gProfile incognitoProfile memberId groupKeys publicMemberCount_ createNewGroupItems :: User -> GroupInfo -> CM () createNewGroupItems user gInfo = do let cd = CDGroupSnd gInfo Nothing createInternalChatItem user cd CIChatBanner (Just epochStart) createInternalChatItem user cd (CISndGroupE2EEInfo $ e2eInfoGroup gInfo) Nothing createGroupFeatureItems user cd CISndGroupFeature gInfo - sendGrpInvitation :: User -> Contact -> GroupInfo -> GroupMember -> ConnReqInvitation -> CM () - sendGrpInvitation user ct@Contact {contactId, localDisplayName} gInfo@GroupInfo {groupId, groupProfile, membership, businessChat} GroupMember {groupMemberId, memberId, memberRole = memRole} cReq = do + sendGrpInvitation :: User -> Contact -> GroupInfoKeys -> GroupMember -> ConnReqInvitation -> CM () + sendGrpInvitation user ct@Contact {contactId, localDisplayName} (GIK gInfo@GroupInfo {groupId, groupProfile, membership, businessChat} gks) m@GroupMember {groupMemberId, memberId, memberRole = memRole} cReq = do let currentMemCount = fromIntegral $ currentMembers $ groupSummary gInfo GroupMember {memberRole = userRole, memberId = userMemberId} = membership groupInv = GroupInvitation { fromMember = MemberIdRole userMemberId userRole, + fromMemberKey = Just $ groupMemberKey gks, invitedMember = MemberIdRole memberId memRole, connRequest = cReq, groupProfile, @@ -4420,7 +4450,7 @@ processChatCommand cxt nm = \case -- local search only: look up #d then @d in the store, without online name resolution | resolveMode == PRMNever -> connectPlanNoName $ ChatError CENotResolvedLocally | otherwise -> - tryAllErrors (withAgent $ \a -> resolveSimplexName a nm (aUserId user) d) >>= \case + tryAllErrors (resolveNameRecord user nm d) >>= \case Right nr | isJust (firstNameLink CCTChannel (nrSimplexChannel nr)) -> (addOther nr <$> connectPlanName NTPublicGroup (Right nr)) `catchAllErrors` \e -> @@ -4571,7 +4601,7 @@ processChatCommand cxt nm = \case -- resolve a name to its first contact/channel short link resolveNameLink :: SimplexNameInfo -> CM (ConnShortLink 'CMContact) resolveNameLink SimplexNameInfo {nameType, nameDomain} = do - NameRecord {nrSimplexContact, nrSimplexChannel} <- maybe (withAgent $ \a -> resolveSimplexName a nm (aUserId user) nameDomain) (ExceptT . pure) nameRec + NameRecord {nrSimplexContact, nrSimplexChannel} <- maybe (resolveNameRecord user nm nameDomain) (ExceptT . pure) nameRec let (candidates, ctType') = case nameType of NTContact -> (nrSimplexContact, CCTContact) NTPublicGroup -> (nrSimplexChannel, CCTChannel) @@ -4800,7 +4830,8 @@ processChatCommand cxt nm = \case Just file -> do let User {profile = LocalProfile {localBadge}} = user fileSize <- checkSndFile (if contactConnIncognito ct then Nothing else localBadge) file - (fInv, ciFile) <- xftpSndFileTransfer user file fileSize 1 $ CGContact ct + binding_ <- ifM ((not (contactConnIncognito ct) &&) <$> fileNeedsBadge fileSize) (directChatBinding ct) (pure Nothing) + (fInv, ciFile) <- xftpSndFileTransfer user file fileSize 1 (CGContact ct) binding_ pure (Just fInv, Just ciFile) Nothing -> pure (Nothing, Nothing) prepareMsgs :: NonEmpty (ComposedMessageReq, Maybe FileInvitation) -> Maybe CITimed -> CM (NonEmpty (MsgContainer, Maybe (CIQuote 'CTDirect))) @@ -4827,17 +4858,17 @@ processChatCommand cxt nm = \case quoteData ChatItem {content = CISndMsgContent qmc} = pure (qmc, CIQDirectSnd, True) quoteData ChatItem {content = CIRcvMsgContent qmc} = pure (qmc, CIQDirectRcv, False) quoteData _ = throwError SEInvalidQuote - sendGroupContentMessages :: User -> GroupInfo -> Maybe GroupChatScope -> ShowGroupAsSender -> Bool -> Maybe Int -> Bool -> NonEmpty ComposedMessageReq -> CM ChatResponse - sendGroupContentMessages user gInfo scope showGroupAsSender live itemTTL sign cmrs = do + sendGroupContentMessages :: User -> GroupInfoKeys -> Maybe GroupChatScope -> ShowGroupAsSender -> Bool -> Maybe Int -> Bool -> NonEmpty ComposedMessageReq -> CM ChatResponse + sendGroupContentMessages user gInfo@(GIK g _) scope showGroupAsSender live itemTTL sign cmrs = do assertMultiSendable live cmrs chatScopeInfo <- mapM (getChatScopeInfo cxt user) scope - recipients <- getGroupRecipients cxt user gInfo chatScopeInfo modsCompatVersion + recipients <- getGroupRecipients cxt user g chatScopeInfo modsCompatVersion sendGroupContentMessages_ user gInfo scope showGroupAsSender chatScopeInfo recipients live itemTTL sign cmrs where hasReport = any (\(ComposedMessage {msgContent}, _, _, _) -> isReport msgContent) cmrs modsCompatVersion = if hasReport then contentReportsVersion else groupKnockingVersion - sendGroupContentMessages_ :: User -> GroupInfo -> Maybe GroupChatScope -> ShowGroupAsSender -> Maybe GroupChatScopeInfo -> [GroupMember] -> Bool -> Maybe Int -> Bool -> NonEmpty ComposedMessageReq -> CM ChatResponse - sendGroupContentMessages_ user gInfo@GroupInfo {groupId, membership} scope showGroupAsSender chatScopeInfo recipients live itemTTL sign cmrs = do + sendGroupContentMessages_ :: User -> GroupInfoKeys -> Maybe GroupChatScope -> ShowGroupAsSender -> Maybe GroupChatScopeInfo -> [GroupMember] -> Bool -> Maybe Int -> Bool -> NonEmpty ComposedMessageReq -> CM ChatResponse + sendGroupContentMessages_ user g@(GIK gInfo@GroupInfo {groupId, membership} _) scope showGroupAsSender chatScopeInfo recipients live itemTTL sign cmrs = do forM_ allowedRole $ assertUserGroupRole gInfo assertGroupContentAllowed processComposedMessages @@ -4867,7 +4898,7 @@ processChatCommand cxt nm = \case (fInvs_, ciFiles_) <- L.unzip <$> setupSndFileTransfers (length recipients) timed_ <- sndGroupCITimed live gInfo itemTTL (chatMsgEvents, quotedItems_) <- L.unzip <$> prepareMsgs (L.zip cmrs fInvs_) timed_ - (msgs_, gsr) <- sendGroupMessages user gInfo Nothing showGroupAsSender recipients signMsgs chatMsgEvents + (msgs_, gsr) <- sendGroupMessages user g Nothing showGroupAsSender recipients signMsgs chatMsgEvents let itemsData = prepareSndItemsData (L.toList cmrs) (L.toList ciFiles_) (L.toList quotedItems_) (L.toList msgs_) cis_ <- saveSndChatItems user (CDGroupSnd gInfo chatScopeInfo) showGroupAsSender itemsData timed_ live when (length cis_ /= length cmrs) $ logError "sendGroupContentMessages: cmrs and cis_ length mismatch" @@ -4885,7 +4916,9 @@ processChatCommand cxt nm = \case Just file -> do let User {profile = LocalProfile {localBadge}} = user fileSize <- checkSndFile (if incognitoMembership gInfo then Nothing else localBadge) file - (fInv, ciFile) <- xftpSndFileTransfer user file fileSize n $ CGGroup gInfo recipients + needsBadge <- fileNeedsBadge fileSize + let binding_ = if needsBadge && not (incognitoMembership gInfo) then sndGroupChatBinding gInfo showGroupAsSender else Nothing + (fInv, ciFile) <- xftpSndFileTransfer user file fileSize n (CGGroup gInfo recipients) binding_ fInv' <- if signMsgs && useRelays' gInfo then (\d -> (fInv :: FileInvitation) {fileDigest = Just d}) <$> cryptoFileDigest file @@ -4943,9 +4976,9 @@ processChatCommand cxt nm = \case -- batching retrieval of quoted messages (prepareMsgs). when (live || length (L.filter (\(ComposedMessage {quotedItemId}, _, _, _) -> isJust quotedItemId) cmrs) > 1) $ throwCmdError "invalid multi send: live and more than one quote not supported" - xftpSndFileTransfer :: User -> CryptoFile -> Integer -> Int -> ContactOrGroup -> CM (FileInvitation, CIFile 'MDSnd) - xftpSndFileTransfer user file fileSize n contactOrGroup = do - (fInv, ciFile, ft) <- xftpSndFileTransfer_ user file fileSize n $ Just contactOrGroup + xftpSndFileTransfer :: User -> CryptoFile -> Integer -> Int -> ContactOrGroup -> Maybe ByteString -> CM (FileInvitation, CIFile 'MDSnd) + xftpSndFileTransfer user file fileSize n contactOrGroup binding_ = do + (fInv, ciFile, ft) <- xftpSndFileTransfer_ user file fileSize n (Just contactOrGroup) binding_ case contactOrGroup of CGContact Contact {activeConn} -> forM_ activeConn $ \conn -> withFastStore' $ \db -> createSndFTDescrXFTP db user Nothing conn ft dummyFileDescr @@ -4989,12 +5022,12 @@ processChatCommand cxt nm = \case where getDirectCI :: DB.Connection -> ChatItemId -> IO (Either ChatError (CChatItem 'CTDirect)) getDirectCI db itemId = runExceptT . withExceptT ChatErrorStore $ getDirectChatItem db user ctId itemId - getCommandGroupChatItems :: User -> Int64 -> NonEmpty ChatItemId -> CM (GroupInfo, [CChatItem 'CTGroup]) + getCommandGroupChatItems :: User -> Int64 -> NonEmpty ChatItemId -> CM (GroupInfoKeys, [CChatItem 'CTGroup]) getCommandGroupChatItems user gId itemIds = do - gInfo <- withFastStore $ \db -> getGroupInfo db cxt user gId + g@(GIK gInfo _) <- withFastStore $ \db -> getGroupInfoKeys db cxt user gId (errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getGroupCI db gInfo) (L.toList itemIds)) unless (null errs) $ toView $ CEvtChatErrors errs - pure (gInfo, items) + pure (g, items) where getGroupCI :: DB.Connection -> GroupInfo -> ChatItemId -> IO (Either ChatError (CChatItem 'CTGroup)) getGroupCI db gInfo itemId = runExceptT . withExceptT ChatErrorStore $ getGroupCIWithReactions db user gInfo itemId @@ -5035,7 +5068,7 @@ processChatCommand cxt nm = \case chunkSize <- asks $ fileChunkSize . config withFastStore' $ \db -> do fileId <- createLocalFile CIFSSndStored db user nf createdAt cf fileSize chunkSize - pure CIFile {fileId, fileName = takeFileName filePath, fileSize, fileSource = Just cf, fileStatus = CIFSSndStored, fileProtocol = FPLocal, fileExpires = Nothing} + pure CIFile {fileId, fileName = takeFileName filePath, fileSize, fileSource = Just cf, fileStatus = CIFSSndStored, fileProtocol = FPLocal, fileExpires = Nothing, fileProhibited = Nothing} prepareLocalItemsData :: NonEmpty ComposedMessageReq -> NonEmpty (Maybe (CIFile 'MDSnd)) -> @@ -5077,12 +5110,20 @@ firstNameLink ctType = foldr (\t r -> nameLink t <|> r) Nothing nameResolvesTo :: ConnShortLink 'CMContact -> [Text] -> Bool nameResolvesTo sLnk = any (either (const False) (sameShortLinkContact sLnk) . strDecode . encodeUtf8) +-- the resolver now also reports names that are not registered, which stay the agent's NAME NOT_FOUND +resolveNameRecord :: User -> NetworkRequestMode -> SimplexDomain -> CM NameRecord +resolveNameRecord user nm domain = do + NameResponse {registration} <- withAgent $ \a -> resolveSimplexName a nm (aUserId user) domain + case registration of + NRRegistered {nameRecord} -> pure nameRecord + _ -> throwError $ chatErrorAgent $ SMP "" (NAME SMP.NOT_FOUND) + verifyEntityDomain :: User -> NetworkRequestMode -> SimplexNameType -> SimplexDomainClaim -> Maybe AConnShortLink -> CM (Maybe Bool, Maybe Text) verifyEntityDomain user nm nameType SimplexDomainClaim {domain = StrJSON domain, proof = proof_} connLink_ = case (proof_, connLink_) of (Nothing, _) -> pure (Nothing, Just "no name proof to verify") (_, Nothing) -> pure (Nothing, Just "no connection link to check the name against") (Just proof, Just (ACSL SCMContact profileSLnk)) -> do - NameRecord {nrSimplexContact, nrSimplexChannel} <- withAgent $ \a -> resolveSimplexName a nm (aUserId user) domain + NameRecord {nrSimplexContact, nrSimplexChannel} <- resolveNameRecord user nm domain let resolvedLinks = case nameType of NTContact -> nrSimplexContact NTPublicGroup -> nrSimplexChannel @@ -5168,30 +5209,483 @@ createContactsSndFeatureItems user cts = CUPContact {preference} -> preference CUPUser {preference} -> preference +-- | Verify an own credential against the configured issuer keys. +-- Nothing means its key index is not among them, so this version cannot verify it at all. +verifyOwnBadge :: BadgeCredential -> CM (Maybe Bool) +verifyOwnBadge cred@(BadgeCredential keyIdx _ _ _) = do + keys <- asks $ badgePublicKeys . config + forM (M.lookup keyIdx keys) $ \key -> liftIO $ verifyCredential key cred + -- attach an issued badge credential to the user's own profile and present it to all current contacts. -- the credential is stored once; every profile send generates a fresh single-use proof (see presentUserBadge). addUserBadge :: User -> BadgeCredential -> CM () -addUserBadge user cred@(BadgeCredential keyIdx _ _ info) = do - keys <- asks $ badgePublicKeys . config - key <- maybe (throwCmdError "unknown badge key index") pure $ M.lookup keyIdx keys - verified <- liftIO $ verifyCredential key cred - unless verified $ throwCmdError "badge credential does not verify against configured key" - now <- liftIO getCurrentTime - let badge = Just (OwnBadge cred (mkBadgeStatus now (Just True) info)) - user' <- withFastStore' $ \db -> setUserBadge db user badge - asks currentUser >>= atomically . (`writeTVar` Just user') - lift $ withAgent' $ \a -> setUserEntitlement a (aUserId user') (badgeServerCredential badge) - cxt <- asks $ mkStoreCxt . config +addUserBadge user cred@(BadgeCredential _ _ _ info) = + verifyOwnBadge cred >>= \case + Nothing -> throwCmdError "unknown badge key index" + Just False -> throwCmdError "badge credential does not verify against configured key" + Just True -> do + now <- liftIO getCurrentTime + user' <- withFastStore $ \db -> setUserBadge db user (Just (OwnBadge cred (mkBadgeStatus now (Just True) info))) + presentUserBadgeToContacts user' + +presentUserBadgeToContacts :: User -> CM () +presentUserBadgeToContacts user'@User {userId, profile = LocalProfile {localBadge}} = do + -- a badge worker runs for every profile, not only the active one, so this refreshes the active + -- record where it is the same profile and must never switch to another + chatModifyVar currentUser $ \case + Just User {userId = activeId} | activeId == userId -> Just user' + active_ -> active_ + lift $ withAgent' $ \a -> setUserEntitlement a (aUserId user') (badgeServerCredential localBadge) + cxt <- chatStoreCxt contacts <- withFastStore' $ \db -> getUserContacts db cxt user' - withChatLock "addUserBadge" $ forM_ contacts $ \ct -> + withChatLock "presentUserBadge" $ forM_ contacts $ \ct -> case contactSendConn_ ct of Right conn | not (connIncognito conn) -> do let ct' = updateMergedPreferences user' ct p <- presentUserBadge user' Nothing $ userProfileDirect user' Nothing (Just ct') False - void (sendDirectContactMessage user' ct' (XInfo p)) `catchAllErrors` eToView + void (sendDirectContactMessage user' ct' (XInfo p Nothing)) `catchAllErrors` eToView _ -> pure () +-- | The check character is verified before anything leaves the device, and the signing keys are +-- stashed before the request is sent, so a retry reaches the service as the same signer. +-- A terminal answer drops the stash; a timeout keeps it. +redeemBadgeCode :: NetworkRequestMode -> User -> Text -> CM ChatResponse +redeemBadgeCode nm user@User {userId} codeText = do + code <- maybe (throwRedeemError BREInvalidCode) pure $ parseBadgeCode codeText + sendTarget <- asks (badgeServiceAddress . config) >>= maybe (throwRedeemError BREServiceNotConfigured) pure + g <- asks random + now <- liftIO getCurrentTime + let codeSent = badgeCodeText code + -- the guard, the request and the write are one section: without it two codes redeemed at once + -- both pass the guard and are both spent, for one badge + (present_, redeemed) <- withEntityLock "badgeRedeem" (CLBadgeUser userId) $ do + redemption_ <- withStore' $ \db -> getBadgeCodeRedemption db user codeSent + -- a code already redeemed here is allowed through: re-sending it returns the badge it bought + -- and adds nothing. Refused before its keys are stashed and before the request, so it stays unspent + replaying <- maybe (pure False) (\r -> withStore' $ \db -> isJust <$> getCodeBadgePurchase db r) redemption_ + unless replaying $ whenM (withStore' (`userHasBadge` user)) $ throwRedeemError BREBadgeActive + redemption@BadgeCodeRedemption {purchaseKey, purchasePrivKey, masterKey} <- + maybe (withStore' $ \db -> createBadgeCodeRedemption db g user codeSent now) pure redemption_ + let req = BadgeServiceRequest {version = currentBadgeServiceVersion, purchaseKey = Just purchaseKey, request = BSCRedeemBadgeCode {masterKey, code = codeSent}} + respBytes <- sendServiceRequestBytes nm user sendTarget Nothing (Just purchasePrivKey) req + respData <- either (const $ throwRedeemError $ BREInvalidResponse "not JSON") pure $ J.eitherDecodeStrict' respBytes + case J.fromJSON (J.Object respData) of + J.Error _ -> throwRedeemError $ BREInvalidResponse "not a badge service response" + J.Success BSPError {code = errCode} -> do + when (terminalCodeError errCode) $ withStore' $ \db -> deleteBadgeCodeRedemption db (redemptionId redemption) + throwRedeemError $ BREServiceError errCode + J.Success BSPBadgeCredential {credential = Just cred, statement} -> storeRedeemedBadge user redemption cred statement + J.Success _ -> throwRedeemError $ BREInvalidResponse "unexpected response type" + -- outside the badge lock: the chat lock must not be taken under it + mapM_ presentUserBadgeToContacts present_ + pure redeemed + where + -- the code will never work, so the keys stashed for it are dead; a timeout keeps them + terminalCodeError = \case + BSECodeInvalid -> True + BSECodeUsed -> True + BSECodeExpired -> True + _ -> False + +throwRedeemError :: BadgeRedeemError -> CM a +throwRedeemError = throwChatError . CEBadgeRedeemError + +-- | An unknown code is reported, since the service is deployed ahead of clients, but its text is +-- the service's - so it is bounded and stripped before reaching a terminal that acts on controls. +badgeServiceErrorText :: BadgeServiceErrorCode -> Text +badgeServiceErrorText = \case + BSEUnknown t -> case T.filter errorCodeChar (T.take 32 t) of + "" -> "unknown" + t' -> t' + code -> textEncode code + where + errorCodeChar c = isAsciiLower c || isDigit c || c == '_' + +-- | The only clock badge code reads, so a test can move the client and the service together. +badgeNow :: CM UTCTime +badgeNow = asks (badgeCurrentTime . config) >>= liftIO + +-- | A signal carries nothing: each pass derives its work from stored state, so a signal lost or +-- duplicated changes no outcome. +startBadgeWork :: User -> CM' () +startBadgeWork user = whenM (isJust <$> asks (badgeServiceAddress . config)) $ void $ getBadgeWorker user + +-- | Exactly one caller starts the thread and the rest wait for it: the lookup and the create cannot +-- be one transaction, because starting a thread is not STM. +getBadgeWorker :: User -> CM' BadgeWorker +getBadgeWorker User {userId} = do + ws <- asks badgeWorkers + seq' <- asks badgeSeq + now <- liftIO getCurrentTime + withGetSessVar' seq' userId ws now startWorker signalWorker + where + startWorker v = do + badgeWork <- newTMVarIO () + badgeWorkerAsync <- async $ void $ runExceptT $ runBadgeWorker userId badgeWork + let w = BadgeWorker {badgeWorkerAsync, badgeWork} + w <$ atomically (putTMVar (sessionVar v) w) + signalWorker v = do + w <- atomically $ readTMVar $ sessionVar v + w <$ atomically (void $ tryPutTMVar (badgeWork w) ()) + +-- | The alert last raised, so it is not repeated on every pass. The snooze is in the key because +-- kind and episode do not change when it lapses: the alert would match and stay silent until a restart. +type BadgeOccurrence = (BadgeAlertKind, Text, Maybe UTCTime) + +-- | Nothing ends a pass, so a persistent fault is one attempt per stall interval rather than a hot +-- loop: every error returns a wake, and the wait is outside the retries. +runBadgeWorker :: UserId -> TMVar () -> CM () +runBadgeWorker userId badgeWork = do + emitted <- newTVarIO Nothing + ri <- asks $ badgeRetryInterval . config + forever $ do + at_ <- withRetryInterval ri $ \_ loop -> do + lift waitChatStartedAndActivated + now <- badgeNow + let stalled = pure $ Just $ badgeStalledInterval `addUTCTime` now + updateUserBadge userId emitted now `catchAllErrors` retryBadgeError loop stalled + now <- badgeNow + liftIO $ waitBadgeWake badgeWork now at_ + +retryBadgeError :: CM a -> CM a -> ChatError -> CM a +retryBadgeError loop stalled e = eToView e >> if badgeErrorRetry e then loop else stalled + +-- | The signal is taken only by the wait that reports it - the take and the timer read are one +-- transaction. now is the badge clock, so the remaining time counts down rather than re-reading it. +waitBadgeWake :: TMVar () -> UTCTime -> Maybe UTCTime -> IO () +waitBadgeWake badgeWork now = \case + Nothing -> atomically $ takeTMVar badgeWork + Just at -> waitFor $ diffToMicroseconds $ min badgeMaxWake $ diffUTCTime at now + where + waitFor time + | time <= 0 = pure () + | otherwise = do + let maxWait = min time $ fromIntegral (maxBound :: Int) + timer <- registerDelay $ fromIntegral maxWait + signalled <- atomically $ do + w <- tryTakeTMVar badgeWork + fired <- readTVar timer + unless (isJust w || fired) retry + pure $ isJust w + unless signalled $ waitFor $ time - maxWait + +-- | Bounds the wait: a paidThrough far enough out would overflow the microsecond conversion, +-- wrap negative and spin the worker. Longer than any entitlement, so no real wake is early. +badgeMaxWake :: NominalDiffTime +badgeMaxWake = 100 * 365 * nominalDay + +-- | Retire what has ended, renew what is due, then report the next wake. Waking early, late or not +-- at all changes only timing: each run reads stored state and works out what to do. +updateUserBadge :: UserId -> TVar (Maybe BadgeOccurrence) -> UTCTime -> CM (Maybe UTCTime) +updateUserBadge userId emitted now = do + user <- withStore $ \db -> getUser db userId + withStore' (`getUserBadgePurchase` user) >>= \case + Nothing -> pure Nothing + Just p@UserBadgePurchase {badgePurchaseId} -> + withStore' (`getBadgeLedgerLastEntry` badgePurchaseId) >>= \case + Nothing -> pure Nothing + Just balance -> do + -- retirement needs no service and an unbounded retry would not return before it + retired <- retireExpiredBadge user p now balance + latest <- withStore' (`getLatestIssuedCredential` badgePurchaseId) + let requestDue = not retired && badgeRequestDue now (shownBadgeCredential user p) latest balance + (balance', serviceAt) <- + if requestDue + then either ((balance,) . Just) (,Nothing) <$> requestBadgeIssue userId p now + else pure (balance, Nothing) + -- presenting broadcasts the record it is handed, and the request above can block for the + -- whole service timeout, so this read belongs after it and not at the top of the pass + user' <- withStore $ \db -> getUser db userId + -- and the purchase, or an alert acked while the request was in flight is raised again + p' <- fromMaybe p <$> withStore' (`getBadgePurchase` badgePurchaseId) + let issued = balanceStartTs balance' /= balanceStartTs balance + -- outside the badge lock: the chat lock must not be taken under it + unless retired $ presentIssuedBadge user' p' now + emitBadgeAlert user' emitted p' now balance' + -- retiring and presenting both replace the badge on the record read above, so it is read again + user'' <- withStore $ \db -> getUser db userId + when (retired || issued) $ toView . CEvtBadgeChanged user'' =<< getUserBadgeState user'' + -- a snooze is the one wake that is not in the ledger: nothing else brings the alert back, + -- since support having ended leaves both ledger boundaries in the past + let UserBadgePurchase {alertSnoozeUntil} = p' + snoozeAt = find (> now) alertSnoozeUntil + stalledAt = if requestDue && not issued then Just $ badgeStalledInterval `addUTCTime` now else Nothing + pure $ earliestTime [serviceAt, snoozeAt, stalledAt, badgeBoundary now (shownBadgeCredential user'' p') balance'] + +-- | Support ended is the only alert raised here: the others need subscriptions, and warning before +-- a prepaid badge ends is not actionable while topping up cannot credit months without issuing. +-- TODO [badges] BAPrepaidEnding belongs here, three days before paidThrough, once that exists. +derivedBadgeAlert :: UTCTime -> StatementEntry -> Maybe BadgeAlert +derivedBadgeAlert now b + | balanceMonths b == 0 && endsAt <= now = + Just BadgeAlert {kind = BASupportEnded, episode = safeDecodeUtf8 $ strEncode endsAt, date = endsAt, price = Nothing} + | otherwise = Nothing + where + endsAt = L.paidThrough b + +-- | Derived from state rather than kept pending: raised unless this occurrence is the one already +-- answered, and raised again once a snooze that answered it lapses. +unansweredBadgeAlert :: UTCTime -> UserBadgePurchase -> StatementEntry -> Maybe BadgeAlert +unansweredBadgeAlert now UserBadgePurchase {alertAcked, alertSnoozeUntil} balance = + case derivedBadgeAlert now balance of + Just alert@BadgeAlert {kind, episode} + | alertAcked /= Just (kind, episode) || maybe False (now >=) alertSnoozeUntil -> Just alert + _ -> Nothing + +emitBadgeAlert :: User -> TVar (Maybe BadgeOccurrence) -> UserBadgePurchase -> UTCTime -> StatementEntry -> CM () +emitBadgeAlert user emitted p@UserBadgePurchase {alertSnoozeUntil} now balance = + forM_ (unansweredBadgeAlert now p balance) $ \alert@BadgeAlert {kind, episode} -> do + let occurrence = Just (kind, episode, alertSnoozeUntil) + raised <- atomically $ stateTVar emitted (,occurrence) + when (raised /= occurrence) $ toView $ CEvtBadgeAlert user alert + +-- | Read from stored rows alone; the worker's results follow as CEvtBadgeChanged. +getUserBadgeState :: User -> CM (Maybe BadgeState) +getUserBadgeState user = do + now <- badgeNow + withStore' (`getUserBadgePurchase` user) >>= \case + Nothing -> pure Nothing + Just p@UserBadgePurchase {badgePurchaseId} -> + fmap (badgeStateOf now p) <$> withStore' (`getBadgeLedgerLastEntry` badgePurchaseId) + where + badgeStateOf now p@UserBadgePurchase {badgePurchaseId, purchaseKey, badgeType, shown} balance = + BadgeState + { badgePurchaseId, + purchaseKey, + badgeType, + shown = BoolDef shown, + monthsLeft = balanceMonths balance, + paidThrough = L.paidThrough balance, + renewsAt = Nothing, + willRenew = False, + alert = unansweredBadgeAlert now p balance + } + +-- | How long a month that did not issue waits before it is tried again, whatever stopped it. Not +-- derived from the failure, so a misclassified one cannot leave a funded badge to expire. +badgeStalledInterval :: NominalDiffTime +badgeStalledInterval = nominalDay + +-- | The wait after a service refusal, floored at initialInterval so answering 0 cannot spin the +-- worker, and uncapped above it. +badgeRetryAfter :: RetryInterval -> Maybe Word32 -> NominalDiffTime +badgeRetryAfter RetryInterval {initialInterval} = maybe badgeStalledInterval (max floorWait . fromIntegral) + where + floorWait = fromIntegral initialInterval / 1000000 + +-- | How far ahead of the shown credential's expiry the renewal is requested - a day, so a failure +-- has that long to retry. The wake and the due check both derive from it and have to agree. +badgeRequestLead :: NominalDiffTime +badgeRequestLead = nominalDay + +-- | The credential the profile is showing for this purchase. Nothing when the purchase is not the +-- one being shown, or when a crash left the issuance written and the profile not. +shownBadgeCredential :: User -> UserBadgePurchase -> Maybe BadgeCredential +shownBadgeCredential User {profile = LocalProfile {localBadge}} UserBadgePurchase {shown} + | not shown = Nothing + | otherwise = case localBadge of + Just (OwnBadge cred _) -> Just cred + _ -> Nothing + +credentialExpiry :: BadgeCredential -> UTCTime +credentialExpiry (BadgeCredential _ _ _ BadgeInfo {badgeExpiry}) = badgeExpiry + +-- | Timed off the shown credential, not the period end: renewing around its shared expiry is what +-- joins the anonymity set. Latest still equal to shown means this month has not been asked for. +badgeRequestDue :: UTCTime -> Maybe BadgeCredential -> Maybe BadgeCredential -> StatementEntry -> Bool +badgeRequestDue now shownCred latestCred balance = + balanceMonths balance > 0 && latestCred == shownCred && maybe False lapsingSoon shownCred + where + lapsingSoon cred = credentialExpiry cred <= badgeRequestLead `addUTCTime` now + +-- | The request and the presentation, a day apart, both read off the credential the profile shows, +-- and the end of what is paid for. The credential's expiry window is what covers renewal, so it +-- says nothing about entitlement: paidThrough is when that ends and the badge has to come off. +-- TODO [badges] every client whose credential shares an expiry requests at the same instant. Only +-- the expiry has to be shared, so the request could fall anywhere in its lead without splitting +-- the anonymity set - spreading the load, and any outage, off a single moment. +badgeBoundary :: UTCTime -> Maybe BadgeCredential -> StatementEntry -> Maybe UTCTime +badgeBoundary now shownCred balance = case filter (> now) moments of + [] -> Nothing + ts -> Just $ minimum ts + where + moments = L.paidThrough balance : maybe [] renewalMoments shownCred + renewalMoments cred = + let expiry = credentialExpiry cred + in [negate badgeRequestLead `addUTCTime` expiry, expiry] + +earliestTime :: [Maybe UTCTime] -> Maybe UTCTime +earliestTime ts = case catMaybes ts of + [] -> Nothing + ts' -> Just $ minimum ts' + +-- | Only a failure that can clear on its own is repeated; every other throw is terminal, and +-- repeating it would spin. Service errors are classified by retryAfter in requestBadgeIssue. +badgeErrorRetry :: ChatError -> Bool +badgeErrorRetry = \case + ChatErrorAgent {agentError} -> retryable agentError + _ -> False + where + -- an unanswered request is the likeliest renewal failure and temporaryOrHostError does not + -- cover it: that classifies reaching the server, and this timeout is the agent's own + retryable = \case + AGENT (A_SERVICE ASETimeout) -> True + e -> temporaryOrHostError e + +-- | Ask the service for the month that is due and apply the response. A timeout writes nothing, so +-- the same request is sent again on the next pass. 'Left' is a service error, already reported, and +-- carries when to try again, since a service error is answered rather than thrown. +requestBadgeIssue :: UserId -> UserBadgePurchase -> UTCTime -> CM (Either UTCTime StatementEntry) +requestBadgeIssue userId UserBadgePurchase {badgePurchaseId, badgeType, purchaseKey, purchasePrivKey, masterKey} now = do + sendTarget <- asks (badgeServiceAddress . config) >>= maybe (throwCmdError "badge service not configured") pure + withEntityLock "badgeIssue" (CLBadgeUser userId) $ do + user <- withStore $ \db -> getUser db userId + lastEntry <- withStore' (`getBadgeLedgerLastEntry` badgePurchaseId) >>= maybe (throwCmdError "badge ledger has no entry to assert") pure + let req = + BadgeServiceRequest + { version = currentBadgeServiceVersion, + purchaseKey = Just purchaseKey, + request = BSCIssueBadge {balance = BadgeBalance {lastEntry}} + } + respData <- sendServiceRequestTo NRMBackground user sendTarget Nothing (Just purchasePrivKey) req + case J.fromJSON (J.Object respData) of + J.Success BSPBadgeCredential {credential, statement} -> do + cred_ <- verifyIssuedCredential masterKey credential + -- TODO [badges] the statement is applied either way, so a failed verification spends the + -- month with nothing to show for it; that needs an alert, not only a line in the log + g <- asks random + -- read again: now was taken before a lock wait and an untimed request, and the check reads + -- it as the client's clock against the timestamps the service put on the rows + storedAt <- badgeNow + applied <- withStore' $ \db -> applyBadgeStatement db g badgePurchaseId badgeType statement cred_ storedAt + unless applied $ eToView $ ChatError $ CEInternalError "issued badge credential has no ledger row to store it against" + Right <$> (withStore' (`getBadgeLedgerLastEntry` badgePurchaseId) >>= maybe (throwCmdError "badge ledger has no balance") pure) + J.Success BSPError {code, retryAfter} -> do + eToView $ ChatError $ CECommandError $ "badge service error: " <> T.unpack (badgeServiceErrorText code) + ri <- asks $ badgeRetryInterval . config + pure $ Left $ badgeRetryAfter ri retryAfter `addUTCTime` now + _ -> throwCmdError "unexpected badge service response" + +-- | The signature covers the master key inside the credential, so it verifies no matter which key +-- that is - the credential is stored only when that key is also this purchase's. +verifyIssuedCredential :: BadgeMasterKey -> Maybe BadgeCredential -> CM (Maybe BadgeCredential) +verifyIssuedCredential _ Nothing = pure Nothing +verifyIssuedCredential masterKey (Just cred@(BadgeCredential _ credMasterKey _ _)) = + verifyOwnBadge cred >>= \case + Just True | credMasterKey == masterKey -> pure $ Just cred + Just True -> Nothing <$ eToView (ChatError $ CEInternalError "issued badge credential is for a different master key") + _ -> Nothing <$ eToView (ChatError $ CEInternalError "issued badge credential does not verify") + +-- | Present the newest issued credential once the one on the profile has run out. It is written in +-- a separate transaction from the issuance, so a crash between the two is repaired at the next run. +presentIssuedBadge :: User -> UserBadgePurchase -> UTCTime -> CM () +presentIssuedBadge user p@UserBadgePurchase {badgePurchaseId, shown} now + | not shown = pure () + | otherwise = do + cred_ <- withStore' (`getLatestIssuedCredential` badgePurchaseId) + forM_ cred_ $ \cred@(BadgeCredential _ _ _ info) -> + when (presentDue cred) $ do + user' <- withStore $ \db -> setUserBadge db user (Just $ OwnBadge cred (mkBadgeStatus now (Just True) info)) + presentUserBadgeToContacts user' + where + shownCred = shownBadgeCredential user p + -- Held back until the shown credential lapses, so the broadcast does not correlate with the + -- request that produced it. Nothing shown at all is the state a lost profile write leaves. + presentDue cred = Just cred /= shownCred && maybe True ((<= now) . credentialExpiry) shownCred + +-- | The visible half of "the badge expired". +retireExpiredBadge :: User -> UserBadgePurchase -> UTCTime -> StatementEntry -> CM Bool +retireExpiredBadge user UserBadgePurchase {badgePurchaseId, shown} now balance + | not (shown && L.paidThrough balance <= now) = pure False + | otherwise = do + user' <- withStore $ \db -> do + liftIO $ clearShownBadge db user badgePurchaseId + setUserBadge db user Nothing + True <$ presentUserBadgeToContacts user' + +-- | Waiting on the var rather than skipping an empty one is what catches a worker whose creator +-- had not filled it when the map was swapped out. +stopBadgeWorkers :: TM.TMap UserId (SessionVar BadgeWorker) -> IO () +stopBadgeWorkers workers = + atomically (swapTVar workers M.empty) >>= mapM_ cancelBadgeWorker + where + cancelBadgeWorker v = + void $ forkIO $ atomically (badgeWorkerAsync <$> readTMVar (sessionVar v)) >>= uninterruptibleCancel + +-- | Verify the credential before writing anything; the purchase, the statement's rows, the +-- issuance and the profile's badge go in one transaction. Answers the user to tell contacts about, +-- which the caller does once the badge lock is released. +storeRedeemedBadge :: User -> BadgeCodeRedemption -> BadgeCredential -> BadgeStatement -> CM (Maybe User, ChatResponse) +storeRedeemedBadge user@User {userId} redemption@BadgeCodeRedemption {masterKey} cred@(BadgeCredential _ credMasterKey _ info@BadgeInfo {badgeType}) statement = + verifyOwnBadge cred >>= \case + Nothing -> throwRedeemError BREUnknownKeyIndex + Just False -> throwRedeemError BRECredentialNotVerified + -- verifyCredential checks the signature against the key inside the credential, not the one we + -- sent - so a credential over any other master key also verifies + Just True | credMasterKey /= masterKey -> throwRedeemError $ BREInvalidResponse "credential is for a different master key" + Just True -> do + g <- asks random + now <- badgeNow + let badge = OwnBadge cred (mkBadgeStatus now (Just True) info) + -- TODO [badges] retire a previously held badge + (user', newBadge, applied) <- withStore $ \db -> do + (purchaseId, newBadge) <- liftIO $ createCodeBadgePurchase db user redemption cred now + applied <- liftIO $ applyBadgeStatement db g purchaseId badgeType statement (Just cred) now + -- a replay must not put a superseded badge back, or tell every contact again + user' <- if newBadge then setUserBadge db user (Just badge) else getUser db userId + pure (user', newBadge, applied) + unless applied $ eToView $ ChatError $ CEInternalError "redeemed badge credential has no ledger row to store it against" + -- nothing is due yet, but a pass is what arms the next wake, and this is the first purchase + lift $ startBadgeWork user' + badgeState <- getUserBadgeState user' + pure (if newBadge then Just user' else Nothing, CRBadgeRedeemed user' badge newBadge badgeState) + +-- | Store the statement's rows, then the credential against the badge debit row among them. +-- 'False' when that row cannot be found, which the caller reports rather than drop in silence. +applyBadgeStatement :: DB.Connection -> TVar ChaChaDRG -> Int64 -> BadgeType -> BadgeStatement -> Maybe BadgeCredential -> UTCTime -> IO Bool +applyBadgeStatement db g purchaseId badgeType BadgeStatement {entries} cred_ now = do + -- TODO [badges] a service that no longer holds the asserted row re-sends its whole history, which + -- joins onto the tip without following it, and every row of it verifies. The service is to heal + -- and restate as one opening credit instead (badges-rpc.md), which is checked without a tip. + tip <- getBadgeLedgerLastEntry db purchaseId + storeBadgeStatement db purchaseId badgeType tip entries now + case (,) <$> cred_ <*> issuedEntryId of + Nothing -> pure True + Just (cred, entryUuid) -> + getBadgeLedgerEntryId db purchaseId entryUuid >>= \case + Nothing -> pure False + Just entryId -> storeBadgeIssuance db g purchaseId entryId cred now + where + -- the credential belongs to the last month the statement issued + issuedEntryId = case [entryId | StatementEntry {entryId, entryType = SEDebit SDBadge} <- entries] of + [] -> Nothing + ids -> Just (last ids) + +sendServiceRequestTo :: J.ToJSON a => NetworkRequestMode -> User -> ConnectTarget 'CMContact -> Maybe NominalDiffTime -> Maybe C.PrivateKeyEd25519 -> a -> CM J.Object +sendServiceRequestTo nm user sendTarget requestTimeout signKey request = + sendServiceRequestBytes nm user sendTarget requestTimeout signKey request + >>= either (const $ throwCmdError "invalid service response") pure . J.eitherDecodeStrict' + +sendServiceRequestBytes :: J.ToJSON a => NetworkRequestMode -> User -> ConnectTarget 'CMContact -> Maybe NominalDiffTime -> Maybe C.PrivateKeyEd25519 -> a -> CM ByteString +sendServiceRequestBytes nm user sendTarget requestTimeout signKey request = do + cReq <- resolveServiceTarget sendTarget + withAgent $ \a -> sendServiceRequestAsync a (aUserId user) cReq requestTimeout signKey (LB.toStrict $ J.encode request) + where + resolveServiceTarget = \case + CTFullContact cReq -> pure cReq + CTShortContact (CTLink sLnk) -> resolveShortLink sLnk + CTShortContact (CTName SimplexNameInfo {nameType, nameDomain}) -> case nameType of + NTContact -> resolveDomain nameDomain + _ -> throwCmdError "service request target must be a contact" + CTDomain d -> resolveDomain d + resolveDomain d = do + nr <- resolveNameRecord user nm d + case firstNameLink CCTContact (nrSimplexContact nr) of + Just sLnk -> resolveShortLink sLnk + Nothing -> throwChatError $ CESimplexDomainNotReady d SDENoValidLink + resolveShortLink sLnk = (\(_, _, cReq) -> cReq) <$> getShortLinkConnReq nm user sLnk + assertDirectAllowed :: User -> MsgDirection -> Contact -> CMEventTag e -> CM () assertDirectAllowed user dir ct event = unless (allowedChatEvent || anyDirectOrUsed ct) . unlessM directMessagesAllowed $ @@ -5375,8 +5869,8 @@ runRelayGroupLinkChecks user = do where checkRelayServedGroups = do cxt <- chatStoreCxt - relayGroups <- withStore' $ \db -> getRelayServedGroups db cxt user - forM_ relayGroups $ \gInfo@GroupInfo {groupProfile = gp} -> flip catchAllErrors eToView $ do + relayGroups <- withStore $ \db -> getRelayServedGroups db cxt user + forM_ relayGroups $ \g@(GIK gInfo@GroupInfo {groupProfile = gp} _) -> flip catchAllErrors eToView $ do case publicGroup gp of Just PublicGroupProfile {groupLink = sLnk} -> do (_, ContactLinkData _ UserContactData {relays = relayLinks}, _) <- @@ -5392,7 +5886,7 @@ runRelayGroupLinkChecks user = do else void $ withStore' $ \db -> updateRelayOwnStatusFromTo db gInfo RSActive RSInactive _ -> pure () _ -> pure () - sendRelayCapIfNeeded user gInfo + sendRelayCapIfNeeded user g checkRelayInactiveGroups = do cxt <- chatStoreCxt ttl <- asks (relayInactiveTTL . config) @@ -5555,7 +6049,7 @@ chatCommandP = *> ( APIGetChats <$> A.decimal <*> (" pcc=on" $> True <|> " pcc=off" $> False <|> pure False) - <*> (A.space *> paginationByTimeP <|> pure (PTLast 5000)) + <*> optional (A.space *> paginationByTimeP) <*> (A.space *> jsonP <|> pure clqNoFilters) ), "/_get chat " *> (APIGetChat <$> chatRefP <*> optional (" content=" *> strP) <* A.space <*> chatPaginationP <*> optional (" search=" *> textP)), @@ -5592,6 +6086,10 @@ chatCommandP = "/_accept" *> (APIAcceptContact <$> incognitoOnOffP <* A.space <*> A.decimal), "/_reject " *> (APIRejectContact <$> A.decimal <*> (" notify=" *> onOffP <|> pure False)), "/_service_request " *> (APISendServiceRequest <$> A.decimal <* A.space <*> strP <*> optional (" timeout=" *> (realToFrac <$> A.double)) <*> optional (" sign_key=" *> strP) <* A.space <*> jsonP), + "/_redeem_badge_code " *> (APIRedeemBadgeCode <$> A.decimal <* A.space <*> textP), + "/_badge state " *> (APIGetBadgeState <$> A.decimal), + "/_badge ledger " *> (APIGetBadgeLedger <$> A.decimal <* A.space <*> A.decimal), + "/_badge ack " *> (APIAckBadgeAlert <$> A.decimal <* A.space <*> A.decimal <* A.space <*> badgeAlertKindP <* A.space <*> onOffP <* A.space <*> textP), "/_service_response " *> (APISendServiceResponse <$> A.decimal <* A.space <*> strP <* A.space <*> jsonP), "/_wallet create new" $> APICreateWallet Nothing, "/_wallet create mnemonic=" *> (APICreateWallet . Just <$> textP), @@ -6021,6 +6519,9 @@ chatCommandP = descr <- A.takeWhile1 isSpace *> (T.dropWhileEnd isSpace <$> textP) <|> pure "" pure $ if T.null descr then Nothing else Just $ T.take 160 descr textP = safeDecodeUtf8 <$> A.takeByteString + badgeAlertKindP = do + t <- A.takeTill (== ' ') + maybe (fail "bad badge alert kind") pure $ textDecode $ safeDecodeUtf8 t pwdP = jsonP <|> (UserPwd . safeDecodeUtf8 <$> A.takeTill (== ' ')) verifyCodeP = safeDecodeUtf8 <$> A.takeWhile (\c -> isDigit c || c == ' ') msgTextP = jsonP <|> textP diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 79b4578d8f..e40e2466a5 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -40,7 +40,7 @@ import Data.Foldable (foldr') import Data.Functor (($>)) import Data.Functor.Identity import Data.Int (Int64) -import Data.List (foldl', mapAccumL, partition) +import Data.List (find, foldl', mapAccumL, partition) import Data.List.NonEmpty (NonEmpty (..), (<|)) import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) @@ -53,7 +53,7 @@ import Data.Text.Encoding (encodeUtf8) import Data.Time (addUTCTime) import Data.Time.Calendar (fromGregorian) import Data.Time.Clock (UTCTime (..), diffUTCTime, getCurrentTime, nominalDiffTimeToSeconds, secondsToDiffTime) -import Simplex.Chat.Badges (BadgeCredential (..), ProofPresHeader (..), BadgeProof (..), BadgeStatus (..), LocalBadge (..), badgeProof, mkBadgeStatus, verifyBadge) +import Simplex.Chat.Badges (BadgeCredential (..), BadgeInfo (..), ProofPresHeader (..), BadgeProof (..), BadgeProofKind (..), BadgeStatus (..), FileSizeLimits (..), LocalBadge (..), badgeProof, badgeSndGraceInterval, generateBadgeProof, localBadgeStatus, maxXFTPFileSize, mkBadgeStatus, verifyBadge) import Simplex.Chat.Names (SimplexDomainClaim (..), claimDomain) import Simplex.Chat.Call import Simplex.Chat.Controller @@ -98,7 +98,8 @@ import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport (..), pattern PQEncOff, pattern PQEncOn, pattern PQSupportOff, pattern PQSupportOn) import qualified Simplex.Messaging.Crypto.Ratchet as CR -import Simplex.Messaging.Encoding (smpEncode) +import Simplex.Messaging.Crypto.BBS (BBSPresHeader (..)) +import Simplex.Messaging.Encoding (smpDecode, smpEncode) 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 @@ -435,10 +436,11 @@ roundedFDCount n | n <= 0 = 4 | otherwise = max 4 $ fromIntegral $ (2 :: Integer) ^ (ceiling (logBase 2 (fromIntegral n) :: Double) :: Integer) -xftpSndFileTransfer_ :: User -> CryptoFile -> Integer -> Int -> Maybe ContactOrGroup -> CM (FileInvitation, CIFile 'MDSnd, FileTransferMeta) -xftpSndFileTransfer_ user file@(CryptoFile filePath cfArgs) fileSize n contactOrGroup_ = do +xftpSndFileTransfer_ :: User -> CryptoFile -> Integer -> Int -> Maybe ContactOrGroup -> Maybe ByteString -> CM (FileInvitation, CIFile 'MDSnd, FileTransferMeta) +xftpSndFileTransfer_ user file@(CryptoFile filePath cfArgs) fileSize n contactOrGroup_ binding_ = do + fileBadge <- pure binding_ $>>= \chatBinding -> sndBadgeProof user PHFileInv {chatBinding, fileSize = fromInteger fileSize} let fileName = takeFileName filePath - fInv = xftpFileInvitation fileName fileSize dummyFileDescr + fInv = (xftpFileInvitation fileName fileSize dummyFileDescr :: FileInvitation) {fileBadge} fsFilePath <- lift $ toFSFilePath filePath let srcFile = CryptoFile fsFilePath cfArgs aFileId <- withAgent $ \a -> xftpSendFile a (aUserId user) srcFile (roundedFDCount n) Nothing @@ -446,9 +448,32 @@ xftpSndFileTransfer_ user file@(CryptoFile filePath cfArgs) fileSize n contactOr chSize <- asks $ fileChunkSize . config ft@FileTransferMeta {fileId} <- withStore' $ \db -> createSndFileTransferXFTP db user contactOrGroup_ file fInv (AgentSndFileId aFileId) Nothing chSize let fileSource = Just $ CryptoFile filePath cfArgs - ciFile = CIFile {fileId, fileName, fileSize, fileSource, fileStatus = CIFSSndStored, fileProtocol = FPXFTP, fileExpires = Nothing} + ciFile = CIFile {fileId, fileName, fileSize, fileSource, fileStatus = CIFSSndStored, fileProtocol = FPXFTP, fileExpires = Nothing, fileProhibited = Nothing} pure (fInv, ciFile, ft) +fileNeedsBadge :: Integer -> CM Bool +fileNeedsBadge fileSize = (fileSize >) . noBadge <$> asks (fileSizeLimits . config) + +sndBadgeProof :: User -> ProofPresHeader -> CM (Maybe BadgeProof) +sndBadgeProof user = sndBadgeProof_ user . BBSPresHeader . strEncode + +sndBadgeProof_ :: User -> BBSPresHeader -> CM (Maybe BadgeProof) +sndBadgeProof_ User {profile = LocalProfile {localBadge}} ph = case localBadge of + Just (OwnBadge cred@(BadgeCredential keyIdx _ _ _) _) -> do + keys <- asks $ badgePublicKeys . config + case M.lookup keyIdx keys of + Nothing -> Nothing <$ logError "sndBadgeProof: badge key index not in config" + Just key -> + liftIO (generateBadgeProof key cred ph) >>= \case + Right proof -> pure $ Just proof + Left e -> Nothing <$ logError ("sndBadgeProof: proof generation failed: " <> T.pack e) + _ -> pure Nothing + +sndGroupChatBinding :: GroupInfo -> ShowGroupAsSender -> Maybe ByteString +sndGroupChatBinding gInfo@GroupInfo {membership = GroupMember {memberId, memberPubKey}} asGroup + | asGroup = (\PublicGroupProfile {publicGroupId} -> encodeChatBinding CBChannel $ smpEncode publicGroupId) <$> publicGroup' gInfo + | otherwise = (\k -> encodeChatBinding CBGroup $ groupBindingData gInfo memberId k) <$> memberPubKey + cryptoFileDigest :: CryptoFile -> CM FD.FileDigest cryptoFileDigest (CryptoFile filePath cfArgs) = do fsPath <- lift $ toFSFilePath filePath @@ -744,10 +769,11 @@ rctFileCancelled = \case _ -> False acceptFileReceive :: User -> RcvFileTransfer -> Bool -> Maybe Bool -> Maybe FilePath -> CM AChatItem -acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileInvitation = FileInvitation {fileName = fName, fileConnReq, fileInline, fileSize}, fileStatus, grpMemberId, cryptoArgs} userApprovedRelays rcvInline_ filePath_ = do +acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileInvitation = FileInvitation {fileName = fName, fileConnReq, fileInline, fileSize}, fileProhibited, fileStatus, grpMemberId, cryptoArgs} userApprovedRelays rcvInline_ filePath_ = do unless (fileStatus == RFSNew) $ case fileStatus of RFSCancelled _ -> throwChatError $ CEFileCancelled fName _ -> throwChatError $ CEFileAlreadyReceiving fName + when (isJust fileProhibited) $ throwChatError $ CEFileSize fName cxt <- chatStoreCxt case (xftpRcvFile, fileConnReq) of -- XFTP @@ -970,7 +996,7 @@ acceptContactRequest nm user@User {userId} UserContactRequest {agentInvitationId incognitoProfile <- forM customUserProfileId $ \pId -> withFastStore $ \db -> getProfileById db userId pId pure (ct, conn, ExistingIncognito <$> incognitoProfile) profileToSend <- presentUserBadge user incognitoProfile $ userProfileDirect user (fromIncognitoProfile <$> incognitoProfile) (Just ct) True - dm <- encodeConnInfoPQ pqSup' $ XInfo profileToSend + dm <- encodeConnInfoPQ pqSup' $ XInfo profileToSend Nothing (ct,conn,) <$> withAgent (\a -> acceptContact a nm (aUserId user) (aConnId conn) True invId dm pqSup' subMode) acceptContactRequestAsync :: User -> Int64 -> Contact -> UserContactRequest -> Maybe IncognitoProfile -> CM Contact @@ -991,14 +1017,14 @@ acceptContactRequestAsync Connection {connId} <- liftIO $ createAcceptedContactConn db user (Just uclId) contactId acId chatV cReqChatVRange cReqPQSup incognitoProfile subMode currentTs liftIO $ setCommandConnId db user cmdId connId getContact db cxt user contactId - agentAcceptContactAsync cmdId acId True cReqInvId (XInfo profileToSend) cReqPQSup subMode + agentAcceptContactAsync cmdId acId True cReqInvId (XInfo profileToSend Nothing) cReqPQSup subMode pure ct' -acceptGroupJoinRequestAsync :: User -> Int64 -> GroupInfo -> InvitationId -> VersionRangeChat -> Profile -> Maybe XContactId -> Maybe MemberId -> Maybe SharedMsgId -> GroupAcceptance -> GroupMemberRole -> Maybe IncognitoProfile -> Maybe MemberKey -> Maybe GroupMember -> CM GroupMember +acceptGroupJoinRequestAsync :: User -> Int64 -> GroupInfoKeys -> InvitationId -> VersionRangeChat -> Profile -> Maybe XContactId -> Maybe MemberId -> Maybe SharedMsgId -> GroupAcceptance -> GroupMemberRole -> Maybe IncognitoProfile -> Maybe MemberKey -> Maybe GroupMember -> CM GroupMember acceptGroupJoinRequestAsync user@User {userId} uclId - gInfo@GroupInfo {groupProfile, membership, businessChat} + (GIK gInfo@GroupInfo {groupProfile, membership, businessChat} gks) cReqInvId cReqChatVRange cReqProfile @@ -1032,6 +1058,7 @@ acceptGroupJoinRequestAsync GroupLinkInvitation { fromMember = MemberIdRole userMemberId userRole, fromMemberName = displayName, + fromMemberKey = Just $ groupMemberKey gks, invitedMember = MemberIdRole memberId gLinkMemRole, groupProfile, accepted = Just gAccepted, @@ -1079,11 +1106,11 @@ acceptGroupJoinSendRejectAsync agentAcceptContactAsync cmdId acId False cReqInvId msg PQSupportOff subMode pure m -acceptBusinessJoinRequestAsync :: User -> Int64 -> GroupInfo -> GroupMember -> UserContactRequest -> CM (GroupInfo, GroupMember) +acceptBusinessJoinRequestAsync :: User -> Int64 -> GroupInfoKeys -> GroupMember -> UserContactRequest -> CM (GroupInfo, GroupMember) acceptBusinessJoinRequestAsync user uclId - gInfo@GroupInfo {membership = GroupMember {memberRole = userRole, memberId = userMemberId}} + (GIK gInfo@GroupInfo {membership = GroupMember {memberRole = userRole, memberId = userMemberId}} gks) clientMember@GroupMember {groupMemberId, memberId} UserContactRequest {agentInvitationId = AgentInvId cReqInvId, cReqChatVRange, xContactId} = do cxt <- chatStoreCxt @@ -1095,6 +1122,7 @@ acceptBusinessJoinRequestAsync GroupLinkInvitation { fromMember = MemberIdRole userMemberId userRole, fromMemberName = displayName, + fromMemberKey = Just $ groupMemberKey gks, invitedMember = MemberIdRole memberId GRMember, groupProfile = businessGroupProfile userProfile groupPreferences, accepted = Just GAAccepted, @@ -1169,15 +1197,15 @@ businessGroupProfile :: Profile -> GroupPreferences -> GroupProfile businessGroupProfile Profile {displayName, fullName, shortDescr, description, image} groupPreferences = GroupProfile {displayName, fullName, description, shortDescr, image, publicGroup = Nothing, groupPreferences = Just groupPreferences, memberAdmission = Nothing} -introduceToModerators :: StoreCxt -> User -> GroupInfo -> GroupMember -> CM () -introduceToModerators cxt user gInfo@GroupInfo {groupId} m@GroupMember {memberRole, memberId} = do +introduceToModerators :: StoreCxt -> User -> GroupInfoKeys -> GroupMember -> CM () +introduceToModerators cxt user gInfo@(GIK g@GroupInfo {groupId} _) m@GroupMember {memberRole, memberId} = do forM_ (memberConn m) $ \mConn -> do let msg = if maxVersion (memberChatVRange m) >= groupKnockingVersion then XGrpLinkAcpt GAPendingReview memberRole memberId else XMsgNew $ mcSimple (MCText pendingReviewMessage) void $ sendDirectMemberMessage mConn msg groupId - modMs <- withStore' $ \db -> getGroupModerators db cxt user gInfo + modMs <- withStore' $ \db -> getGroupModerators db cxt user g let rcpModMs = filter shouldIntroduceToMod modMs introduceMember user gInfo m rcpModMs (Just $ MSMember $ memberId' m) where @@ -1187,15 +1215,15 @@ introduceToModerators cxt user gInfo@GroupInfo {groupId} m@GroupMember {memberRo && groupMemberId' mem /= groupMemberId' m && maxVersion (memberChatVRange mem) >= groupKnockingVersion -introduceToAll :: StoreCxt -> User -> GroupInfo -> GroupMember -> CM () -introduceToAll cxt user gInfo m = do - (members, vector) <- withStore $ \db -> liftM2 (,) (liftIO $ getGroupMembers db cxt user gInfo) (getMemberRelationsVector db m) +introduceToAll :: StoreCxt -> User -> GroupInfoKeys -> GroupMember -> CM () +introduceToAll cxt user gInfo@(GIK g _) m = do + (members, vector) <- withStore $ \db -> liftM2 (,) (liftIO $ getGroupMembers db cxt user g) (getMemberRelationsVector db m) let recipients = filter (shouldIntroduce m vector) members introduceMember user gInfo m recipients Nothing -introduceToRemaining :: StoreCxt -> User -> GroupInfo -> GroupMember -> CM () -introduceToRemaining cxt user gInfo m = do - (members, vector) <- withStore $ \db -> liftM2 (,) (liftIO $ getGroupMembers db cxt user gInfo) (getMemberRelationsVector db m) +introduceToRemaining :: StoreCxt -> User -> GroupInfoKeys -> GroupMember -> CM () +introduceToRemaining cxt user gInfo@(GIK g _) m = do + (members, vector) <- withStore $ \db -> liftM2 (,) (liftIO $ getGroupMembers db cxt user g) (getMemberRelationsVector db m) let recipients = filter (shouldIntroduce m vector) members introduceMember user gInfo m recipients Nothing @@ -1205,17 +1233,17 @@ shouldIntroduce m vec mem = && groupMemberId' mem /= groupMemberId' m && getRelation (indexInGroup mem) vec == MRNew -introduceMember :: User -> GroupInfo -> GroupMember -> [GroupMember] -> Maybe MsgScope -> CM () +introduceMember :: User -> GroupInfoKeys -> GroupMember -> [GroupMember] -> Maybe MsgScope -> CM () introduceMember _ _ GroupMember {activeConn = Nothing} _ _ = throwChatError $ CEInternalError "member connection not active" -introduceMember user gInfo toMember@GroupMember {activeConn = Just conn} introduceToMembers msgScope = do - void . sendGroupMessage' user gInfo introduceToMembers $ XGrpMemNew (memberInfo gInfo toMember) msgScope +introduceMember user gInfo@(GIK g _) toMember@GroupMember {activeConn = Just conn} introduceToMembers msgScope = do + void . sendGroupMessage' user gInfo introduceToMembers $ XGrpMemNew (memberInfo g toMember) msgScope sendIntroductions introduceToMembers where sendIntroductions reMembers = do updateToMemberVector reMembers updateReMembersVectors reMembers shuffledReMembers <- liftIO $ shuffleMembers reMembers - let events = map (memberIntroEvt gInfo) shuffledReMembers + let events = map (memberIntroEvt g) shuffledReMembers forM_ (L.nonEmpty events) $ \events' -> sendGroupMemberMessages user gInfo conn events' updateToMemberVector :: [GroupMember] -> CM () @@ -1244,11 +1272,11 @@ memberIntroEvt gInfo reMember = -- Forward the saved owner-signed roster verbatim (reusing its signed shared_msg_id), then the -- blob chunks, so the recipient verifies the owner signature. -serveRoster :: User -> GroupInfo -> GroupMember -> CM () -serveRoster user gInfo member = +serveRoster :: User -> GroupInfoKeys -> GroupMember -> CM () +serveRoster user gInfo@(GIK g _) member = when (member `supportsVersion` groupRosterVersion) $ do cxt <- chatStoreCxt - withStore' (\db -> getStoredGroupRoster db gInfo) >>= \case + withStore' (\db -> getStoredGroupRoster db g) >>= \case Just (ownerGMId, brokerTs, sm@SignedMsg {signedBody}, blob_, storedVer_) -> case J.eitherDecodeStrict' signedBody :: Either String (ChatMessage 'Json) of Left e -> logError $ "serveRoster: cannot decode saved roster message: " <> tshow e @@ -1269,24 +1297,24 @@ serveRoster user gInfo member = -- Used in groups with relays to introduce moderators and above to a new member, -- and to announce the new member to moderators and above. -- This doesn't create introduction records in db, compared to above methods. -introduceInChannel :: StoreCxt -> User -> GroupInfo -> GroupMember -> CM () +introduceInChannel :: StoreCxt -> User -> GroupInfoKeys -> GroupMember -> CM () introduceInChannel _ _ _ GroupMember {activeConn = Nothing} = throwChatError $ CEInternalError "member connection not active" -introduceInChannel cxt user gInfo subscriber@GroupMember {activeConn = Just conn, indexInGroup = subscriberIdx} = do +introduceInChannel cxt user g@(GIK gInfo _) subscriber@GroupMember {activeConn = Just conn, indexInGroup = subscriberIdx} = do (owners, adminsMods) <- withStore' $ \db -> (,) <$> getGroupOwners db cxt user gInfo <*> getGroupAdminsMods db cxt user gInfo let modMs = owners <> adminsMods - void $ sendGroupMessage' user gInfo modMs $ XGrpMemNew (memberInfo gInfo subscriber) Nothing + void $ sendGroupMessage' user g modMs $ XGrpMemNew (memberInfo gInfo subscriber) Nothing withStore' $ \db -> setMemberVectorNewRelations db subscriber [(indexInGroup m, (IDSubjectIntroduced, MRIntroduced)) | m <- modMs] -- owner intros first so the joiner has the owner profile loaded before applying the saved roster (signed by the owner) sendIntros owners - serveRoster user gInfo subscriber + serveRoster user g subscriber sendIntros adminsMods withStore' $ \db -> setMembersVectorsNewRelation db modMs subscriberIdx IDSubjectIntroduced MRIntroduced where sendIntros ms = forM_ (L.nonEmpty $ map (memberIntroEvt gInfo) ms) $ \evts -> - sendGroupMemberMessages user gInfo conn evts + sendGroupMemberMessages user g conn evts userProfileInGroup :: User -> GroupInfo -> Maybe Profile -> Profile userProfileInGroup user g = userProfileInGroup' user (Just g) @@ -1417,15 +1445,17 @@ sendHistory user gInfo@GroupInfo {membership} m@GroupMember {activeConn = Just c resolveAuthor (Just gmId) = do cxt <- chatStoreCxt eitherToMaybe <$> withStore' (\db -> runExceptT $ getGroupMemberById db cxt user gmId) - getRcvFileInvDescr :: CIFile 'MDRcv -> CM (Maybe (FileInvitation, RcvFileDescrText, Maybe UTCTime)) + getRcvFileInvDescr :: CIFile 'MDRcv -> CM (Maybe HistoryFile) getRcvFileInvDescr ciFile@CIFile {fileId, fileProtocol, fileStatus, fileExpires} = do expired <- fileExpired fileExpires if fileProtocol /= FPXFTP || fileStatus == CIFSRcvCancelled || expired then pure Nothing else do - rfd <- withStore $ \db -> getRcvFileDescrByRcvFileId db fileId - pure $ invCompleteDescr ciFile rfd - getSndFileInvDescr :: CIFile 'MDSnd -> CM (Maybe (FileInvitation, RcvFileDescrText, Maybe UTCTime)) + (rfd, (invBadge, descrBadge)) <- withStore $ \db -> do + rfd <- getRcvFileDescrByRcvFileId db fileId + (rfd,) <$> liftIO (getFileBadgeProofs db fileId) + pure $ invCompleteDescr ciFile rfd invBadge descrBadge + getSndFileInvDescr :: CIFile 'MDSnd -> CM (Maybe HistoryFile) getSndFileInvDescr ciFile@CIFile {fileId, fileProtocol, fileStatus, fileExpires} = do expired <- fileExpired fileExpires if fileProtocol /= FPXFTP || fileStatus == CIFSSndCancelled || expired @@ -1433,28 +1463,54 @@ sendHistory user gInfo@GroupInfo {membership} m@GroupMember {activeConn = Just c else do -- can also lookup in extra_xftp_file_descriptions, though it can be empty; -- would be best if snd file had a single rcv description for all members saved in files table - rfd <- withStore $ \db -> getRcvFileDescrBySndFileId db fileId - pure $ invCompleteDescr ciFile rfd + now <- liftIO getCurrentTime + (rfd, (invBadge, descrBadge)) <- withStore $ \db -> do + rfd <- getRcvFileDescrBySndFileId db fileId + (rfd,) <$> liftIO (getFileBadgeProofs db fileId) + -- a signed item forwards the author's original bytes, so its invitation proof cannot be replaced + (invBadge', descrBadge') <- + if isNothing signedMsg_ && ownBadgeActive && (staleBadge now invBadge || staleBadge now descrBadge) + then refreshSndBadges fileId invBadge descrBadge + else pure (invBadge, descrBadge) + pure $ invCompleteDescr ciFile rfd invBadge' descrBadge' + staleBadge :: UTCTime -> Maybe BadgeProof -> Bool + staleBadge now = \case + Just BadgeProof {badgeInfo = BadgeInfo {badgeExpiry}} -> addUTCTime badgeSndGraceInterval badgeExpiry < now + Nothing -> False + ownBadgeActive :: Bool + ownBadgeActive = maybe False ((BSActive ==) . localBadgeStatus) localBadge + where + User {profile = LocalProfile {localBadge}} = user + -- both proofs are made with the same badge, and are re-made with the current badge over the stored headers + refreshSndBadges :: FileTransferId -> Maybe BadgeProof -> Maybe BadgeProof -> CM (Maybe BadgeProof, Maybe BadgeProof) + refreshSndBadges fileId invBadge descrBadge = do + invBadge' <- mapM reProve invBadge + descrBadge' <- mapM reProve descrBadge + withStore' $ \db -> do + forM_ invBadge' $ createFileBadgeProof db fileId BPKInvitation + forM_ descrBadge' $ createFileBadgeProof db fileId BPKDescription + pure (invBadge', descrBadge') + where + reProve badge@BadgeProof {presHeader} = fromMaybe badge <$> sndBadgeProof_ user presHeader fileExpired :: Maybe UTCTime -> CM Bool fileExpired fileExpires = do ttl <- asks $ rcvFilesTTL . agentConfig . config now <- liftIO getCurrentTime pure $ fromMaybe (addUTCTime ttl $ chatItemTs cci) fileExpires < now - invCompleteDescr :: CIFile d -> RcvFileDescr -> Maybe (FileInvitation, RcvFileDescrText, Maybe UTCTime) - invCompleteDescr CIFile {fileName, fileSize, fileExpires} RcvFileDescr {fileDescrText, fileDescrComplete} + invCompleteDescr :: CIFile d -> RcvFileDescr -> Maybe BadgeProof -> Maybe BadgeProof -> Maybe HistoryFile + invCompleteDescr CIFile {fileName, fileSize, fileExpires} RcvFileDescr {fileDescrText, fileDescrComplete} invBadge descrBadge | fileDescrComplete = - let fInvDescr = FileDescr {fileDescrText = "", fileDescrPartNo = 0, fileDescrComplete = False} - fInv = xftpFileInvitation fileName fileSize fInvDescr - in Just (fInv, fileDescrText, fileExpires) + let fInv = (xftpFileInvitation fileName fileSize dummyFileDescr :: FileInvitation) {fileBadge = invBadge} + in Just (fInv, fileDescrText, fileExpires, descrBadge) | otherwise = Nothing - processContentItem :: Maybe GroupMember -> ChatItem 'CTGroup d -> MsgContent -> Maybe (FileInvitation, RcvFileDescrText, Maybe UTCTime) -> CM [(GrpMsgForward, VerifiedMsg 'Json)] + processContentItem :: Maybe GroupMember -> ChatItem 'CTGroup d -> MsgContent -> Maybe HistoryFile -> CM [(GrpMsgForward, VerifiedMsg 'Json)] processContentItem member_ ChatItem {formattedText, meta, quotedItem, mentions} mc fInvDescr_ = if isNothing fInvDescr_ && not (msgContentHasText mc) then pure [] else do let CIMeta {itemTs, itemSharedMsgId, itemTimed, showGroupAsSender} = meta quotedItemId_ = quoteItemId =<< quotedItem - fInv_ = (\(fInv, _, _) -> fInv) <$> fInvDescr_ + fInv_ = (\(fInv, _, _, _) -> fInv) <$> fInvDescr_ (mc', _, mentions') = updatedMentionNames mc formattedText mentions mentions'' = M.map (\CIMention {memberId} -> MsgMention {memberId}) mentions' -- for channel messages default chat version range to membership range @@ -1473,10 +1529,10 @@ sendHistory user gInfo@GroupInfo {membership} m@GroupMember {activeConn = Just c (chatMsgEvent, _) <- withStore $ \db -> prepareGroupMsg db user gInfo Nothing showGroupAsSender mc' mentions'' quotedItemId_ Nothing fInv_ itemTimed False pure $ VMUnsigned ChatMessage {chatVRange = senderVRange, msgId = itemSharedMsgId, chatMsgEvent} fileDescrEvents <- case (fInvDescr_, itemSharedMsgId) of - (Just (_, fileDescrText, fileExpires), Just msgId) -> do + (Just (_, fileDescrText, fileExpires, descrBadge), Just msgId) -> do partSize <- asks $ xftpDescrPartSize . config - let parts = splitFileDescr partSize fileDescrText - pure . L.toList $ L.map (\fd -> XMsgFileDescr msgId fd fileExpires) parts + let parts = splitFileDescr partSize (maybe partSize (const badgeDescrPartSize) descrBadge) fileDescrText + pure . L.toList $ L.map (\fd@FileDescr {fileDescrComplete} -> XMsgFileDescr msgId fd fileExpires (if fileDescrComplete then descrBadge else Nothing)) parts _ -> pure [] let fileDescrVMs = map (VMUnsigned . ChatMessage senderVRange Nothing) fileDescrEvents pure $ map ((,) fwd) (contentVM : fileDescrVMs) @@ -1486,40 +1542,45 @@ memberShortenedName GroupMember {memberProfile = LocalProfile {displayName}} | T.length displayName <= 16 = displayName | otherwise = T.take 16 displayName `T.snoc` '…' -splitFileDescr :: Int -> RcvFileDescrText -> NonEmpty FileDescr -splitFileDescr partSize rfdText = splitParts 1 rfdText +-- the description proof travels on the last part, so that part leaves room for it +badgeDescrPartSize :: Int +badgeDescrPartSize = 13500 + +splitFileDescr :: Int -> Int -> RcvFileDescrText -> NonEmpty FileDescr +splitFileDescr partSize lastSize rfdText = splitParts 1 rfdText where splitParts partNo remText = - let (part, rest) = T.splitAt partSize remText + let n = T.length remText + (part, rest) = T.splitAt (if n <= lastSize then n else if n <= partSize then lastSize else partSize) remText complete = T.null rest fileDescr = FileDescr {fileDescrText = part, fileDescrPartNo = partNo, fileDescrComplete = complete} in if complete then fileDescr :| [] else fileDescr <| splitParts (partNo + 1) rest -setGroupLinkData' :: NetworkRequestMode -> User -> GroupInfo -> CM (Maybe GroupLink) -setGroupLinkData' nm user gInfo = - withFastStore' (\db -> runExceptT $ getGroupLink db user gInfo) >>= \case +setGroupLinkData' :: NetworkRequestMode -> User -> GroupInfoKeys -> CM (Maybe GroupLink) +setGroupLinkData' nm user gInfo@(GIK g _) = + withFastStore' (\db -> runExceptT $ getGroupLink db user g) >>= \case Right gLink@GroupLink {shortLinkDataSet} | shortLinkDataSet -> Just <$> setGroupLinkData nm user gInfo gLink _ -> pure Nothing -setGroupLinkData :: NetworkRequestMode -> User -> GroupInfo -> GroupLink -> CM GroupLink -setGroupLinkData nm user gInfo gLink = do +setGroupLinkData :: NetworkRequestMode -> User -> GroupInfoKeys -> GroupLink -> CM GroupLink +setGroupLinkData nm user g@(GIK gInfo _) gLink = do cxt <- chatStoreCxt (conn, groupRelays) <- withFastStore $ \db -> (,) <$> getGroupLinkConnection db cxt user gInfo <*> liftIO (getPublishableGroupRelays db cxt user gInfo) - let (userLinkData, crClientData) = groupLinkData gInfo gLink groupRelays + let (userLinkData, crClientData) = groupLinkData g gLink groupRelays linkType = if useRelays' gInfo then CCTChannel else CCTGroup sLnk <- shortenShortLink' . setShortLinkType_ linkType =<< withAgent (\a -> setConnShortLink a nm (aConnId conn) SCMContact userLinkData (Just crClientData) False Nothing) withFastStore' $ \db -> setGroupLinkShortLink db gLink sLnk -setGroupLinkDataAsync :: User -> GroupInfo -> GroupLink -> CM () -setGroupLinkDataAsync user gInfo gLink = do +setGroupLinkDataAsync :: User -> GroupInfoKeys -> GroupLink -> CM () +setGroupLinkDataAsync user g@(GIK gInfo _) gLink = do cxt <- chatStoreCxt (conn, groupRelays) <- withStore $ \db -> (,) <$> getGroupLinkConnection db cxt user gInfo <*> liftIO (getPublishableGroupRelays db cxt user gInfo) - let (userLinkData, crClientData) = groupLinkData gInfo gLink groupRelays + let (userLinkData, crClientData) = groupLinkData g gLink groupRelays setAgentConnShortLinkAsync user conn userLinkData (Just crClientData) connectToRelayAsync :: User -> GroupInfo -> ShortLinkContact -> CM () @@ -1534,15 +1595,15 @@ connectToRelayAsync user gInfo relayLink = do newConnIds <- getAgentConnShortLinkAsync user CFGetRelayDataJoin Nothing relayLink withFastStore' $ \db -> createRelayMemberConnectionAsync db user gInfo relayMember relayLink newConnIds subMode -updatePublicGroupData :: User -> GroupInfo -> CM GroupInfo -updatePublicGroupData user gInfo +updatePublicGroupData :: User -> GroupInfo -> GroupKeys -> CM GroupInfo +updatePublicGroupData user gInfo gks | useRelays' gInfo && memberRole' (membership gInfo) == GROwner = do cxt <- chatStoreCxt (gInfo', gLink) <- withStore $ \db -> do gInfo' <- updatePublicMemberCount db cxt user gInfo gLink <- getGroupLink db user gInfo' pure (gInfo', gLink) - setGroupLinkDataAsync user gInfo' gLink + setGroupLinkDataAsync user (GIK gInfo' gks) gLink pure gInfo' | useRelays' gInfo && isRelay (membership gInfo) = do cxt <- chatStoreCxt @@ -1586,14 +1647,14 @@ updateContactFromLinkData user ct@Contact {profile = profile@LocalProfile {conta verifyChanged = contactDomainVerified /= Just True || claimChanged -- TODO [relays] owner: set owners on updating link data (multi-owner) -groupLinkData :: GroupInfo -> GroupLink -> [GroupRelay] -> (UserConnLinkData 'CMContact, CRClientData) -groupLinkData gInfo@GroupInfo {groupProfile, groupSummary = GroupSummary {publicMemberCount}, membership = GroupMember {memberId}, groupKeys} GroupLink {groupLinkId} groupRelays = +groupLinkData :: GroupInfoKeys -> GroupLink -> [GroupRelay] -> (UserConnLinkData 'CMContact, CRClientData) +groupLinkData (GIK gInfo@GroupInfo {groupProfile, groupSummary = GroupSummary {publicMemberCount}, membership = GroupMember {memberId}} gks) GroupLink {groupLinkId} groupRelays = let direct = not $ useRelays' gInfo relays = mapMaybe (\GroupRelay {relayLink} -> relayLink) groupRelays publicGroupData_ = PublicGroupData <$> publicMemberCount userData = encodeShortLinkData $ GroupShortLinkData {groupProfile, publicGroupData = publicGroupData_} - owners = case groupKeys of - Just GroupKeys {groupRootKey = GRKPrivate rootPrivKey, memberPrivKey} -> + owners = case gks of + GKPublicGroup {groupRootKey = GRKPrivate rootPrivKey, memberPrivKey} -> let ownerId = unMemberId memberId ownerKey = C.publicKey memberPrivKey authOwnerSig = C.sign' rootPrivKey (ownerId <> C.encodePubKey ownerKey) @@ -2247,28 +2308,105 @@ createSndMessages idsEvents = do encodeMessage sharedMsgId = encodeChatMessage maxEncodedMsgLength ChatMessage {chatVRange = vr, msgId = Just sharedMsgId, chatMsgEvent = evnt} -groupMsgSigning :: Bool -> GroupInfo -> ChatMsgEvent e -> Maybe MsgSigning -groupMsgSigning sign gInfo@GroupInfo {membership = GroupMember {memberId}, groupKeys = Just GroupKeys {publicGroupId, memberPrivKey}} evt - | useRelays' gInfo && shouldSign = - Just $ MsgSigning CBGroup (smpEncode (publicGroupId, memberId)) KRMember memberPrivKey +groupMsgSigning :: Bool -> GroupInfoKeys -> ChatMsgEvent e -> Maybe MsgSigning +groupMsgSigning sign (GIK gInfo@GroupInfo {membership = GroupMember {memberId}} gks) evt + | shouldSign = Just $ MsgSigning CBGroup bindingData KRMember memberPrivKey' + | otherwise = Nothing where + memberPrivKey' = memberPrivKey gks tag = toCMEventTag evt shouldSign = requiresSignature tag || (sign && signableContent tag) -groupMsgSigning _ _ _ = Nothing + bindingData = groupBindingData gInfo memberId (C.publicKey memberPrivKey') -sendGroupMemberMessages :: forall e. MsgEncodingI e => User -> GroupInfo -> Connection -> NonEmpty (ChatMsgEvent e) -> CM () -sendGroupMemberMessages user gInfo@GroupInfo {groupId} conn events = do +groupBindingData :: GroupInfo -> MemberId -> C.PublicKeyEd25519 -> ByteString +groupBindingData gInfo memberId memberKey = case publicGroup' gInfo of + Just PublicGroupProfile {publicGroupId} -> smpEncode (publicGroupId, memberId) + Nothing -> smpEncode (memberId, memberKey) + +type HistoryFile = (FileInvitation, RcvFileDescrText, Maybe UTCTime, Maybe BadgeProof) + +directChatBinding :: Contact -> CM (Maybe ByteString) +directChatBinding ct = + forM (contactConn ct) $ \conn -> + encodeChatBinding CBDirect <$> withAgent (`getConnectionRatchetAdHash` aConnId conn) + +rcvGroupChatBinding :: GroupInfo -> Maybe GroupMember -> ShowGroupAsSender -> Maybe BadgeProof -> Maybe ByteString +rcvGroupChatBinding gInfo m_ asGroup badge_ = + case (publicGroup' gInfo, asGroup, m_) of + (Just PublicGroupProfile {publicGroupId}, True, _) -> + Just $ encodeChatBinding CBChannel $ smpEncode publicGroupId + (Just PublicGroupProfile {publicGroupId}, False, Just GroupMember {memberId}) -> + Just $ encodeChatBinding CBGroup $ smpEncode (publicGroupId, memberId) + (Nothing, False, Just GroupMember {memberId, memberPubKey}) -> + (\k -> encodeChatBinding CBGroup $ smpEncode (memberId, k)) <$> (memberPubKey <|> proofMemberKey memberId badge_) + _ -> Nothing + +proofMemberKey :: MemberId -> Maybe BadgeProof -> Maybe C.PublicKeyEd25519 +proofMemberKey memberId badge_ = do + BadgeProof _ (BBSPresHeader phBytes) _ _ <- badge_ + binding <- headerChatBinding =<< eitherToMaybe (strDecode phBytes) + d <- B.stripPrefix (smpEncode CBGroup) binding + (mId, k) <- eitherToMaybe (smpDecode d :: Either String (MemberId, C.PublicKeyEd25519)) + if mId == memberId then Just k else Nothing + where + headerChatBinding = \case + PHFileInv {chatBinding} -> Just chatBinding + PHFileDescr {chatBinding} -> Just chatBinding + _ -> Nothing + +badgeProofStatus :: Maybe ProofPresHeader -> BadgeProof -> CM BadgeStatus +badgeProofStatus expected_ badge@BadgeProof {presHeader = BBSPresHeader phBytes, badgeInfo} = + case expected_ of + Just expected | phBytes == strEncode expected -> do + keys <- asks $ badgePublicKeys . config + verified <- liftIO $ verifyBadge keys badge + now <- liftIO getCurrentTime + pure $ mkBadgeStatus now verified badgeInfo + _ -> pure BSFailed + +rcvDirectFileProhibited :: Contact -> FileInvitation -> CM (Maybe FileProhibited) +rcvDirectFileProhibited ct fInv@FileInvitation {fileBadge} = do + binding_ <- if isJust fileBadge then directChatBinding ct else pure Nothing + rcvFileProhibited binding_ fInv + +rcvGroupFileProhibited :: GroupInfo -> Maybe GroupMember -> ShowGroupAsSender -> FileInvitation -> CM (Maybe FileProhibited) +rcvGroupFileProhibited gInfo m_ asGroup fInv@FileInvitation {fileBadge} = + rcvFileProhibited (rcvGroupChatBinding gInfo m_ asGroup fileBadge) fInv + +rcvFileProhibited :: Maybe ByteString -> FileInvitation -> CM (Maybe FileProhibited) +rcvFileProhibited binding_ FileInvitation {fileSize, fileBadge} = do + lims <- asks $ fileSizeLimits . config + if fileSize <= noBadge lims + then pure Nothing + else case fileBadge of + Nothing -> pure $ Just FileProhibited {maxSize = noBadge lims, badgeStatus = Nothing} + Just badge -> do + st <- badgeProofStatus ((\chatBinding -> PHFileInv {chatBinding, fileSize = fromInteger fileSize}) <$> binding_) badge + let maxSize = maxXFTPFileSize lims $ Just $ PeerBadge badge st + pure $ + if fileSize <= maxSize + then Nothing + else Just FileProhibited {maxSize, badgeStatus = Just st} + +groupMemberKey :: GroupKeys -> MemberKey +groupMemberKey gks = MemberKey $ C.publicKey $ memberPrivKey gks + +sendGroupMemberMessages :: forall e. MsgEncodingI e => User -> GroupInfoKeys -> Connection -> NonEmpty (ChatMsgEvent e) -> CM () +sendGroupMemberMessages user g@(GIK gInfo@GroupInfo {groupId} _) conn events = do when (connDisabled conn) $ throwChatError (CEConnectionDisabled conn) - let idsEvts = L.map (\evt -> (GroupId groupId, groupMsgSigning False gInfo evt, evt)) events - mode = if useRelays' gInfo then BMBinary else BMJson + let idsEvts = L.map (\evt -> (GroupId groupId, groupMsgSigning False g evt, evt)) events (errs, msgs) <- lift $ partitionEithers . L.toList <$> createSndMessages idsEvts unless (null errs) $ toView $ CEvtChatErrors errs forM_ (L.nonEmpty msgs) $ \msgs' -> - batchSendConnMessages mode user conn MsgFlags {notification = True} msgs' + batchSendConnMessages gInfo user conn MsgFlags {notification = True} msgs' -batchSendConnMessages :: BatchMode -> User -> Connection -> MsgFlags -> NonEmpty SndMessage -> CM ([Either ChatError SndMessage], Maybe PQEncryption) -batchSendConnMessages mode user conn msgFlags msgs = +batchSendConnMessages :: GroupInfo -> User -> Connection -> MsgFlags -> NonEmpty SndMessage -> CM ([Either ChatError SndMessage], Maybe PQEncryption) +batchSendConnMessages gInfo user conn msgFlags msgs = batchSendConnMessagesB mode user conn msgFlags $ L.map Right msgs + where + mode + | useRelays' gInfo || maxVersion (peerChatVRange conn) >= relayWebCapVersion = BMBinary + | otherwise = BMJson batchSendConnMessagesB :: BatchMode -> User -> Connection -> MsgFlags -> NonEmpty (Either ChatError SndMessage) -> CM ([Either ChatError SndMessage], Maybe PQEncryption) batchSendConnMessagesB mode _user conn msgFlags msgs_ = do @@ -2323,14 +2461,13 @@ encodeSignedConnInfo signing chatMsgEvent = do -- signed XMember for a relay-group join: proves the joiner holds the member key it asserts, and carries -- viaRelay = the target relay's memberId inside the signed body so a sibling relay can't accept a replay -encodeXMemberConnInfo :: GroupInfo -> MemberId -> Profile -> CM ByteString -encodeXMemberConnInfo GroupInfo {membership = GroupMember {memberId}, groupKeys} relayMemberId profileToSend = - case groupKeys of - Just GroupKeys {publicGroupId, memberPrivKey} -> - let xMemberEvt = XMember profileToSend memberId (MemberKey $ C.publicKey memberPrivKey) (Just relayMemberId) - signing = MsgSigning CBGroup (smpEncode (publicGroupId, memberId)) KRMember memberPrivKey - in encodeSignedConnInfo signing xMemberEvt - Nothing -> throwChatError $ CEInternalError "no group keys for channel membership" +encodeXMemberConnInfo :: GroupInfoKeys -> MemberId -> Profile -> CM ByteString +encodeXMemberConnInfo (GIK gInfo@GroupInfo {membership = GroupMember {memberId}} gks) relayMemberId profileToSend = + let memberPrivKey' = memberPrivKey gks + xMemberEvt = XMember profileToSend memberId (MemberKey $ C.publicKey memberPrivKey') (Just relayMemberId) + bindingData = groupBindingData gInfo memberId (C.publicKey memberPrivKey') + signing = MsgSigning CBGroup bindingData KRMember memberPrivKey' + in encodeSignedConnInfo signing xMemberEvt deliverMessage :: Connection -> CMEventTag e -> MsgBody -> MessageId -> CM (Int64, PQEncryption) deliverMessage conn cmEventTag msgBody msgId = do @@ -2393,13 +2530,13 @@ deliverMessagesB msgReqs = do where updatePQ = updateConnPQSndEnabled db connId pqSndEnabled' -sendGroupMessage :: MsgEncodingI e => User -> GroupInfo -> Maybe GroupChatScope -> [GroupMember] -> Bool -> ChatMsgEvent e -> CM SndMessage +sendGroupMessage :: MsgEncodingI e => User -> GroupInfoKeys -> Maybe GroupChatScope -> [GroupMember] -> Bool -> ChatMsgEvent e -> CM SndMessage sendGroupMessage user gInfo gcScope members sign chatMsgEvent = do sendGroupMessages user gInfo gcScope False members sign (chatMsgEvent :| []) >>= \case ((Right msg) :| [], _) -> pure msg _ -> throwChatError $ CEInternalError "sendGroupMessage: expected 1 message" -sendGroupMessage' :: MsgEncodingI e => User -> GroupInfo -> [GroupMember] -> ChatMsgEvent e -> CM SndMessage +sendGroupMessage' :: MsgEncodingI e => User -> GroupInfoKeys -> [GroupMember] -> ChatMsgEvent e -> CM SndMessage sendGroupMessage' user gInfo members chatMsgEvent = sendGroupMessages_ user gInfo members False (chatMsgEvent :| []) >>= \case ((Right msg) :| [], _) -> pure msg @@ -2425,8 +2562,8 @@ applyRosterDelta delta current = case delta of -- advances past a version the owner hasn't recorded), then broadcast the matching blob with the change projected -- onto the served roster (so it excludes demoted/removed members). Returns the reserved version for the delta -- that follows. The blob send is best-effort - a failed send heals on the next change or on resume. -broadcastRoster :: User -> GroupInfo -> RosterDelta -> CM VersionRoster -broadcastRoster user gInfo delta = do +broadcastRoster :: User -> GroupInfoKeys -> RosterDelta -> CM VersionRoster +broadcastRoster user g@(GIK gInfo _) delta = do let rosterVer = maybe (VersionRoster 0) (\(VersionRoster n) -> VersionRoster (n + 1)) (rosterVersion gInfo) withStore' $ \db -> setGroupRosterVersion db gInfo rosterVer sendRosterBlob rosterVer `catchAllErrors` eToView @@ -2437,18 +2574,18 @@ broadcastRoster user gInfo delta = do (relays, rosterMems) <- withStore' $ \db -> (,) <$> getGroupRelayMembers db cxt user gInfo <*> getGroupRosterMembers db cxt user gInfo forM_ (L.nonEmpty relays) $ \relays' -> - sendRoster user gInfo (L.toList relays') rosterVer (buildGroupRoster $ applyRosterDelta delta rosterMems) + sendRoster user g (L.toList relays') rosterVer (buildGroupRoster $ applyRosterDelta delta rosterMems) -- Send the current roster (no version bump) to a newly added relay so it can serve joiners. -sendGroupRosterToRelay :: User -> GroupInfo -> GroupMember -> CM () -sendGroupRosterToRelay user gInfo relayMember = +sendGroupRosterToRelay :: User -> GroupInfoKeys -> GroupMember -> CM () +sendGroupRosterToRelay user g@(GIK gInfo _) relayMember = forM_ (rosterVersion gInfo) $ \rosterVer -> do cxt <- chatStoreCxt rosterMems <- withStore' $ \db -> getGroupRosterMembers db cxt user gInfo - sendRoster user gInfo [relayMember] rosterVer (buildGroupRoster rosterMems) + sendRoster user g [relayMember] rosterVer (buildGroupRoster rosterMems) -- Row-less send (no files/snd_files rows, so no send-side cleanup); redelivery is the agent's. -sendRoster :: User -> GroupInfo -> [GroupMember] -> VersionRoster -> [RosterMember] -> CM () +sendRoster :: User -> GroupInfoKeys -> [GroupMember] -> VersionRoster -> [RosterMember] -> CM () sendRoster user gInfo members rosterVer roster = do let blob = encodeRosterBlob roster fileInv = InlineFileInvitation {fileSize = fromIntegral (B.length blob), fileDigest = FD.FileDigest $ LC.sha512Hash $ LB.fromStrict blob} @@ -2456,7 +2593,7 @@ sendRoster user gInfo members rosterVer roster = do sendInlineBlobChunks user gInfo members sharedMsgId blob -- Send a binary blob as BFileChunks under a shared_msg_id to the given members (chunked by fileChunkSize). -sendInlineBlobChunks :: User -> GroupInfo -> [GroupMember] -> SharedMsgId -> ByteString -> CM () +sendInlineBlobChunks :: User -> GroupInfoKeys -> [GroupMember] -> SharedMsgId -> ByteString -> CM () sendInlineBlobChunks user gInfo members sharedMsgId blob = do chSize <- fromIntegral <$> asks (fileChunkSize . config) go chSize 1 blob @@ -2469,8 +2606,8 @@ sendInlineBlobChunks user gInfo members sharedMsgId blob = do -- Relay advertises its current web preview capability to channel owners. -- Idempotent: sends only when the configured web domain differs from what was last sent, and only to -- owners whose recorded chat version supports relayWebCapVersion (older apps can't parse XGrpRelayCap). -sendRelayCapIfNeeded :: User -> GroupInfo -> CM () -sendRelayCapIfNeeded user gInfo = do +sendRelayCapIfNeeded :: User -> GroupInfoKeys -> CM () +sendRelayCapIfNeeded user g@(GIK gInfo _) = do ChatConfig {webPreviewConfig} <- asks config let currentWebDomain = (\WebPreviewConfig {webDomain} -> webDomain) <$> webPreviewConfig sentWebDomain <- withStore' (`getRelaySentWebDomain` gInfo) @@ -2479,22 +2616,22 @@ sendRelayCapIfNeeded user gInfo = do owners <- withStore' $ \db -> getGroupOwners db cxt user gInfo let capableOwners = filter (\m -> memberCurrent m && m `supportsVersion` relayWebCapVersion) owners unless (null capableOwners) $ do - void $ sendGroupMessage' user gInfo capableOwners (XGrpRelayCap RelayCapabilities {webDomain = currentWebDomain}) + void $ sendGroupMessage' user g capableOwners (XGrpRelayCap RelayCapabilities {webDomain = currentWebDomain}) withStore' $ \db -> updateRelaySentWebDomain db gInfo currentWebDomain -sendGroupMessages :: MsgEncodingI e => User -> GroupInfo -> Maybe GroupChatScope -> ShowGroupAsSender -> [GroupMember] -> Bool -> NonEmpty (ChatMsgEvent e) -> CM (NonEmpty (Either ChatError SndMessage), GroupSndResult) +sendGroupMessages :: MsgEncodingI e => User -> GroupInfoKeys -> Maybe GroupChatScope -> ShowGroupAsSender -> [GroupMember] -> Bool -> NonEmpty (ChatMsgEvent e) -> CM (NonEmpty (Either ChatError SndMessage), GroupSndResult) sendGroupMessages user gInfo scope asGroup members sign events = do sendGroupProfileUpdate user gInfo scope asGroup members sendGroupMessages_ user gInfo members sign events -- per-item signer variant of sendGroupMessages (used for per-item delete signing); preserves the profile-update prelude -sendGroupSignedMessages :: MsgEncodingI e => User -> GroupInfo -> Maybe GroupChatScope -> ShowGroupAsSender -> [GroupMember] -> NonEmpty (Maybe MsgSigning, ChatMsgEvent e) -> CM (NonEmpty (Either ChatError SndMessage), GroupSndResult) -sendGroupSignedMessages user gInfo scope asGroup members signedEvents = do +sendGroupSignedMessages :: MsgEncodingI e => User -> GroupInfoKeys -> Maybe GroupChatScope -> ShowGroupAsSender -> [GroupMember] -> NonEmpty (Maybe MsgSigning, ChatMsgEvent e) -> CM (NonEmpty (Either ChatError SndMessage), GroupSndResult) +sendGroupSignedMessages user gInfo@(GIK g _) scope asGroup members signedEvents = do sendGroupProfileUpdate user gInfo scope asGroup members - sendGroupSignedMessages_ gInfo members signedEvents + sendGroupSignedMessages_ g members signedEvents -sendGroupProfileUpdate :: User -> GroupInfo -> Maybe GroupChatScope -> ShowGroupAsSender -> [GroupMember] -> CM () -sendGroupProfileUpdate user gInfo scope asGroup members = +sendGroupProfileUpdate :: User -> GroupInfoKeys -> Maybe GroupChatScope -> ShowGroupAsSender -> [GroupMember] -> CM () +sendGroupProfileUpdate user g@(GIK gInfo gks) scope asGroup members = -- TODO [knocking] send current profile to pending member after approval? when shouldSendProfileUpdate $ sendProfileUpdate `catchAllErrors` eToView @@ -2513,7 +2650,7 @@ sendGroupProfileUpdate user gInfo scope asGroup members = sendProfileUpdate = do -- shouldSendProfileUpdate excludes incognito membership, so the badge is presented profileUpdate <- presentUserBadge user Nothing $ redactedMemberProfile gInfo (membership gInfo) $ fromLocalProfile p - void $ sendGroupMessage' user gInfo members $ XInfo profileUpdate + void $ sendGroupMessage' user g members $ XInfo profileUpdate (Just $ groupMemberKey gks) currentTs <- liftIO getCurrentTime withStore' $ \db -> updateUserMemberProfileSentAt db user gInfo currentTs @@ -2523,9 +2660,9 @@ data GroupSndResult = GroupSndResult forwarded :: [GroupMember] } -sendGroupMessages_ :: MsgEncodingI e => User -> GroupInfo -> [GroupMember] -> Bool -> NonEmpty (ChatMsgEvent e) -> CM (NonEmpty (Either ChatError SndMessage), GroupSndResult) -sendGroupMessages_ _user gInfo recipientMembers sign events = - sendGroupSignedMessages_ gInfo recipientMembers $ L.map (\evt -> (groupMsgSigning sign gInfo evt, evt)) events +sendGroupMessages_ :: MsgEncodingI e => User -> GroupInfoKeys -> [GroupMember] -> Bool -> NonEmpty (ChatMsgEvent e) -> CM (NonEmpty (Either ChatError SndMessage), GroupSndResult) +sendGroupMessages_ _user gInfo@(GIK g _) recipientMembers sign events = + sendGroupSignedMessages_ g recipientMembers $ L.map (\evt -> (groupMsgSigning sign gInfo evt, evt)) events sendGroupSignedMessages_ :: MsgEncodingI e => GroupInfo -> [GroupMember] -> NonEmpty (Maybe MsgSigning, ChatMsgEvent e) -> CM (NonEmpty (Either ChatError SndMessage), GroupSndResult) sendGroupSignedMessages_ gInfo@GroupInfo {groupId} recipientMembers signedEvents = do @@ -2533,7 +2670,7 @@ sendGroupSignedMessages_ gInfo@GroupInfo {groupId} recipientMembers signedEvents recipientMembers' <- liftIO $ shuffleMembers recipientMembers let msgFlags = MsgFlags {notification = any (hasNotification . toCMEventTag) events} (toSend, toPending, forwarded, _, dups) = - foldr' (addMember recipientMembers') ([], [], [], S.empty, 0 :: Int) recipientMembers' + foldr' (addMember recipientMembers') (([], []), [], [], S.empty, 0 :: Int) recipientMembers' when (dups /= 0) $ logError $ "sendGroupMessages_: " <> tshow dups <> " duplicate members" -- TODO PQ either somehow ensure that group members connections cannot have pqSupport/pqEncryption or pass Off's here -- Deliver to toSend members @@ -2557,26 +2694,30 @@ sendGroupSignedMessages_ gInfo@GroupInfo {groupId} recipientMembers signedEvents liftM2 (<>) (shuffle adminMs) (shuffle otherMs) where isAdmin GroupMember {memberRole} = memberRole >= GRAdmin - addMember members m acc@(toSend, pending, forwarded, !mIds, !dups) = + addMember members m acc@(toSend@(toSendBin, toSendJson), pending, forwarded, !mIds, !dups) = case memberSendAction gInfo events members m of Just a | mId `S.member` mIds -> (toSend, pending, forwarded, mIds, dups + 1) | otherwise -> case a of - MSASend conn -> ((m, conn) : toSend, pending, forwarded, mIds', dups) + MSASend conn -> + let toSend' = case batchMode gInfo m of + BMBinary -> ((m, conn) : toSendBin, toSendJson) + BMJson -> (toSendBin, (m, conn) : toSendJson) + in (toSend', pending, forwarded, mIds', dups) MSAPending -> (toSend, m : pending, forwarded, mIds', dups) MSAForwarded -> (toSend, pending, m : forwarded, mIds', dups) Nothing -> acc where mId = groupMemberId' m mIds' = S.insert mId mIds - prepareMsgReqs :: MsgFlags -> NonEmpty (Either ChatError SndMessage) -> [(GroupMember, Connection)] -> ([GroupMemberId], [Either ChatError ChatMsgReq]) - prepareMsgReqs msgFlags msgs toSend = do - let mode = if useRelays' gInfo then BMBinary else BMJson - batched_ = batchSndMessagesJSON mode msgs - case L.nonEmpty batched_ of - Just batched' -> foldMembers (length batched' + length msgs) msgBatchMBR batched' toSend - Nothing -> ([], []) + prepareMsgReqs :: MsgFlags -> NonEmpty (Either ChatError SndMessage) -> ([(GroupMember, Connection)], [(GroupMember, Connection)]) -> ([GroupMemberId], [Either ChatError ChatMsgReq]) + prepareMsgReqs msgFlags msgs (toSendBin, toSendJson) = + batchReqs 1 BMBinary toSendBin <> batchReqs 2 BMJson toSendJson where + batchReqs _ _ [] = ([], []) + batchReqs n mode toSend' = case L.nonEmpty (batchSndMessagesJSON mode msgs) of + Just batched -> foldMembers (n * (length batched + length msgs)) msgBatchMBR batched toSend' + Nothing -> ([], []) foldMembers :: forall a. Int -> (Maybe Int -> Int -> a -> (ValueOrRef MsgBody, [MessageId])) -> NonEmpty (Either ChatError a) -> [(GroupMember, Connection)] -> ([GroupMemberId], [Either ChatError ChatMsgReq]) foldMembers lastRef mkMb mbs mems = snd $ foldr' foldMsgBodies (lastMemIdx_, ([], [])) mems where @@ -2609,6 +2750,11 @@ sendGroupSignedMessages_ gInfo@GroupInfo {groupId} recipientMembers signedEvents createPendingMsg db (groupMemberId, msgId) = createPendingGroupMessage db groupMemberId msgId $> Right () +batchMode :: GroupInfo -> GroupMember -> BatchMode +batchMode gInfo m + | useRelays' gInfo || m `supportsVersion` relayWebCapVersion = BMBinary + | otherwise = BMJson + data MemberSendAction = MSASend Connection | MSAPending | MSAForwarded memberSendAction :: GroupInfo -> NonEmpty (ChatMsgEvent e) -> [GroupMember] -> GroupMember -> Maybe MemberSendAction @@ -2682,10 +2828,9 @@ sendFwdMemberMessage member fwd verifiedMsg = -- TODO ensure order - pending messages interleave with user input messages sendPendingGroupMessages :: User -> GroupInfo -> GroupMember -> Connection -> CM () sendPendingGroupMessages user gInfo GroupMember {groupMemberId} conn = do - let mode = if useRelays' gInfo then BMBinary else BMJson msgs <- withStore' $ \db -> getPendingGroupMessages db groupMemberId forM_ (L.nonEmpty msgs) $ \msgs' -> do - void $ batchSendConnMessages mode user conn MsgFlags {notification = True} msgs' + void $ batchSendConnMessages gInfo user conn MsgFlags {notification = True} msgs' lift . void . withStoreBatch' $ \db -> L.map (\SndMessage {msgId} -> deletePendingGroupMessage db groupMemberId msgId) msgs' saveDirectRcvMSG :: forall e. MsgEncodingI e => Connection -> MsgMeta -> ChatMessage e -> CM (Connection, RcvMessage) @@ -2890,10 +3035,19 @@ joinAgentConnectionAsync :: CommandId -> Bool -> ConnId -> Bool -> ConnectionReq joinAgentConnectionAsync cmdId updateConn connId enableNtfs cReqUri cInfo subMode = withAgent $ \a -> joinConnectionAsync a (aCorrId cmdId) updateConn connId enableNtfs cReqUri cInfo PQSupportOff subMode -allowAgentConnectionAsync :: MsgEncodingI e => User -> Connection -> ConfirmationId -> ChatMsgEvent e -> CM () -allowAgentConnectionAsync user conn@Connection {connId, pqSupport} confId msg = do +allowAgentConnectionAsync :: MsgEncodingI e => User -> Connection -> ConfirmationId -> Maybe GroupInfoKeys -> ChatMsgEvent e -> CM () +allowAgentConnectionAsync user conn@Connection {pqSupport} confId gInfo_ msg = do + let signing_ = case gInfo_ of + Just gInfo@(GIK g _) | useRelays' g || maxVersion (peerChatVRange conn) >= relayWebCapVersion -> groupMsgSigning False gInfo msg + _ -> Nothing + dm <- case signing_ of + Just signing -> encodeSignedConnInfo signing msg + Nothing -> encodeConnInfoPQ pqSupport msg + allowAgentConnectionInfo user conn confId dm + +allowAgentConnectionInfo :: User -> Connection -> ConfirmationId -> ByteString -> CM () +allowAgentConnectionInfo user conn@Connection {connId} confId dm = do cmdId <- withStore' $ \db -> createCommand db user (Just connId) CFAllowConn - dm <- encodeConnInfoPQ pqSupport msg withAgent $ \a -> allowConnectionAsync a (aCorrId cmdId) (aConnId conn) confId dm withStore' $ \db -> updateConnectionStatus db conn ConnAccepted @@ -3127,7 +3281,7 @@ createChatItems :: createChatItems user itemTs_ dirsCIContents = do createdAt <- liftIO getCurrentTime let itemTs = fromMaybe createdAt itemTs_ - cxt <- chatStoreCxt' + cxt <- asks storeCxt void . withStoreBatch' $ \db -> map (updateChat db cxt createdAt) dirsCIContents withStoreBatch' $ \db -> concatMap (createACIs db itemTs createdAt) dirsCIContents where @@ -3225,13 +3379,9 @@ waitChatStartedAndActivated = do unless (isJust started && activated) retry chatStoreCxt :: CM StoreCxt -chatStoreCxt = lift chatStoreCxt' +chatStoreCxt = asks storeCxt {-# INLINE chatStoreCxt #-} -chatStoreCxt' :: CM' StoreCxt -chatStoreCxt' = mkStoreCxt <$> asks config -{-# INLINE chatStoreCxt' #-} - chatVersionRange :: CM VersionRangeChat chatVersionRange = lift chatVersionRange' {-# INLINE chatVersionRange #-} diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 47625a9fd2..2288763791 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -46,6 +46,7 @@ import Data.Time.Format (defaultTimeLocale, formatTime) import qualified Data.UUID as UUID import qualified Data.UUID.V4 as V4 import Data.Word (Word32) +import Simplex.Chat.Badges (BadgeProof, BadgeProofKind (..), BadgeStatus (..), FileSizeLimits (..), ProofPresHeader (..)) import Simplex.Chat.Call import Simplex.Chat.Controller import Simplex.Chat.Delivery @@ -76,7 +77,7 @@ import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.FileTransfer.Description (ValidFileDescription) import qualified Simplex.FileTransfer.Description as FD -import Simplex.FileTransfer.Protocol (FilePartyI, GrantedStorageTime (..)) +import Simplex.FileTransfer.Protocol (FileParty (..), FilePartyI, GrantedStorageTime (..)) import qualified Simplex.FileTransfer.Transport as XFTP import Simplex.FileTransfer.Types (FileErrorType (..), RcvFileId, SndFileId) import Simplex.Messaging.Agent @@ -112,11 +113,11 @@ import qualified Data.Aeson as J smallGroupsRcptsMemLimit :: Int smallGroupsRcptsMemLimit = 20 --- Verifies member signatures over CBGroup <> (publicGroupId, memberId) <> signedBody under the given key. +-- Verifies member signatures over CBGroup <> (publicGroupId, memberId) or (memberId, pubKey) <> signedBody under the given key. -- signatures is NonEmpty so the verification can't be vacuously true. -verifyGroupSig :: C.PublicKeyEd25519 -> B64UrlByteString -> MemberId -> NonEmpty MsgSignature -> ByteString -> Bool -verifyGroupSig key publicGroupId memberId signatures signedBody = - let prefix = smpEncode CBGroup <> smpEncode (publicGroupId, memberId) +verifyGroupSig :: C.PublicKeyEd25519 -> GroupInfo -> MemberId -> NonEmpty MsgSignature -> ByteString -> Bool +verifyGroupSig key gInfo memberId signatures signedBody = + let prefix = encodeChatBinding CBGroup $ groupBindingData gInfo memberId key in all (\case (MsgSignature KRMember sig) -> C.verify (C.APublicVerifyKey C.SEd25519 key) sig (prefix <> signedBody)) signatures processAgentMessage :: ACorrId -> ConnId -> AEvent 'AEConn -> CM () @@ -134,10 +135,38 @@ processAgentMessage corrId connId msg = do lockEntity <- critical connId (withStore (`getChatLockEntity` AgentConnId connId)) withEntityLock "processAgentMessage" lockEntity $ do cxt <- chatStoreCxt - -- getUserByAConnId never throws logical errors, only SEDBBusyError can be thrown here - critical connId (withStore' (`getUserByAConnId` AgentConnId connId)) >>= \case - Just user -> processAgentMessageConn cxt user corrId connId msg `catchAllErrors` eToView + -- Missing connection/entity errors here will be sent to the view but not shown as CRITICAL alert, + -- as in this case no need to ACK message - we can't process messages for this connection anyway. + critical connId (withStore $ getUserEntity cxt) >>= \case + Just (user, entity, gks_) -> processAgentMessageConn cxt user entity gks_ corrId connId msg `catchAllErrors` eToView _ -> throwChatError $ CENoConnectionUser (AgentConnId connId) + where + getUserEntity :: StoreCxt -> DB.Connection -> ExceptT StoreError IO (Maybe (User, ConnectionEntity, Maybe GroupKeys)) + getUserEntity cxt db = + liftIO (getUserByAConnId db $ AgentConnId connId) + >>= mapM (\user -> do + (entity, groupKeysData_) <- getConnectionEntityKeys db cxt user (AgentConnId connId) + gks_ <- case entity of + RcvGroupMsgConnection _ gInfo _ -> mapM (mkGroupKeys db cxt gInfo) groupKeysData_ + _ -> pure Nothing + (user,,gks_) <$> liftIO (updateConnStatus db entity)) + + updateConnStatus :: DB.Connection -> ConnectionEntity -> IO ConnectionEntity + updateConnStatus db acEntity = case agentMsgConnStatus (entityConnection acEntity) msg of + Just connStatus -> do + let conn = (entityConnection acEntity) {connStatus} + updateConnectionStatus db conn connStatus + pure $ updateEntityConnStatus acEntity connStatus + Nothing -> pure acEntity + + agentMsgConnStatus :: Connection -> AEvent e -> Maybe ConnStatus + agentMsgConnStatus Connection {connStatus = cs} = \case + JOINED True -> Just ConnSndReady + CONF {} -> Just ConnRequested + INFO {} -> Just ConnSndReady + CON _ -> Just ConnReady + ERR err | cs /= ConnReady && not (temporaryOrHostError err) -> Just $ ConnFailed (tshow err) + _ -> Nothing -- CRITICAL error will be shown to the user as alert with restart button in Android/desktop apps. -- SEDBBusyError will only be thrown on IO exceptions or SQLError during DB queries, @@ -224,7 +253,7 @@ processAgentMsgSndFile _corrId aFileId msg = do -- we have 1 chunk - use it as URI whether it is redirect or not ft' <- maybe (pure ft) (\fId -> withStore $ \db -> getFileTransferMeta db user fId) xftpRedirectFor toView $ CEvtSndStandaloneFileComplete user ft' $ map (decodeLatin1 . strEncode . FD.fileDescriptionURI) rfds' - Just (AChatItem _ d cInfo _ci@ChatItem {meta = CIMeta {itemSharedMsgId = msgId_, itemDeleted}}) -> + Just (AChatItem _ d cInfo _ci@ChatItem {meta = CIMeta {itemSharedMsgId = msgId_, itemDeleted, showGroupAsSender}}) -> case (msgId_, itemDeleted) of (Just sharedMsgId, Nothing) -> do when (length rfds < length sfts) $ throwChatError $ CEInternalError "not enough XFTP file descriptions to send" @@ -232,9 +261,16 @@ processAgentMsgSndFile _corrId aFileId msg = do toView $ CEvtSndFileProgressXFTP user ci ft 1 1 case (rfds, sfts, d, cInfo) of (rfd : extraRFDs, sft : _, SMDSnd, DirectChat ct) -> do - withStore' $ \db -> createExtraSndFTDescrs db user fileId (map fileDescrText extraRFDs) conn@Connection {connId} <- liftEither $ contactSendConn_ ct - sendFileDescriptions (ConnectionId connId) ((conn, sft, fileDescrText rfd) :| []) sharedMsgId fileExpires >>= \case + let FileTransferMeta {fileSize} = ft + binding_ <- ifM ((not (contactConnIncognito ct) &&) <$> fileNeedsBadge fileSize) (directChatBinding ct) (pure Nothing) + descrBadge <- pure binding_ $>>= \chatBinding -> + let FD.ValidFileDescription fd = sndDescr + in sndBadgeProof user PHFileDescr {chatBinding, fileSize = fromInteger fileSize, descrHash = FD.sharedDescriptionHash fd, fileExpires} + withStore' $ \db -> do + createExtraSndFTDescrs db user fileId (map fileDescrText extraRFDs) + forM_ descrBadge $ createFileBadgeProof db fileId BPKDescription + sendFileDescriptions (ConnectionId connId) ((conn, sft, fileDescrText rfd) :| []) sharedMsgId fileExpires descrBadge >>= \case Just rs -> case L.last rs of Right ([msgDeliveryId], _) -> withStore' $ \db -> updateSndFTDeliveryXFTP db sft msgDeliveryId @@ -247,9 +283,17 @@ processAgentMsgSndFile _corrId aFileId msg = do 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) + FileTransferMeta {fileSize} = ft + needsBadge <- fileNeedsBadge fileSize + let binding_ = if needsBadge && not (incognitoMembership g) then sndGroupChatBinding g showGroupAsSender else Nothing + descrBadge <- pure binding_ $>>= \chatBinding -> + let FD.ValidFileDescription fd = sndDescr + in sndBadgeProof user PHFileDescr {chatBinding, fileSize = fromInteger fileSize, descrHash = FD.sharedDescriptionHash fd, fileExpires} + withStore' $ \db -> do + createExtraSndFTDescrs db user fileId (map fileDescrText extraRFDs) + forM_ descrBadge $ createFileBadgeProof db fileId BPKDescription forM_ (L.nonEmpty rfdsMemberFTs) $ \rfdsMemberFTs' -> - sendFileDescriptions (GroupId groupId) rfdsMemberFTs' sharedMsgId fileExpires + sendFileDescriptions (GroupId groupId) rfdsMemberFTs' sharedMsgId fileExpires descrBadge ci' <- withStore $ \db -> do liftIO $ updateCIFileStatus db user fileId CIFSSndComplete getChatItemByFileId db cxt user fileId @@ -278,8 +322,8 @@ processAgentMsgSndFile _corrId aFileId msg = do where fileDescrText :: FilePartyI p => ValidFileDescription p -> T.Text fileDescrText = safeDecodeUtf8 . strEncode - sendFileDescriptions :: ConnOrGroupId -> NonEmpty (Connection, SndFileTransfer, RcvFileDescrText) -> SharedMsgId -> Maybe UTCTime -> CM (Maybe (NonEmpty (Either ChatError ([Int64], PQEncryption)))) - sendFileDescriptions connOrGroupId connsTransfersDescrs sharedMsgId fileExpires = do + sendFileDescriptions :: ConnOrGroupId -> NonEmpty (Connection, SndFileTransfer, RcvFileDescrText) -> SharedMsgId -> Maybe UTCTime -> Maybe BadgeProof -> CM (Maybe (NonEmpty (Either ChatError ([Int64], PQEncryption)))) + sendFileDescriptions connOrGroupId connsTransfersDescrs sharedMsgId fileExpires descrBadge = do lift . void . withStoreBatch' $ \db -> L.map (\(_, sft, rfdText) -> updateSndFTDescrXFTP db user sft rfdText) connsTransfersDescrs partSize <- asks $ xftpDescrPartSize . config let connsIdsEvts = connDescrEvents partSize @@ -295,7 +339,7 @@ processAgentMsgSndFile _corrId aFileId msg = do where splitText :: (Connection, SndFileTransfer, RcvFileDescrText) -> [(Connection, (ConnOrGroupId, Maybe MsgSigning, ChatMsgEvent 'Json))] splitText (conn, _, rfdText) = - map (\fileDescr -> (conn, (connOrGroupId, Nothing, XMsgFileDescr {msgId = sharedMsgId, fileDescr, fileExpires}))) (L.toList $ splitFileDescr partSize rfdText) + map (\fileDescr@FileDescr {fileDescrComplete} -> (conn, (connOrGroupId, Nothing, XMsgFileDescr {msgId = sharedMsgId, fileDescr, fileExpires, fileBadge = if fileDescrComplete then descrBadge else Nothing}))) (L.toList $ splitFileDescr partSize (maybe partSize (const badgeDescrPartSize) descrBadge) rfdText) toMsgReq :: (Connection, (ConnOrGroupId, Maybe MsgSigning, ChatMsgEvent 'Json)) -> SndMessage -> ChatMsgReq toMsgReq (conn, _) SndMessage {msgId, msgBody} = (conn, MsgFlags {notification = hasNotification XMsgFileDescr_}, (vrValue msgBody, [msgId])) @@ -392,11 +436,8 @@ processAgentMsgRcvFile _corrId aFileId msg = do type ShouldDeleteGroupConns = Bool -processAgentMessageConn :: StoreCxt -> User -> ACorrId -> ConnId -> AEvent 'AEConn -> CM () -processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = do - -- Missing connection/entity errors here will be sent to the view but not shown as CRITICAL alert, - -- as in this case no need to ACK message - we can't process messages for this connection anyway. - entity <- critical agentConnId $ withStore (\db -> getConnectionEntity db cxt user $ AgentConnId agentConnId) >>= updateConnStatus +processAgentMessageConn :: StoreCxt -> User -> ConnectionEntity -> Maybe GroupKeys -> ACorrId -> ConnId -> AEvent 'AEConn -> CM () +processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId agentMessage = case agentMessage of END -> case entity of RcvDirectMsgConnection _ (Just ct) -> toView $ CEvtContactAnotherClient user ct @@ -405,28 +446,12 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = _ -> case entity of RcvDirectMsgConnection conn contact_ -> processDirectMessage agentMessage entity conn contact_ - RcvGroupMsgConnection conn gInfo m -> - processGroupMessage agentMessage entity conn gInfo m + RcvGroupMsgConnection conn gInfo m -> case gks_ of + Just gks -> processGroupMessage agentMessage entity conn (GIK gInfo gks) m + Nothing -> throwChatError $ CEInternalError "group connection entity without group keys" UserContactConnection conn uc -> processContactConnMessage agentMessage entity conn uc where - updateConnStatus :: ConnectionEntity -> CM ConnectionEntity - updateConnStatus acEntity = case agentMsgConnStatus (entityConnection acEntity) agentMessage of - Just connStatus -> do - let conn = (entityConnection acEntity) {connStatus} - withStore' $ \db -> updateConnectionStatus db conn connStatus - pure $ updateEntityConnStatus acEntity connStatus - Nothing -> pure acEntity - - agentMsgConnStatus :: Connection -> AEvent e -> Maybe ConnStatus - agentMsgConnStatus Connection {connStatus = cs} = \case - JOINED True -> Just ConnSndReady - CONF {} -> Just ConnRequested - INFO {} -> Just ConnSndReady - CON _ -> Just ConnReady - ERR err | cs /= ConnReady && not (temporaryOrHostError err) -> Just $ ConnFailed (tshow err) - _ -> Nothing - processCONFpqSupport :: Connection -> PQSupport -> CM Connection processCONFpqSupport conn@Connection {connId, pqSupport = pq} pq' | pq == PQSupportOn && pq' == PQSupportOff = do @@ -478,10 +503,10 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId) profileToSend <- presentUserBadge user incognitoProfile $ case gInfo_ of - Just gInfo -> userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) + Just (GIK 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 + allowAgentConnectionAsync user conn'' confId gInfo_ $ XInfo profileToSend ((\(GIK _ gks) -> groupMemberKey gks) <$> gInfo_) INFO pqSupport connInfo -> do processINFOpqSupport conn pqSupport void $ saveConnInfo conn connInfo @@ -491,7 +516,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = withAckMessage' "new contact msg" agentConnId meta $ pure () SENT msgId _proxy -> do void $ continueSending connEntity conn - sentMsgDeliveryEvent conn msgId + withStore' $ \db -> sentMsgDeliveryEvent db conn msgId OK -> -- [async agent commands] continuation on receiving OK when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () @@ -550,7 +575,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = let ct'' = ct' {activeConn = Just conn''} :: Contact case event of XMsgNew mc -> newContentMessage ct'' mc msg msgMeta - XMsgFileDescr sharedMsgId fileDescr fileExpires -> messageFileDescription ct'' sharedMsgId fileDescr fileExpires + XMsgFileDescr sharedMsgId fileDescr fileExpires fileBadge -> messageFileDescription ct'' sharedMsgId fileDescr fileExpires fileBadge XMsgUpdate sharedMsgId mContent _ ttl live _msgScope _ -> messageUpdate ct'' sharedMsgId mContent msg msgMeta ttl live XMsgDel sharedMsgId _ _ _ -> messageDelete ct'' sharedMsgId msg msgMeta XMsgReact sharedMsgId _ _ reaction add -> directMsgReaction ct'' sharedMsgId reaction add msg msgMeta @@ -558,7 +583,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = XFile fInv -> processFileInvitation' ct'' fInv msg msgMeta XFileCancel sharedMsgId -> xFileCancel ct'' sharedMsgId XFileAcptInv sharedMsgId fileConnReq_ fName -> xFileAcptInv ct'' sharedMsgId fileConnReq_ fName - XInfo p -> xInfo ct'' p + XInfo p _ -> xInfo ct'' p XDirectDel -> xDirectDel ct'' msg msgMeta XGrpInv gInv -> processGroupInvitation ct'' gInv msg msgMeta XInfoProbe probe -> xInfoProbe (COMContact ct'') probe @@ -591,24 +616,27 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = -- TODO check member ID -- TODO update member profile -- [async agent commands] no continuation needed, but command should be asynchronous for stability - allowAgentConnectionAsync user conn'' confId XOk - XInfo profile -> do + allowAgentConnectionAsync user conn'' confId Nothing XOk + XInfo profile _ -> do ct' <- processContactProfileUpdate ct profile False `catchAllErrors` const (pure ct) -- [incognito] send incognito profile incognitoProfile <- forM customUserProfileId $ \profileId -> withStore $ \db -> getProfileById db userId profileId p <- presentUserBadge user incognitoProfile $ userProfileDirect user (fromLocalProfile <$> incognitoProfile) (Just ct') True - allowAgentConnectionAsync user conn'' confId $ XInfo p + allowAgentConnectionAsync user conn'' confId Nothing $ XInfo p Nothing void $ withStore' $ \db -> resetMemberContactFields db ct' XGrpLinkInv glInv -> do -- XGrpLinkInv here means we are connecting via business contact card, so we replace contact with group + when (isPublicGroupInv glInv) $ throwChatError $ CEInvalidChatMessage conn'' Nothing (safeDecodeUtf8 connInfo) "x.grp.link.inv: publicGroup not allowed in p2p groups" + memberKeys <- atomically . C.generateKeyPair =<< asks random (gInfo, host) <- withStore $ \db -> do liftIO $ deleteContactCardKeepConn db connId ct - createGroupInvitedViaLink db cxt user conn'' glInv + createGroupInvitedViaLink db cxt user conn'' memberKeys glInv void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing Nothing (Just epochStart) -- [incognito] send saved profile incognitoProfile <- forM customUserProfileId $ \pId -> withStore (\db -> getProfileById db userId pId) profileToSend <- presentUserBadge user incognitoProfile $ userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) - allowAgentConnectionAsync user conn'' confId $ XInfo profileToSend + let gks = GKGroup {memberPrivKey = snd memberKeys} + allowAgentConnectionAsync user conn'' confId (Just $ GIK gInfo gks) $ XInfo profileToSend (Just $ groupMemberKey gks) toView $ CEvtBusinessLinkConnecting user gInfo host ct _ -> messageError "CONF for existing contact must have x.grp.mem.info or x.info" INFO pqSupport connInfo -> do @@ -620,7 +648,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = -- TODO check member ID -- TODO update member profile pure () - XInfo profile -> do + XInfo profile _ -> do let prepared = isJust (preparedContact ct) || isJust (contactRequestId' ct) void $ processContactProfileUpdate ct profile prepared XOk -> pure () @@ -651,11 +679,12 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = withStore' $ \db -> resetContactConnInitiated db user conn' SENT msgId proxy -> do void $ continueSending connEntity conn - sentMsgDeliveryEvent conn msgId - checkSndInlineFTComplete conn msgId - cis <- withStore $ \db -> do + (fileEvent_, cis) <- withStore $ \db -> do + liftIO $ sentMsgDeliveryEvent db conn msgId + fileEvent_ <- checkSndInlineFTComplete db conn msgId cis <- updateDirectItemsStatus' db ct conn msgId (CISSndSent SSPComplete) - liftIO $ forM cis $ \ci -> setDirectSndChatItemViaProxy db user ct ci (isJust proxy) + (fileEvent_,) <$> liftIO (forM cis $ \ci -> setDirectSndChatItemViaProxy db user ct ci (isJust proxy)) + mapM_ toView fileEvent_ let acis = map ctItem cis unless (null acis) $ toView $ CEvtChatItemsStatusesUpdated user acis where @@ -733,8 +762,8 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndMsgContent mc) toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDSnd (DirectChat ct) ci] - processGroupMessage :: AEvent e -> ConnectionEntity -> Connection -> GroupInfo -> GroupMember -> CM () - processGroupMessage agentMsg connEntity conn@Connection {connId, customUserProfileId, connectionCode} gInfo@GroupInfo {groupId, groupProfile, membership, chatSettings} m = case agentMsg of + processGroupMessage :: AEvent e -> ConnectionEntity -> Connection -> GroupInfoKeys -> GroupMember -> CM () + processGroupMessage agentMsg connEntity conn@Connection {connId, customUserProfileId, connectionCode} g@(GIK gInfo@GroupInfo {groupId, groupProfile, membership, chatSettings} gks) m = case agentMsg of INV (ACR _ cReq) -> withCompletedCommand conn agentMsg $ \CommandData {cmdFunction} -> case cReq of @@ -754,11 +783,12 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = case memberCategory m of GCInviteeMember -> case chatMsgEvent of - XGrpAcpt memId + XGrpAcpt memId mKey | sameMemberId memId m -> do withStore $ \db -> liftIO $ updateGroupMemberStatus db userId m GSMemAccepted + forM_ mKey $ \(MemberKey k) -> withStore' $ \db -> setMemberPubKey db (groupMemberId' m) k -- [async agent commands] no continuation needed, but command should be asynchronous for stability - allowAgentConnectionAsync user conn' confId XOk + allowAgentConnectionAsync user conn' confId (Just g) XOk | otherwise -> messageError "x.grp.acpt: memberId is different from expected" XGrpRelayAcpt relayLink relayCap | memberRole' membership == GROwner && isRelay m -> do @@ -778,7 +808,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = liftIO $ updateGroupMemberStatus db userId m GSMemLeft pure (relay', m {memberStatus = GSMemLeft}) -- complete the contact handshake so the relay receives INFO and cleans up its transient bookkeeping - allowAgentConnectionAsync user conn' confId XOk + allowAgentConnectionAsync user conn' confId (Just g) XOk toView $ CEvtGroupRelayUpdated user gInfo m' relay' toViewTE $ TERelayRejected user gInfo reason | otherwise -> messageError "x.grp.relay.reject: only owner should receive relay rejection" @@ -793,8 +823,8 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = (gInfo', m') <- withStore $ \db -> updatePreparedUserAndHostMembersInvited db cxt user gInfo m glInv -- [incognito] send saved profile incognitoProfile <- forM customUserProfileId $ \pId -> withStore (\db -> getProfileById db userId pId) - profileToSend <- presentUserBadge user incognitoProfile $ userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) - allowAgentConnectionAsync user conn' confId $ XInfo profileToSend + profileToSend <- presentUserBadge user incognitoProfile $ userProfileInGroup user gInfo' (fromLocalProfile <$> incognitoProfile) + allowAgentConnectionAsync user conn' confId (Just $ GIK gInfo' gks) $ XInfo profileToSend (Just $ groupMemberKey gks) toView $ CEvtGroupLinkConnecting user gInfo' m' | otherwise -> messageError "x.grp.link.inv: publicGroupId mismatch" XGrpLinkReject glRjct@GroupLinkRejection {rejectionReason} -> do @@ -810,11 +840,11 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = membershipProfile <- presentUserBadge user (incognitoMembershipProfile gInfo) $ redactedMemberProfile gInfo membership $ 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 + allowAgentConnectionAsync user conn' confId (Just g) $ XGrpMemInfo membershipMemId membershipProfile | otherwise -> messageError "x.grp.mem.info: memberId is different from expected" _ -> messageError "CONF from member must have x.grp.mem.info" INFO _pqSupport connInfo -> do - ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage conn connInfo + (signedMsg_, ChatMessage {chatVRange, chatMsgEvent}) <- parseChatMessage' conn connInfo _conn' <- updatePeerChatVRange conn chatVRange case chatMsgEvent of XGrpMemInfo memId _memProfile @@ -823,11 +853,12 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = pure () | otherwise -> messageError "x.grp.mem.info: memberId is different from expected" -- sent when connecting via group link - XInfo _ -> + XInfo _ mKey -- TODO Keep rejected member to allow them to appeal against rejection. - when (memberStatus m == GSMemRejected) $ do - deleteMemberConnection' m True - withStore' $ \db -> deleteGroupMember db user m + | memberStatus m == GSMemRejected -> do + deleteMemberConnection' m True + withStore' $ \db -> deleteGroupMember db user m + | otherwise -> mapM_ (storeMemberKey gInfo m signedMsg_) mKey XOk -> -- transient relay-reject row cleanup after the rejection handshake completes when (memberCategory m == GCHostMember && not (relayServesGroup gInfo)) $ do @@ -890,12 +921,12 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = Nothing -> do withStore' $ \db -> setGroupRosterVersion db gInfo (VersionRoster 0) pure gInfo {rosterVersion = Just (VersionRoster 0)} - sendGroupRosterToRelay user gInfo' m + sendGroupRosterToRelay user (GIK gInfo' gks) m else do -- a relay below groupRosterVersion can't ack a roster; publish it on connect as before -- the handshake (getPublishableGroupRelays and the LINK handler include/activate it by version) gLink <- withStore $ \db -> getGroupLink db user gInfo - setGroupLinkDataAsync user gInfo gLink + setGroupLinkDataAsync user g gLink | otherwise -> do (gInfo', mStatus) <- if not (memberPending m) @@ -913,28 +944,28 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = _ -> pure () toView $ CEvtJoinedGroupMember user gInfo'' m' {memberStatus = mStatus} let Connection {viaUserContactLink} = conn - when (isJust viaUserContactLink && isNothing (memberContactId m')) $ sendXGrpLinkMem gInfo'' + when (isJust viaUserContactLink && isNothing (memberContactId m')) $ sendXGrpLinkMem gInfo'' m' if useRelays' gInfo'' then do - introduceInChannel cxt user gInfo'' m' + introduceInChannel cxt user (GIK gInfo'' gks) m' case mStatus of GSMemPendingApproval -> pure () GSMemPendingReview -> pure () _ -> when (groupFeatureAllowed SGFHistory gInfo'') $ sendHistory user gInfo'' m' else case mStatus of GSMemPendingApproval -> pure () - GSMemPendingReview -> introduceToModerators cxt user gInfo'' m' + GSMemPendingReview -> introduceToModerators cxt user (GIK gInfo'' gks) m' _ -> do - introduceToAll cxt user gInfo'' m' + introduceToAll cxt user (GIK gInfo'' gks) m' let memberIsCustomer = case businessChat gInfo'' of Just BusinessChatInfo {chatType = BCCustomer, customerId} -> memberId' m' == customerId _ -> False when (groupFeatureAllowed SGFHistory gInfo'' && not memberIsCustomer) $ sendHistory user gInfo'' m' where - sendXGrpLinkMem gInfo'' = do + sendXGrpLinkMem gInfo'' m' = do let incognitoProfile = ExistingIncognito <$> incognitoMembershipProfile gInfo'' - profileToSend <- presentUserBadge user incognitoProfile $ userProfileInGroup user gInfo (fromIncognitoProfile <$> incognitoProfile) - void $ sendDirectMemberMessage conn (XGrpLinkMem profileToSend) groupId + profileToSend <- presentUserBadge user incognitoProfile $ userProfileInGroup user gInfo'' (fromIncognitoProfile <$> incognitoProfile) + sendGroupMemberMessages user (GIK gInfo'' gks) conn [XGrpLinkMem profileToSend (Just $ groupMemberKey gks)] _ -> do unless (memberPending m) $ withStore' $ \db -> updateGroupMemberStatus db userId m GSMemConnected notifyMemberConnected gInfo m Nothing @@ -998,7 +1029,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = case fwd_ of Just fwd | SJson <- enc -> do logInfo $ "group fwd=" <> tshow tag <> " " <> eInfo - xGrpMsgForward gInfo' scopeInfo m' fwd parsedMsg brokerTs + xGrpMsgForward (GIK gInfo' gks) scopeInfo m' fwd parsedMsg brokerTs `catchAllErrors` \e -> eToView e pure newDeliveryTasks -- direct JSON and binary messages; binary events don't produce delivery tasks @@ -1035,7 +1066,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = where MsgContainer {scope, asGroup} = mc -- file description is always allowed, to allow sending files to support scope - XMsgFileDescr sharedMsgId fileDescr fileExpires -> groupMessageFileDescription gInfo' (Just m'') sharedMsgId fileDescr fileExpires + XMsgFileDescr sharedMsgId fileDescr fileExpires fileBadge -> groupMessageFileDescription gInfo' (Just m'') sharedMsgId fileDescr fileExpires fileBadge XMsgUpdate sharedMsgId mContent mentions ttl live msgScope asGroup_ -> checkSendAsGroup asGroup_ $ memberCanSend (Just m'') msgScope $ @@ -1047,38 +1078,38 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = XFile fInv -> Nothing <$ processGroupFileInvitation' gInfo' m'' fInv msg brokerTs XFileCancel sharedMsgId -> xFileCancelGroup gInfo' (Just m'') sharedMsgId XFileAcptInv sharedMsgId fileConnReq_ fName -> Nothing <$ xFileAcptInvGroup gInfo' m'' sharedMsgId fileConnReq_ fName - XInfo p -> fmap ctx <$> xInfoMember gInfo' m'' p msg brokerTs - XGrpLinkMem p -> Nothing <$ xGrpLinkMem gInfo' m'' conn' p - XGrpLinkAcpt acceptance role memberId -> Nothing <$ xGrpLinkAcpt gInfo' m'' acceptance role memberId msg brokerTs + XInfo p mKey -> fmap ctx <$> xInfoMember gInfo' m'' p mKey msg brokerTs + XGrpLinkMem p mKey -> Nothing <$ xGrpLinkMem gInfo' m'' conn' p mKey msg + XGrpLinkAcpt acceptance role memberId -> Nothing <$ xGrpLinkAcpt (GIK gInfo' gks) m'' acceptance role memberId msg brokerTs XGrpRelayNew rl -> fmap ctx <$> xGrpRelayNew gInfo' m'' rl XGrpRelayCap relayCap | memberRole' membership == GROwner && isRelay m'' -> Nothing <$ withStore' (\db -> updateRelayCapabilities db m'' relayCap) | otherwise -> Nothing <$ messageWarning "x.grp.relay.cap: only owner should receive relay capabilities" - XGrpMemNew memInfo msgScope -> fmap ctx <$> xGrpMemNew gInfo' m'' memInfo msgScope msg brokerTs + XGrpMemNew memInfo msgScope -> fmap ctx <$> xGrpMemNew (GIK gInfo' gks) 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 memberKey rosterVer -> fmap ctx <$> xGrpMemRole gInfo' Nothing m'' memId memRole memberKey rosterVer msg brokerTs + XGrpMemRole memId memRole memberKey rosterVer -> fmap ctx <$> xGrpMemRole (GIK gInfo' gks) Nothing m'' memId memRole memberKey rosterVer msg brokerTs XGrpMemRestrict memId memRestrictions -> fmap ctx <$> xGrpMemRestrict gInfo' m'' memId memRestrictions msg brokerTs XGrpMemCon memId -> Nothing <$ xGrpMemCon gInfo' m'' memId XGrpMemDel memId withMessages rosterVer -> case encoding @e of - SJson -> fmap ctx <$> xGrpMemDel gInfo' Nothing m'' memId withMessages rosterVer verifiedMsg msg brokerTs False + SJson -> fmap ctx <$> xGrpMemDel (GIK gInfo' gks) Nothing m'' memId withMessages rosterVer verifiedMsg msg brokerTs False SBinary -> pure Nothing - XGrpLeave -> fmap ctx <$> xGrpLeave gInfo' m'' msg brokerTs + XGrpLeave -> fmap ctx <$> xGrpLeave (GIK gInfo' gks) m'' msg brokerTs XGrpDel -> Just (DeliveryTaskContext (DJSGroup {jobSpec = DJRelayRemoved}) False) <$ xGrpDel gInfo' m'' msg brokerTs - XGrpInfo p' -> fmap ctx <$> xGrpInfo gInfo' m'' p' msg brokerTs + XGrpInfo p' -> fmap ctx <$> xGrpInfo (GIK gInfo' gks) m'' p' msg brokerTs XGrpPrefs ps' -> fmap ctx <$> xGrpPrefs gInfo' m'' ps' msg XGrpRoster gr -> fmap ctx <$> xGrpRoster gInfo' m'' m'' gr verifiedMsg sharedMsgId_ brokerTs - XGrpRosterAck ackVer ackErr -> Nothing <$ xGrpRosterAck gInfo' m'' ackVer ackErr - XGrpRosterRequest reqVer -> Nothing <$ xGrpRosterRequest gInfo' m'' reqVer + XGrpRosterAck ackVer ackErr -> Nothing <$ xGrpRosterAck (GIK gInfo' gks) m'' ackVer ackErr + XGrpRosterRequest reqVer -> Nothing <$ xGrpRosterRequest (GIK gInfo' gks) m'' reqVer -- TODO [knocking] why don't we forward these messages? XGrpDirectInv connReq mContent_ msgScope -> memberCanSend (Just m'') msgScope $ Nothing <$ xGrpDirectInv gInfo' m'' conn' connReq mContent_ msg brokerTs - XGrpMsgForward fwd msg' -> Nothing <$ xGrpMsgForward gInfo' Nothing m'' fwd (ParsedMsg Nothing Nothing msg') brokerTs + XGrpMsgForward fwd msg' -> Nothing <$ xGrpMsgForward (GIK gInfo' gks) Nothing m'' fwd (ParsedMsg Nothing Nothing msg') 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' m'' sharedMsgId chunk msgMeta + BFileChunk sharedMsgId chunk -> Nothing <$ bFileChunkGroup (GIK gInfo' gks) m'' sharedMsgId chunk msgMeta _ -> Nothing <$ messageError ("unsupported message: " <> tshow event) forM deliveryTaskContext_ $ \taskContext -> do let contentChanged :: CM () @@ -1138,11 +1169,14 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = groupMsgReceived gInfo m conn msgMeta msgRcpt SENT msgId proxy -> do continued <- continueSending connEntity conn - sentMsgDeliveryEvent conn msgId - checkSndInlineFTComplete conn msgId - updateGroupItemsStatus gInfo m conn msgId GSSSent (Just $ isJust proxy) + (fileEvent_, acis) <- withStore $ \db -> do + liftIO $ sentMsgDeliveryEvent db conn msgId + fileEvent_ <- checkSndInlineFTComplete db conn msgId + (fileEvent_,) <$> updateGroupItemsStatus db gInfo m conn msgId GSSSent (Just $ isJust proxy) + mapM_ toView fileEvent_ + unless (null acis) $ toView $ CEvtChatItemsStatusesUpdated user acis when continued $ do - when (isUserGrpFwdRelay gInfo) $ serveRoster user gInfo m -- roster ahead of the resumed backlog + when (isUserGrpFwdRelay gInfo) $ serveRoster user g m -- roster ahead of the resumed backlog sendPendingGroupMessages user gInfo m conn SWITCH qd phase cStats -> do toView $ CEvtGroupMemberSwitch user gInfo m (SwitchProgress qd phase cStats) @@ -1213,7 +1247,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = withStore' $ \db -> updateConnLinkData db user conn cReq cReqHash groupLinkId chatV pqSup let incognitoProfile = fromLocalProfile <$> incognitoMembershipProfile gInfo profileToSend <- presentUserBadge user incognitoProfile $ userProfileInGroup user gInfo incognitoProfile - dm <- encodeXMemberConnInfo gInfo relayMemberId profileToSend + dm <- encodeXMemberConnInfo g relayMemberId profileToSend subMode <- chatReadVar subscriptionMode (cmdId, connId') <- prepareAgentJoin user (Just conn) True cReq joinAgentConnectionAsync cmdId True connId' True cReq dm subMode @@ -1229,7 +1263,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = liftIO $ updateGroupMemberStatus db userId m GSMemAccepted (m', relay) <- setRelayLinkAccepted db cxt user m (MemberKey relayKey) relayProfile pure (confId, m', relay) - allowAgentConnectionAsync user conn confId XOk + allowAgentConnectionAsync user conn confId (Just g) XOk toView $ CEvtGroupRelayUpdated user gInfo m' relay else -- TODO [relays] owner: TBC failed RelayStatus? @@ -1238,7 +1272,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = QCONT -> do continued <- continueSending connEntity conn when continued $ do - when (isUserGrpFwdRelay gInfo) $ serveRoster user gInfo m -- roster ahead of the resumed backlog + when (isUserGrpFwdRelay gInfo) $ serveRoster user g m -- roster ahead of the resumed backlog sendPendingGroupMessages user gInfo m conn MWARN msgId err -> do withStore' $ \db -> updateGroupItemsErrorStatus db msgId (groupMemberId' m) (GSSWarning $ agentSndError err) @@ -1283,9 +1317,9 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = _ -> pure Nothing sendGroupAutoReply mc = \case Just UserContactRequest {welcomeSharedMsgId = Just smId} -> - void $ sendGroupMessage' user gInfo [m] $ XMsgUpdate smId mc M.empty Nothing Nothing Nothing Nothing + void $ sendGroupMessage' user g [m] $ XMsgUpdate smId mc M.empty Nothing Nothing Nothing Nothing _ -> do - msg <- sendGroupMessage' user gInfo [m] $ XMsgNew $ mcSimple mc + msg <- sendGroupMessage' user g [m] $ XMsgNew $ mcSimple mc ci <- saveSndChatItem user (CDGroupSnd gInfo Nothing) msg (CISndMsgContent mc) withStore' $ \db -> createGroupSndStatus db (chatItemId' ci) (groupMemberId' m) GSSNew toView $ CEvtNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat gInfo Nothing) ci] @@ -1312,7 +1346,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = r n'' = Just (ci, CIRcvDecryptionError mde n'') mdeUpdatedCI _ _ = Nothing - receiveFileChunk :: Maybe GroupInfo -> RcvFileTransfer -> Maybe Connection -> MsgMeta -> FileChunk -> CM () + receiveFileChunk :: Maybe GroupInfoKeys -> RcvFileTransfer -> Maybe Connection -> MsgMeta -> FileChunk -> CM () receiveFileChunk gInfo_ ft@RcvFileTransfer {fileId, fileType, chunkSize} conn_ MsgMeta {recipient = (msgId, _), integrity} = \case FileChunkCancel -> case fileType of -- cancel only this source's transfer; other relays' in-flight transfers are independent @@ -1359,9 +1393,9 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = REQ invId pqSupport _ connInfo rejectionSupported -> do (signedMsg_, ChatMessage {chatVRange, chatMsgEvent}) <- parseChatMessage' conn connInfo case chatMsgEvent of - XContact p xContactId_ welcomeMsgId_ requestMsg_ -> profileContactRequest invId chatVRange p xContactId_ welcomeMsgId_ requestMsg_ pqSupport rejectionSupported + XContact p memberKey_ xContactId_ welcomeMsgId_ requestMsg_ -> profileContactRequest invId chatVRange p memberKey_ xContactId_ welcomeMsgId_ requestMsg_ pqSupport rejectionSupported XMember p joiningMemberId joiningMemberKey viaRelay -> memberJoinRequestViaRelay invId chatVRange signedMsg_ p joiningMemberId joiningMemberKey viaRelay - XInfo p -> profileContactRequest invId chatVRange p Nothing Nothing Nothing pqSupport rejectionSupported + XInfo p _ -> profileContactRequest invId chatVRange p Nothing Nothing Nothing Nothing pqSupport rejectionSupported XGrpRelayInv groupRelayInv -> xGrpRelayInv invId chatVRange groupRelayInv XGrpRelayTest challenge _ -> xGrpRelayTest invId chatVRange challenge -- TODO show/log error, other events in contact request @@ -1380,13 +1414,13 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = CFSetShortLink -> case (ucGroupId_, auData) of (Just groupId, UserContactLinkData UserContactData {relays = relayLinks}) -> do - (gInfo, gLink, relays, relaysChanged, newlyActiveLinks) <- withStore $ \db -> do - gInfo <- getGroupInfo db cxt user groupId + (g@(GIK gInfo _), gLink, relays, relaysChanged, newlyActiveLinks) <- withStore $ \db -> do + g@(GIK gInfo _) <- getGroupInfoKeys db cxt user groupId gLink <- getGroupLink db user gInfo relays <- liftIO $ getGroupRelays db gInfo (relays', changed, newlyActiveLinks) <- liftIO $ foldrM (updateRelay db) ([], False, []) relays liftIO $ setGroupInProgressDone db gInfo - pure (gInfo, gLink, relays', changed, newlyActiveLinks) + pure (g, gLink, relays', changed, newlyActiveLinks) toView $ CEvtGroupLinkDataUpdated user gInfo gLink relays relaysChanged let GroupSummary {publicMemberCount} = groupSummary gInfo -- Owner is counted in publicMemberCount; > 1 means at least one subscriber. @@ -1402,7 +1436,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = allRelayMembers events = XGrpRelayNew <$> newlyActive unless (null recipients) $ - void $ sendGroupMessages user gInfo Nothing False recipients False events + void $ sendGroupMessages user g Nothing False recipients False events where updateRelay :: DB.Connection -> GroupRelay -> ([GroupRelay], Bool, [ShortLinkContact]) -> IO ([GroupRelay], Bool, [ShortLinkContact]) updateRelay db relay@GroupRelay {relayLink, relayStatus} (acc, changed, newlyActiveLinks) = @@ -1435,8 +1469,8 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = -- TODO add debugging output _ -> pure () where - profileContactRequest :: InvitationId -> VersionRangeChat -> Profile -> Maybe XContactId -> Maybe SharedMsgId -> Maybe (SharedMsgId, MsgContent) -> PQSupport -> Bool -> CM () - profileContactRequest invId chatVRange p@Profile {displayName} xContactId_ welcomeMsgId_ requestMsg_ reqPQSup rejectionSupported = do + profileContactRequest :: InvitationId -> VersionRangeChat -> Profile -> Maybe MemberKey -> Maybe XContactId -> Maybe SharedMsgId -> Maybe (SharedMsgId, MsgContent) -> PQSupport -> Bool -> CM () + profileContactRequest invId chatVRange p@Profile {displayName} memberKey_ xContactId_ welcomeMsgId_ requestMsg_ reqPQSup rejectionSupported = do (ucl, gLinkInfo_) <- withStore $ \db -> getUserContactLinkById db userId uclId let v = maxVersion chatVRange case gLinkInfo_ of @@ -1451,7 +1485,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = REContact ct -> -- TODO [short links] update request msg toView $ CEvtContactRequestAlreadyAccepted user ct - REBusinessChat gInfo _clientMember -> + REBusinessChat (GIK gInfo _) _clientMember -> -- TODO [short links] update request msg toView $ CEvtBusinessRequestAlreadyAccepted user gInfo RSCurrentRequest prevUcr_ ucr@UserContactRequest {welcomeSharedMsgId} re_ -> case re_ of @@ -1485,8 +1519,8 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = else pure Nothing ct' <- acceptContactRequestAsync user uclId ct ucr incognitoProfile toView $ CEvtAcceptingContactRequest user ct' - Just (REBusinessChat gInfo clientMember) -> do - (_gInfo', _clientMember') <- acceptBusinessJoinRequestAsync user uclId gInfo clientMember ucr + Just (REBusinessChat g@(GIK gInfo _) clientMember) -> do + (_gInfo', _clientMember') <- acceptBusinessJoinRequestAsync user uclId g clientMember ucr let cd = CDGroupRcv gInfo Nothing clientMember void $ case prevUcr_ of Just UserContactRequest {requestSharedMsgId = prevSharedMsgId_} -> @@ -1578,7 +1612,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = -- ##### Group link join requests (don't create contact requests) ##### Just gli@GroupLinkInfo {groupId, memberRole = gLinkMemRole} -> do -- TODO [short links] deduplicate request by xContactId? - gInfo <- withStore $ \db -> getGroupInfo db cxt user groupId + g@(GIK gInfo _) <- withStore $ \db -> getGroupInfoKeys db cxt user groupId if | useRelays' gInfo -> messageWarning $ "processContactConnMessage (group " <> groupName' gInfo <> "): ignored direct join request from " <> displayName <> " (group uses relays)" @@ -1589,7 +1623,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = maybe (pure $ Right (GAAccepted, gLinkMemRole)) (\am -> liftIO $ am gInfo gli p) acceptMember_ >>= \case Right (acceptance, useRole) -> do let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo - mem <- acceptGroupJoinRequestAsync user uclId gInfo invId chatVRange p xContactId_ Nothing welcomeMsgId_ acceptance useRole profileMode Nothing Nothing + mem <- acceptGroupJoinRequestAsync user uclId g invId chatVRange p xContactId_ Nothing welcomeMsgId_ acceptance useRole profileMode memberKey_ Nothing (gInfo', mem', scopeInfo) <- mkGroupChatScope gInfo mem createInternalChatItem user (CDGroupRcv gInfo' scopeInfo mem') (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing toView $ CEvtAcceptingGroupJoinRequestMember user gInfo' mem' @@ -1633,7 +1667,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = (_ucl, gLinkInfo_) <- withStore $ \db -> getUserContactLinkById db userId uclId case gLinkInfo_ of Just GroupLinkInfo {groupId, memberRole = gLinkMemRole} -> do - gInfo <- withStore $ \db -> getGroupInfo db cxt user groupId + g@(GIK gInfo _) <- withStore $ \db -> getGroupInfoKeys db cxt user groupId existing_ <- withStore' $ \db -> eitherToMaybe <$> runExceptT (getGroupMemberByMemberId db cxt user gInfo joiningMemberId) case existing_ of Just rosterMem @@ -1641,21 +1675,21 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = -- possession of that exact key, otherwise this is an attempt to impersonate it | isRosterRole (memberRole' rosterMem) -> if verifyKey gInfo rosterMem - then acceptJoin gInfo (Just rosterMem) (memberRole' rosterMem) + then acceptJoin g (Just rosterMem) (memberRole' rosterMem) else messageError "memberJoinRequestViaRelay: rejected join claiming privileged memberId (key mismatch or invalid signature)" - _ -> acceptJoin gInfo Nothing gLinkMemRole + _ -> acceptJoin g Nothing gLinkMemRole Nothing -> messageError "memberJoinRequestViaRelay: no group link info for relay link" where -- replay defense: the viaRelay == own memberId check (viaRelay is in the signed body); without it a sibling relay could replay a privileged member's signed join - verifyKey gInfo rosterMem = case (signedMsg_, groupKeys gInfo) of - (Just SignedMsg {chatBinding = CBGroup, signatures, signedBody}, Just GroupKeys {publicGroupId}) -> + verifyKey gInfo rosterMem = case signedMsg_ of + Just SignedMsg {chatBinding = CBGroup, signatures, signedBody} -> memberPubKey rosterMem == Just joiningKey - && verifyGroupSig joiningKey publicGroupId joiningMemberId signatures signedBody + && verifyGroupSig joiningKey gInfo joiningMemberId signatures signedBody && viaRelay == Just (memberId' (membership gInfo)) _ -> False - acceptJoin gInfo existingMem_ acceptRole = do - mem <- acceptGroupJoinRequestAsync user uclId gInfo invId chatVRange p Nothing (Just joiningMemberId) Nothing GAAccepted acceptRole Nothing (Just joiningMemberKey) existingMem_ + acceptJoin g@(GIK gInfo _) existingMem_ acceptRole = do + mem <- acceptGroupJoinRequestAsync user uclId g invId chatVRange p Nothing (Just joiningMemberId) Nothing GAAccepted acceptRole Nothing (Just joiningMemberKey) existingMem_ (gInfo', mem', scopeInfo) <- mkGroupChatScope gInfo mem createInternalChatItem user (CDGroupRcv gInfo' scopeInfo mem') (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing toView $ CEvtAcceptingGroupJoinRequestMember user gInfo' mem' @@ -1773,9 +1807,9 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = ackMsg :: MsgMeta -> Maybe MsgReceiptInfo -> CM () ackMsg MsgMeta {recipient = (msgId, _)} rcpt = withAgent $ \a -> ackMessageAsync a "" cId msgId rcpt - sentMsgDeliveryEvent :: Connection -> AgentMsgId -> CM () - sentMsgDeliveryEvent Connection {connId} msgId = - withStore' $ \db -> updateSndMsgDeliveryStatus db connId msgId MDSSndSent + sentMsgDeliveryEvent :: DB.Connection -> Connection -> AgentMsgId -> IO () + sentMsgDeliveryEvent db Connection {connId} msgId = + updateSndMsgDeliveryStatus db connId msgId MDSSndSent agentSndError :: AgentErrorType -> SndError agentSndError = \case @@ -1891,7 +1925,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = let MsgContainer {ttl = itemTTL, live = live_} = mc timed_ = rcvContactCITimed ct itemTTL live = fromMaybe False live_ - file_ <- processFileInvitation fInv_ content $ \db -> createRcvFileTransfer db userId ct + file_ <- processFileInvitation fInv_ content (rcvDirectFileProhibited ct) $ \db -> createRcvFileTransfer db userId ct newChatItem (CIRcvMsgContent content, msgContentTexts content) (snd <$> file_) timed_ live autoAcceptFile file_ where @@ -1907,36 +1941,37 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = ChatConfig {autoAcceptFileSize = sz} <- asks config when (sz > fileSize) $ receiveFileEvt' user ft False Nothing Nothing >>= toView - messageFileDescription :: Contact -> SharedMsgId -> FileDescr -> Maybe UTCTime -> CM () - messageFileDescription Contact {contactId} sharedMsgId fileDescr fileExpires = do + messageFileDescription :: Contact -> SharedMsgId -> FileDescr -> Maybe UTCTime -> Maybe BadgeProof -> CM () + messageFileDescription ct@Contact {contactId} sharedMsgId fileDescr fileExpires fileBadge = do (fileId, aci) <- withStore $ \db -> do fileId <- getFileIdBySharedMsgId db userId contactId sharedMsgId aci <- getChatItemByFileId db cxt user fileId pure (fileId, aci) - processFDMessage fileId aci fileDescr fileExpires + binding_ <- if isJust fileBadge then directChatBinding ct else pure Nothing + processFDMessage binding_ fileId aci fileDescr fileExpires fileBadge - groupMessageFileDescription :: GroupInfo -> Maybe GroupMember -> SharedMsgId -> FileDescr -> Maybe UTCTime -> CM (Maybe DeliveryTaskContext) - groupMessageFileDescription g@GroupInfo {groupId} m_ sharedMsgId fileDescr fileExpires = do + groupMessageFileDescription :: GroupInfo -> Maybe GroupMember -> SharedMsgId -> FileDescr -> Maybe UTCTime -> Maybe BadgeProof -> CM (Maybe DeliveryTaskContext) + groupMessageFileDescription g@GroupInfo {groupId} m_ sharedMsgId fileDescr fileExpires fileBadge = do (fileId, aci) <- withStore $ \db -> do fileId <- getGroupFileIdBySharedMsgId db userId groupId sharedMsgId aci <- getChatItemByFileId db cxt user fileId pure (fileId, aci) case aci of - AChatItem SCTGroup SMDRcv (GroupChat _g scopeInfo) ChatItem {chatDir} + AChatItem SCTGroup SMDRcv (GroupChat _g scopeInfo) ChatItem {chatDir, meta = CIMeta {showGroupAsSender}} | validSender m_ chatDir -> do -- in processFDMessage some paths are programmed as errors, -- 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 fileExpires `catchAllErrors` \_ -> pure () + processFDMessage (rcvGroupChatBinding g m_ showGroupAsSender fileBadge) fileId aci fileDescr fileExpires fileBadge `catchAllErrors` \_ -> pure () pure $ Just $ infoToDeliveryContext g scopeInfo (isChannelDir chatDir) | otherwise -> messageError "x.msg.file.descr: file/sender mismatch" $> Nothing _ -> messageError "x.msg.file.descr: invalid file description part" $> Nothing - processFDMessage :: FileTransferId -> AChatItem -> FileDescr -> Maybe UTCTime -> CM () - processFDMessage fileId aci fileDescr fileExpires = do + processFDMessage :: Maybe ByteString -> FileTransferId -> AChatItem -> FileDescr -> Maybe UTCTime -> Maybe BadgeProof -> CM () + processFDMessage binding_ fileId aci fileDescr fileExpires fileBadge = do ft <- withStore $ \db -> getRcvFileTransfer db user fileId unless (rcvFileCompleteOrCancelled ft) $ do - (rfd@RcvFileDescr {fileDescrComplete}, ft'@RcvFileTransfer {fileStatus, xftpRcvFile, cryptoArgs, fileInvitation = FileInvitation {fileSize}}) <- withStore $ \db -> do + (rfd@RcvFileDescr {fileDescrComplete}, ft'@RcvFileTransfer {fileStatus, xftpRcvFile, cryptoArgs, fileProhibited, fileInvitation = FileInvitation {fileSize}}) <- withStore $ \db -> do rfd <- appendRcvFD db userId fileId fileDescr forM_ fileExpires $ liftIO . setFileExpiration db user fileId -- reading second time in the same transaction as appending description @@ -1944,16 +1979,41 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = ft' <- getRcvFileTransfer db user fileId pure (rfd, ft') when fileDescrComplete $ toView $ CEvtRcvFileDescrReady user aci ft' rfd - case (fileStatus, xftpRcvFile) of - (RFSAccepted _, Just XFTPRcvFile {userApprovedRelays}) -> receiveViaCompleteFD user fileId rfd fileSize userApprovedRelays cryptoArgs - _ -> pure () + maxSize <- asks $ noBadge . fileSizeLimits . config + let descrBadgeRequired = fileDescrComplete && fileSize > maxSize && isNothing fileProhibited + prohibited_ <- case fileBadge of + _ | not descrBadgeRequired -> pure Nothing + Nothing -> pure $ Just FileProhibited {maxSize, badgeStatus = Nothing} + Just badge -> do + st <- descrBadgeStatus binding_ fileSize rfd fileExpires badge + if st == BSActive + then do + withStore' $ \db -> createFileBadgeProof db fileId BPKDescription badge + pure Nothing + else pure $ Just FileProhibited {maxSize, badgeStatus = Just st} + case prohibited_ of + Nothing -> case (fileStatus, xftpRcvFile) of + (RFSAccepted _, Just XFTPRcvFile {userApprovedRelays}) -> receiveViaCompleteFD user fileId rfd fileSize userApprovedRelays cryptoArgs + _ -> pure () + -- the file may already be accepted, so it is reset to an invitation the apps refuse by its prohibition + Just prohibited -> do + withStore' $ \db -> setFileProhibited db user fileId prohibited + aci_ <- resetRcvCIFileStatus user fileId CIFSRcvInvitation + forM_ aci_ $ \aci' -> toView $ CEvtChatItemUpdated user aci' - processFileInvitation :: Maybe FileInvitation -> MsgContent -> (DB.Connection -> FileInvitation -> Maybe InlineFileMode -> Integer -> ExceptT StoreError IO RcvFileTransfer) -> CM (Maybe (RcvFileTransfer, CIFile 'MDRcv)) - processFileInvitation fInv_ mc createRcvFT = forM fInv_ $ \fInv -> do + descrBadgeStatus :: Maybe ByteString -> Integer -> RcvFileDescr -> Maybe UTCTime -> BadgeProof -> CM BadgeStatus + descrBadgeStatus binding_ fileSize RcvFileDescr {fileDescrText} fileExpires badge = do + FD.ValidFileDescription fd <- parseFileDescription @'FRecipient fileDescrText + let descrHash = FD.sharedDescriptionHash fd + badgeProofStatus ((\chatBinding -> PHFileDescr {chatBinding, fileSize = fromInteger fileSize, descrHash, fileExpires}) <$> binding_) badge + + processFileInvitation :: Maybe FileInvitation -> MsgContent -> (FileInvitation -> CM (Maybe FileProhibited)) -> (DB.Connection -> FileInvitation -> Maybe FileProhibited -> Maybe InlineFileMode -> Integer -> ExceptT StoreError IO RcvFileTransfer) -> CM (Maybe (RcvFileTransfer, CIFile 'MDRcv)) + processFileInvitation fInv_ mc fileProhibited_ createRcvFT = forM fInv_ $ \fInv -> do ChatConfig {fileChunkSize} <- asks config fInv'@FileInvitation {fileName, fileSize} <- validateFileInvitation fInv + fileProhibited <- fileProhibited_ fInv' inline <- receiveInlineMode fInv' (Just mc) fileChunkSize - ft@RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvFT db fInv' inline fileChunkSize + ft@RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvFT db fInv' fileProhibited inline fileChunkSize let fileProtocol = if isJust xftpRcvFile then FPXFTP else FPSMP (filePath, fileStatus, ft') <- case inline of Just IFMSent -> do @@ -1965,15 +2025,19 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = _ -> pure (Nothing, CIFSRcvInvitation, ft) let RcvFileTransfer {cryptoArgs} = ft' fileSource = (`CryptoFile` cryptoArgs) <$> filePath - pure (ft', CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol, fileExpires = Nothing}) + pure (ft', CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol, fileExpires = Nothing, fileProhibited}) mkValidFileInvitation :: FileInvitation -> FileInvitation mkValidFileInvitation fInv@FileInvitation {fileName} = fInv {fileName = safeFileNameStr fileName} validateFileInvitation :: FileInvitation -> CM FileInvitation - validateFileInvitation fInv@FileInvitation {fileName, fileSize} - | fileSize > 0 = pure $ mkValidFileInvitation fInv - | otherwise = throwChatError $ CEFileSize fileName + validateFileInvitation fInv@FileInvitation {fileName, fileSize, fileDescr} + | fileSize <= 0 = throwChatError $ CEFileSize fileName + | otherwise = do + -- a file that requires a badge is received from the description message, where the proof binds the description + needsBadge <- fileNeedsBadge fileSize + let fileDescr' = if needsBadge then dummyFileDescr <$ fileDescr else fileDescr + pure $ mkValidFileInvitation fInv {fileDescr = fileDescr'} messageUpdate :: Contact -> SharedMsgId -> MsgContent -> RcvMessage -> MsgMeta -> Maybe Int -> Maybe Bool -> CM () messageUpdate ct@Contact {contactId} sharedMsgId mc msg@RcvMessage {msgId} msgMeta ttl live_ = do @@ -2196,7 +2260,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = unless (maybe False memberBlocked m') $ autoAcceptFile file_ processFileInv gInfo' m' = let fileMember_ = if sentAsGroup then Nothing else m' - in processFileInvitation fInv_ content $ \db -> createRcvGroupFileTransfer db userId gInfo' fileMember_ FTNormal sharedMsgId_ + in processFileInvitation fInv_ content (rcvGroupFileProhibited gInfo' m' sentAsGroup) $ \db -> createRcvGroupFileTransfer db userId gInfo' fileMember_ FTNormal sharedMsgId_ newChatItem gInfo' m' scopeInfo ciContent ciFile_ timed live = do let mentions' = if maybe False memberBlocked m' then M.empty else mentions (ci, cInfo) <- saveRcvCI gInfo' m' scopeInfo ciContent ciFile_ timed live mentions' @@ -2418,10 +2482,11 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = processFileInvitation' ct fInv msg@RcvMessage {sharedMsgId_} msgMeta = do ChatConfig {fileChunkSize} <- asks config fInv'@FileInvitation {fileName, fileSize} <- validateFileInvitation fInv + fileProhibited <- rcvDirectFileProhibited ct fInv' inline <- receiveInlineMode fInv' Nothing fileChunkSize - RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvFileTransfer db userId ct fInv' inline fileChunkSize + RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvFileTransfer db userId ct fInv' fileProhibited inline fileChunkSize let fileProtocol = if isJust xftpRcvFile then FPXFTP else FPSMP - ciFile = Just $ CIFile {fileId, fileName, fileSize, fileSource = Nothing, fileStatus = CIFSRcvInvitation, fileProtocol, fileExpires = Nothing} + ciFile = Just $ CIFile {fileId, fileName, fileSize, fileSource = Nothing, fileStatus = CIFSRcvInvitation, fileProtocol, fileExpires = Nothing, fileProhibited} content = ciContentNoParse $ CIRcvMsgContent $ MCFile "" (ci, cInfo) <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ brokerTs content ciFile Nothing False M.empty toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv cInfo ci] @@ -2433,10 +2498,11 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = processGroupFileInvitation' gInfo m fInv msg@RcvMessage {sharedMsgId_} brokerTs = do ChatConfig {fileChunkSize} <- asks config fInv'@FileInvitation {fileName, fileSize} <- validateFileInvitation fInv + fileProhibited <- rcvGroupFileProhibited gInfo (Just m) False fInv' inline <- receiveInlineMode fInv' Nothing fileChunkSize - RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvGroupFileTransfer db userId gInfo (Just m) FTNormal sharedMsgId_ fInv' inline fileChunkSize + RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvGroupFileTransfer db userId gInfo (Just m) FTNormal sharedMsgId_ fInv' fileProhibited inline fileChunkSize let fileProtocol = if isJust xftpRcvFile then FPXFTP else FPSMP - ciFile = Just $ CIFile {fileId, fileName, fileSize, fileSource = Nothing, fileStatus = CIFSRcvInvitation, fileProtocol, fileExpires = Nothing} + ciFile = Just $ CIFile {fileId, fileName, fileSize, fileSource = Nothing, fileStatus = CIFSRcvInvitation, fileProtocol, fileExpires = Nothing, fileProhibited} content = ciContentNoParse $ CIRcvMsgContent $ MCFile "" (ci, cInfo) <- saveRcvChatItem' user (CDGroupRcv gInfo Nothing m) msg sharedMsgId_ brokerTs content ciFile Nothing False M.empty ci' <- blockedMemberCI gInfo m ci @@ -2505,18 +2571,17 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = imageOrVoice _ = False assertSMPAcceptNotProhibited _ = pure () - checkSndInlineFTComplete :: Connection -> AgentMsgId -> CM () - checkSndInlineFTComplete conn agentMsgId = do - sft_ <- withStore' $ \db -> getSndFTViaMsgDelivery db user conn agentMsgId - forM_ sft_ $ \sft@SndFileTransfer {fileId} -> do - ci@(AChatItem _ _ _ ChatItem {file}) <- withStore $ \db -> do - liftIO $ updateSndFileStatus db sft FSComplete - updateDirectCIFileStatus db cxt user fileId CIFSSndComplete + checkSndInlineFTComplete :: DB.Connection -> Connection -> AgentMsgId -> ExceptT StoreError IO (Maybe ChatEvent) + checkSndInlineFTComplete db conn agentMsgId = do + sft_ <- liftIO $ getSndFTViaMsgDelivery db user conn agentMsgId + forM sft_ $ \sft@SndFileTransfer {fileId} -> do + liftIO $ updateSndFileStatus db sft FSComplete + ci@(AChatItem _ _ _ ChatItem {file}) <- updateDirectCIFileStatus db cxt user fileId CIFSSndComplete case file of Just CIFile {fileProtocol = FPXFTP} -> do - ft <- withStore $ \db -> getFileTransferMeta db user fileId - toView $ CEvtSndFileCompleteXFTP user ci ft - _ -> toView $ CEvtSndFileComplete user ci sft + ft <- getFileTransferMeta db user fileId + pure $ CEvtSndFileCompleteXFTP user ci ft + _ -> pure $ CEvtSndFileComplete user ci sft allowSendInline :: Integer -> Maybe InlineFileMode -> CM Bool allowSendInline fileSize = \case @@ -2533,8 +2598,8 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = -- A group BFileChunk is a normal inline file chunk or a roster blob chunk, both located by -- (group_id, shared_msg_id). A chunk matching no in-flight transfer (an orphaned re-served roster -- chunk, or a missing normal file) is ignored; the outer withAckMessage acks it. - bFileChunkGroup :: GroupInfo -> GroupMember -> SharedMsgId -> FileChunk -> MsgMeta -> CM () - bFileChunkGroup gInfo@GroupInfo {groupId} fromMember sharedMsgId chunk meta = do + bFileChunkGroup :: GroupInfoKeys -> GroupMember -> SharedMsgId -> FileChunk -> MsgMeta -> CM () + bFileChunkGroup gInfo@(GIK GroupInfo {groupId} _) fromMember sharedMsgId chunk meta = do fileId_ <- withStore' $ \db -> getGroupRcvFileId db userId groupId (groupMemberId' fromMember) sharedMsgId forM_ fileId_ $ \fileId -> do ft <- withStore $ \db -> getRcvFileTransfer db user fileId @@ -2554,7 +2619,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = -- A roster re-serve re-sends the blob from chunk 1; discard any partial first, else chunk 1 over a -- partial is out-of-order (RcvChunkError) and appending after the stale prefix corrupts the blob. - receiveRosterChunk :: GroupInfo -> RcvFileTransfer -> MsgMeta -> FileChunk -> CM () + receiveRosterChunk :: GroupInfoKeys -> RcvFileTransfer -> MsgMeta -> FileChunk -> CM () receiveRosterChunk gInfo ft meta chunk = do case chunk of FileChunk {chunkNo} | chunkNo == 1 -> do @@ -2618,14 +2683,15 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = when (fromRole < GRAdmin || fromRole < memRole) $ throwChatError (CEGroupContactRole c) when (fromMemId == memId) $ throwChatError CEGroupDuplicateMemberId -- [incognito] if direct connection with host is incognito, create membership using the same incognito profile - (gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership}, hostId) <- withStore $ \db -> createGroupInvitation db cxt user ct inv customUserProfileId + memberKeys <- atomically . C.generateKeyPair =<< asks random + (GIK gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership} gks, hostId) <- withStore $ \db -> createGroupInvitation db cxt user ct inv customUserProfileId memberKeys void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing Nothing (Just epochStart) let GroupMember {groupMemberId, memberId = membershipMemId} = membership -- hostContact is only reported for group links, where the client replaces -- the transient host connection view with the group and removes its chat joinGroupAsync hostContact_ sameLink = do subMode <- chatReadVar subscriptionMode - dm <- encodeConnInfo $ XGrpAcpt membershipMemId + dm <- encodeConnInfo $ XGrpAcpt membershipMemId (Just $ groupMemberKey gks) connIds@(cmdId, acId) <- prepareAgentJoin user Nothing True connRequest withStore' $ \db -> do when sameLink $ setViaGroupLinkUri db groupId connId @@ -2729,24 +2795,37 @@ processAgentMessageConn cxt 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 -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) - xInfoMember gInfo m p' msg brokerTs = do + xInfoMember :: GroupInfo -> GroupMember -> Profile -> Maybe MemberKey -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) + xInfoMember gInfo m p' mKey msg@RcvMessage {signedMsg_} brokerTs = do + mapM_ (storeMemberKey gInfo m signedMsg_) mKey void $ processMemberProfileUpdate gInfo m p' (Just (msg, brokerTs)) pure $ memberEventDeliveryScope m - xGrpLinkMem :: GroupInfo -> GroupMember -> Connection -> Profile -> CM () - xGrpLinkMem gInfo@GroupInfo {membership, businessChat} m@GroupMember {groupMemberId, memberCategory} Connection {viaGroupLink} p' = do + xGrpLinkMem :: GroupInfo -> GroupMember -> Connection -> Profile -> Maybe MemberKey -> RcvMessage -> CM () + xGrpLinkMem gInfo@GroupInfo {membership, businessChat} m@GroupMember {groupMemberId, memberCategory} Connection {viaGroupLink} p' mKey RcvMessage {signedMsg_} = do xGrpLinkMemReceived <- withStore $ \db -> getXGrpLinkMemReceived db groupMemberId if (viaGroupLink || isJust businessChat) && isNothing (memberContactId m) && memberCategory == GCHostMember && not xGrpLinkMemReceived then do + mapM_ (storeMemberKey gInfo m signedMsg_) mKey m' <- processMemberProfileUpdate gInfo m p' Nothing withStore' $ \db -> setXGrpLinkMemReceived db groupMemberId True let connectedIncognito = memberIncognito membership probeMatchingMemberContact m' connectedIncognito else messageError "x.grp.link.mem error: invalid group link host profile update" - xGrpLinkAcpt :: GroupInfo -> GroupMember -> GroupAcceptance -> GroupMemberRole -> MemberId -> RcvMessage -> UTCTime -> CM () - xGrpLinkAcpt gInfo@GroupInfo {membership} m acceptance role memberId msg brokerTs + storeMemberKey :: GroupInfo -> GroupMember -> Maybe SignedMsg -> MemberKey -> CM () + storeMemberKey gInfo GroupMember {groupMemberId, memberPubKey, memberId} signedMsg_ (MemberKey k) = case memberPubKey of + Just k0 -> when (k /= k0) $ messageError "member key change rejected, keeping current key" + Nothing + | signed -> withStore' $ \db -> setMemberPubKey db groupMemberId k + | otherwise -> messageError "member key not signed by that key, ignored" + where + signed = case signedMsg_ of + Just SignedMsg {chatBinding = CBGroup, signatures, signedBody} -> verifyGroupSig k gInfo memberId signatures signedBody + _ -> False + + xGrpLinkAcpt :: GroupInfoKeys -> GroupMember -> GroupAcceptance -> GroupMemberRole -> MemberId -> RcvMessage -> UTCTime -> CM () + xGrpLinkAcpt g@(GIK gInfo@GroupInfo {membership} _) m acceptance role memberId msg brokerTs | memberRole' m < GRModerator || memberRole' m < role = messageError "x.grp.link.acpt with insufficient member permissions" | sameMemberId memberId membership = processUserAccepted @@ -2795,7 +2874,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = GAPendingApproval -> messageWarning "x.grp.link.acpt: unexpected group acceptance - pending approval" introduceToRemainingMembers acceptedMember = do - introduceToRemaining cxt user gInfo acceptedMember + introduceToRemaining cxt user g acceptedMember when (groupFeatureAllowed SGFHistory gInfo) $ sendHistory user gInfo acceptedMember maybeCreateGroupDescrLocal :: GroupInfo -> GroupMember -> CM () @@ -2840,7 +2919,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = updateBusinessChatProfile g@GroupInfo {businessChat} = case businessChat of Just bc | isMainBusinessMember bc m -> do g' <- withStore $ \db -> updateGroupProfileFromMember db user g p' - toView $ CEvtGroupUpdated user g g' (Just m) Nothing + toView $ CEvtGroupUpdated user g g' (Just m) ((\(RcvMessage {msgSigned}, _) -> msgSigned) =<< msgTs_) _ -> pure () isMainBusinessMember BusinessChatInfo {chatType, businessId, customerId} GroupMember {memberId} = case chatType of BCBusiness -> businessId == memberId @@ -3071,29 +3150,35 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = toView $ CEvtContactAndMemberAssociated user c2 g m1 c2' pure c2' - saveConnInfo :: Connection -> ConnInfo -> CM (Connection, Maybe GroupInfo) + saveConnInfo :: Connection -> ConnInfo -> CM (Connection, Maybe GroupInfoKeys) saveConnInfo activeConn connInfo = do ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage activeConn connInfo conn' <- updatePeerChatVRange activeConn chatVRange case chatMsgEvent of - XInfo p -> do + XInfo p _ -> do ct <- withStore $ \db -> createDirectContact db cxt user conn' p toView $ CEvtContactConnecting user ct pure (conn', Nothing) XGrpLinkInv glInv -> do - (gInfo, host) <- withStore $ \db -> createGroupInvitedViaLink db cxt user conn' glInv + when (isPublicGroupInv glInv) $ throwChatError $ CEInvalidChatMessage conn' Nothing (safeDecodeUtf8 connInfo) "x.grp.link.inv: publicGroup not allowed in p2p groups" + memberKeys <- atomically . C.generateKeyPair =<< asks random + (gInfo, host) <- withStore $ \db -> createGroupInvitedViaLink db cxt user conn' memberKeys glInv toView $ CEvtGroupLinkConnecting user gInfo host - pure (conn', Just gInfo) + pure (conn', Just $ GIK gInfo GKGroup {memberPrivKey = snd memberKeys}) XGrpLinkReject glRjct@GroupLinkRejection {rejectionReason} -> do - (gInfo, host) <- withStore $ \db -> createGroupRejectedViaLink db cxt user conn' glRjct + memberKeys <- atomically . C.generateKeyPair =<< asks random + (gInfo, host) <- withStore $ \db -> createGroupRejectedViaLink db cxt user conn' memberKeys glRjct toView $ CEvtGroupLinkConnecting user gInfo host toViewTE $ TEGroupLinkRejected user gInfo rejectionReason - pure (conn', Just gInfo) + pure (conn', Just $ GIK gInfo GKGroup {memberPrivKey = snd memberKeys}) -- TODO show/log error, other events in SMP confirmation _ -> pure (conn', Nothing) - xGrpMemNew :: GroupInfo -> GroupMember -> MemberInfo -> Maybe MsgScope -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) - xGrpMemNew gInfo m memInfo@(MemberInfo memId memRole _ _ assertedKey_) msgScope_ msg brokerTs = do + isPublicGroupInv :: GroupLinkInvitation -> Bool + isPublicGroupInv GroupLinkInvitation {groupProfile = GroupProfile {publicGroup}} = isJust publicGroup + + xGrpMemNew :: GroupInfoKeys -> GroupMember -> MemberInfo -> Maybe MsgScope -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) + xGrpMemNew (GIK gInfo gks) m memInfo@(MemberInfo memId memRole _ _ assertedKey_) msgScope_ msg brokerTs = do unless (useRelays' gInfo) $ checkHostRole m memRole if sameMemberId memId (membership gInfo) then pure Nothing @@ -3113,7 +3198,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = messageWarning $ "x.grp.mem.new: relay asserted key differs from roster-established key, keeping roster key, memberId=" <> safeDecodeUtf8 (strEncode memId) updatedMember <- withStore $ \db -> updateRosterMemberAnnounced db cxt user m unknownMember memInfo initialStatus -- roster members can't be pending, so no members-require-attention update - gInfo' <- updatePublicGroupData user gInfo + gInfo' <- updatePublicGroupData user gInfo gks toView $ CEvtUnknownMemberAnnounced user gInfo' m unknownMember updatedMember memberAnnouncedToView updatedMember gInfo' pure $ deliveryJobScope updatedMember @@ -3128,7 +3213,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = then liftIO $ increaseGroupMembersRequireAttention db user gInfo else pure gInfo pure (updatedMember, gInfo') - gInfo'' <- updatePublicGroupData user gInfo' + gInfo'' <- updatePublicGroupData user gInfo' gks toView $ CEvtUnknownMemberAnnounced user gInfo'' m unknownMember updatedMember memberAnnouncedToView updatedMember gInfo'' pure $ deliveryJobScope updatedMember @@ -3146,7 +3231,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = then liftIO $ increaseGroupMembersRequireAttention db user gInfo else pure gInfo pure (newMember, gInfo') - gInfo'' <- updatePublicGroupData user gInfo' + gInfo'' <- updatePublicGroupData user gInfo' gks memberAnnouncedToView newMember gInfo'' pure $ deliveryJobScope newMember where @@ -3266,8 +3351,8 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = -- batch), then advance it in the same transaction; a strictly lower version is a replay and is ignored. -- Only an owner sender may advance it: a non-owner signed event is rejected by the action that follows, -- but must not bump roster_version first, or every later owner roster at a lower version is dropped. - applyAtRosterVersion :: GroupInfo -> Maybe GroupMember -> GroupMember -> Maybe VersionRoster -> CM (Maybe DeliveryJobScope) -> CM (Maybe DeliveryJobScope) - applyAtRosterVersion gInfo fwdRelay_ sender rosterVer_ action + applyAtRosterVersion :: GroupInfoKeys -> Maybe GroupMember -> GroupMember -> Maybe VersionRoster -> CM (Maybe DeliveryJobScope) -> CM (Maybe DeliveryJobScope) + applyAtRosterVersion g@(GIK gInfo _) fwdRelay_ sender rosterVer_ action | not (useRelays' gInfo) = action | otherwise = case rosterVer_ of Nothing -> action @@ -3305,19 +3390,19 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = | otherwise = case fwdRelay_ of Just relay | gap, relay `supportsVersion` groupRosterVersion -> - void $ sendGroupMessage' user gInfo [relay] (XGrpRosterRequest prevComplete) + void $ sendGroupMessage' user g [relay] (XGrpRosterRequest prevComplete) _ -> pure () where gap = v > nextCompleteVersion prevComplete - xGrpMemRole :: GroupInfo -> Maybe GroupMember -> GroupMember -> MemberId -> GroupMemberRole -> Maybe MemberKey -> Maybe VersionRoster -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) - xGrpMemRole gInfo@GroupInfo {membership} fwdRelay_ m@GroupMember {memberRole = senderRole} memId memRole memberKey_ rosterVer_ msg@RcvMessage {msgSigned} brokerTs + xGrpMemRole :: GroupInfoKeys -> Maybe GroupMember -> GroupMember -> MemberId -> GroupMemberRole -> Maybe MemberKey -> Maybe VersionRoster -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) + xGrpMemRole g@(GIK gInfo@GroupInfo {membership} _) fwdRelay_ m@GroupMember {memberRole = senderRole} memId memRole memberKey_ rosterVer_ msg@RcvMessage {msgSigned} brokerTs | memRole == GRRelay = messageError "x.grp.mem.role: relay role can't be assigned" $> Nothing | membershipMemId == memId = - applyAtRosterVersion gInfo fwdRelay_ m rosterVer_ $ + applyAtRosterVersion g fwdRelay_ m rosterVer_ $ let gInfo' = gInfo {membership = membership {memberRole = memRole}} in changeMemberRole gInfo' membership False (\db -> updateGroupMemberRole db user membership memRole) (RGEUserRole memRole) True - | otherwise = applyAtRosterVersion gInfo fwdRelay_ m rosterVer_ $ do + | otherwise = applyAtRosterVersion g fwdRelay_ m rosterVer_ $ do defaultRole <- unknownMemberRole gInfo -- an owner-signed event with a key TOFU-creates an unknown member only for a roster role; else a plain lookup let allowCreate = useRelays' gInfo && senderRole == GROwner && isRosterRole memRole && isJust memberKey_ @@ -3389,7 +3474,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = cleanupRosterTransfer gInfo (groupMemberId' fromMember) let relayHdr = if isUserGrpFwdRelay gInfo then Just sm else Nothing chSize <- asks $ fileChunkSize . config - let rosterFInv = FileInvitation {fileName = "roster", fileSize, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Just IFMSent, fileDescr = Nothing} + let rosterFInv = FileInvitation {fileName = "roster", fileSize, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Just IFMSent, fileDescr = Nothing, fileBadge = Nothing} -- transfer record + its scratch file in one transaction (file owned by the transfer, keyed per source) rft@RcvFileTransfer {fileId} <- withStore $ \db -> do transferId <- liftIO $ createRosterTransfer db gInfo (groupMemberId' fromMember) newVer fileDigest (groupMemberId' author) brokerTs relayHdr @@ -3411,8 +3496,8 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = -- Blob arrived: verify the owner-attested digest over the plaintext and guard against -- downgrade before applying; on a relay, ack the owner and re-serve to members. - rosterCompletion :: GroupInfo -> RcvFileTransfer -> CM () - rosterCompletion gInfo RcvFileTransfer {fileId, fileStatus} = + rosterCompletion :: GroupInfoKeys -> RcvFileTransfer -> CM () + rosterCompletion g@(GIK gInfo _) RcvFileTransfer {fileId, fileStatus} = withStore' (\db -> getRosterTransfer db fileId) >>= \case -- defensive: the file always has its transfer (created together, deleted together) Nothing -> lift (closeFileHandle fileId rcvFiles) >> forM_ (rosterFilePath fileStatus) removeFsFile @@ -3422,7 +3507,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = let isRelay' = isUserGrpFwdRelay gInfo ackErr err = do cleanupRosterTransferById transferId - when isRelay' $ forM_ owner_ $ \owner -> sendRosterAck gInfo owner pendingVer (Just err) + when isRelay' $ forM_ owner_ $ \owner -> sendRosterAck g owner pendingVer (Just err) if FD.FileDigest (LC.sha512Hash (LB.fromStrict blob)) /= pendingDigest then ackErr "relay could not verify the roster blob" else case parseAll rosterBlobP blob of @@ -3447,7 +3532,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = emitRosterResults gInfo author rosterBrokerTs results -- ack while setting up (own status accepted/acknowledged); a serving (active) relay must not ack broadcasts. when (isRelay' && (relayOwnStatus gInfo == Just RSAccepted || relayOwnStatus gInfo == Just RSAcknowledgedRoster)) $ do - sendRosterAck gInfo author pendingVer Nothing + sendRosterAck g author pendingVer Nothing withStore' $ \db -> void $ updateRelayOwnStatusFromTo db gInfo RSAccepted RSAcknowledgedRoster where rosterFilePath = \case @@ -3513,11 +3598,11 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = else pure (gInfo, author) toView CEvtMemberRole {user, groupInfo = gInfo', byMember = author', member, fromRole, toRole, msgSigned = Just MSSVerified} - sendRosterAck :: GroupInfo -> GroupMember -> VersionRoster -> Maybe Text -> CM () + sendRosterAck :: GroupInfoKeys -> GroupMember -> VersionRoster -> Maybe Text -> CM () sendRosterAck gInfo owner ackVer err = void $ sendGroupMessage' user gInfo [owner] (XGrpRosterAck ackVer err) - xGrpRosterAck :: GroupInfo -> GroupMember -> VersionRoster -> Maybe Text -> CM () - xGrpRosterAck gInfo m ackVer err = do + xGrpRosterAck :: GroupInfoKeys -> GroupMember -> VersionRoster -> Maybe Text -> CM () + xGrpRosterAck g@(GIK gInfo _) m ackVer err = do relay_ <- withStore' $ \db -> eitherToMaybe <$> runExceptT (getGroupRelayByGMId db (groupMemberId' m)) case relay_ of Just relay@GroupRelay {relayStatus = RSAccepted} -> case err of @@ -3527,7 +3612,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = relay' <- liftIO $ updateRelayStatus db relay RSAcknowledgedRoster gLink <- getGroupLink db user gInfo pure (relay', gLink) - setGroupLinkDataAsync user gInfo gLink + setGroupLinkDataAsync user g gLink toView $ CEvtGroupRelayUpdated user gInfo m relay' | otherwise -> messageWarning "x.grp.roster.ack: stale version, awaiting ack for the current roster" Just e -> do @@ -3541,13 +3626,13 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = -- - the latter bounds reflected amplification (a member can't re-trigger a full serve). Gating on the stored -- blob (not roster_version, the gate) means the relay serves only a blob the requester will accept. -- serveRoster records the served version (on all serve paths) and is a no-op without a roster. - xGrpRosterRequest :: GroupInfo -> GroupMember -> Maybe VersionRoster -> CM () - xGrpRosterRequest gInfo m reqVer_ = + xGrpRosterRequest :: GroupInfoKeys -> GroupMember -> Maybe VersionRoster -> CM () + xGrpRosterRequest g@(GIK gInfo _) m reqVer_ = when (isUserGrpFwdRelay gInfo) $ do (stored_, served_) <- withStore' $ \db -> (,) <$> getStoredRosterVersion db gInfo <*> getMemberRosterServedVersion db m forM_ stored_ $ \stored -> - when (maybe True (stored >) reqVer_ && maybe True (stored >) served_) $ serveRoster user gInfo m + when (maybe True (stored >) reqVer_ && maybe True (stored >) served_) $ serveRoster user g m checkHostRole :: GroupMember -> GroupMemberRole -> CM () checkHostRole GroupMember {memberRole, localDisplayName} memRole = @@ -3593,11 +3678,11 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = withStore $ \db -> setMemberVectorRelationConnected db sendingMem refMem MRSubjectConnected withStore $ \db -> setMemberVectorRelationConnected db refMem sendingMem MRReferencedConnected - xGrpMemDel :: GroupInfo -> Maybe GroupMember -> GroupMember -> MemberId -> Bool -> Maybe VersionRoster -> VerifiedMsg 'Json -> RcvMessage -> UTCTime -> Bool -> CM (Maybe DeliveryJobScope) - xGrpMemDel gInfo@GroupInfo {membership} fwdRelay_ m@GroupMember {memberRole = senderRole} memId withMessages rosterVer_ verifiedMsg msg@RcvMessage {msgSigned} brokerTs forwarded = do + xGrpMemDel :: GroupInfoKeys -> Maybe GroupMember -> GroupMember -> MemberId -> Bool -> Maybe VersionRoster -> VerifiedMsg 'Json -> RcvMessage -> UTCTime -> Bool -> CM (Maybe DeliveryJobScope) + xGrpMemDel g@(GIK gInfo@GroupInfo {membership} gks) fwdRelay_ m@GroupMember {memberRole = senderRole} memId withMessages rosterVer_ verifiedMsg msg@RcvMessage {msgSigned} brokerTs forwarded = do let GroupMember {memberId = membershipMemId} = membership if membershipMemId == memId - then applyAtRosterVersion gInfo fwdRelay_ m rosterVer_ $ checkRole membership $ do + then applyAtRosterVersion g fwdRelay_ m rosterVer_ $ checkRole membership $ do deleteGroupLinkIfExists user gInfo -- TODO [relays] possible improvement is to immediately delete rcv queues if isUserGrpFwdRelay unless (isUserGrpFwdRelay gInfo) $ deleteGroupConnections user gInfo False @@ -3609,7 +3694,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = deleteMemberItem msg gInfo RGEUserDeleted toView $ CEvtDeletedMemberUser user gInfo {membership = membership'} m withMessages msgSigned pure $ Just DJSGroup {jobSpec = DJRelayRemoved} - else applyAtRosterVersion gInfo fwdRelay_ m rosterVer_ $ + else applyAtRosterVersion g fwdRelay_ m rosterVer_ $ withStore' (\db -> runExceptT $ getGroupMemberByMemberId db cxt user gInfo memId) >>= \case Left _ -> do messageError "x.grp.mem.del with unknown member ID" @@ -3635,7 +3720,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = fullyDeleteMemberRecord user gInfo deletedMember -- Undeleted "member connected" chat item will prevent deletion of member record. | otherwise -> deleteOrUpdateMemberRecord user gInfo deletedMember - gInfo'' <- updatePublicGroupData user gInfo' + gInfo'' <- updatePublicGroupData user gInfo' gks let wasDeleted = memberStatus == GSMemRemoved || memberStatus == GSMemLeft -- Clear forwardedByMember if it references the deleted member, -- as the member record was already deleted above. @@ -3677,12 +3762,12 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = | useRelays' gInfo = asks $ channelSubscriberRole . config | otherwise = pure GRAuthor - xGrpLeave :: GroupInfo -> GroupMember -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) - xGrpLeave gInfo m msg@RcvMessage {msgSigned} brokerTs = do + xGrpLeave :: GroupInfoKeys -> GroupMember -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) + xGrpLeave (GIK gInfo gks) m msg@RcvMessage {msgSigned} brokerTs = do deleteMemberConnection m -- member record is not deleted to allow creation of "member left" chat item gInfo' <- updateMemberRecordDeleted user gInfo m GSMemLeft - gInfo'' <- updatePublicGroupData user gInfo' + gInfo'' <- updatePublicGroupData user gInfo' gks unless (muteEventInChannel gInfo'' m) $ do (gInfo''', m', scopeInfo) <- mkGroupChatScope gInfo'' m (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo''' scopeInfo m') msg brokerTs (CIRcvGroupEvent RGEMemberLeft) @@ -3702,8 +3787,8 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = groupMsgToView cInfo ci toView $ CEvtGroupDeleted user gInfo'' {membership = membership {memberStatus = GSMemGroupDeleted}} m' msgSigned - xGrpInfo :: GroupInfo -> GroupMember -> GroupProfile -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) - xGrpInfo g@GroupInfo {groupProfile = p@GroupProfile {publicGroup = pg}, businessChat} m@GroupMember {memberRole} p'@GroupProfile {publicGroup = pg'} msg@RcvMessage {msgSigned} brokerTs + xGrpInfo :: GroupInfoKeys -> GroupMember -> GroupProfile -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) + xGrpInfo gik@(GIK g@GroupInfo {groupProfile = p@GroupProfile {publicGroup = pg}, businessChat} gks) m@GroupMember {memberRole} p'@GroupProfile {publicGroup = pg'} msg@RcvMessage {msgSigned} brokerTs | memberRole < GROwner = messageError "x.grp.info with insufficient member permissions" $> Nothing | let pgId = fmap (\PublicGroupProfile {publicGroupId} -> publicGroupId), useRelays' g && (isNothing pg' || pgId pg' /= pgId pg) = messageError "x.grp.info: publicGroupId mismatch for channel" $> Nothing @@ -3723,10 +3808,10 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = -- other owners receiving the update do not refresh the same link ChatConfig {updateGroupLinksFromApp} <- asks config unless (useRelays' g'' || updateGroupLinksFromApp) $ - void $ forkIO $ void $ setGroupLinkData' NRMBackground user g'' + void $ forkIO $ void $ setGroupLinkData' NRMBackground user (GIK g'' gks) Just _ -> updateGroupPrefs_ msgSigned g m $ fromMaybe defaultBusinessGroupPrefs $ groupPreferences p' -- relay advertises its web capability now that the owner's version is known (bumped by saveGroupRcvMsg) - when (isRelay (membership g)) $ sendRelayCapIfNeeded user g + when (isRelay (membership g)) $ sendRelayCapIfNeeded user gik pure $ Just DJSGroup {jobSpec = DJDeliveryJob {includePending = True}} xGrpPrefs :: GroupInfo -> GroupMember -> GroupPreferences -> RcvMessage -> CM (Maybe DeliveryJobScope) @@ -3826,7 +3911,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = -- [incognito] send membership incognito profile p <- presentUserBadge user (incognitoMembershipProfile g) $ userProfileDirect user (fromLocalProfile <$> incognitoMembershipProfile g) Nothing True -- TODO PQ should negotitate contact connection with PQSupportOn? (use encodeConnInfoPQ) - dm <- encodeConnInfo $ XInfo p + dm <- encodeConnInfo $ XInfo p Nothing joinAgentConnectionAsync cmdId False acId True connReq dm subMode createItems mCt' m' = do (g', m'', scopeInfo) <- mkGroupChatScope g m' @@ -3841,8 +3926,8 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = toViewTE $ TEContactVerificationReset user ct createInternalChatItem user (CDDirectRcv ct) (CIRcvConnEvent RCEVerificationCodeReset) Nothing - xGrpMsgForward :: GroupInfo -> Maybe GroupChatScopeInfo -> GroupMember -> GrpMsgForward -> ParsedMsg 'Json -> UTCTime -> CM () - xGrpMsgForward gInfo scopeInfo m@GroupMember {localDisplayName} GrpMsgForward {fwdSender, fwdBrokerTs = msgTs} parsedMsg@(ParsedMsg _ _ chatMsg@ChatMessage {chatMsgEvent}) brokerTs = do + xGrpMsgForward :: GroupInfoKeys -> Maybe GroupChatScopeInfo -> GroupMember -> GrpMsgForward -> ParsedMsg 'Json -> UTCTime -> CM () + xGrpMsgForward g@(GIK gInfo _) scopeInfo m@GroupMember {localDisplayName} GrpMsgForward {fwdSender, fwdBrokerTs = msgTs} parsedMsg@(ParsedMsg _ _ chatMsg@ChatMessage {chatMsgEvent}) brokerTs = do unless (isMemberGrpFwdRelay gInfo m) $ throwChatError (CEGroupContactRole localDisplayName) case fwdSender of FwdMember memberId memberName -> do @@ -3878,21 +3963,21 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = where MsgContainer {scope} = mc -- file description is always allowed, to allow sending files to support scope - XMsgFileDescr sharedMsgId fileDescr fileExpires -> void $ groupMessageFileDescription gInfo author_ sharedMsgId fileDescr fileExpires + XMsgFileDescr sharedMsgId fileDescr fileExpires fileBadge -> void $ groupMessageFileDescription gInfo author_ sharedMsgId fileDescr fileExpires fileBadge XMsgUpdate sharedMsgId mContent mentions ttl live msgScope asGroup_ -> void $ memberCanSend author_ msgScope $ groupMessageUpdate gInfo author_ sharedMsgId mContent mentions msgScope rcvMsg msgTs ttl live asGroup_ XMsgDel sharedMsgId memId scope_ _ -> void $ groupMessageDelete gInfo author_ sharedMsgId memId scope_ False rcvMsg msgTs XMsgReact sharedMsgId memId scope_ reaction add -> withAuthor XMsgReact_ $ \author -> void $ groupMsgReaction gInfo author sharedMsgId memId scope_ reaction add rcvMsg msgTs XFileCancel sharedMsgId -> void $ xFileCancelGroup gInfo author_ sharedMsgId - XInfo p -> withAuthor XInfo_ $ \author -> void $ xInfoMember gInfo author p rcvMsg msgTs + XInfo p mKey -> withAuthor XInfo_ $ \author -> void $ xInfoMember gInfo author p mKey rcvMsg msgTs XGrpRelayNew rl -> withAuthor XGrpRelayNew_ $ \author -> void $ xGrpRelayNew gInfo author rl - XGrpMemNew memInfo msgScope -> withAuthor XGrpMemNew_ $ \author -> void $ xGrpMemNew gInfo author memInfo msgScope rcvMsg msgTs - XGrpMemRole memId memRole memberKey rosterVer -> withAuthor XGrpMemRole_ $ \author -> void $ xGrpMemRole gInfo (Just m) author memId memRole memberKey rosterVer rcvMsg msgTs + XGrpMemNew memInfo msgScope -> withAuthor XGrpMemNew_ $ \author -> void $ xGrpMemNew g author memInfo msgScope rcvMsg msgTs + XGrpMemRole memId memRole memberKey rosterVer -> withAuthor XGrpMemRole_ $ \author -> void $ xGrpMemRole g (Just m) author memId memRole memberKey rosterVer rcvMsg msgTs XGrpMemRestrict memId memRestrictions -> withAuthor XGrpMemRestrict_ $ \author -> void $ xGrpMemRestrict gInfo author memId memRestrictions rcvMsg msgTs - XGrpMemDel memId withMessages rosterVer -> withAuthor XGrpMemDel_ $ \author -> void $ xGrpMemDel gInfo (Just m) author memId withMessages rosterVer verifiedMsg rcvMsg msgTs True - XGrpLeave -> withAuthor XGrpLeave_ $ \author -> void $ xGrpLeave gInfo author rcvMsg msgTs + XGrpMemDel memId withMessages rosterVer -> withAuthor XGrpMemDel_ $ \author -> void $ xGrpMemDel g (Just m) author memId withMessages rosterVer verifiedMsg rcvMsg msgTs True + XGrpLeave -> withAuthor XGrpLeave_ $ \author -> void $ xGrpLeave g author rcvMsg msgTs XGrpDel -> withAuthor XGrpDel_ $ \author -> void $ xGrpDel gInfo author rcvMsg msgTs - XGrpInfo p' -> withAuthor XGrpInfo_ $ \author -> void $ xGrpInfo gInfo author p' rcvMsg msgTs + XGrpInfo p' -> withAuthor XGrpInfo_ $ \author -> void $ xGrpInfo g author p' rcvMsg msgTs XGrpPrefs ps' -> withAuthor XGrpPrefs_ $ \author -> void $ xGrpPrefs gInfo author ps' rcvMsg XGrpRoster gr -> withAuthor XGrpRoster_ $ \author -> void $ xGrpRoster gInfo m author gr verifiedMsg sharedMsgId_ msgTs _ -> messageError $ "x.grp.msg.forward: unsupported forwarded event " <> T.pack (show $ toCMEventTag event) @@ -3903,7 +3988,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = Nothing -> messageError $ "x.grp.msg.forward: event " <> tshow tag <> " requires author" withVerifiedMsg :: GroupInfo -> Maybe GroupChatScopeInfo -> GroupMember -> ParsedMsg e -> UTCTime -> (VerifiedMsg e -> CM a) -> CM (Maybe a) - withVerifiedMsg gInfo@GroupInfo {membership} scopeInfo member (ParsedMsg _ signedMsg_ chatMsg@ChatMessage {chatMsgEvent}) ts action = + withVerifiedMsg gInfo@GroupInfo {membership} scopeInfo member@GroupMember {memberPubKey, memberId} (ParsedMsg _ signedMsg_ chatMsg@ChatMessage {chatMsgEvent}) ts action = case verified of Just verifiedMsg -> Just <$> action verifiedMsg Nothing -> do @@ -3911,17 +3996,11 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = pure Nothing where verified = case signedMsg_ of - Just sm@SignedMsg {chatBinding, signatures, signedBody} - | GroupMember {memberPubKey = Just pubKey, memberId} <- member -> - case chatBinding of - CBGroup - | Just GroupKeys {publicGroupId} <- groupKeys gInfo -> - signed MSSVerified <$ guard (verifyGroupSig pubKey publicGroupId memberId signatures signedBody) - | otherwise -> - let prefix = smpEncode chatBinding <> smpEncode (memberId, pubKey) -- forward compatibility for verifying signed messages in p2p groups - in signed MSSVerified <$ guard (all (\case (MsgSignature KRMember sig) -> C.verify (C.APublicVerifyKey C.SEd25519 pubKey) sig (prefix <> signedBody)) signatures) - _ -> signed MSSSignedNoKey <$ guard signatureOptional - | otherwise -> signed MSSSignedNoKey <$ guard (signatureOptional || unverifiedAllowed membership member tag) + Just sm@SignedMsg {chatBinding, signatures, signedBody} -> case memberPubKey of + Just pubKey -> case chatBinding of + CBGroup -> signed MSSVerified <$ guard (verifyGroupSig pubKey gInfo memberId signatures signedBody) + _ -> signed MSSSignedNoKey <$ guard signatureOptional + Nothing -> signed MSSSignedNoKey <$ guard (signatureOptional || unverifiedAllowed membership member tag) where signed status = VMSigned status sm chatMsg Nothing -> VMUnsigned chatMsg <$ guard signatureOptional @@ -3941,8 +4020,10 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m checkIntegrityCreateItem (CDGroupRcv gInfo' scopeInfo m') msgMeta `catchAllErrors` \_ -> pure () forM_ msgRcpts $ \MsgReceipt {agentMsgId, msgRcptStatus} -> do - withStore' $ \db -> updateSndMsgDeliveryStatus db connId agentMsgId $ MDSSndRcvd msgRcptStatus - updateGroupItemsStatus gInfo' m' conn agentMsgId (GSSRcvd msgRcptStatus) Nothing + acis <- withStore $ \db -> do + liftIO $ updateSndMsgDeliveryStatus db connId agentMsgId $ MDSSndRcvd msgRcptStatus + updateGroupItemsStatus db gInfo' m' conn agentMsgId (GSSRcvd msgRcptStatus) Nothing + unless (null acis) $ toView $ CEvtChatItemsStatusesUpdated user acis -- Searches chat items for many agent message IDs and updates their status updateDirectItemsStatusMsgs :: Contact -> Connection -> [AgentMsgId] -> CIStatus 'MDSnd -> CM () @@ -3983,22 +4064,20 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = | otherwise -> updateGroupSndStatus db itemId groupMemberId newStatus $> True _ -> pure False - updateGroupItemsStatus :: GroupInfo -> GroupMember -> Connection -> AgentMsgId -> GroupSndStatus -> Maybe Bool -> CM () - updateGroupItemsStatus gInfo@GroupInfo {groupId} GroupMember {groupMemberId} Connection {connId} msgId newMemStatus viaProxy_ = do - acis <- withStore $ \db -> do - items <- liftIO $ getGroupChatItemsByAgentMsgId db user groupId connId msgId - cis <- catMaybes <$> mapM (updateItem db) items - -- SENT and RCVD events are received for messages that may be batched in single scope, - -- so we can look up scope of first item - scopeInfo <- case cis of - (ci : _) -> getGroupChatScopeInfoForItem db cxt user gInfo (chatItemId' ci) - _ -> pure Nothing - pure $ map (gItem scopeInfo) cis - unless (null acis) $ toView $ CEvtChatItemsStatusesUpdated user acis + updateGroupItemsStatus :: DB.Connection -> GroupInfo -> GroupMember -> Connection -> AgentMsgId -> GroupSndStatus -> Maybe Bool -> ExceptT StoreError IO [AChatItem] + updateGroupItemsStatus db gInfo@GroupInfo {groupId} GroupMember {groupMemberId} Connection {connId} msgId newMemStatus viaProxy_ = do + items <- liftIO $ getGroupChatItemsByAgentMsgId db user groupId connId msgId + cis <- catMaybes <$> mapM updateItem items + -- SENT and RCVD events are received for messages that may be batched in single scope, + -- so we can look up scope of first item + scopeInfo <- case cis of + (ci : _) -> getGroupChatScopeInfoForItem db cxt user gInfo (chatItemId' ci) + _ -> pure Nothing + pure $ map (gItem scopeInfo) cis where gItem scopeInfo ci = AChatItem SCTGroup SMDSnd (GroupChat gInfo scopeInfo) ci - updateItem :: DB.Connection -> CChatItem 'CTGroup -> ExceptT StoreError IO (Maybe (ChatItem 'CTGroup 'MDSnd)) - updateItem db = \case + updateItem :: CChatItem 'CTGroup -> ExceptT StoreError IO (Maybe (ChatItem 'CTGroup 'MDSnd)) + updateItem = \case (CChatItem SMDSnd ChatItem {meta = CIMeta {itemStatus = CISSndRcvd _ SSPComplete}}) -> pure Nothing (CChatItem SMDSnd ChatItem {meta = CIMeta {itemId, itemStatus}}) -> do forM_ viaProxy_ $ \viaProxy -> liftIO $ setGroupSndViaProxy db itemId groupMemberId viaProxy @@ -4379,7 +4458,7 @@ runRelayRequestWorker a Worker {doWork} = do r -> pure r scheduleRequest :: GroupId -> NominalDiffTime -> CM () scheduleRequest groupId delay = do - v_ <- liftIO $ atomically $ + v_ <- atomically $ ifM (isNothing <$> TM.lookup groupId delayThreads) (newEmptyTMVar >>= \v -> TM.insert groupId v delayThreads $> Just v) @@ -4390,7 +4469,7 @@ runRelayRequestWorker a Worker {doWork} = do atomically $ TM.delete groupId delayThreads void $ atomically $ tryPutTMVar doWork () weakTId <- liftIO $ mkWeakThreadId tId - liftIO $ atomically $ putTMVar v weakTId + atomically $ putTMVar v weakTId retryTmpError :: (Int, NominalDiffTime) -> GroupId -> RelayRequestData -> ChatError -> CM () retryTmpError (retriesThreshold, ttl) groupId RelayRequestData {reqDelay, reqRetries, reqCreatedAt} = \case ChatErrorAgent {agentError} | temporaryOrHostError agentError -> do @@ -4407,10 +4486,10 @@ runRelayRequestWorker a Worker {doWork} = do eToView e processRelayRequest :: GroupId -> RelayRequestData -> CM () processRelayRequest groupId rrd = do - (gInfo, groupLink_) <- withStore $ \db -> do - gInfo <- getGroupInfo db cxt user groupId + (g@(GIK gInfo _), groupLink_) <- withStore $ \db -> do + g@(GIK gInfo _) <- getGroupInfoKeys db cxt user groupId groupLink_ <- liftIO $ runExceptT $ getGroupLink db user gInfo - pure (gInfo, groupLink_) + pure (g, groupLink_) -- Check if relay link already exists (recovery case) case groupLink_ of Right GroupLink {connLinkContact = CCLink _ sLnk_} -> @@ -4418,11 +4497,14 @@ runRelayRequestWorker a Worker {doWork} = do Just sLnk -> acceptOwnerConnection rrd gInfo sLnk Nothing -> throwChatError $ CEException "processRelayRequest: relay link doesn't have short link" Left _ -> do - (gInfo', sLnk) <- getLinkDataCreateRelayLink rrd gInfo + (gInfo', sLnk) <- getLinkDataCreateRelayLink rrd g acceptOwnerConnection rrd gInfo' sLnk where - getLinkDataCreateRelayLink :: RelayRequestData -> GroupInfo -> CM (GroupInfo, ShortLinkContact) - getLinkDataCreateRelayLink RelayRequestData {reqGroupLink} gInfo = do + getLinkDataCreateRelayLink :: RelayRequestData -> GroupInfoKeys -> CM (GroupInfo, ShortLinkContact) + getLinkDataCreateRelayLink RelayRequestData {reqGroupLink} (GIK gInfo gks) = do + memberPrivKey' <- case gks of + GKRelayRequest {memberPrivKey} -> pure memberPrivKey + _ -> throwChatError $ CEException "getLinkDataCreateRelayLink: group is not a relay request" (FixedLinkData {linkEntityId, rootKey}, cData@(ContactLinkData _ UserContactData {owners}), _) <- getShortLinkConnReq' NRMBackground user reqGroupLink liftIO (decodeLinkUserData cData) >>= \case Nothing -> throwChatError $ CEException "getLinkDataCreateRelayLink: no group link data" @@ -4432,10 +4514,10 @@ runRelayRequestWorker a Worker {doWork} = do | B64UrlByteString entityId == publicGroupId -> pure pg _ -> throwChatError $ CEException "getLinkDataCreateRelayLink: linkEntityId does not match profile publicGroupId" validateGroupProfile gp - ((_, memberPrivKey), sLnk) <- createRelayLink gInfo + sLnk <- createRelayLink gInfo (C.publicKey memberPrivKey', memberPrivKey') gInfo' <- withStore $ \db -> do void $ updateGroupProfile db user gInfo gp - updateRelayGroupKeys db user gInfo pg rootKey memberPrivKey owners + updateRelayGroupKeys db user gInfo pg rootKey owners getGroupInfo db cxt user groupId pure (gInfo', sLnk) where @@ -4443,14 +4525,13 @@ runRelayRequestWorker a Worker {doWork} = do validateGroupProfile _groupProfile = do -- TODO [relays] relay: validate group profile, verify owner's signature pure () - createRelayLink :: GroupInfo -> CM (C.KeyPairEd25519, ShortLinkContact) - createRelayLink gi = do + createRelayLink :: GroupInfo -> C.KeyPairEd25519 -> CM ShortLinkContact + createRelayLink gi sigKeys = do let GroupInfo {membership} = gi GroupMember {memberId = MemberId relayMemId, memberProfile = p} = membership gVar <- asks random groupLinkId <- GroupLinkId <$> drgRandomBytes 16 subMode <- chatReadVar subscriptionMode - sigKeys <- liftIO $ atomically $ C.generateKeyPair gVar let crClientData = encodeJSON $ CRDataGroup groupLinkId -- prepare link with relayMemId as linkEntityId (no server request) (ccLink, preparedParams) <- withAgent $ \a' -> prepareConnectionLink a' (aUserId user) sigKeys relayMemId True (Just crClientData) CR.IKPQOff False Nothing @@ -4465,7 +4546,7 @@ runRelayRequestWorker a Worker {doWork} = do -- TODO [relays] starting role should be communicated in protocol from owner to relays subRole <- asks $ channelSubscriberRole . config void $ withFastStore $ \db -> createGroupLink db gVar user gi connId ccLink' groupLinkId subRole subMode - pure (sigKeys, sLnk) + pure sLnk acceptOwnerConnection :: RelayRequestData -> GroupInfo -> ShortLinkContact -> CM () acceptOwnerConnection RelayRequestData {relayInvId, reqChatVRange} gi relayLink = do ownerMember <- withStore $ \db -> getHostMember db cxt user groupId diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 5a62ca7a81..3c6dca2ac8 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -688,7 +688,8 @@ data CIFile (d :: MsgDirection) = CIFile fileSource :: Maybe CryptoFile, -- local file path with optional key and nonce fileStatus :: CIFileStatus d, fileProtocol :: FileProtocol, - fileExpires :: Maybe UTCTime + fileExpires :: Maybe UTCTime, + fileProhibited :: Maybe FileProhibited } deriving (Show) diff --git a/src/Simplex/Chat/Messages/Batch.hs b/src/Simplex/Chat/Messages/Batch.hs index 1f9ae0b9ea..0aa177cf27 100644 --- a/src/Simplex/Chat/Messages/Batch.hs +++ b/src/Simplex/Chat/Messages/Batch.hs @@ -67,7 +67,7 @@ batchMessages mode maxLen = addBatch . foldr addToBatch ([], [], [], 0, 0) | msgLen <= maxLen = (addBatch acc, [body], [msg], msgLen, 1) | otherwise = (errLarge msg : addBatch acc, [], [], 0, 0) where - body = encodeBatchElement signedMsg_ msgBody + body = encodeBatchElement (if mode == BMBinary then signedMsg_ else Nothing) msgBody msgLen = B.length body len' = len + msgLen n' = n + 1 diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 09af069ef7..085df84956 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -12,6 +12,7 @@ module Simplex.Chat.Mobile where import Control.Concurrent.STM import Control.Exception (SomeException, catch) +import Control.Monad import Control.Monad.Except import Control.Monad.Reader import Data.Aeson (ToJSON (..)) @@ -27,6 +28,7 @@ import Data.List (find) import qualified Data.List.NonEmpty as L import Data.Maybe (fromMaybe) import Data.Text (Text) +import Data.Text.Encoding (encodeUtf8) import Data.Word (Word8) import Foreign.C.String import Foreign.C.Types (CInt (..)) @@ -34,7 +36,9 @@ import Foreign.Ptr import Foreign.StablePtr import Foreign.Storable (poke) import GHC.IO.Encoding (setFileSystemEncoding, setForeignEncoding, setLocaleEncoding) +import Numeric.Natural (Natural) import Simplex.Chat +import Simplex.Chat.Badges.Code (badgeCodeText, parseBadgeCode) import Simplex.Chat.Controller import Simplex.Chat.Library.Commands import Simplex.Chat.Markdown (ParsedMarkdown (..), parseMaybeMarkdownList, parseUri, sanitizeUri) @@ -71,6 +75,7 @@ import qualified Simplex.Messaging.Agent.Store.DB as DB data DBMigrationResult = DBMOk | DBMInvalidConfirmation + | DBMInvalidQueueSize | DBMErrorNotADatabase {dbFile :: String} | DBMErrorMigration {dbFile :: String, migrationError :: MigrationError} | DBMErrorSQL {dbFile :: String, migrationSQLError :: String} @@ -111,6 +116,8 @@ foreign export ccall "chat_migrate_init" cChatMigrateInit :: CString -> CString foreign export ccall "chat_migrate_init_key" cChatMigrateInitKey :: CString -> CString -> CInt -> CString -> CInt -> Ptr (StablePtr ChatController) -> IO CJSONString +foreign export ccall "chat_migrate_init_queue" cChatMigrateInitQueue :: CString -> CString -> CString -> CInt -> Ptr (StablePtr ChatController) -> IO CJSONString + foreign export ccall "chat_close_store" cChatCloseStore :: StablePtr ChatController -> IO CString foreign export ccall "chat_reopen_store" cChatReopenStore :: StablePtr ChatController -> IO CString @@ -137,6 +144,8 @@ foreign export ccall "chat_password_hash" cChatPasswordHash :: CString -> CStrin foreign export ccall "chat_valid_name" cChatValidName :: CString -> IO CString +foreign export ccall "chat_parse_badge_code" cChatParseBadgeCode :: CString -> IO CString + foreign export ccall "chat_json_length" cChatJsonLength :: CString -> IO CInt foreign export ccall "chat_badge_keygen" cChatBadgeKeygen :: IO CJSONString @@ -162,7 +171,14 @@ cChatMigrateInit fp key conf = cChatMigrateInitKey fp key 0 conf 0 -- For postgres first param is schema prefix, second param is database connection string. cChatMigrateInitKey :: CString -> CString -> CInt -> CString -> CInt -> Ptr (StablePtr ChatController) -> IO CJSONString -cChatMigrateInitKey fp key keepKey conf background ctrl = do +cChatMigrateInitKey fp key keepKey conf background = cChatMigrateInit_ fp key (keepKey /= 0) conf (background /= 0) mobileQueueSize + +-- | queueSize is the size of internal queues, same as terminal option --queue-size +cChatMigrateInitQueue :: CString -> CString -> CString -> CInt -> Ptr (StablePtr ChatController) -> IO CJSONString +cChatMigrateInitQueue fp key conf queueSize = cChatMigrateInit_ fp key False conf False (fromIntegral queueSize) + +cChatMigrateInit_ :: CString -> CString -> Bool -> CString -> Bool -> Int -> Ptr (StablePtr ChatController) -> IO CJSONString +cChatMigrateInit_ fp key keepKey conf background queueSize ctrl = do -- ensure we are set to UTF-8; iOS does not have locale, and will default to -- US-ASCII all the time. setLocaleEncoding utf8 @@ -172,7 +188,7 @@ cChatMigrateInitKey fp key keepKey conf background ctrl = do chatDbOpts <- mobileDbOpts fp key confirm <- peekCAString conf r <- - chatMigrateInitKey chatDbOpts (keepKey /= 0) confirm (background /= 0) >>= \case + chatMigrateInitKey chatDbOpts keepKey confirm background queueSize >>= \case Right cc -> (newStablePtr cc >>= poke ctrl) $> DBMOk Left e -> pure e newCStringFromLazyBS $ J.encode r @@ -240,12 +256,21 @@ cChatPasswordHash cPwd cSalt = do cChatValidName :: CString -> IO CString cChatValidName cName = newCString . mkValidName =<< peekCString cName +-- | canonical form of a code that passes its check character, empty string if it does not parse +cChatParseBadgeCode :: CString -> IO CString +cChatParseBadgeCode cCode = do + code <- safeDecodeUtf8 <$> B.packCString cCode + newCStringFromBS $ maybe "" (encodeUtf8 . badgeCodeText) $ parseBadgeCode code + -- | returns length of JSON encoded string cChatJsonLength :: CString -> IO CInt cChatJsonLength s = fromIntegral . subtract 2 . LB.length . J.encode . safeDecodeUtf8 <$> B.packCString s -mobileChatOpts :: ChatDbOpts -> ChatOpts -mobileChatOpts dbOptions = +mobileQueueSize :: Int +mobileQueueSize = 4096 + +mobileChatOpts :: ChatDbOpts -> Natural -> ChatOpts +mobileChatOpts dbOptions tbqSize = ChatOpts { coreOptions = CoreChatOpts @@ -258,7 +283,8 @@ mobileChatOpts dbOptions = logServerHosts = True, logAgent = Nothing, logFile = Nothing, - tbqSize = 4096, + tbqSize, + maxChats = 5000, deviceName = Nothing, chatRelay = False, webPreviewConfig = Nothing, @@ -302,18 +328,19 @@ getActiveUser_ st = find activeUser <$> withTransaction st getUsers chatMigrateInit :: String -> ScrubbedBytes -> String -> IO (Either DBMigrationResult ChatController) chatMigrateInit dbFilePrefix dbKey confirm = do let chatDBOpts = ChatDbOpts {dbFilePrefix, dbKey, trackQueries = DB.TQSlow 5000, vacuumOnMigration = True} - chatMigrateInitKey chatDBOpts False confirm False + chatMigrateInitKey chatDBOpts False confirm False mobileQueueSize #endif -chatMigrateInitKey :: ChatDbOpts -> Bool -> String -> Bool -> IO (Either DBMigrationResult ChatController) -chatMigrateInitKey chatDbOpts keepKey confirm backgroundMode = runExceptT $ do +chatMigrateInitKey :: ChatDbOpts -> Bool -> String -> Bool -> Int -> IO (Either DBMigrationResult ChatController) +chatMigrateInitKey chatDbOpts keepKey confirm backgroundMode queueSize = runExceptT $ do + unless (queueSize > 0) $ throwError DBMInvalidQueueSize confirmMigrations <- liftEitherWith (const DBMInvalidConfirmation) $ strDecode $ B.pack confirm let migrationConfig = MigrationConfig confirmMigrations (Just "") chatStore <- migrate createChatStore (toDBOpts chatDbOpts chatSuffix keepKey chatDBFunctions) migrationConfig agentStore <- migrate createAgentStore (toDBOpts chatDbOpts agentSuffix keepKey []) migrationConfig ExceptT $ initialize chatStore ChatDatabase {chatStore, agentStore} where - opts = mobileChatOpts $ removeDbKey chatDbOpts + opts = mobileChatOpts (removeDbKey chatDbOpts) (fromIntegral queueSize) initialize st db = do user_ <- liftIO $ getActiveUser_ st first DBMAgentError <$> newChatController db user_ defaultMobileConfig opts backgroundMode diff --git a/src/Simplex/Chat/Options.hs b/src/Simplex/Chat/Options.hs index 4e47868fa0..d8bbb31606 100644 --- a/src/Simplex/Chat/Options.hs +++ b/src/Simplex/Chat/Options.hs @@ -67,6 +67,7 @@ data CoreChatOpts = CoreChatOpts logAgent :: Maybe LogLevel, logFile :: Maybe FilePath, tbqSize :: Natural, + maxChats :: Int, deviceName :: Maybe Text, chatRelay :: Bool, webPreviewConfig :: Maybe WebPreviewConfig, @@ -234,6 +235,15 @@ coreChatOptsP appDir defaultDbName = do <> value 1024 <> showDefault ) + maxChats <- + option + auto + ( long "max-chats" + <> metavar "COUNT" + <> help "Max number of chats loaded by chat list API" + <> value 5000 + <> showDefault + ) deviceName <- optional $ strOption @@ -340,6 +350,7 @@ coreChatOptsP appDir defaultDbName = do logAgent = if logAgent || logLevel == CLLDebug then Just $ agentLogLevel logLevel else Nothing, logFile, tbqSize, + maxChats, deviceName, chatRelay, webPreviewConfig, diff --git a/src/Simplex/Chat/PaymentService.hs b/src/Simplex/Chat/PaymentService.hs new file mode 100644 index 0000000000..38f94b2118 --- /dev/null +++ b/src/Simplex/Chat/PaymentService.hs @@ -0,0 +1,37 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE TemplateHaskell #-} + +module Simplex.Chat.PaymentService + ( ServiceInvoice (..), + ServicePayment (..), + module Simplex.Chat.PaymentService.Types, + ) where + +import qualified Data.Aeson.TH as JQ +import Data.Text (Text) +import Data.Time.Clock (UTCTime) +import Simplex.Chat.PaymentService.Types +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, taggedObjectJSON) + +data ServiceInvoice = ServiceInvoice + { invoiceId :: InvoiceId, + price :: CurrencyAmount, + discount :: Maybe CurrencyAmount, -- discount amount from the price + credit :: Maybe CurrencyAmount, -- credit for upgrade + amount :: CurrencyAmount, -- price - discount - credit + currency :: Text, + expiresAt :: UTCTime, + paymentTo :: ServicePaymentDestination + } + deriving (Show) + +data ServicePayment + = SPApple {jws :: Text} + | SPGoogle {token :: Text} + | SPInvoice {invoiceId :: InvoiceId} + | SPReceipt {receipt :: Text} -- transfer of unissued months + deriving (Show) + +$(JQ.deriveJSON defaultJSON ''ServiceInvoice) + +$(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "SP") ''ServicePayment) diff --git a/src/Simplex/Chat/PaymentService/Types.hs b/src/Simplex/Chat/PaymentService/Types.hs new file mode 100644 index 0000000000..5df3aea92a --- /dev/null +++ b/src/Simplex/Chat/PaymentService/Types.hs @@ -0,0 +1,147 @@ +{-# LANGUAGE DerivingStrategies #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE GeneralizedNewtypeDeriving #-} +{-# LANGUAGE TemplateHaskell #-} + +module Simplex.Chat.PaymentService.Types + ( CurrencyAmount (..), + InvoiceId (..), + PaymentId (..), + PaymentProvider (..), + CardProvider (..), + CryptoCurrency (..), + ServicePaymentMethod (..), + ServicePaymentDestination (..), + InvoiceStatus (..), + StoredInvoice (..), + StoredPayment (..), + PaymentFunding (..), + PaymentTerm (..), + PaymentStatus (..), + ) where + +import Data.Aeson (FromJSON, ToJSON) +import qualified Data.Aeson.TH as JQ +import Data.ByteString.Char8 (ByteString) +import Data.Text (Text) +import Data.Time.Clock (UTCTime) +import Data.Word (Word32) +import Simplex.Messaging.Parsers (dropPrefix, enumJSON, taggedObjectJSON) + +-- USD etc. are in minor units, following Stripe etc. convention +newtype CurrencyAmount = CurrencyAmount Word32 + deriving (Eq, Show) + deriving newtype (ToJSON, FromJSON) + +-- confirmed +newtype InvoiceId = InvoiceId Text + deriving newtype (Eq, Show, ToJSON, FromJSON) + +-- confirmed +newtype PaymentId = PaymentId Text + deriving newtype (Eq, Show) + +-- confirmed +data PaymentProvider = PPApple | PPGoogle | PPStripe | PPCrypto | PPCode | PPReceipt + deriving (Eq, Show) + +data CardProvider = CPStripe + deriving (Eq, Show) + +data CryptoCurrency = CCBtc | CCXmr + deriving (Eq, Show) + +data ServicePaymentMethod + = SPMCard {provider :: CardProvider} + | SPMCrypto {currency :: CryptoCurrency} + deriving (Eq, Show) + +data ServicePaymentDestination + = SPDCard + { provider :: CardProvider, + url :: Text + } + | SPDCrypto + { currency :: CryptoCurrency, + address :: Text, + cryptoAmount :: Text + } + deriving (Show) + +-- confirmed +data InvoiceStatus = ISOpen | ISPaid | ISExpired + deriving (Eq, Show) + +-- confirmed +data StoredInvoice = StoredInvoice + { invoiceId :: InvoiceId, + price :: CurrencyAmount, + discountAmount :: CurrencyAmount, + creditAmount :: CurrencyAmount, + amount :: CurrencyAmount, -- price - discount - credit + currency :: Text, + paymentTo :: ServicePaymentDestination, + expiresAt :: UTCTime, + status :: InvoiceStatus, + createdAt :: UTCTime, + updatedAt :: UTCTime + } + deriving (Show) + +-- to review +data StoredPayment = StoredPayment + { paymentId :: PaymentId, + funding :: PaymentFunding, + term :: PaymentTerm, + status :: PaymentStatus, + createdAt :: UTCTime, + updatedAt :: UTCTime + } + deriving (Show) + +-- to review +data PaymentFunding + = PFInvoice + { invoiceId :: InvoiceId, + providerRef :: Text, + amount :: CurrencyAmount, + currency :: Text, + receiptCode :: Maybe Text -- client; the service holds its hash + } + | PFApple + { providerRef :: Text, + amount :: CurrencyAmount, + currency :: Text, + evidence :: Maybe ByteString -- client only + } + | PFGoogle + { providerRef :: Text, + amount :: CurrencyAmount, + currency :: Text, + evidence :: Maybe ByteString -- client only + } + | PFCode + | PFReceipt + deriving (Show) + +-- to review +data PaymentTerm + = PTOneOff + | PTSubscription + { renewsAt :: UTCTime, + graceUntil :: Maybe UTCTime, + cancelled :: Bool + } + deriving (Show) + +-- to review +data PaymentStatus = PSPending | PSSettled | PSFailed {exception :: Text} + deriving (Show) + +$(JQ.deriveJSON (enumJSON $ dropPrefix "CP") ''CardProvider) + +$(JQ.deriveJSON (enumJSON $ dropPrefix "CC") ''CryptoCurrency) + +$(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "SPM") ''ServicePaymentMethod) + +$(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "SPD") ''ServicePaymentDestination) diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 7c932d89ad..4671a927d1 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -50,7 +50,7 @@ import Data.Time.Clock.System (systemToUTCTime, utcToSystemTime) import Data.Type.Equality import Data.Typeable (Typeable) import Data.Word (Word16, Word32) -import Simplex.Chat.Badges (LocalBadge) +import Simplex.Chat.Badges (BadgeProof, LocalBadge) import Simplex.Chat.Call import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Chat.Types @@ -86,12 +86,13 @@ import Simplex.Messaging.Version hiding (version) -- 17 - allow host voice messages during member approval regardless of group voice setting (2026-02-10) -- 18 - relay web capabilities (2026-05-31) -- 19 - group roster (2026-06-18) +-- 20 - p2p group member keys for signing (2026-07-26) -- This should not be used directly in code, instead use `maxVersion chatVRange` from ChatConfig. -- This indirection is needed for backward/forward compatibility testing. -- Testing with real app versions is still needed, as tests use the current code with different version ranges, not the old code. currentChatVersion :: VersionChat -currentChatVersion = VersionChat 19 +currentChatVersion = VersionChat 20 -- This should not be used directly in code, instead use `chatVRange` from ChatConfig (see comment above) supportedChatVRange :: VersionRangeChat @@ -135,6 +136,10 @@ relayWebCapVersion = VersionChat 18 groupRosterVersion :: VersionChat groupRosterVersion = VersionChat 19 +-- members sign messages in p2p groups; member keys are distributed for verification +groupMemberKeyVersion :: VersionChat +groupMemberKeyVersion = VersionChat 20 + data ConnectionEntity = RcvDirectMsgConnection {entityConnection :: Connection, contact :: Maybe Contact} | RcvGroupMsgConnection {entityConnection :: Connection, groupInfo :: GroupInfo, groupMember :: GroupMember} @@ -446,7 +451,7 @@ signChatMsgBody MsgSigning {bindingTag, bindingData, keyRef, privKey} msgBody = data ChatMsgEvent (e :: MsgEncoding) where XMsgNew :: MsgContainer -> ChatMsgEvent 'Json - XMsgFileDescr :: {msgId :: SharedMsgId, fileDescr :: FileDescr, fileExpires :: Maybe UTCTime} -> ChatMsgEvent 'Json + XMsgFileDescr :: {msgId :: SharedMsgId, fileDescr :: FileDescr, fileExpires :: Maybe UTCTime, fileBadge :: Maybe BadgeProof} -> ChatMsgEvent 'Json XMsgUpdate :: {msgId :: SharedMsgId, content :: MsgContent, mentions :: Map MemberName MsgMention, ttl :: Maybe Int, live :: Maybe Bool, scope :: Maybe MsgScope, asGroup :: Maybe Bool} -> ChatMsgEvent 'Json XMsgDel :: {msgId :: SharedMsgId, memberId :: Maybe MemberId, scope :: Maybe MsgScope, onlyHistory :: Bool} -> ChatMsgEvent 'Json XMsgDeleted :: ChatMsgEvent 'Json @@ -455,15 +460,15 @@ data ChatMsgEvent (e :: MsgEncoding) where XFileAcpt :: String -> ChatMsgEvent 'Json -- direct file protocol XFileAcptInv :: SharedMsgId -> Maybe ConnReqInvitation -> String -> ChatMsgEvent 'Json XFileCancel :: SharedMsgId -> ChatMsgEvent 'Json - XInfo :: Profile -> ChatMsgEvent 'Json - XContact :: {profile :: Profile, contactReqId :: Maybe XContactId, welcomeMsgId :: Maybe SharedMsgId, requestMsg :: Maybe (SharedMsgId, MsgContent)} -> ChatMsgEvent 'Json + XInfo :: {profile :: Profile, memberKey :: Maybe MemberKey} -> ChatMsgEvent 'Json + XContact :: {profile :: Profile, memberKey :: Maybe MemberKey, contactReqId :: Maybe XContactId, welcomeMsgId :: Maybe SharedMsgId, requestMsg :: Maybe (SharedMsgId, MsgContent)} -> ChatMsgEvent 'Json XMember :: {profile :: Profile, newMemberId :: MemberId, newMemberKey :: MemberKey, viaRelay :: Maybe MemberId} -> ChatMsgEvent 'Json XDirectDel :: ChatMsgEvent 'Json XGrpInv :: GroupInvitation -> ChatMsgEvent 'Json - XGrpAcpt :: MemberId -> ChatMsgEvent 'Json + XGrpAcpt :: MemberId -> Maybe MemberKey -> ChatMsgEvent 'Json XGrpLinkInv :: GroupLinkInvitation -> ChatMsgEvent 'Json XGrpLinkReject :: GroupLinkRejection -> ChatMsgEvent 'Json - XGrpLinkMem :: Profile -> ChatMsgEvent 'Json + XGrpLinkMem :: Profile -> Maybe MemberKey -> ChatMsgEvent 'Json XGrpLinkAcpt :: GroupAcceptance -> GroupMemberRole -> MemberId -> ChatMsgEvent 'Json XGrpRelayInv :: GroupRelayInvitation -> ChatMsgEvent 'Json XGrpRelayAcpt :: ShortLinkContact -> RelayCapabilities -> ChatMsgEvent 'Json @@ -522,7 +527,7 @@ isForwardedGroupMsg ev = case ev of XMsgDel {} -> True XMsgReact {} -> True XFileCancel _ -> True - XInfo _ -> True + XInfo {} -> True XGrpRelayNew _ -> True XGrpMemNew {} -> True XGrpMemRole {} -> True @@ -1248,15 +1253,15 @@ toCMEventTag msg = case msg of XFileAcpt _ -> XFileAcpt_ XFileAcptInv {} -> XFileAcptInv_ XFileCancel _ -> XFileCancel_ - XInfo _ -> XInfo_ + XInfo {} -> XInfo_ XContact {} -> XContact_ XMember {} -> XMember_ XDirectDel -> XDirectDel_ XGrpInv _ -> XGrpInv_ - XGrpAcpt _ -> XGrpAcpt_ + XGrpAcpt {} -> XGrpAcpt_ XGrpLinkInv _ -> XGrpLinkInv_ XGrpLinkReject _ -> XGrpLinkReject_ - XGrpLinkMem _ -> XGrpLinkMem_ + XGrpLinkMem {} -> XGrpLinkMem_ XGrpLinkAcpt {} -> XGrpLinkAcpt_ XGrpRelayInv _ -> XGrpRelayInv_ XGrpRelayAcpt {} -> XGrpRelayAcpt_ @@ -1342,6 +1347,7 @@ requiresSignature = \case XGrpRelayNew_ -> True XGrpRoster_ -> True XInfo_ -> True + XGrpLinkMem_ -> True _ -> False -- | Content events a member may sign (XMsgNew opt-in; XMsgUpdate/XMsgDel when the target was signed). @@ -1391,7 +1397,7 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do msg :: CMEventTag 'Json -> Either String (ChatMsgEvent 'Json) msg = \case XMsgNew_ -> XMsgNew <$> JT.parseEither parseJSON (J.Object params) - XMsgFileDescr_ -> XMsgFileDescr <$> p "msgId" <*> p "fileDescr" <*> opt "fileExpires" + XMsgFileDescr_ -> XMsgFileDescr <$> p "msgId" <*> p "fileDescr" <*> opt "fileExpires" <*> opt "fileBadge" XMsgUpdate_ -> do msgId' <- p "msgId" content <- p "content" @@ -1408,22 +1414,23 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do XFileAcpt_ -> XFileAcpt <$> p "fileName" XFileAcptInv_ -> XFileAcptInv <$> p "msgId" <*> opt "fileConnReq" <*> p "fileName" XFileCancel_ -> XFileCancel <$> p "msgId" - XInfo_ -> XInfo <$> p "profile" + XInfo_ -> XInfo <$> p "profile" <*> opt "memberKey" XContact_ -> do profile <- p "profile" + memberKey <- opt "memberKey" contactReqId <- opt "contactReqId" welcomeMsgId <- opt "welcomeMsgId" reqMsgId <- opt "msgId" reqContent <- opt "content" let requestMsg = (,) <$> reqMsgId <*> reqContent - pure XContact {profile, contactReqId, welcomeMsgId, requestMsg} + pure XContact {profile, memberKey, contactReqId, welcomeMsgId, requestMsg} XMember_ -> XMember <$> p "profile" <*> p "newMemberId" <*> p "newMemberKey" <*> opt "viaRelay" XDirectDel_ -> pure XDirectDel XGrpInv_ -> XGrpInv <$> p "groupInvitation" - XGrpAcpt_ -> XGrpAcpt <$> p "memberId" + XGrpAcpt_ -> XGrpAcpt <$> p "memberId" <*> opt "memberKey" XGrpLinkInv_ -> XGrpLinkInv <$> p "groupLinkInvitation" XGrpLinkReject_ -> XGrpLinkReject <$> p "groupLinkRejection" - XGrpLinkMem_ -> XGrpLinkMem <$> p "profile" + XGrpLinkMem_ -> XGrpLinkMem <$> p "profile" <*> opt "memberKey" XGrpLinkAcpt_ -> XGrpLinkAcpt <$> p "acceptance" <*> p "role" <*> p "memberId" XGrpRelayInv_ -> XGrpRelayInv <$> p "groupRelayInvitation" XGrpRelayAcpt_ -> XGrpRelayAcpt <$> p "relayLink" <*> (fromMaybe defaultRelayCapabilities <$> opt "relayCap") @@ -1482,7 +1489,7 @@ chatToAppMessage chatMsg@ChatMessage {chatVRange, msgId, chatMsgEvent} = case en XMsgNew mc -> case toJSON mc of J.Object obj -> obj _ -> JM.empty - XMsgFileDescr msgId' fileDescr fileExpires -> o $ ("fileExpires" .=? fileExpires) ["msgId" .= msgId', "fileDescr" .= fileDescr] + XMsgFileDescr msgId' fileDescr fileExpires fileBadge -> o $ ("fileExpires" .=? fileExpires) $ ("fileBadge" .=? fileBadge) ["msgId" .= msgId', "fileDescr" .= fileDescr] XMsgUpdate {msgId = msgId', content, mentions, ttl, live, scope, asGroup} -> o $ ("asGroup" .=? asGroup) $ ("ttl" .=? ttl) $ ("live" .=? live) $ ("scope" .=? scope) $ ("mentions" .=? nonEmptyMap mentions) ["msgId" .= msgId', "content" .= content] XMsgDel msgId' memberId scope onlyHistory -> o $ ("memberId" .=? memberId) $ ("scope" .=? scope) $ ("onlyHistory" .=? justTrue onlyHistory) ["msgId" .= msgId'] XMsgDeleted -> JM.empty @@ -1491,15 +1498,15 @@ chatToAppMessage chatMsg@ChatMessage {chatVRange, msgId, chatMsgEvent} = case en XFileAcpt fileName -> o ["fileName" .= fileName] XFileAcptInv sharedMsgId fileConnReq fileName -> o $ ("fileConnReq" .=? fileConnReq) ["msgId" .= sharedMsgId, "fileName" .= fileName] XFileCancel sharedMsgId -> o ["msgId" .= sharedMsgId] - XInfo profile -> o ["profile" .= profile] - XContact {profile, contactReqId, welcomeMsgId, requestMsg} -> o $ ("contactReqId" .=? contactReqId) $ ("welcomeMsgId" .=? welcomeMsgId) $ ("msgId" .=? (fst <$> requestMsg)) $ ("content" .=? (snd <$> requestMsg)) $ ["profile" .= profile] + XInfo {profile, memberKey} -> o $ ("memberKey" .=? memberKey) ["profile" .= profile] + XContact {profile, memberKey, contactReqId, welcomeMsgId, requestMsg} -> o $ ("contactReqId" .=? contactReqId) $ ("welcomeMsgId" .=? welcomeMsgId) $ ("msgId" .=? (fst <$> requestMsg)) $ ("content" .=? (snd <$> requestMsg)) $ ("memberKey" .=? memberKey) $ ["profile" .= profile] XMember {profile, newMemberId, newMemberKey, viaRelay} -> o $ ("viaRelay" .=? viaRelay) ["profile" .= profile, "newMemberId" .= newMemberId, "newMemberKey" .= newMemberKey] XDirectDel -> JM.empty XGrpInv groupInv -> o ["groupInvitation" .= groupInv] - XGrpAcpt memId -> o ["memberId" .= memId] + XGrpAcpt memId memberKey -> o $ ("memberKey" .=? memberKey) ["memberId" .= memId] XGrpLinkInv groupLinkInv -> o ["groupLinkInvitation" .= groupLinkInv] XGrpLinkReject groupLinkRjct -> o ["groupLinkRejection" .= groupLinkRjct] - XGrpLinkMem profile -> o ["profile" .= profile] + XGrpLinkMem profile memberKey -> o $ ("memberKey" .=? memberKey) ["profile" .= profile] XGrpLinkAcpt acceptance role memberId -> o ["acceptance" .= acceptance, "role" .= role, "memberId" .= memberId] XGrpRelayInv groupRelayInv -> o ["groupRelayInvitation" .= groupRelayInv] XGrpRelayAcpt relayLink relayCap -> o ["relayLink" .= relayLink, "relayCap" .= relayCap] diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 4ccc97d816..de1b812b87 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 [7, 0, 0, 11] +minRemoteCtrlVersion = AppVersion [7, 1, 0, 5] -- when acting as controller minRemoteHostVersion :: AppVersion -minRemoteHostVersion = AppVersion [7, 0, 0, 11] +minRemoteHostVersion = AppVersion [7, 1, 0, 5] currentAppVersion :: AppVersion currentAppVersion = AppVersion SC.version diff --git a/src/Simplex/Chat/Store/Badges.hs b/src/Simplex/Chat/Store/Badges.hs new file mode 100644 index 0000000000..d581150df8 --- /dev/null +++ b/src/Simplex/Chat/Store/Badges.hs @@ -0,0 +1,356 @@ +{-# LANGUAGE CPP #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} +{-# LANGUAGE TypeOperators #-} + +module Simplex.Chat.Store.Badges + ( BadgeCodeRedemption (..), + UserBadgePurchase (..), + getUserBadgePurchase, + getBadgePurchase, + userHasBadge, + setBadgeAlertAcked, + clearShownBadge, + getBadgeCodeRedemption, + createBadgeCodeRedemption, + deleteBadgeCodeRedemption, + createCodeBadgePurchase, + getCodeBadgePurchase, + storeBadgeIssuance, + getLatestIssuedCredential, + storeBadgeStatement, + getBadgeLedgerLastEntry, + getBadgeLedger, + getBadgeLedgerEntryId, + ) +where + +import Control.Concurrent.STM (TVar, atomically) +import Crypto.Random (ChaChaDRG) +import qualified Data.Aeson as J +import qualified Data.ByteString.Lazy.Char8 as LB +import Data.Int (Int64) +import Data.Maybe (isJust, mapMaybe) +import Data.Text (Text) +import Data.Time.Clock (UTCTime) +import Simplex.Chat.Badges +import Simplex.Chat.Badges.Ledger +import Simplex.Chat.Badges.Service (StatementCreditType (..), StatementDebitType (..), StatementEntry (..), StatementEntryType (..)) +import Simplex.Chat.Badges.Types (BadgeAlertKind, BadgePurchaseStatus (..)) +import Simplex.Chat.Store.Shared (insertedRowId) +import Simplex.Chat.Types +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.Encoding.String (strEncode) +import Simplex.Messaging.Util (decodeJSON, maybeFirstRow, maybeFirstRow', safeDecodeUtf8) + +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (Only (..), (:.) (..)) +import Database.PostgreSQL.Simple.SqlQQ (sql) +#else +import Database.SQLite.Simple (Only (..), (:.) (..)) +import Database.SQLite.Simple.QQ (sql) +#endif + +-- | The keys one redemption attempt is signed with, stashed before the request is sent so that a +-- retry reaches the service as the same signer and is answered with the credential already issued. +data BadgeCodeRedemption = BadgeCodeRedemption + { redemptionId :: Int64, + purchaseKey :: C.PublicKeyEd25519, + purchasePrivKey :: C.PrivateKeyEd25519, + masterKey :: BadgeMasterKey + } + +getBadgeCodeRedemption :: DB.Connection -> User -> Text -> IO (Maybe BadgeCodeRedemption) +getBadgeCodeRedemption db User {userId} code = + maybeFirstRow toRedemption $ + DB.query + db + [sql| + SELECT badge_code_redemption_id, purchase_key, purchase_priv_key, master_key + FROM badge_code_redemptions + WHERE user_id = ? AND code = ? + |] + (userId, code) + where + toRedemption (redemptionId, purchaseKey, purchasePrivKey, Binary mk) = + BadgeCodeRedemption {redemptionId, purchaseKey, purchasePrivKey, masterKey = BadgeMasterKey mk} + +createBadgeCodeRedemption :: DB.Connection -> TVar ChaChaDRG -> User -> Text -> UTCTime -> IO BadgeCodeRedemption +createBadgeCodeRedemption db g User {userId} code now = do + (purchaseKey, purchasePrivKey) <- atomically $ C.generateKeyPair g + masterKey@(BadgeMasterKey mk) <- generateMasterKey g + DB.execute + db + [sql| + INSERT INTO badge_code_redemptions (user_id, code, purchase_key, purchase_priv_key, master_key, created_at) + VALUES (?,?,?,?,?,?) + |] + (userId, code, purchaseKey, purchasePrivKey, Binary mk, now) + redemptionId <- insertedRowId db + pure BadgeCodeRedemption {redemptionId, purchaseKey, purchasePrivKey, masterKey} + +-- | Drop a stashed attempt whose code the service refused for good, unless a purchase already +-- came from it - badge_purchases references this row. +deleteBadgeCodeRedemption :: DB.Connection -> Int64 -> IO () +deleteBadgeCodeRedemption db redemptionId = + DB.execute + db + [sql| + DELETE FROM badge_code_redemptions + WHERE badge_code_redemption_id = ? + AND NOT EXISTS (SELECT 1 FROM badge_purchases WHERE badge_code_redemption_id = ?) + |] + (redemptionId, redemptionId) + +-- | 'False' when the code was already redeemed here: the service replays the credential it +-- issued, and that must add no purchase and leave the shown badge alone. +createCodeBadgePurchase :: DB.Connection -> User -> BadgeCodeRedemption -> BadgeCredential -> UTCTime -> IO (Int64, Bool) +createCodeBadgePurchase db User {userId} redemption credential now = + getCodeBadgePurchase db redemption >>= \case + Just purchaseId -> pure (purchaseId, False) + Nothing -> do + DB.execute + db + [sql| + INSERT INTO badge_purchases + (user_id, purchase_key, purchase_priv_key, master_key, initial_badge_type, current_badge_type, status, badge_code_redemption_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?) + |] + (userId, purchaseKey, purchasePrivKey, Binary mk, badgeType, badgeType, PSIssued, redemptionId, now, now) + purchaseId <- insertedRowId db + DB.execute db "UPDATE users SET shown_badge_id = ? WHERE user_id = ?" (purchaseId, userId) + pure (purchaseId, True) + where + BadgeCodeRedemption {redemptionId, purchaseKey, purchasePrivKey, masterKey = BadgeMasterKey mk} = redemption + BadgeCredential {badgeInfo = BadgeInfo {badgeType}} = credential + +-- | The period comes from the ledger, the expiry from the credential, which runs a week longer. +-- 'False' means no issuance row was written, which the caller reports rather than drop in silence. +-- A replayed statement names a month already issued, and one month has one issuance. +storeBadgeIssuance :: DB.Connection -> TVar ChaChaDRG -> Int64 -> Int64 -> BadgeCredential -> UTCTime -> IO Bool +storeBadgeIssuance db g badgePurchaseId entryId credential now = + getIssuedPeriod db badgePurchaseId entryId >>= \case + Nothing -> pure False + Just (periodStart, periodEnd) -> do + issuanceId <- safeDecodeUtf8 . strEncode <$> atomically (C.randomBytes 16 g) + DB.execute + db + [sql| + INSERT INTO badge_issuances (issuance_id, badge_purchase_id, entry_id, badge_type, period_start, period_end, expiry, credential, created_at) + VALUES (?,?,?,?,?,?,?,?,?) + ON CONFLICT (badge_purchase_id, entry_id) DO NOTHING + |] + ((issuanceId, badgePurchaseId, entryId, badgeType) :. (periodStart, periodEnd, badgeExpiry, Binary (LB.toStrict $ J.encode credential), now)) + pure True + where + BadgeCredential {badgeInfo = BadgeInfo {badgeType, badgeExpiry}} = credential + +getLatestIssuedCredential :: DB.Connection -> Int64 -> IO (Maybe BadgeCredential) +getLatestIssuedCredential db badgePurchaseId = do + rows <- + DB.query + db + [sql| + SELECT credential FROM badge_issuances + WHERE badge_purchase_id = ? + ORDER BY period_end DESC + LIMIT 1 + |] + (Only badgePurchaseId) + pure $ case rows of + [Only (Binary bs)] -> J.decodeStrict' bs + _ -> Nothing + +-- the start is read from the row before rather than by subtracting a month, which clips +getIssuedPeriod :: DB.Connection -> Int64 -> Int64 -> IO (Maybe (UTCTime, UTCTime)) +getIssuedPeriod db badgePurchaseId entryId = do + rows <- + DB.query + db + [sql| + SELECT + (SELECT prev.balance_start_ts FROM badge_ledger prev + WHERE prev.badge_purchase_id = issued.badge_purchase_id AND prev.entry_id < issued.entry_id + ORDER BY prev.entry_id DESC LIMIT 1), + issued.balance_start_ts + FROM badge_ledger issued + WHERE issued.badge_purchase_id = ? AND issued.entry_id = ? + |] + (badgePurchaseId, entryId) + -- no preceding row means no credit was ever stored, so the period this row issued is unknown + pure $ case rows of + [(Just periodStart, periodEnd)] -> Just (periodStart, periodEnd) + _ -> Nothing + +getCodeBadgePurchase :: DB.Connection -> BadgeCodeRedemption -> IO (Maybe Int64) +getCodeBadgePurchase db BadgeCodeRedemption {redemptionId} = + maybeFirstRow fromOnly $ + DB.query db "SELECT badge_purchase_id FROM badge_purchases WHERE badge_code_redemption_id = ?" (Only redemptionId) + +data UserBadgePurchase = UserBadgePurchase + { badgePurchaseId :: Int64, + purchaseKey :: C.PublicKeyEd25519, + purchasePrivKey :: C.PrivateKeyEd25519, + masterKey :: BadgeMasterKey, + badgeType :: BadgeType, + shown :: Bool, + alertAcked :: Maybe (BadgeAlertKind, Text), + alertSnoozeUntil :: Maybe UTCTime + } + +-- | Newest, not the one shown_badge_id points at - retirement clears that, and the support ended +-- alert is recomputed from this purchase after the badge stops being shown. +getUserBadgePurchase :: DB.Connection -> User -> IO (Maybe UserBadgePurchase) +getUserBadgePurchase db User {userId} = + maybeFirstRow fromOnly newestId >>= maybe (pure Nothing) (getBadgePurchase db) + where + newestId = + DB.query + db + [sql| + SELECT badge_purchase_id FROM badge_purchases + WHERE user_id = ? AND purchase_priv_key IS NOT NULL + ORDER BY badge_purchase_id DESC + LIMIT 1 + |] + (Only userId) + +-- shown is a CASE because in Postgres a comparison is boolean, which BoolInt rejects. +getBadgePurchase :: DB.Connection -> Int64 -> IO (Maybe UserBadgePurchase) +getBadgePurchase db purchaseId = + maybeFirstRow toPurchase $ + DB.query + db + [sql| + SELECT p.badge_purchase_id, p.purchase_key, p.purchase_priv_key, p.master_key, p.current_badge_type, + (CASE WHEN u.shown_badge_id = p.badge_purchase_id THEN 1 ELSE 0 END), + p.alert_acked_kind, p.alert_acked_episode, p.alert_snooze_until + FROM badge_purchases p + JOIN users u ON u.user_id = p.user_id + WHERE p.badge_purchase_id = ? AND p.purchase_priv_key IS NOT NULL + |] + (Only purchaseId) + where + toPurchase (badgePurchaseId, purchaseKey, purchasePrivKey, Binary mk, badgeType, shown_, ackedKind_, ackedEpisode_, alertSnoozeUntil) = + UserBadgePurchase + { badgePurchaseId, + purchaseKey, + purchasePrivKey, + masterKey = BadgeMasterKey mk, + badgeType, + shown = unBI shown_, + alertAcked = (,) <$> ackedKind_ <*> ackedEpisode_, + alertSnoozeUntil + } + +-- | Whether a badge is on the profile now: set when a redemption stores one, cleared when it is +-- retired. Read as the id rather than as a comparison, which in Postgres would be a boolean. +userHasBadge :: DB.Connection -> User -> IO Bool +userHasBadge db User {userId} = + maybeFirstRow' False shownBadge $ + DB.query db "SELECT shown_badge_id FROM users WHERE user_id = ?" (Only userId) + where + shownBadge :: Only (Maybe Int64) -> Bool + shownBadge = isJust . fromOnly + +-- | An ack and a snooze both record the occurrence answered; a snooze also records how long it +-- holds, so that it silences that occurrence and not whichever one is derived next. +setBadgeAlertAcked :: DB.Connection -> User -> Int64 -> BadgeAlertKind -> Text -> Maybe UTCTime -> IO () +setBadgeAlertAcked db User {userId} badgePurchaseId kind episode snoozeUntil = + DB.execute + db + "UPDATE badge_purchases SET alert_acked_kind = ?, alert_acked_episode = ?, alert_snooze_until = ? WHERE badge_purchase_id = ? AND user_id = ?" + (kind, episode, snoozeUntil, badgePurchaseId, userId) + +-- | Stop showing a badge that has expired unrenewed; the profile update is broadcast by the caller. +clearShownBadge :: DB.Connection -> User -> Int64 -> IO () +clearShownBadge db User {userId} badgePurchaseId = + DB.execute db "UPDATE users SET shown_badge_id = NULL WHERE user_id = ? AND shown_badge_id = ?" (userId, badgePurchaseId) + +-- | Verbatim, entry_uuid and type included: the client authors no row, or the two sides stop +-- holding the same ledger. DO NOTHING makes a re-applied statement a no-op rather than a throw. +-- An entry whose balance does not follow from the one before it is stored and marked, not refused. +storeBadgeStatement :: DB.Connection -> Int64 -> BadgeType -> Maybe StatementEntry -> [StatementEntry] -> UTCTime -> IO () +storeBadgeStatement db badgePurchaseId badgeType tip entries now = + mapM_ storeEntry $ balanceChecked now badgeType tip entries + where + storeEntry (StatementEntry {entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType, wasPausedSince, createdAt, entryType}, checked) = + DB.execute + db + [sql| + INSERT INTO badge_ledger + (entry_uuid, badge_purchase_id, change_months, balance_months, balance_start_ts, balance_anchor_ts, balance_badge_type, + was_paused_since, service_created_at, created_at, entry_type, entry_credit_type, entry_debit_type, + entry_type_unknown, entry_type_value, balance_checked) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT (entry_uuid) DO NOTHING + |] + ( (entryId, badgePurchaseId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType, wasPausedSince) + :. (createdAt, now, entryTypeT, creditType, debitType, BI typeUnknown, entryTypeValue, BI <$> checked) + ) + where + (entryTypeT, creditType, debitType) = entryTypeColumns entryType + -- kept for every entry, not only for a type this version cannot decode: a tag alone does + -- not rebuild the types that name an invoice, a charge or another purchase + entryTypeValue = safeDecodeUtf8 . LB.toStrict $ case entryType of + SECredit c -> J.encode c + SEDebit d -> J.encode d + typeUnknown = case entryType of + SECredit SCUnknown {} -> True + SEDebit SDUnknown {} -> True + _ -> False + +-- | The balance is the last row; nothing derives it by summing the history. +getBadgeLedgerLastEntry :: DB.Connection -> Int64 -> IO (Maybe StatementEntry) +getBadgeLedgerLastEntry db badgePurchaseId = + maybeFirstRow' Nothing toStatementEntry $ + DB.query + db + [sql| + SELECT entry_uuid, change_months, balance_months, balance_start_ts, balance_anchor_ts, balance_badge_type, + was_paused_since, service_created_at, entry_type, entry_credit_type, entry_debit_type, entry_type_value + FROM badge_ledger + WHERE badge_purchase_id = ? + ORDER BY entry_id DESC + LIMIT 1 + |] + (Only badgePurchaseId) + +-- | Oldest first. A row whose type this version cannot rebuild is left out, as it is from the tip. +getBadgeLedger :: DB.Connection -> User -> Int64 -> IO [StatementEntry] +getBadgeLedger db User {userId} badgePurchaseId = + mapMaybe toStatementEntry + <$> DB.query + db + [sql| + SELECT l.entry_uuid, l.change_months, l.balance_months, l.balance_start_ts, l.balance_anchor_ts, l.balance_badge_type, + l.was_paused_since, l.service_created_at, l.entry_type, l.entry_credit_type, l.entry_debit_type, l.entry_type_value + FROM badge_ledger l + JOIN badge_purchases p ON p.badge_purchase_id = l.badge_purchase_id + WHERE l.badge_purchase_id = ? AND p.user_id = ? + ORDER BY l.entry_id + |] + (badgePurchaseId, userId) + +toStatementEntry :: (Text, Int, Int, UTCTime, UTCTime, BadgeType) :. (Maybe UTCTime, UTCTime, Text, Maybe Text, Maybe Text, Maybe Text) -> Maybe StatementEntry +toStatementEntry ((entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType) :. (wasPausedSince, createdAt, entryType_, credit_, debit_, value_)) = + (\entryType -> StatementEntry {entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType, wasPausedSince, createdAt, entryType}) + <$> maybe (entryTypeFromColumns entryType_ credit_ debit_) (entryTypeFromValue entryType_) value_ + +-- | Decodes the stored JSON rather than rebuilding from the tag, so a version that has since +-- learnt the type reads it with its fields, and one that has not still gets it back verbatim. +entryTypeFromValue :: Text -> Text -> Maybe StatementEntryType +entryTypeFromValue entryTypeT value_ = case entryTypeT of + "credit" -> SECredit <$> decodeJSON value_ + "debit" -> SEDebit <$> decodeJSON value_ + _ -> Nothing + +getBadgeLedgerEntryId :: DB.Connection -> Int64 -> Text -> IO (Maybe Int64) +getBadgeLedgerEntryId db badgePurchaseId entryUuid = + maybeFirstRow fromOnly $ + DB.query db "SELECT entry_id FROM badge_ledger WHERE badge_purchase_id = ? AND entry_uuid = ?" (badgePurchaseId, entryUuid) diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index fe7539fad7..1057a71a02 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -12,6 +12,7 @@ module Simplex.Chat.Store.Connections ( getChatLockEntity, getConnectionEntity, + getConnectionEntityKeys, getConnectionEntityByConnReq, getConnectionEntityViaShortLink, getContactConnEntityByConnReqHash, @@ -76,18 +77,23 @@ getChatLockEntity db agentConnId = do -- - from receiving: getConnectionEntity, getContactConnEntityByConnReqHash -- - from subscribing: getContactConnsToSub, getUCLConnsToSub, getMemberConnsToSub, getPendingConnsToSub getConnectionEntity :: DB.Connection -> StoreCxt -> User -> AgentConnId -> ExceptT StoreError IO ConnectionEntity -getConnectionEntity db cxt user@User {userId, userContactId} agentConnId = do +getConnectionEntity db cxt user agentConnId = fst <$> getConnectionEntityKeys db cxt user agentConnId + +getConnectionEntityKeys :: DB.Connection -> StoreCxt -> User -> AgentConnId -> ExceptT StoreError IO (ConnectionEntity, Maybe GroupKeysRow) +getConnectionEntityKeys db cxt user@User {userId, userContactId} agentConnId = do c@Connection {connType, entityId} <- getConnection_ case entityId of Nothing -> if connType == ConnContact - then pure $ RcvDirectMsgConnection c Nothing + then pure (RcvDirectMsgConnection c Nothing, Nothing) else throwError $ SEInternalError $ "connection " <> show connType <> " without entity" Just entId -> case connType of - ConnMember -> uncurry (RcvGroupMsgConnection c) <$> getGroupAndMember_ entId c - ConnContact -> RcvDirectMsgConnection c . Just <$> getContactRec_ entId c - ConnUserContact -> UserContactConnection c <$> getUserContact_ entId + ConnMember -> do + ((gInfo, keysData), m) <- getGroupAndMember_ entId c + pure (RcvGroupMsgConnection c gInfo m, Just keysData) + ConnContact -> (,Nothing) . RcvDirectMsgConnection c . Just <$> getContactRec_ entId c + ConnUserContact -> (,Nothing) . UserContactConnection c <$> getUserContact_ entId where getConnection_ :: ExceptT StoreError IO Connection getConnection_ = ExceptT $ do @@ -134,7 +140,7 @@ getConnectionEntity db cxt user@User {userId, userContactId} agentConnId = do contactRequest = UserContactRequestRef <$> contactRequestId <*> (unBI <$> rejectionSupported_) groupDirectInv = toGroupDirectInvitation groupDirectInvRow in Contact {contactId, localDisplayName, profile, activeConn, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, preparedContact, contactRequestId, contactRequest, contactGroupMemberId, contactGrpInvSent, groupDirectInv, chatTags, chatItemTTL, uiThemes, chatDeleted, customData} - getGroupAndMember_ :: Int64 -> Connection -> ExceptT StoreError IO (GroupInfo, GroupMember) + getGroupAndMember_ :: Int64 -> Connection -> ExceptT StoreError IO ((GroupInfo, GroupKeysRow), GroupMember) getGroupAndMember_ groupMemberId c = do currentTs <- liftIO getCurrentTime gm <- @@ -178,8 +184,8 @@ getConnectionEntity db cxt user@User {userId, userContactId} agentConnId = do AND mu.member_status NOT IN (?,?,?) |] (groupMemberId, userId, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted) - liftIO $ bitraverse (addGroupChatTags db) pure gm - toGroupAndMember :: UTCTime -> Connection -> GroupInfoRow :. GroupMemberRow -> (GroupInfo, GroupMember) + liftIO $ bitraverse (\(g, keysData) -> (,keysData) <$> addGroupChatTags db g) pure gm + toGroupAndMember :: UTCTime -> Connection -> GroupInfoRow :. GroupMemberRow -> ((GroupInfo, GroupKeysRow), GroupMember) toGroupAndMember currentTs c (groupInfoRow :. memberRow) = let groupInfo = toGroupInfo currentTs cxt userContactId [] groupInfoRow member = toGroupMember currentTs userContactId memberRow diff --git a/src/Simplex/Chat/Store/ContactRequest.hs b/src/Simplex/Chat/Store/ContactRequest.hs index 146f957947..f87a1e242a 100644 --- a/src/Simplex/Chat/Store/ContactRequest.hs +++ b/src/Simplex/Chat/Store/ContactRequest.hs @@ -91,11 +91,12 @@ createOrUpdateContactRequest pure $ RSAcceptedRequest cr (REContact ct) Nothing -> liftIO (getAcceptedBusinessChat xContactId) >>= \case - Just gInfo@GroupInfo {businessChat = Just BusinessChatInfo {customerId}} -> do + Just (gInfo@GroupInfo {businessChat = Just BusinessChatInfo {customerId}}, keysData) -> do clientMember <- getGroupMemberByMemberId db cxt user gInfo customerId cr <- liftIO $ getContactRequestByXContactId xContactId - pure $ RSAcceptedRequest cr (REBusinessChat gInfo clientMember) - Just GroupInfo {businessChat = Nothing} -> throwError SEInvalidBusinessChatContactRequest + gks <- mkGroupKeys db cxt gInfo keysData + pure $ RSAcceptedRequest cr (REBusinessChat (GIK gInfo gks) 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 @@ -131,7 +132,7 @@ createOrUpdateContactRequest |] (userId, xContactId) mapM (addDirectChatTags db) ct_ - getAcceptedBusinessChat :: XContactId -> IO (Maybe GroupInfo) + getAcceptedBusinessChat :: XContactId -> IO (Maybe (GroupInfo, GroupKeysRow)) getAcceptedBusinessChat xContactId = do currentTs <- getCurrentTime g_ <- @@ -140,7 +141,7 @@ createOrUpdateContactRequest db (groupInfoQuery <> " WHERE g.business_xcontact_id = ? AND g.user_id = ? AND mu.contact_id = ?") (xContactId, userId, userContactId) - mapM (addGroupChatTags db) g_ + forM g_ $ \(g, keysData) -> (,keysData) <$> addGroupChatTags db g getContactRequestByXContactId :: XContactId -> IO (Maybe UserContactRequest) getContactRequestByXContactId xContactId = do currentTs <- getCurrentTime @@ -214,7 +215,7 @@ createOrUpdateContactRequest pure $ RSCurrentRequest Nothing ucr (Just $ REContact ct) createBusinessChat = do let groupPreferences = maybe defaultBusinessGroupPrefs businessGroupPrefs $ preferences' user - (gInfo@GroupInfo {groupId}, clientMember) <- + (gInfo@(GIK GroupInfo {groupId} _), clientMember) <- createBusinessRequestGroup db cxt gVar user cReqChatVRange profile profileId ldn groupPreferences liftIO $ DB.execute @@ -302,11 +303,12 @@ createOrUpdateContactRequest ct <- getContact db cxt user contactId pure $ Just (REContact ct) (Nothing, Just businessGroupId) -> do - gInfo <- getGroupInfo db cxt user businessGroupId + (gInfo, keysData) <- getGroupInfoRow db cxt user businessGroupId case gInfo of GroupInfo {businessChat = Just BusinessChatInfo {customerId}} -> do clientMember <- getGroupMemberByMemberId db cxt user gInfo customerId - pure $ Just (REBusinessChat gInfo clientMember) + gks <- mkGroupKeys db cxt gInfo keysData + pure $ Just (REBusinessChat (GIK gInfo gks) clientMember) _ -> throwError SEInvalidBusinessChatContactRequest (Nothing, Nothing) -> pure Nothing _ -> throwError $ SEInvalidContactRequestEntity contactRequestId diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 53d5cd619e..a87d266b03 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -188,7 +188,7 @@ createConnReqConnection db userId acId preparedEntity_ cReq cReqHash sLnk xConta connId <- insertedRowId db case preparedEntity_ of -- For relay groups, setPreparedGroupLinkInfo_ is called via updatePreparedRelayedGroup before the relay loop - Just (PCEGroup gInfo _) | not (useRelays' gInfo) -> + Just (PCEGroup (GIK gInfo _) _) | not (useRelays' gInfo) -> setPreparedGroupLinkInfo_ db gInfo cReq cReqHash customUserProfileId Nothing currentTs _ -> pure () pure diff --git a/src/Simplex/Chat/Store/Files.hs b/src/Simplex/Chat/Store/Files.hs index 5ed3d9e8d5..1ba7c98fce 100644 --- a/src/Simplex/Chat/Store/Files.hs +++ b/src/Simplex/Chat/Store/Files.hs @@ -45,6 +45,9 @@ module Simplex.Chat.Store.Files updateSndFileStatus, createRcvFileTransfer, createRcvGroupFileTransfer, + createFileBadgeProof, + getFileBadgeProofs, + setFileProhibited, createRosterRcvFile, createRcvStandaloneFileTransfer, appendRcvFD, @@ -86,6 +89,7 @@ import Control.Monad.IO.Class import Data.Either (rights) import Data.Functor ((<&>)) import Data.Int (Int64) +import Data.List (foldl') import Data.Maybe (fromMaybe, isJust, listToMaybe) import Data.Text (Text) import qualified Data.Text as T @@ -93,6 +97,7 @@ import Data.Time (addUTCTime) import Data.Time.Clock (UTCTime (..), getCurrentTime, nominalDay) import Data.Type.Equality import Data.Word (Word32) +import Simplex.Chat.Badges (BadgeProof, BadgeProofKind (..), BadgeProofRow, BadgeStatus (..), badgeProofToRow, rowToBadgeProof) import Simplex.Chat.Messages import Simplex.Chat.Messages.CIContent import Simplex.Chat.Store.Messages @@ -180,7 +185,7 @@ getSndFTViaMsgDelivery db User {userId} Connection {connId, agentConnId} agentMs <$> (contactName_ <|> memberName_) createSndFileTransferXFTP :: DB.Connection -> User -> Maybe ContactOrGroup -> CryptoFile -> FileInvitation -> AgentSndFileId -> Maybe FileTransferId -> Integer -> IO FileTransferMeta -createSndFileTransferXFTP db User {userId} contactOrGroup_ (CryptoFile filePath cryptoArgs) FileInvitation {fileName, fileSize} agentSndFileId xftpRedirectFor chunkSize = do +createSndFileTransferXFTP db User {userId} contactOrGroup_ (CryptoFile filePath cryptoArgs) FileInvitation {fileName, fileSize, fileBadge} agentSndFileId xftpRedirectFor chunkSize = do currentTs <- getCurrentTime let xftpSndFile = Just XFTPSndFile {agentSndFileId, privateSndFileDescr = Nothing, agentSndFileDeleted = False, cryptoArgs} DB.execute @@ -188,6 +193,7 @@ createSndFileTransferXFTP db User {userId} contactOrGroup_ (CryptoFile filePath "INSERT INTO files (contact_id, group_id, user_id, file_name, file_path, file_crypto_key, file_crypto_nonce, file_size, chunk_size, redirect_file_id, agent_snd_file_id, ci_file_status, protocol, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" (maybe (Nothing, Nothing) contactAndGroupIds contactOrGroup_ :. (userId, fileName, filePath, CF.fileKey <$> cryptoArgs, CF.fileNonce <$> cryptoArgs, fileSize, chunkSize) :. (xftpRedirectFor, agentSndFileId, CIFSSndStored, FPXFTP, currentTs, currentTs)) fileId <- insertedRowId db + forM_ fileBadge $ createFileBadgeProof db fileId BPKInvitation pure FileTransferMeta {fileId, xftpSndFile, xftpRedirectFor, fileName, filePath, fileSize, fileInline = Nothing, chunkSize, cancelled = False} createSndFTDescrXFTP :: DB.Connection -> User -> Maybe GroupMember -> Connection -> FileTransferMeta -> FileDescr -> IO () @@ -445,8 +451,8 @@ 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) -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 +createRcvFileTransfer :: DB.Connection -> UserId -> Contact -> FileInvitation -> Maybe FileProhibited -> Maybe InlineFileMode -> Integer -> ExceptT StoreError IO RcvFileTransfer +createRcvFileTransfer db userId Contact {contactId, localDisplayName = c} f@FileInvitation {fileName, fileSize, fileConnReq, fileInline, fileDescr, fileBadge} prohibited_ rcvFileInline chunkSize = do currentTs <- liftIO getCurrentTime rfd_ <- mapM (createRcvFD_ db userId currentTs) fileDescr let rfdId = (\RcvFileDescr {fileDescrId} -> fileDescrId) <$> rfd_ @@ -456,18 +462,57 @@ createRcvFileTransfer db userId Contact {contactId, localDisplayName = c} f@File fileId <- liftIO $ do DB.execute db - "INSERT INTO files (user_id, contact_id, file_name, file_size, chunk_size, file_inline, ci_file_status, protocol, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)" - (userId, contactId, fileName, fileSize, chunkSize, fileInline, CIFSRcvInvitation, fileProtocol, currentTs, currentTs) + "INSERT INTO files (user_id, contact_id, file_name, file_size, chunk_size, file_inline, ci_file_status, protocol, file_max_size, file_badge_status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)" + ((userId, contactId, fileName, fileSize, chunkSize, fileInline, CIFSRcvInvitation, fileProtocol) :. prohibitedRow prohibited_ :. (currentTs, currentTs)) insertedRowId db - liftIO $ + liftIO $ do DB.execute db "INSERT INTO rcv_files (file_id, file_status, file_queue_info, file_inline, rcv_file_inline, file_descr_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)" (fileId, FSNew, fileConnReq, fileInline, rcvFileInline, rfdId, currentTs, currentTs) - pure RcvFileTransfer {fileId, xftpRcvFile, fileInvitation = f, fileStatus = RFSNew, fileType = FTNormal, rcvFileInline, senderDisplayName = c, chunkSize, cancelled = False, grpMemberId = Nothing, cryptoArgs = Nothing} + forM_ (storedBadge prohibited_ fileBadge) $ createFileBadgeProof db fileId BPKInvitation + pure RcvFileTransfer {fileId, xftpRcvFile, fileInvitation = f, fileProhibited = prohibited_, fileStatus = RFSNew, fileType = FTNormal, rcvFileInline, senderDisplayName = c, chunkSize, cancelled = False, grpMemberId = Nothing, cryptoArgs = Nothing} -createRcvGroupFileTransfer :: DB.Connection -> UserId -> GroupInfo -> Maybe GroupMember -> FileType -> Maybe SharedMsgId -> FileInvitation -> Maybe InlineFileMode -> Integer -> ExceptT StoreError IO RcvFileTransfer -createRcvGroupFileTransfer db userId GroupInfo {groupId, localDisplayName = gName} m_ fileType sharedMsgId_ f@FileInvitation {fileName, fileSize, fileDigest, fileConnReq, fileInline, fileDescr} rcvFileInline chunkSize = do +setFileProhibited :: DB.Connection -> User -> Int64 -> FileProhibited -> IO () +setFileProhibited db User {userId} fileId FileProhibited {maxSize, badgeStatus} = do + currentTs <- getCurrentTime + DB.execute + db + "UPDATE files SET file_max_size = ?, file_badge_status = ?, updated_at = ? WHERE user_id = ? AND file_id = ?" + (maxSize, badgeStatus, currentTs, userId, fileId) + +prohibitedRow :: Maybe FileProhibited -> (Maybe Integer, Maybe BadgeStatus) +prohibitedRow = \case + Just FileProhibited {maxSize, badgeStatus} -> (Just maxSize, badgeStatus) + Nothing -> (Nothing, Nothing) + +-- a proof that did not verify is not stored - files.file_badge_status records that it failed +storedBadge :: Maybe FileProhibited -> Maybe BadgeProof -> Maybe BadgeProof +storedBadge prohibited_ badge_ = case prohibited_ of + Just FileProhibited {badgeStatus = Just st} | st /= BSActive -> Nothing + _ -> badge_ + +createFileBadgeProof :: DB.Connection -> Int64 -> BadgeProofKind -> BadgeProof -> IO () +createFileBadgeProof db fileId kind badge = do + currentTs <- getCurrentTime + DB.execute + db + [sql| + INSERT INTO file_badge_proofs (file_id, proof_kind, badge_proof, badge_pres_header, badge_key_idx, badge_type, badge_expiry, badge_extra, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?) + ON CONFLICT (file_id, proof_kind) DO UPDATE SET + badge_proof = excluded.badge_proof, + badge_pres_header = excluded.badge_pres_header, + badge_key_idx = excluded.badge_key_idx, + badge_type = excluded.badge_type, + badge_expiry = excluded.badge_expiry, + badge_extra = excluded.badge_extra, + updated_at = excluded.updated_at + |] + ((fileId, kind) :. badgeProofToRow badge :. (currentTs, currentTs)) + +createRcvGroupFileTransfer :: DB.Connection -> UserId -> GroupInfo -> Maybe GroupMember -> FileType -> Maybe SharedMsgId -> FileInvitation -> Maybe FileProhibited -> Maybe InlineFileMode -> Integer -> ExceptT StoreError IO RcvFileTransfer +createRcvGroupFileTransfer db userId GroupInfo {groupId, localDisplayName = gName} m_ fileType sharedMsgId_ f@FileInvitation {fileName, fileSize, fileDigest, fileConnReq, fileInline, fileDescr, fileBadge} prohibited_ rcvFileInline chunkSize = do currentTs <- liftIO getCurrentTime rfd_ <- mapM (createRcvFD_ db userId currentTs) fileDescr let rfdId = (\RcvFileDescr {fileDescrId} -> fileDescrId) <$> rfd_ @@ -479,15 +524,16 @@ createRcvGroupFileTransfer db userId GroupInfo {groupId, localDisplayName = gNam fileId <- liftIO $ do DB.execute db - "INSERT INTO files (user_id, group_id, file_name, file_size, chunk_size, file_inline, ci_file_status, protocol, file_type, shared_msg_id, created_at, updated_at, file_digest) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)" - ((userId, groupId, fileName, fileSize, chunkSize, fileInline, CIFSRcvInvitation, fileProtocol, fileType, sharedMsgId_, currentTs, currentTs) :. Only fileDigest) + "INSERT INTO files (user_id, group_id, file_name, file_size, chunk_size, file_inline, ci_file_status, protocol, file_type, shared_msg_id, created_at, updated_at, file_digest, file_max_size, file_badge_status) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" + ((userId, groupId, fileName, fileSize, chunkSize, fileInline, CIFSRcvInvitation, fileProtocol, fileType, sharedMsgId_, currentTs, currentTs) :. Only fileDigest :. prohibitedRow prohibited_) insertedRowId db - liftIO $ + liftIO $ do DB.execute db "INSERT INTO rcv_files (file_id, file_status, file_queue_info, file_inline, rcv_file_inline, group_member_id, file_descr_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" (fileId, FSNew, fileConnReq, fileInline, rcvFileInline, grpMemberId_, rfdId, currentTs, currentTs) - pure RcvFileTransfer {fileId, xftpRcvFile, fileInvitation = f, fileStatus = RFSNew, fileType, rcvFileInline, senderDisplayName = senderName, chunkSize, cancelled = False, grpMemberId = grpMemberId_, cryptoArgs = Nothing} + forM_ (storedBadge prohibited_ fileBadge) $ createFileBadgeProof db fileId BPKInvitation + pure RcvFileTransfer {fileId, xftpRcvFile, fileInvitation = f, fileProhibited = prohibited_, fileStatus = RFSNew, fileType, rcvFileInline, senderDisplayName = senderName, chunkSize, cancelled = False, grpMemberId = grpMemberId_, cryptoArgs = Nothing} -- Roster scratch file owned by a per-source transfer: group_member_id is the delivering relay (so chunk -- streams from different relays are distinct files), roster_transfer_id links to the metadata record. @@ -506,7 +552,7 @@ createRosterRcvFile db userId GroupInfo {groupId} src@GroupMember {localDisplayN db "INSERT INTO rcv_files (file_id, file_status, file_queue_info, file_inline, rcv_file_inline, group_member_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)" (fileId, FSNew, fileConnReq, fileInline, rcvFileInline, grpMemberId_, currentTs, currentTs) - pure RcvFileTransfer {fileId, xftpRcvFile = Nothing, fileInvitation = f, fileStatus = RFSNew, fileType = FTRoster, rcvFileInline, senderDisplayName = senderName, chunkSize, cancelled = False, grpMemberId = Just grpMemberId_, cryptoArgs = Nothing} + pure RcvFileTransfer {fileId, xftpRcvFile = Nothing, fileInvitation = f, fileProhibited = Nothing, fileStatus = RFSNew, fileType = FTRoster, rcvFileInline, senderDisplayName = senderName, chunkSize, cancelled = False, grpMemberId = Just grpMemberId_, cryptoArgs = Nothing} createRcvStandaloneFileTransfer :: DB.Connection -> UserId -> CryptoFile -> Int64 -> Word32 -> ExceptT StoreError IO Int64 createRcvStandaloneFileTransfer db userId (CryptoFile filePath cfArgs_) fileSize chunkSize = do @@ -586,7 +632,7 @@ rcvFileDescrWithinLimits partNo descrText = && T.length descrText <= maxRcvFileDescrTextLength getRcvFileDescrByRcvFileId :: DB.Connection -> FileTransferId -> ExceptT StoreError IO RcvFileDescr -getRcvFileDescrByRcvFileId db fileId = do +getRcvFileDescrByRcvFileId db fileId = liftIO (getRcvFileDescrByRcvFileId_ db fileId) >>= \case Nothing -> throwError $ SERcvFileDescrNotFound fileId Just rfd -> pure rfd @@ -625,6 +671,19 @@ getRcvFileDescrBySndFileId_ db fileId = |] (Only fileId) +getFileBadgeProofs :: DB.Connection -> Int64 -> IO (Maybe BadgeProof, Maybe BadgeProof) +getFileBadgeProofs db fileId = foldl' addProof (Nothing, Nothing) <$> DB.query db q (Only fileId) + where + q = + [sql| + SELECT proof_kind, badge_proof, badge_pres_header, badge_key_idx, badge_type, badge_expiry, badge_extra + FROM file_badge_proofs + WHERE file_id = ? + |] + addProof (inv_, descr_) (Only kind :. row) = case kind of + BPKInvitation -> (rowToBadgeProof row, descr_) + BPKDescription -> (inv_, rowToBadgeProof row) + toRcvFileDescr :: (Int64, Text, Int, BoolInt) -> RcvFileDescr toRcvFileDescr (fileDescrId, fileDescrText, fileDescrPartNo, BI fileDescrComplete) = RcvFileDescr {fileDescrId, fileDescrText, fileDescrPartNo, fileDescrComplete} @@ -652,7 +711,8 @@ 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, g.local_display_name, f.file_type, f.file_digest + r.agent_rcv_file_id, r.agent_rcv_file_deleted, r.user_approved_relays, g.local_display_name, f.file_type, f.file_digest, + f.file_max_size, f.file_badge_status FROM rcv_files r JOIN files f USING (file_id) LEFT JOIN contacts cs ON cs.contact_id = f.contact_id @@ -666,9 +726,9 @@ 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 ContactName, FileType, Maybe FileDigest) -> + (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 ContactName, FileType, Maybe FileDigest, Maybe Integer, Maybe BadgeStatus) -> 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) :. (groupName_, fileType, fileDigest_)) = + rcvFileTransfer rfd_ ((fileStatus', fileConnReq, grpMemberId, fileName, fileSize, chunkSize, cancelled_) :. (contactName_, memberName_, filePath_, fileKey, fileNonce, fileInline, rcvFileInline, agentRcvFileId, BI agentRcvFileDeleted, BI userApprovedRelays) :. (groupName_, fileType, fileDigest_, fileMaxSize_, fileBadgeStatus_)) = case contactName_ <|> memberName_ <|> groupName_ <|> standaloneName_ of Nothing -> throwError $ SERcvFileInvalid fileId Just name -> @@ -683,10 +743,11 @@ getRcvFileTransfer_ db userId fileId = do (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 = fileDigest_, fileConnReq, fileInline, fileDescr = Nothing} + let fileInvitation = FileInvitation {fileName, fileSize, fileDigest = fileDigest_, fileConnReq, fileInline, fileDescr = Nothing, fileBadge = Nothing} + fileProhibited = (\maxSize -> FileProhibited {maxSize, badgeStatus = fileBadgeStatus_}) <$> fileMaxSize_ cryptoArgs = CFArgs <$> fileKey <*> fileNonce xftpRcvFile = (\rfd -> XFTPRcvFile {rcvFileDescription = rfd, agentRcvFileId, agentRcvFileDeleted, userApprovedRelays}) <$> rfd_ - in RcvFileTransfer {fileId, xftpRcvFile, fileInvitation, fileStatus, fileType, rcvFileInline, senderDisplayName, chunkSize, cancelled, grpMemberId, cryptoArgs} + in RcvFileTransfer {fileId, xftpRcvFile, fileInvitation, fileProhibited, fileStatus, fileType, rcvFileInline, senderDisplayName, chunkSize, cancelled, grpMemberId, cryptoArgs} filePath = case filePath_ of Nothing -> throwError $ SERcvFileInvalid fileId Just fp -> pure fp diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index bac41a0dff..2c0b2dc080 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -40,6 +40,7 @@ module Simplex.Chat.Store.Groups createGroupRejectedViaLink, setGroupInvitationChatItemId, getGroup, + getGroupKeys_, getGroupInfoByUserContactLinkConnReq, getGroupInfoViaUserTarget, getGroupViaShortLinkToConnect, @@ -107,6 +108,8 @@ module Simplex.Chat.Store.Groups deleteRosterTransfer, deleteGroupRosterTransfers, setGroupMemberKeyRole, + setUserMemberKey, + setMemberPubKey, setGroupMemberVerified, createRelayForOwner, getCreateRelayForMember, @@ -142,7 +145,7 @@ module Simplex.Chat.Store.Groups updatePreparedRelayedGroup, updatePublicMemberCount, setPublicMemberCount, - updateGroupMemberKeys, + setGroupRootKey, updateRelayGroupKeys, updateGroupMemberStatus, updateGroupMemberStatusById, @@ -374,24 +377,24 @@ setGroupLinkShortLink db gLnk@GroupLink {userContactLinkId, connLinkContact = CC pure gLnk {connLinkContact = CCLink connFullLink (Just shortLink), shortLinkDataSet = True, shortLinkLargeDataSet = BoolDef True} -- | creates completely new group with a single member - the current user -createNewGroup :: DB.Connection -> StoreCxt -> User -> GroupProfile -> Maybe Profile -> Bool -> MemberId -> Maybe GroupKeys -> Maybe Int64 -> ExceptT StoreError IO GroupInfo -createNewGroup db cxt user@User {userId} groupProfile incognitoProfile useRelays memberId groupKeys publicMemberCount_ = ExceptT $ do +createNewGroup :: DB.Connection -> StoreCxt -> User -> GroupProfile -> Maybe Profile -> MemberId -> GroupKeys -> Maybe Int64 -> ExceptT StoreError IO GroupInfo +createNewGroup db cxt user@User {userId} groupProfile incognitoProfile memberId groupKeys publicMemberCount_ = ExceptT $ do let GroupProfile {displayName, fullName, shortDescr, description, image, publicGroup, groupPreferences, memberAdmission} = groupProfile (groupType_, groupLink_, publicGroupId_) = case publicGroup of Just PublicGroupProfile {groupType, groupLink, publicGroupId} -> (Just groupType, Just groupLink, Just publicGroupId) Nothing -> (Nothing, Nothing, Nothing) fullGroupPreferences = mergeGroupPreferences groupPreferences + useRelays = isPublicGroup groupKeys rosterVersion0 = if useRelays then Just (VersionRoster 0) else Nothing currentTs <- getCurrentTime customUserProfileId <- mapM (createIncognitoProfile_ db userId currentTs) incognitoProfile withLocalDisplayName db userId displayName $ \ldn -> runExceptT $ do - let (rootPrivKey_, rootPubKey_, memberPrivKey_) = case groupKeys of - Nothing -> (Nothing, Nothing, Nothing) - Just GroupKeys {groupRootKey, memberPrivKey} -> - let (rpk, rpub) = case groupRootKey of - GRKPrivate pk -> (Just pk, Nothing) - GRKPublic k -> (Nothing, Just k) - in (rpk, rpub, Just memberPrivKey) + let (rootPrivKey_, rootPubKey_) = case groupKeys of + GKPublicGroup {groupRootKey} -> case groupRootKey of + GRKPrivate pk -> (Just pk, Nothing) + GRKPublic k -> (Nothing, Just k) + _ -> (Nothing, Nothing) + memberPrivKey_ = Just $ memberPrivKey groupKeys groupId <- liftIO $ do DB.execute db @@ -419,7 +422,7 @@ createNewGroup db cxt user@User {userId} groupProfile incognitoProfile useRelays :. (rootPrivKey_, rootPubKey_, memberPrivKey_, publicMemberCount_, rosterVersion0) ) insertedRowId db - let memberPubKey = C.publicKey . memberPrivKey <$> groupKeys + let memberPubKey = Just $ C.publicKey $ memberPrivKey groupKeys membership <- createContactMemberInv_ db user groupId Nothing user (MemberIdRole memberId GROwner) GCUserMember GSMemCreator IBUser customUserProfileId memberPubKey currentTs (vr cxt) let chatSettings = ChatSettings {enableNtfs = MFAll, sendRcpts = Nothing, favorite = False} pure @@ -447,18 +450,17 @@ createNewGroup db cxt user@User {userId} groupProfile incognitoProfile useRelays customData = Nothing, membersRequireAttention = 0, viaGroupLinkUri = Nothing, - groupKeys, groupDomainVerified = Nothing } -- | creates a new group record for the group the current user was invited to, or returns an existing one -createGroupInvitation :: DB.Connection -> StoreCxt -> User -> Contact -> GroupInvitation -> Maybe ProfileId -> ExceptT StoreError IO (GroupInfo, GroupMemberId) -createGroupInvitation _ _ _ Contact {localDisplayName, activeConn = Nothing} _ _ = throwError $ SEContactNotReady localDisplayName -createGroupInvitation db cxt user@User {userId} contact@Contact {contactId, activeConn = Just Connection {peerChatVRange}} GroupInvitation {fromMember, invitedMember, connRequest, groupProfile, business} incognitoProfileId = do +createGroupInvitation :: DB.Connection -> StoreCxt -> User -> Contact -> GroupInvitation -> Maybe ProfileId -> C.KeyPairEd25519 -> ExceptT StoreError IO (GroupInfoKeys, GroupMemberId) +createGroupInvitation _ _ _ Contact {localDisplayName, activeConn = Nothing} _ _ _ = throwError $ SEContactNotReady localDisplayName +createGroupInvitation db cxt user@User {userId} contact@Contact {contactId, activeConn = Just Connection {peerChatVRange}} GroupInvitation {fromMember, fromMemberKey, invitedMember, connRequest, groupProfile, business} incognitoProfileId memberKeys = do liftIO getInvitationGroupId_ >>= \case Nothing -> createGroupInvitation_ Just gId -> do - gInfo@GroupInfo {membership, groupProfile = p'} <- getGroupInfo db cxt user gId + GIK gInfo@GroupInfo {membership, groupProfile = p'} gks <- getGroupInfoKeys db cxt user gId hostId <- getHostMemberId_ db user gId let GroupMember {groupMemberId, memberId, memberRole} = membership MemberIdRole {memberId = invMemberId, memberRole = invMemberRole} = invitedMember @@ -468,13 +470,13 @@ createGroupInvitation db cxt user@User {userId} contact@Contact {contactId, acti if p' == groupProfile then pure gInfo else updateGroupProfile db user gInfo groupProfile - pure (gInfo', hostId) + pure (GIK gInfo' gks, hostId) where getInvitationGroupId_ :: IO (Maybe Int64) getInvitationGroupId_ = maybeFirstRow fromOnly $ DB.query db "SELECT group_id FROM groups WHERE inv_queue_info = ? AND user_id = ? LIMIT 1" (connRequest, userId) - createGroupInvitation_ :: ExceptT StoreError IO (GroupInfo, GroupMemberId) + createGroupInvitation_ :: ExceptT StoreError IO (GroupInfoKeys, GroupMemberId) createGroupInvitation_ = do let GroupProfile {displayName, fullName, shortDescr, description, image, groupPreferences, memberAdmission} = groupProfile fullGroupPreferences = mergeGroupPreferences groupPreferences @@ -492,14 +494,14 @@ createGroupInvitation db cxt user@User {userId} contact@Contact {contactId, acti [sql| INSERT INTO groups (group_profile_id, local_display_name, inv_queue_info, user_id, enable_ntfs, - created_at, updated_at, chat_ts, user_member_profile_sent_at, business_chat, business_member_id, customer_member_id) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + created_at, updated_at, chat_ts, user_member_profile_sent_at, member_priv_key, business_chat, business_member_id, customer_member_id) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ((profileId, localDisplayName, connRequest, userId, BI True, currentTs, currentTs, currentTs, currentTs) :. businessChatInfoRow business) + ((profileId, localDisplayName, connRequest, userId, BI True, currentTs, currentTs, currentTs, currentTs, snd memberKeys) :. businessChatInfoRow business) insertedRowId db let hostVRange = adjustedMemberVRange (vr cxt) peerChatVRange - GroupMember {groupMemberId} <- createContactMemberInv_ db user groupId Nothing contact fromMember GCHostMember GSMemInvited IBUnknown Nothing Nothing currentTs hostVRange - membership <- createContactMemberInv_ db user groupId (Just groupMemberId) user invitedMember GCUserMember GSMemInvited (IBContact contactId) incognitoProfileId Nothing currentTs (vr cxt) + GroupMember {groupMemberId} <- createContactMemberInv_ db user groupId Nothing contact fromMember GCHostMember GSMemInvited IBUnknown Nothing ((\(MemberKey k) -> k) <$> fromMemberKey) currentTs hostVRange + membership <- createContactMemberInv_ db user groupId (Just groupMemberId) user invitedMember GCUserMember GSMemInvited (IBContact contactId) incognitoProfileId (Just $ fst memberKeys) currentTs (vr cxt) let chatSettings = ChatSettings {enableNtfs = MFAll, sendRcpts = Nothing, favorite = False} pure ( GroupInfo @@ -526,9 +528,9 @@ createGroupInvitation db cxt user@User {userId} contact@Contact {contactId, acti customData = Nothing, membersRequireAttention = 0, viaGroupLinkUri = Nothing, - groupKeys = Nothing, groupDomainVerified = Nothing - }, + } + `GIK` GKGroup {memberPrivKey = snd memberKeys}, groupMemberId ) @@ -647,8 +649,9 @@ deleteContactCardKeepConn db connId Contact {contactId, profile = LocalProfile { createPreparedGroup :: DB.Connection -> TVar ChaChaDRG -> StoreCxt -> User -> GroupProfile -> Bool -> CreatedLinkContact -> Maybe SharedMsgId -> Bool -> GroupMemberRole -> Maybe Int64 -> Maybe SimplexDomain -> ExceptT StoreError IO (GroupInfo, Maybe GroupMember) createPreparedGroup db gVar cxt user@User {userId, userContactId} groupProfile business connLinkToConnect welcomeSharedMsgId useRelays userMemberRole publicMemberCount_ verifiedDomain = do currentTs <- liftIO getCurrentTime + (memberPubKey, memberPrivKey) <- atomically $ C.generateKeyPair gVar let prepared = Just (connLinkToConnect, welcomeSharedMsgId) - (groupId, groupLDN) <- createGroup_ db userId groupProfile prepared Nothing useRelays Nothing publicMemberCount_ currentTs + (groupId, groupLDN) <- createGroup_ db userId groupProfile prepared Nothing useRelays Nothing publicMemberCount_ (Just memberPrivKey) currentTs hostMemberId_ <- if useRelays then pure Nothing @@ -658,8 +661,7 @@ createPreparedGroup db gVar cxt user@User {userId, userContactId} groupProfile b then liftIO $ MemberId <$> encodedRandomBytes gVar 12 else pure $ MemberId $ encodeUtf8 groupLDN <> "_user_unknown_id" let userMember = MemberIdRole userMemberId userMemberRole - -- TODO [member keys] user key must be included here. Should key be added when group is prepared? - membership <- createContactMemberInv_ db user groupId hostMemberId_ user userMember GCUserMember GSMemUnknown IBUnknown Nothing Nothing currentTs (vr cxt) + membership <- createContactMemberInv_ db user groupId hostMemberId_ user userMember GCUserMember GSMemUnknown IBUnknown Nothing (Just memberPubKey) currentTs (vr cxt) hostMember_ <- forM hostMemberId_ $ getGroupMember db cxt user groupId forM_ hostMember_ $ \hostMember -> when business $ liftIO $ setGroupBusinessChatInfo groupId membership hostMember @@ -781,10 +783,12 @@ updatePreparedGroupUser db cxt user gInfo@GroupInfo {groupId, membership} hostMe safeDeleteLDN db user oldHostLDN updatePreparedUserAndHostMembersInvited :: DB.Connection -> StoreCxt -> User -> GroupInfo -> GroupMember -> GroupLinkInvitation -> ExceptT StoreError IO (GroupInfo, GroupMember) -updatePreparedUserAndHostMembersInvited db cxt user gInfo hostMember GroupLinkInvitation {fromMember, fromMemberName, invitedMember, groupProfile, accepted, business} = do +updatePreparedUserAndHostMembersInvited db cxt user gInfo hostMember GroupLinkInvitation {fromMember, fromMemberKey, fromMemberName, invitedMember, groupProfile, accepted, business} = do let fromMemberProfile = profileFromName fromMemberName initialStatus = maybe GSMemAccepted (acceptanceToStatus $ memberAdmission groupProfile) accepted - updatePreparedUserAndHostMembers' db cxt user gInfo hostMember fromMember fromMemberProfile invitedMember groupProfile business initialStatus + r@(_, hostMember') <- updatePreparedUserAndHostMembers' db cxt user gInfo hostMember fromMember fromMemberProfile invitedMember groupProfile business initialStatus + forM_ fromMemberKey $ \(MemberKey k) -> liftIO $ setMemberPubKey db (groupMemberId' hostMember') k + pure r updatePreparedUserAndHostMembersRejected :: DB.Connection -> StoreCxt -> User -> GroupInfo -> GroupMember -> GroupLinkRejection -> ExceptT StoreError IO (GroupInfo, GroupMember) updatePreparedUserAndHostMembersRejected db cxt user gInfo hostMember GroupLinkRejection {fromMember = fromMember@MemberIdRole {memberId}, invitedMember, groupProfile} = do @@ -846,36 +850,37 @@ updatePreparedUserAndHostMembers' (memberId, memberRole, currentTs, gmId) getGroupMemberById db cxt user gmId -createGroupInvitedViaLink :: DB.Connection -> StoreCxt -> User -> Connection -> GroupLinkInvitation -> ExceptT StoreError IO (GroupInfo, GroupMember) -createGroupInvitedViaLink db cxt user conn GroupLinkInvitation {fromMember, fromMemberName, invitedMember, groupProfile, accepted, business} = do +createGroupInvitedViaLink :: DB.Connection -> StoreCxt -> User -> Connection -> C.KeyPairEd25519 -> GroupLinkInvitation -> ExceptT StoreError IO (GroupInfo, GroupMember) +createGroupInvitedViaLink db cxt user conn memberKeys GroupLinkInvitation {fromMember, fromMemberKey, fromMemberName, invitedMember, groupProfile, accepted, business} = do let fromMemberProfile = profileFromName fromMemberName initialStatus = maybe GSMemAccepted (acceptanceToStatus $ memberAdmission groupProfile) accepted - createGroupViaLink' db cxt user conn fromMember fromMemberProfile invitedMember groupProfile business initialStatus + createGroupViaLink' db cxt user conn memberKeys fromMember fromMemberProfile ((\(MemberKey k) -> k) <$> fromMemberKey) invitedMember groupProfile business initialStatus -createGroupRejectedViaLink :: DB.Connection -> StoreCxt -> User -> Connection -> GroupLinkRejection -> ExceptT StoreError IO (GroupInfo, GroupMember) -createGroupRejectedViaLink db cxt user conn GroupLinkRejection {fromMember = fromMember@MemberIdRole {memberId}, invitedMember, groupProfile} = do +createGroupRejectedViaLink :: DB.Connection -> StoreCxt -> User -> Connection -> C.KeyPairEd25519 -> GroupLinkRejection -> ExceptT StoreError IO (GroupInfo, GroupMember) +createGroupRejectedViaLink db cxt user conn memberKeys GroupLinkRejection {fromMember = fromMember@MemberIdRole {memberId}, invitedMember, groupProfile} = do let fromMemberProfile = profileFromName $ nameFromMemberId memberId - createGroupViaLink' db cxt user conn fromMember fromMemberProfile invitedMember groupProfile Nothing GSMemRejected + createGroupViaLink' db cxt user conn memberKeys fromMember fromMemberProfile Nothing invitedMember groupProfile Nothing GSMemRejected -createGroupViaLink' :: DB.Connection -> StoreCxt -> User -> Connection -> MemberIdRole -> Profile -> MemberIdRole -> GroupProfile -> Maybe BusinessChatInfo -> GroupMemberStatus -> ExceptT StoreError IO (GroupInfo, GroupMember) +createGroupViaLink' :: DB.Connection -> StoreCxt -> User -> Connection -> C.KeyPairEd25519 -> MemberIdRole -> Profile -> Maybe C.PublicKeyEd25519 -> MemberIdRole -> GroupProfile -> Maybe BusinessChatInfo -> GroupMemberStatus -> ExceptT StoreError IO (GroupInfo, GroupMember) createGroupViaLink' db cxt user@User {userId, userContactId} Connection {connId, customUserProfileId} + memberKeys fromMember fromMemberProfile + fromMemberPubKey_ invitedMember groupProfile business membershipStatus = do currentTs <- liftIO getCurrentTime - (groupId, _groupLDN) <- createGroup_ db userId groupProfile Nothing business False Nothing Nothing currentTs + (groupId, _groupLDN) <- createGroup_ db userId groupProfile Nothing business False Nothing Nothing (Just (snd memberKeys)) currentTs hostMemberId <- insertHost_ currentTs groupId liftIO $ DB.execute db "UPDATE connections SET conn_type = ?, group_member_id = ?, updated_at = ? WHERE connection_id = ?" (ConnMember, hostMemberId, currentTs, connId) -- using IBUnknown since host is created without contact - -- TODO [member keys] this is currently not used with public groups. If it needs to be used, member keys need to be added - void $ createContactMemberInv_ db user groupId (Just hostMemberId) user invitedMember GCUserMember membershipStatus IBUnknown customUserProfileId Nothing currentTs (vr cxt) + _membership <- createContactMemberInv_ db user groupId (Just hostMemberId) user invitedMember GCUserMember membershipStatus IBUnknown customUserProfileId (Just (fst memberKeys)) currentTs (vr cxt) liftIO $ setViaGroupLinkUri db groupId connId (,) <$> getGroupInfo db cxt user groupId <*> getGroupMemberById db cxt user hostMemberId where @@ -889,16 +894,16 @@ createGroupViaLink' [sql| INSERT INTO group_members ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, - user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) + user_id, local_display_name, contact_id, contact_profile_id, member_pub_key, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] ( (groupId, indexInGroup, memberId, memberRole, GCHostMember, GSMemAccepted, Binary B.empty, fromInvitedBy userContactId IBUnknown) - :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs) + :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, fromMemberPubKey_, currentTs, currentTs) ) insertedRowId db -createGroup_ :: DB.Connection -> UserId -> GroupProfile -> Maybe (CreatedLinkContact, Maybe SharedMsgId) -> Maybe BusinessChatInfo -> Bool -> Maybe RelayStatus -> Maybe Int64 -> UTCTime -> ExceptT StoreError IO (GroupId, Text) -createGroup_ db userId groupProfile prepared business useRelays relayOwnStatus publicMemberCount_ currentTs = ExceptT $ do +createGroup_ :: DB.Connection -> UserId -> GroupProfile -> Maybe (CreatedLinkContact, Maybe SharedMsgId) -> Maybe BusinessChatInfo -> Bool -> Maybe RelayStatus -> Maybe Int64 -> Maybe C.PrivateKeyEd25519 -> UTCTime -> ExceptT StoreError IO (GroupId, Text) +createGroup_ db userId groupProfile prepared business useRelays relayOwnStatus publicMemberCount_ memberPrivKey_ currentTs = ExceptT $ do let GroupProfile {displayName, fullName, shortDescr, description, image, publicGroup, groupPreferences, memberAdmission} = groupProfile (groupType_, groupLink_, publicGroupId_) = case publicGroup of Just PublicGroupProfile {groupType, groupLink, publicGroupId} -> (Just groupType, Just groupLink, Just publicGroupId) @@ -924,10 +929,10 @@ createGroup_ db userId groupProfile prepared business useRelays relayOwnStatus p INSERT INTO groups (group_profile_id, local_display_name, user_id, enable_ntfs, created_at, updated_at, chat_ts, user_member_profile_sent_at, conn_full_link_to_connect, conn_short_link_to_connect, welcome_shared_msg_id, - business_chat, business_member_id, customer_member_id, use_relays, relay_own_status, public_member_count) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + business_chat, business_member_id, customer_member_id, use_relays, relay_own_status, public_member_count, member_priv_key) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ((profileId, localDisplayName, userId, BI True, currentTs, currentTs, currentTs, currentTs) :. toPreparedGroupRow prepared :. businessChatInfoRow business :. (BI useRelays, relayOwnStatus, publicMemberCount_)) + ((profileId, localDisplayName, userId, BI True, currentTs, currentTs, currentTs, currentTs) :. toPreparedGroupRow prepared :. businessChatInfoRow business :. (BI useRelays, relayOwnStatus, publicMemberCount_, memberPrivKey_)) groupId <- insertedRowId db pure (groupId, localDisplayName) @@ -939,10 +944,13 @@ setGroupInvitationChatItemId db User {userId} groupId chatItemId = do -- TODO return the last connection that is ready, not any last connection -- requires updating connection status getGroup :: DB.Connection -> StoreCxt -> User -> GroupId -> ExceptT StoreError IO Group -getGroup db cxt user groupId = do - gInfo <- getGroupInfo db cxt user groupId +getGroup db cxt user groupId = fst <$> getGroupKeys_ db cxt user groupId + +getGroupKeys_ :: DB.Connection -> StoreCxt -> User -> GroupId -> ExceptT StoreError IO (Group, GroupKeys) +getGroupKeys_ db cxt user groupId = do + GIK gInfo gks <- getGroupInfoKeys db cxt user groupId members <- liftIO $ getGroupMembers db cxt user gInfo - pure $ Group gInfo members + pure (Group gInfo members, gks) deleteGroupChatItems :: DB.Connection -> User -> GroupInfo -> IO () deleteGroupChatItems db User {userId} GroupInfo {groupId} = @@ -1047,7 +1055,7 @@ getInProgressGroups db cxt user@User {userId} createdAtCutoff = do getBaseGroupDetails :: DB.Connection -> StoreCxt -> User -> Maybe ContactId -> Maybe Text -> IO [GroupInfo] getBaseGroupDetails db cxt User {userId, userContactId} _contactId_ search_ = do currentTs <- getCurrentTime - map (toGroupInfo currentTs cxt userContactId []) + map (toGroupInfo_ currentTs cxt userContactId []) <$> DB.query db (groupInfoQuery <> " " <> condition) (userId, userContactId, search, search, search, search) where condition = @@ -1354,11 +1362,11 @@ getGroupInvitation :: DB.Connection -> StoreCxt -> User -> GroupId -> ExceptT St getGroupInvitation db cxt user groupId = getConnRec_ user >>= \case Just connRequest -> do - groupInfo@GroupInfo {membership} <- getGroupInfo db cxt user groupId + GIK groupInfo@GroupInfo {membership} groupKeys <- getGroupInfoKeys db cxt user groupId when (memberStatus membership /= GSMemInvited) $ throwError SEGroupAlreadyJoined hostId <- getHostMemberId_ db user groupId fromMember <- getGroupMember db cxt user groupId hostId - pure ReceivedGroupInvitation {fromMember, connRequest, groupInfo} + pure ReceivedGroupInvitation {fromMember, connRequest, groupInfo, groupKeys} _ -> throwError SEGroupInvitationNotFound where getConnRec_ :: User -> ExceptT StoreError IO (Maybe ConnReqInvitation) @@ -1688,6 +1696,11 @@ setGroupMemberKeyRole db GroupMember {groupMemberId} pubKey role = do currentTs <- getCurrentTime DB.execute db "UPDATE group_members SET member_pub_key = ?, member_role = ?, updated_at = ? WHERE group_member_id = ?" (pubKey, role, currentTs, groupMemberId) +setMemberPubKey :: DB.Connection -> GroupMemberId -> C.PublicKeyEd25519 -> IO () +setMemberPubKey db groupMemberId pubKey = do + currentTs <- getCurrentTime + DB.execute db "UPDATE group_members SET member_pub_key = ?, updated_at = ? WHERE group_member_id = ?" (pubKey, currentTs, groupMemberId) + setGroupMemberVerified :: DB.Connection -> User -> GroupMemberId -> Maybe Text -> IO () setGroupMemberVerified db User {userId} groupMemberId code = do updatedAt <- getCurrentTime @@ -1896,13 +1909,13 @@ createRelayRequestGroup db cxt user@User {userId} GroupRelayInvitation {fromMemb groupPreferences = Nothing, memberAdmission = Nothing } - (groupId, _groupLDN) <- createGroup_ db userId placeholderProfile Nothing Nothing True (Just relayStatus) Nothing currentTs + (_, memberPrivKey) <- atomically $ C.generateKeyPair (drg cxt) + (groupId, _groupLDN) <- createGroup_ db userId placeholderProfile Nothing Nothing True (Just relayStatus) Nothing (Just memberPrivKey) currentTs -- Store relay request data for recovery liftIO $ setRelayRequestData_ groupId currentTs ownerMemberId <- insertOwner_ currentTs groupId let relayMember = MemberIdRole relayMemberId GRRelay - -- TODO [member keys] should relays use member keys? - _membership <- createContactMemberInv_ db user groupId (Just ownerMemberId) user relayMember GCUserMember memberStatus IBUnknown Nothing Nothing currentTs (vr cxt) + _membership <- createContactMemberInv_ db user groupId (Just ownerMemberId) user relayMember GCUserMember memberStatus IBUnknown Nothing (Just $ C.publicKey memberPrivKey) currentTs (vr cxt) ownerMember <- getGroupMember db cxt user groupId ownerMemberId g <- getGroupInfo db cxt user groupId pure (g, ownerMember) @@ -1997,16 +2010,17 @@ isRelayGroupRejected db User {userId} groupLink = (userId, groupLink, RSRejected) ) -getRelayServedGroups :: DB.Connection -> StoreCxt -> User -> IO [GroupInfo] +getRelayServedGroups :: DB.Connection -> StoreCxt -> User -> ExceptT StoreError IO [GroupInfoKeys] getRelayServedGroups db cxt User {userId, userContactId} = do - currentTs <- getCurrentTime - map (toGroupInfo currentTs cxt userContactId []) + currentTs <- liftIO getCurrentTime + rows <- liftIO $ map (toGroupInfo currentTs cxt userContactId []) <$> DB.query db ( groupInfoQuery <> " WHERE g.user_id = ? AND mu.contact_id = ? AND g.relay_own_status IN (?, ?, ?)" ) (userId, userContactId, RSAccepted, RSAcknowledgedRoster, RSActive) + forM rows $ \(g, keysData) -> GIK g <$> mkGroupKeys db cxt g keysData getRelayPublishableGroups :: DB.Connection -> User -> IO [(Int64, B64UrlByteString, Maybe PublicGroupAccess)] getRelayPublishableGroups db User {userId, userContactId} = @@ -2044,7 +2058,7 @@ getRelayInactiveGroups :: DB.Connection -> StoreCxt -> User -> NominalDiffTime - getRelayInactiveGroups db cxt User {userId, userContactId} ttl = do currentTs <- getCurrentTime let cutoffTs = addUTCTime (- ttl) currentTs - map (toGroupInfo currentTs cxt userContactId []) + map (toGroupInfo_ currentTs cxt userContactId []) <$> DB.query db ( groupInfoQuery @@ -2131,7 +2145,7 @@ createJoiningMemberConnection Connection {connId} <- createConnection_ db userId ConnMember (Just groupMemberId) agentConnId ConnNew chatV cReqChatVRange Nothing (Just uclId) Nothing 0 createdAt subMode PQSupportOff setCommandConnId db user cmdId connId -createBusinessRequestGroup :: DB.Connection -> StoreCxt -> TVar ChaChaDRG -> User -> VersionRangeChat -> Profile -> Int64 -> Text -> GroupPreferences -> ExceptT StoreError IO (GroupInfo, GroupMember) +createBusinessRequestGroup :: DB.Connection -> StoreCxt -> TVar ChaChaDRG -> User -> VersionRangeChat -> Profile -> Int64 -> Text -> GroupPreferences -> ExceptT StoreError IO (GroupInfoKeys, GroupMember) createBusinessRequestGroup db cxt @@ -2146,11 +2160,12 @@ createBusinessRequestGroup (groupId, membership@GroupMember {memberId = userMemberId}) <- insertGroup_ currentTs (groupMemberId, memberId) <- insertClientMember_ currentTs groupId membership liftIO $ DB.execute db "UPDATE groups SET business_member_id = ?, customer_member_id = ? WHERE group_id = ?" (userMemberId, memberId, groupId) - groupInfo <- getGroupInfo db cxt user groupId + groupInfo <- getGroupInfoKeys db cxt user groupId clientMember <- getGroupMemberById db cxt user groupMemberId pure (groupInfo, clientMember) where insertGroup_ currentTs = do + (memberPubKey, memberPrivKey) <- atomically $ C.generateKeyPair gVar liftIO $ DB.execute db @@ -2163,14 +2178,13 @@ createBusinessRequestGroup [sql| INSERT INTO groups (group_profile_id, local_display_name, user_id, enable_ntfs, - created_at, updated_at, chat_ts, user_member_profile_sent_at, business_chat) - VALUES (?,?,?,?,?,?,?,?,?) + created_at, updated_at, chat_ts, user_member_profile_sent_at, business_chat, member_priv_key) + VALUES (?,?,?,?,?,?,?,?,?,?) |] - (groupProfileId, ldn, userId, BI True, currentTs, currentTs, currentTs, currentTs, BCCustomer) + (groupProfileId, ldn, userId, BI True, currentTs, currentTs, currentTs, currentTs, BCCustomer, memberPrivKey) groupId <- liftIO $ insertedRowId db memberId <- liftIO $ encodedRandomBytes gVar 12 - -- TODO [member keys] we could support member keys in business groups to allow binding agreements (though identity keys would be better for it. - membership <- createContactMemberInv_ db user groupId Nothing user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser Nothing Nothing currentTs (vr cxt) + membership <- createContactMemberInv_ db user groupId Nothing user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser Nothing (Just memberPubKey) currentTs (vr cxt) pure (groupId, membership) VersionRange minV maxV = cReqChatVRange insertClientMember_ currentTs groupId membership = @@ -2232,14 +2246,14 @@ createMemberConnectionAsync db user@User {userId} groupMemberId (cmdId, agentCon -- which is used in single-connection flows. updatePreparedRelayedGroup :: DB.Connection -> StoreCxt -> User -> GroupInfo -> ConnReqContact -> ConnReqUriHash -> Maybe Profile -> - C.PublicKeyEd25519 -> C.PrivateKeyEd25519 -> Maybe Int64 -> - ExceptT StoreError IO GroupInfo -updatePreparedRelayedGroup db cxt user@User {userId} gInfo cReq cReqHash incognitoProfile rootPubKey memberPrivKey publicMemberCount_ = do + C.PublicKeyEd25519 -> Maybe Int64 -> + ExceptT StoreError IO GroupInfoKeys +updatePreparedRelayedGroup db cxt user@User {userId} gInfo cReq cReqHash incognitoProfile rootPubKey publicMemberCount_ = do currentTs <- liftIO getCurrentTime customUserProfileId <- liftIO $ mapM (createIncognitoProfile_ db userId currentTs) incognitoProfile liftIO $ setPreparedGroupLinkInfo_ db gInfo cReq cReqHash customUserProfileId publicMemberCount_ currentTs - liftIO $ updateGroupMemberKeys db (groupId' gInfo) rootPubKey memberPrivKey (groupMemberId' $ membership gInfo) - getGroupInfo db cxt user (groupId' gInfo) + liftIO $ setGroupRootKey db (groupId' gInfo) rootPubKey + getGroupInfoKeys db cxt user (groupId' gInfo) updatePublicMemberCount :: DB.Connection -> StoreCxt -> User -> GroupInfo -> ExceptT StoreError IO GroupInfo updatePublicMemberCount db cxt user GroupInfo {groupId} = do @@ -2266,23 +2280,15 @@ setPublicMemberCount db cxt user GroupInfo {groupId} publicCount = do liftIO $ DB.execute db "UPDATE groups SET public_member_count = ?, updated_at = ? WHERE group_id = ?" (publicCount, currentTs, groupId) getGroupInfo db cxt user groupId -updateGroupMemberKeys :: DB.Connection -> GroupId -> C.PublicKeyEd25519 -> C.PrivateKeyEd25519 -> GroupMemberId -> IO () -updateGroupMemberKeys db groupId rootPubKey memberPrivKey membershipGMId = do +setGroupRootKey :: DB.Connection -> GroupId -> C.PublicKeyEd25519 -> IO () +setGroupRootKey db groupId rootPubKey = do currentTs <- getCurrentTime - DB.execute - db - "UPDATE groups SET root_pub_key = ?, member_priv_key = ?, updated_at = ? WHERE group_id = ?" - (rootPubKey, memberPrivKey, currentTs, groupId) - DB.execute - db - "UPDATE group_members SET member_pub_key = ?, updated_at = ? WHERE group_member_id = ?" - (C.publicKey memberPrivKey, currentTs, membershipGMId) + DB.execute db "UPDATE groups SET root_pub_key = ?, updated_at = ? WHERE group_id = ?" (rootPubKey, currentTs, groupId) -updateRelayGroupKeys :: DB.Connection -> User -> GroupInfo -> PublicGroupProfile -> C.PublicKeyEd25519 -> C.PrivateKeyEd25519 -> [OwnerAuth] -> ExceptT StoreError IO () -updateRelayGroupKeys db user@User {userId} gInfo PublicGroupProfile {groupType, groupLink, publicGroupId} rootPubKey memberPrivKey owners = do +updateRelayGroupKeys :: DB.Connection -> User -> GroupInfo -> PublicGroupProfile -> C.PublicKeyEd25519 -> [OwnerAuth] -> ExceptT StoreError IO () +updateRelayGroupKeys db user@User {userId} gInfo PublicGroupProfile {groupType, groupLink, publicGroupId} rootPubKey owners = do currentTs <- liftIO getCurrentTime - let membershipGMId = groupMemberId' $ membership gInfo - groupId = groupId' gInfo + let groupId = groupId' gInfo liftIO $ do DB.execute db @@ -2291,14 +2297,7 @@ updateRelayGroupKeys db user@User {userId} gInfo PublicGroupProfile {groupType, WHERE group_profile_id IN (SELECT group_profile_id FROM groups WHERE user_id = ? AND group_id = ?) |] (groupType, groupLink, publicGroupId, currentTs, userId, groupId) - DB.execute - db - "UPDATE groups SET root_pub_key = ?, member_priv_key = ?, updated_at = ? WHERE group_id = ?" - (rootPubKey, memberPrivKey, currentTs, groupId) - DB.execute - db - "UPDATE group_members SET member_pub_key = ?, updated_at = ? WHERE group_member_id = ?" - (C.publicKey memberPrivKey, currentTs, membershipGMId) + setGroupRootKey db groupId rootPubKey -- TODO [relays] relay: if not found, create owner record (multi-owner) forM_ owners $ \OwnerAuth {ownerId, ownerKey} -> do ownerGMId <- getGroupMemberIdViaMemberId db user gInfo (MemberId ownerId) diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 1cb90ab222..3998e1f249 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -164,6 +164,7 @@ import Data.Text (Text) import qualified Data.Text as T import Data.Time (addUTCTime) import Data.Time.Clock (UTCTime (..), getCurrentTime) +import Simplex.Chat.Badges (BadgeStatus) import Simplex.Chat.Controller (ChatListQuery (..), ChatPagination (..), PaginationByTime (..)) import Simplex.Chat.Markdown import Simplex.Chat.Messages @@ -1093,7 +1094,7 @@ getLocalChatPreview_ db user (LocalChatPD _ noteFolderId lastItemId_ stats) = do -- this function can be changed so it never fails, not only avoid failure on invalid json toLocalChatItem :: UTCTime -> ChatItemRow -> Either StoreError (CChatItem 'CTLocal) -toLocalChatItem currentTs ((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sentViaProxy, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. forwardedFromRow :. (timedTTL, timedDeleteAt, itemLive, BI userMention, BI hasLink, msgSigned) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_, fileExpires)) = +toLocalChatItem currentTs ((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sentViaProxy, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. forwardedFromRow :. (timedTTL, timedDeleteAt, itemLive, BI userMention, BI hasLink, msgSigned) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_, fileExpires) :. (fileMaxSize_, fileBadgeStatus_)) = chatItem $ fromRight invalid $ dbParseACIContent itemContentText where invalid = ACIContent msgDir $ CIInvalidJSON itemContentText @@ -1113,7 +1114,8 @@ toLocalChatItem currentTs ((itemId, itemTs, AMsgDirection msgDir, itemContentTex (Just fileId, Just fileName, Just fileSize, Just fileProtocol) -> let cfArgs = CFArgs <$> fileKey <*> fileNonce fileSource = (`CryptoFile` cfArgs) <$> filePath - in Just CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol, fileExpires} + fileProhibited = (\maxSize -> FileProhibited {maxSize, badgeStatus = fileBadgeStatus_}) <$> fileMaxSize_ + in Just CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol, fileExpires, fileProhibited} _ -> Nothing cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTLocal d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTLocal cItem d chatDir ciStatus content file = @@ -2276,7 +2278,7 @@ updateLocalChatItemsRead db User {userId} noteFolderId = do |] (CISRcvRead, currentTs, userId, noteFolderId, CISRcvNew) -type MaybeCIFIleRow = (Maybe Int64, Maybe String, Maybe Integer, Maybe FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe ACIFileStatus, Maybe FileProtocol, Maybe UTCTime) +type MaybeCIFIleRow = (Maybe Int64, Maybe String, Maybe Integer, Maybe FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe ACIFileStatus, Maybe FileProtocol, Maybe UTCTime) :. (Maybe Integer, Maybe BadgeStatus) type ChatItemModeRow = (Maybe Int, Maybe UTCTime, Maybe BoolInt, BoolInt, BoolInt, Maybe MsgVerified) @@ -2304,7 +2306,7 @@ toQuote (quotedItemId, quotedSharedMsgId, quotedSentAt, quotedMsgContent, _) dir -- this function can be changed so it never fails, not only avoid failure on invalid json toDirectChatItem :: UTCTime -> ChatItemRow :. QuoteRow -> Either StoreError (CChatItem 'CTDirect) -toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sentViaProxy, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. forwardedFromRow :. (timedTTL, timedDeleteAt, itemLive, BI userMention, BI hasLink, msgSigned) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_, fileExpires)) :. quoteRow) = +toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sentViaProxy, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. forwardedFromRow :. (timedTTL, timedDeleteAt, itemLive, BI userMention, BI hasLink, msgSigned) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_, fileExpires) :. (fileMaxSize_, fileBadgeStatus_)) :. quoteRow) = chatItem $ fromRight invalid $ dbParseACIContent itemContentText where invalid = ACIContent msgDir $ CIInvalidJSON itemContentText @@ -2324,7 +2326,8 @@ toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentT (Just fileId, Just fileName, Just fileSize, Just fileProtocol) -> let cfArgs = CFArgs <$> fileKey <*> fileNonce fileSource = (`CryptoFile` cfArgs) <$> filePath - in Just CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol, fileExpires} + fileProhibited = (\maxSize -> FileProhibited {maxSize, badgeStatus = fileBadgeStatus_}) <$> fileMaxSize_ + in Just CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol, fileExpires, fileProhibited} _ -> Nothing cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTDirect d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTDirect cItem d chatDir ciStatus content file = @@ -2380,6 +2383,7 @@ toGroupChatItem :. forwardedFromRow :. (timedTTL, timedDeleteAt, itemLive, BI userMention, BI hasLink, msgSigned) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_, fileExpires) + :. (fileMaxSize_, fileBadgeStatus_) ) :. (forwardedByMember, BI showGroupAsSender) :. memberRow_ @@ -2414,7 +2418,8 @@ toGroupChatItem (Just fileId, Just fileName, Just fileSize, Just fileProtocol) -> let cfArgs = CFArgs <$> fileKey <*> fileNonce fileSource = (`CryptoFile` cfArgs) <$> filePath - in Just CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol, fileExpires} + fileProhibited = (\maxSize -> FileProhibited {maxSize, badgeStatus = fileBadgeStatus_}) <$> fileMaxSize_ + in Just CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol, fileExpires, fileProhibited} _ -> Nothing cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTGroup d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTGroup cItem d chatDir ciStatus content file = @@ -2705,7 +2710,7 @@ getDirectChatItem db User {userId} contactId itemId = ExceptT $ do i.fwd_from_group_type, i.fwd_from_group_link, i.fwd_from_public_group_id, i.fwd_from_member_id, i.fwd_from_shared_msg_id, i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link, i.msg_signed, -- CIFile - f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, f.file_expires_at, + f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, f.file_expires_at, f.file_max_size, f.file_badge_status, -- DirectQuote ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent FROM chat_items i @@ -3099,7 +3104,7 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do i.fwd_from_group_type, i.fwd_from_group_link, i.fwd_from_public_group_id, i.fwd_from_member_id, i.fwd_from_shared_msg_id, i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link, i.msg_signed, -- CIFile - f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, f.file_expires_at, + f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, f.file_expires_at, f.file_max_size, f.file_badge_status, -- CIMeta forwardedByMember, showGroupAsSender i.forwarded_by_group_member_id, i.show_group_as_sender, -- GroupMember @@ -3212,7 +3217,7 @@ getLocalChatItem db User {userId} folderId itemId = ExceptT $ do i.fwd_from_group_type, i.fwd_from_group_link, i.fwd_from_public_group_id, i.fwd_from_member_id, i.fwd_from_shared_msg_id, i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link, i.msg_signed, -- CIFile - f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, f.file_expires_at + f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, f.file_expires_at, f.file_max_size, f.file_badge_status FROM chat_items i LEFT JOIN files f ON f.chat_item_id = i.chat_item_id WHERE i.user_id = ? AND i.note_folder_id = ? AND i.chat_item_id = ? diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index 05a21bed47..f580298663 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -49,7 +49,9 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260723_contact_request_rejectio import Simplex.Chat.Store.Postgres.Migrations.M20260813_auto_accept_group_invitations import Simplex.Chat.Store.Postgres.Migrations.M20260822_forward_link import Simplex.Chat.Store.Postgres.Migrations.M20260828_file_expiry -import Simplex.Chat.Store.Postgres.Migrations.M20260908_wallet_seeds +import Simplex.Chat.Store.Postgres.Migrations.M20260904_file_badges +import Simplex.Chat.Store.Postgres.Migrations.M20260915_user_badges +import Simplex.Chat.Store.Postgres.Migrations.M20260921_wallet_seeds import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -99,7 +101,9 @@ schemaMigrations = ("20260813_auto_accept_group_invitations", m20260813_auto_accept_group_invitations, Just down_m20260813_auto_accept_group_invitations), ("20260822_forward_link", m20260822_forward_link, Just down_m20260822_forward_link), ("20260828_file_expiry", m20260828_file_expiry, Just down_m20260828_file_expiry), - ("20260908_wallet_seeds", m20260908_wallet_seeds, Nothing) + ("20260904_file_badges", m20260904_file_badges, Just down_m20260904_file_badges), + ("20260915_user_badges", m20260915_user_badges, Just down_m20260915_user_badges), + ("20260921_wallet_seeds", m20260921_wallet_seeds, Nothing) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260904_file_badges.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260904_file_badges.hs new file mode 100644 index 0000000000..0ef3ef8e87 --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260904_file_badges.hs @@ -0,0 +1,40 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20260904_file_badges where + +import Data.Text (Text) +import Text.RawString.QQ (r) + +m20260904_file_badges :: Text +m20260904_file_badges = + [r| +ALTER TABLE files ADD COLUMN file_max_size BIGINT; +ALTER TABLE files ADD COLUMN file_badge_status TEXT; + +CREATE TABLE file_badge_proofs( + badge_proof_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + file_id BIGINT NOT NULL REFERENCES files ON DELETE CASCADE, + proof_kind TEXT NOT NULL, + badge_proof BYTEA NOT NULL, + badge_pres_header BYTEA NOT NULL, + badge_key_idx BIGINT NOT NULL, + badge_type TEXT NOT NULL, + badge_expiry TIMESTAMPTZ NOT NULL, + badge_extra TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE UNIQUE INDEX idx_file_badge_proofs_file_id_kind ON file_badge_proofs(file_id, proof_kind); +|] + +down_m20260904_file_badges :: Text +down_m20260904_file_badges = + [r| +DROP INDEX idx_file_badge_proofs_file_id_kind; +DROP TABLE file_badge_proofs; + +ALTER TABLE files DROP COLUMN file_badge_status; +ALTER TABLE files DROP COLUMN file_max_size; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260915_user_badges.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260915_user_badges.hs new file mode 100644 index 0000000000..98bdd53db7 --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260915_user_badges.hs @@ -0,0 +1,306 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20260915_user_badges where + +import Data.Text (Text) +import qualified Data.Text as T +import Text.RawString.QQ (r) + +-- the same tables in the client and in the badge service; '@' is replaced with the table name prefix +badgeSchema :: Text -> Text +badgeSchema pfx = withPrefix pfx badgeSchemaTables + +badgeSchemaDown :: Text -> Text +badgeSchemaDown pfx = withPrefix pfx badgeSchemaTablesDown + +withPrefix :: Text -> Text -> Text +withPrefix = T.replace "@" + +badgeSchemaTables :: Text +badgeSchemaTables = + [r| +CREATE TABLE @invoices( + invoice_id TEXT NOT NULL PRIMARY KEY, + provider TEXT NOT NULL, + price BIGINT NOT NULL, + discount_amount BIGINT, + credit_amount BIGINT, + amount BIGINT NOT NULL, + currency TEXT NOT NULL, + payment_url TEXT, + payment_address TEXT, + payment_crypto_currency TEXT, + payment_crypto_amount TEXT, + expires_at TIMESTAMPTZ NOT NULL, + status TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE TABLE @payments( + payment_id TEXT NOT NULL PRIMARY KEY, + invoice_id TEXT REFERENCES @invoices, + provider TEXT NOT NULL, + provider_ref TEXT, + amount BIGINT, + currency TEXT, + status TEXT NOT NULL, + exception TEXT, + subscription_renews_at TIMESTAMPTZ, + grace_until TIMESTAMPTZ, + cancelled SMALLINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX @idx_payments_provider_ref ON @payments(provider, provider_ref); + +CREATE INDEX @idx_payments_invoice ON @payments(invoice_id); + +CREATE TABLE @badge_prices( + price_id TEXT NOT NULL PRIMARY KEY, + badge_type TEXT NOT NULL, + month_price BIGINT NOT NULL, + currency TEXT NOT NULL, + status TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL +); + +CREATE TABLE @badge_offers( + offer_id TEXT NOT NULL PRIMARY KEY, + price_id TEXT REFERENCES @badge_prices, + months SMALLINT NOT NULL, + free_months SMALLINT, + discount SMALLINT, + status TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX @idx_badge_offers_price ON @badge_offers(price_id); + +CREATE TABLE @badge_purchases( + badge_purchase_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + purchase_key BYTEA NOT NULL, + master_key BYTEA NOT NULL, + initial_badge_type TEXT NOT NULL, + current_badge_type TEXT NOT NULL, + status TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + UNIQUE(purchase_key) +); + +CREATE TABLE @badge_ledger( + entry_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + entry_uuid TEXT NOT NULL, + badge_purchase_id BIGINT NOT NULL REFERENCES @badge_purchases ON DELETE CASCADE, + change_months SMALLINT NOT NULL, + balance_months SMALLINT NOT NULL, + balance_start_ts TIMESTAMPTZ NOT NULL, + balance_anchor_ts TIMESTAMPTZ NOT NULL, + balance_badge_type TEXT NOT NULL, + was_paused_since TIMESTAMPTZ, + service_created_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + entry_type TEXT NOT NULL, + entry_credit_type TEXT, + entry_debit_type TEXT +); + +CREATE UNIQUE INDEX @idx_badge_ledger_uuid ON @badge_ledger(entry_uuid); + +CREATE INDEX @idx_badge_ledger_purchase ON @badge_ledger(badge_purchase_id, entry_id); + +CREATE TABLE @badge_issuances( + issuance_id TEXT NOT NULL PRIMARY KEY, + badge_purchase_id BIGINT NOT NULL REFERENCES @badge_purchases ON DELETE CASCADE, + entry_id BIGINT REFERENCES @badge_ledger, + badge_type TEXT NOT NULL, + period_start TIMESTAMPTZ NOT NULL, + period_end TIMESTAMPTZ NOT NULL, + expiry TIMESTAMPTZ NOT NULL, + credential BYTEA NOT NULL, + created_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX @idx_badge_issuances_purchase ON @badge_issuances(badge_purchase_id, issuance_id); + +CREATE INDEX @idx_badge_issuances_entry ON @badge_issuances(entry_id); + +CREATE UNIQUE INDEX @idx_badge_issuances_purchase_entry ON @badge_issuances(badge_purchase_id, entry_id); +|] + +badgeSchemaTablesDown :: Text +badgeSchemaTablesDown = + [r| +DROP INDEX @idx_badge_issuances_purchase; +DROP INDEX @idx_badge_issuances_entry; +DROP INDEX @idx_badge_issuances_purchase_entry; +DROP TABLE @badge_issuances; +DROP INDEX @idx_badge_ledger_uuid; +DROP INDEX @idx_badge_ledger_purchase; +DROP TABLE @badge_ledger; +DROP TABLE @badge_purchases; +DROP INDEX @idx_payments_provider_ref; +DROP INDEX @idx_payments_invoice; +DROP TABLE @payments; +DROP TABLE @invoices; +DROP INDEX @idx_badge_offers_price; +DROP TABLE @badge_offers; +DROP TABLE @badge_prices; +|] + +{- TODO [badges] deferred draft schema for paid purchases, subscriptions, upgrades and transfers. + +CREATE TABLE @subscription_charges( + charge_id TEXT NOT NULL PRIMARY KEY, + payment_id TEXT NOT NULL REFERENCES @payments ON DELETE CASCADE, + provider_charge_ref TEXT NOT NULL, + period_start TIMESTAMPTZ NOT NULL, + period_end TIMESTAMPTZ NOT NULL, + amount BIGINT NOT NULL, + currency TEXT NOT NULL, + charged_at TIMESTAMPTZ NOT NULL, + UNIQUE(payment_id, provider_charge_ref) +); + +ALTER TABLE @badge_purchases ADD COLUMN payment_id TEXT REFERENCES @payments; + +CREATE UNIQUE INDEX @idx_badge_purchases_payment ON @badge_purchases(payment_id); + +CREATE TABLE @badge_invoices( + invoice_id TEXT NOT NULL PRIMARY KEY REFERENCES @invoices ON DELETE CASCADE, + badge_purchase_id BIGINT NOT NULL REFERENCES @badge_purchases ON DELETE CASCADE, + price_id TEXT NOT NULL REFERENCES @badge_prices, + offer_id TEXT REFERENCES @badge_offers, + months SMALLINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX @idx_badge_invoices_purchase ON @badge_invoices(badge_purchase_id); + +CREATE INDEX @idx_badge_invoices_offer ON @badge_invoices(offer_id); + +CREATE INDEX @idx_badge_invoices_price ON @badge_invoices(price_id); + +CREATE TABLE @badge_subscription_changes( + change_id TEXT NOT NULL PRIMARY KEY, + badge_purchase_id BIGINT NOT NULL REFERENCES @badge_purchases ON DELETE CASCADE, + from_badge_type TEXT NOT NULL, + to_badge_type TEXT NOT NULL, + from_provider_ref TEXT, + to_provider_ref TEXT, + effective TEXT NOT NULL, + status TEXT NOT NULL, + effective_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX @idx_badge_subscription_changes_purchase ON @badge_subscription_changes(badge_purchase_id); + +ALTER TABLE @badge_ledger ADD COLUMN payment_id TEXT REFERENCES @payments; + +ALTER TABLE @badge_ledger ADD COLUMN charge_id TEXT REFERENCES @subscription_charges; + +ALTER TABLE @badge_ledger ADD COLUMN from_purchase_id BIGINT REFERENCES @badge_purchases; + +ALTER TABLE @badge_ledger ADD COLUMN to_purchase_id BIGINT REFERENCES @badge_purchases; + +CREATE INDEX @idx_badge_ledger_payment ON @badge_ledger(payment_id); + +CREATE INDEX @idx_badge_ledger_charge ON @badge_ledger(charge_id); + +CREATE INDEX @idx_badge_ledger_from_purchase ON @badge_ledger(from_purchase_id); + +CREATE INDEX @idx_badge_ledger_to_purchase ON @badge_ledger(to_purchase_id); + +-- down +DROP INDEX @idx_badge_ledger_payment; +DROP INDEX @idx_badge_ledger_charge; +DROP INDEX @idx_badge_ledger_from_purchase; +DROP INDEX @idx_badge_ledger_to_purchase; +ALTER TABLE @badge_ledger DROP COLUMN payment_id; +ALTER TABLE @badge_ledger DROP COLUMN charge_id; +ALTER TABLE @badge_ledger DROP COLUMN from_purchase_id; +ALTER TABLE @badge_ledger DROP COLUMN to_purchase_id; +DROP INDEX @idx_badge_subscription_changes_purchase; +DROP TABLE @badge_subscription_changes; +DROP INDEX @idx_badge_invoices_purchase; +DROP INDEX @idx_badge_invoices_offer; +DROP INDEX @idx_badge_invoices_price; +DROP TABLE @badge_invoices; +DROP INDEX @idx_badge_purchases_payment; +ALTER TABLE @badge_purchases DROP COLUMN payment_id; +DROP TABLE @subscription_charges; +-} + +m20260915_user_badges :: Text +m20260915_user_badges = + badgeSchema "" + <> [r| +ALTER TABLE badge_purchases ADD COLUMN user_id BIGINT REFERENCES users ON DELETE CASCADE; + +ALTER TABLE badge_purchases ADD COLUMN purchase_priv_key BYTEA; + +ALTER TABLE badge_purchases ADD COLUMN alert_acked_kind TEXT; + +ALTER TABLE badge_purchases ADD COLUMN alert_acked_episode TEXT; + +ALTER TABLE badge_purchases ADD COLUMN alert_snooze_until TIMESTAMPTZ; + +ALTER TABLE badge_ledger ADD COLUMN entry_type_unknown SMALLINT NOT NULL DEFAULT 0; + +ALTER TABLE badge_ledger ADD COLUMN entry_type_value TEXT; + +ALTER TABLE badge_ledger ADD COLUMN balance_checked SMALLINT; + +CREATE INDEX idx_badge_purchases_user ON badge_purchases(user_id); + +ALTER TABLE users ADD COLUMN shown_badge_id BIGINT REFERENCES badge_purchases ON DELETE SET NULL; + +CREATE INDEX idx_users_shown_badge ON users(shown_badge_id); + +CREATE TABLE badge_code_redemptions( + badge_code_redemption_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE, + code TEXT NOT NULL, + purchase_key BYTEA NOT NULL, + purchase_priv_key BYTEA NOT NULL, + master_key BYTEA NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + UNIQUE(user_id, code) +); + +CREATE INDEX idx_badge_code_redemptions_user ON badge_code_redemptions(user_id); + +ALTER TABLE badge_purchases ADD COLUMN badge_code_redemption_id BIGINT REFERENCES badge_code_redemptions; + +CREATE UNIQUE INDEX idx_badge_purchases_code_redemption ON badge_purchases(badge_code_redemption_id); +|] + +down_m20260915_user_badges :: Text +down_m20260915_user_badges = + [r| +DROP INDEX idx_badge_purchases_code_redemption; +DROP INDEX idx_badge_purchases_user; +DROP INDEX idx_users_shown_badge; +ALTER TABLE users DROP COLUMN shown_badge_id; +|] + <> badgeSchemaDown "" + <> [r| +DROP INDEX idx_badge_code_redemptions_user; +DROP TABLE badge_code_redemptions; +|] + +{- TODO [badges] deferred with the draft above, client only. + +ALTER TABLE payments ADD COLUMN evidence BYTEA; + +ALTER TABLE payments ADD COLUMN receipt_code TEXT; + +-- down +ALTER TABLE payments DROP COLUMN evidence; +ALTER TABLE payments DROP COLUMN receipt_code; +-} diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260908_wallet_seeds.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260921_wallet_seeds.hs similarity index 90% rename from src/Simplex/Chat/Store/Postgres/Migrations/M20260908_wallet_seeds.hs rename to src/Simplex/Chat/Store/Postgres/Migrations/M20260921_wallet_seeds.hs index f4bd3ca2d7..568bd6a21a 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20260908_wallet_seeds.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260921_wallet_seeds.hs @@ -1,13 +1,13 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Store.Postgres.Migrations.M20260908_wallet_seeds where +module Simplex.Chat.Store.Postgres.Migrations.M20260921_wallet_seeds where import Data.Text (Text) import Text.RawString.QQ (r) -m20260908_wallet_seeds :: Text -m20260908_wallet_seeds = +m20260921_wallet_seeds :: Text +m20260921_wallet_seeds = [r| -- the columns are commented in the SQLite migration CREATE TABLE wallet_seeds ( diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index a0d062e1f7..1e2a26d74e 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -168,6 +168,129 @@ CREATE TABLE test_chat_schema.app_settings ( +CREATE TABLE test_chat_schema.badge_code_redemptions ( + badge_code_redemption_id bigint NOT NULL, + user_id bigint NOT NULL, + code text NOT NULL, + purchase_key bytea NOT NULL, + purchase_priv_key bytea NOT NULL, + master_key bytea NOT NULL, + created_at timestamp with time zone NOT NULL +); + + + +ALTER TABLE test_chat_schema.badge_code_redemptions ALTER COLUMN badge_code_redemption_id ADD GENERATED ALWAYS AS IDENTITY ( + SEQUENCE NAME test_chat_schema.badge_code_redemptions_badge_code_redemption_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + + +CREATE TABLE test_chat_schema.badge_issuances ( + issuance_id text NOT NULL, + badge_purchase_id bigint NOT NULL, + entry_id bigint, + badge_type text NOT NULL, + period_start timestamp with time zone NOT NULL, + period_end timestamp with time zone NOT NULL, + expiry timestamp with time zone NOT NULL, + credential bytea NOT NULL, + created_at timestamp with time zone NOT NULL +); + + + +CREATE TABLE test_chat_schema.badge_ledger ( + entry_id bigint NOT NULL, + entry_uuid text NOT NULL, + badge_purchase_id bigint NOT NULL, + change_months smallint NOT NULL, + balance_months smallint NOT NULL, + balance_start_ts timestamp with time zone NOT NULL, + balance_anchor_ts timestamp with time zone NOT NULL, + balance_badge_type text NOT NULL, + was_paused_since timestamp with time zone, + service_created_at timestamp with time zone NOT NULL, + created_at timestamp with time zone NOT NULL, + entry_type text NOT NULL, + entry_credit_type text, + entry_debit_type text, + entry_type_unknown smallint DEFAULT 0 NOT NULL, + entry_type_value text, + balance_checked smallint +); + + + +ALTER TABLE test_chat_schema.badge_ledger ALTER COLUMN entry_id ADD GENERATED ALWAYS AS IDENTITY ( + SEQUENCE NAME test_chat_schema.badge_ledger_entry_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + + +CREATE TABLE test_chat_schema.badge_offers ( + offer_id text NOT NULL, + price_id text, + months smallint NOT NULL, + free_months smallint, + discount smallint, + status text NOT NULL, + created_at timestamp with time zone NOT NULL +); + + + +CREATE TABLE test_chat_schema.badge_prices ( + price_id text NOT NULL, + badge_type text NOT NULL, + month_price bigint NOT NULL, + currency text NOT NULL, + status text NOT NULL, + created_at timestamp with time zone NOT NULL +); + + + +CREATE TABLE test_chat_schema.badge_purchases ( + badge_purchase_id bigint NOT NULL, + purchase_key bytea NOT NULL, + master_key bytea NOT NULL, + initial_badge_type text NOT NULL, + current_badge_type text NOT NULL, + status text NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + user_id bigint, + purchase_priv_key bytea, + alert_acked_kind text, + alert_acked_episode text, + alert_snooze_until timestamp with time zone, + badge_code_redemption_id bigint +); + + + +ALTER TABLE test_chat_schema.badge_purchases ALTER COLUMN badge_purchase_id ADD GENERATED ALWAYS AS IDENTITY ( + SEQUENCE NAME test_chat_schema.badge_purchases_badge_purchase_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + + CREATE TABLE test_chat_schema.calls ( call_id bigint NOT NULL, contact_id bigint NOT NULL, @@ -744,6 +867,33 @@ ALTER TABLE test_chat_schema.extra_xftp_file_descriptions ALTER COLUMN extra_fil +CREATE TABLE test_chat_schema.file_badge_proofs ( + badge_proof_id bigint NOT NULL, + file_id bigint NOT NULL, + proof_kind text NOT NULL, + badge_proof bytea NOT NULL, + badge_pres_header bytea NOT NULL, + badge_key_idx bigint NOT NULL, + badge_type text NOT NULL, + badge_expiry timestamp with time zone NOT NULL, + badge_extra text NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL +); + + + +ALTER TABLE test_chat_schema.file_badge_proofs ALTER COLUMN badge_proof_id ADD GENERATED ALWAYS AS IDENTITY ( + SEQUENCE NAME test_chat_schema.file_badge_proofs_badge_proof_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + + CREATE TABLE test_chat_schema.files ( file_id bigint NOT NULL, contact_id bigint, @@ -771,7 +921,9 @@ CREATE TABLE test_chat_schema.files ( file_type text DEFAULT 'normal'::text NOT NULL, roster_transfer_id bigint, file_digest bytea, - file_expires_at timestamp with time zone + file_expires_at timestamp with time zone, + file_max_size bigint, + file_badge_status text ); @@ -1000,7 +1152,7 @@ CREATE TABLE test_chat_schema.groups ( public_member_count bigint, relay_request_retries bigint DEFAULT 0 NOT NULL, relay_request_delay bigint DEFAULT 0 NOT NULL, - relay_request_execute_at timestamp with time zone DEFAULT '1970-01-01 01:00:00+01'::timestamp with time zone NOT NULL, + relay_request_execute_at timestamp with time zone DEFAULT '1970-01-01 04:00:00+04'::timestamp with time zone NOT NULL, relay_inactive_at timestamp with time zone, relay_sent_web_domain text, roster_version bigint, @@ -1028,6 +1180,26 @@ ALTER TABLE test_chat_schema.groups ALTER COLUMN group_id ADD GENERATED ALWAYS A +CREATE TABLE test_chat_schema.invoices ( + invoice_id text NOT NULL, + provider text NOT NULL, + price bigint NOT NULL, + discount_amount bigint, + credit_amount bigint, + amount bigint NOT NULL, + currency text NOT NULL, + payment_url text, + payment_address text, + payment_crypto_currency text, + payment_crypto_amount text, + expires_at timestamp with time zone NOT NULL, + status text NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL +); + + + CREATE TABLE test_chat_schema.known_servers ( server_id bigint NOT NULL, host text NOT NULL, @@ -1161,6 +1333,24 @@ ALTER TABLE test_chat_schema.operator_usage_conditions ALTER COLUMN operator_usa +CREATE TABLE test_chat_schema.payments ( + payment_id text NOT NULL, + invoice_id text, + provider text NOT NULL, + provider_ref text, + amount bigint, + currency text, + status text NOT NULL, + exception text, + subscription_renews_at timestamp with time zone, + grace_until timestamp with time zone, + cancelled 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.pending_group_messages ( pending_group_message_id bigint NOT NULL, group_member_id bigint NOT NULL, @@ -1517,7 +1707,8 @@ CREATE TABLE test_chat_schema.users ( auto_accept_member_contacts smallint DEFAULT 0 NOT NULL, is_user_chat_relay smallint DEFAULT 0 NOT NULL, client_service smallint DEFAULT 0 NOT NULL, - auto_accept_group_invitations smallint DEFAULT 0 NOT NULL + auto_accept_group_invitations smallint DEFAULT 0 NOT NULL, + shown_badge_id bigint ); @@ -1533,49 +1724,6 @@ ALTER TABLE test_chat_schema.users ALTER COLUMN user_id ADD GENERATED ALWAYS AS -CREATE TABLE test_chat_schema.wallet_accounts ( - wallet_account_id bigint NOT NULL, - wallet_seed_id bigint NOT NULL, - account_index bigint, - user_id bigint, - CONSTRAINT wallet_accounts_account_index_check CHECK (((account_index >= 0) AND (account_index <= 2147483647))) -); - - - -ALTER TABLE test_chat_schema.wallet_accounts ALTER COLUMN wallet_account_id ADD GENERATED ALWAYS AS IDENTITY ( - SEQUENCE NAME test_chat_schema.wallet_accounts_wallet_account_id_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1 -); - - - -CREATE TABLE test_chat_schema.wallet_seeds ( - wallet_seed_id bigint NOT NULL, - entropy bytea NOT NULL, - next_account_index bigint, - single_seed smallint DEFAULT 1 NOT NULL, - CONSTRAINT wallet_seeds_entropy_check CHECK ((length(entropy) = 32)), - CONSTRAINT wallet_seeds_next_account_index_check CHECK (((next_account_index >= 0) AND (next_account_index <= '2147483648'::bigint))) -); - - - -ALTER TABLE test_chat_schema.wallet_seeds ALTER COLUMN wallet_seed_id ADD GENERATED ALWAYS AS IDENTITY ( - SEQUENCE NAME test_chat_schema.wallet_seeds_wallet_seed_id_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1 -); - - - CREATE TABLE test_chat_schema.xftp_file_descriptions ( file_descr_id bigint NOT NULL, user_id bigint NOT NULL, @@ -1599,6 +1747,46 @@ ALTER TABLE test_chat_schema.xftp_file_descriptions ALTER COLUMN file_descr_id A +ALTER TABLE ONLY test_chat_schema.badge_code_redemptions + ADD CONSTRAINT badge_code_redemptions_pkey PRIMARY KEY (badge_code_redemption_id); + + + +ALTER TABLE ONLY test_chat_schema.badge_code_redemptions + ADD CONSTRAINT badge_code_redemptions_user_id_code_key UNIQUE (user_id, code); + + + +ALTER TABLE ONLY test_chat_schema.badge_issuances + ADD CONSTRAINT badge_issuances_pkey PRIMARY KEY (issuance_id); + + + +ALTER TABLE ONLY test_chat_schema.badge_ledger + ADD CONSTRAINT badge_ledger_pkey PRIMARY KEY (entry_id); + + + +ALTER TABLE ONLY test_chat_schema.badge_offers + ADD CONSTRAINT badge_offers_pkey PRIMARY KEY (offer_id); + + + +ALTER TABLE ONLY test_chat_schema.badge_prices + ADD CONSTRAINT badge_prices_pkey PRIMARY KEY (price_id); + + + +ALTER TABLE ONLY test_chat_schema.badge_purchases + ADD CONSTRAINT badge_purchases_pkey PRIMARY KEY (badge_purchase_id); + + + +ALTER TABLE ONLY test_chat_schema.badge_purchases + ADD CONSTRAINT badge_purchases_purchase_key_key UNIQUE (purchase_key); + + + ALTER TABLE ONLY test_chat_schema.calls ADD CONSTRAINT calls_pkey PRIMARY KEY (call_id); @@ -1734,6 +1922,11 @@ ALTER TABLE ONLY test_chat_schema.extra_xftp_file_descriptions +ALTER TABLE ONLY test_chat_schema.file_badge_proofs + ADD CONSTRAINT file_badge_proofs_pkey PRIMARY KEY (badge_proof_id); + + + ALTER TABLE ONLY test_chat_schema.files ADD CONSTRAINT files_pkey PRIMARY KEY (file_id); @@ -1789,6 +1982,11 @@ ALTER TABLE ONLY test_chat_schema.groups +ALTER TABLE ONLY test_chat_schema.invoices + ADD CONSTRAINT invoices_pkey PRIMARY KEY (invoice_id); + + + ALTER TABLE ONLY test_chat_schema.known_servers ADD CONSTRAINT known_servers_pkey PRIMARY KEY (server_id); @@ -1824,6 +2022,11 @@ ALTER TABLE ONLY test_chat_schema.operator_usage_conditions +ALTER TABLE ONLY test_chat_schema.payments + ADD CONSTRAINT payments_pkey PRIMARY KEY (payment_id); + + + ALTER TABLE ONLY test_chat_schema.pending_group_messages ADD CONSTRAINT pending_group_messages_pkey PRIMARY KEY (pending_group_message_id); @@ -1934,16 +2137,6 @@ ALTER TABLE ONLY test_chat_schema.users -ALTER TABLE ONLY test_chat_schema.wallet_accounts - ADD CONSTRAINT wallet_accounts_pkey PRIMARY KEY (wallet_account_id); - - - -ALTER TABLE ONLY test_chat_schema.wallet_seeds - ADD CONSTRAINT wallet_seeds_pkey PRIMARY KEY (wallet_seed_id); - - - ALTER TABLE ONLY test_chat_schema.xftp_file_descriptions ADD CONSTRAINT xftp_file_descriptions_pkey PRIMARY KEY (file_descr_id); @@ -1961,6 +2154,42 @@ CREATE INDEX files_note_folder_id ON test_chat_schema.files USING btree (note_fo +CREATE INDEX idx_badge_code_redemptions_user ON test_chat_schema.badge_code_redemptions USING btree (user_id); + + + +CREATE INDEX idx_badge_issuances_entry ON test_chat_schema.badge_issuances USING btree (entry_id); + + + +CREATE INDEX idx_badge_issuances_purchase ON test_chat_schema.badge_issuances USING btree (badge_purchase_id, issuance_id); + + + +CREATE UNIQUE INDEX idx_badge_issuances_purchase_entry ON test_chat_schema.badge_issuances USING btree (badge_purchase_id, entry_id); + + + +CREATE INDEX idx_badge_ledger_purchase ON test_chat_schema.badge_ledger USING btree (badge_purchase_id, entry_id); + + + +CREATE UNIQUE INDEX idx_badge_ledger_uuid ON test_chat_schema.badge_ledger USING btree (entry_uuid); + + + +CREATE INDEX idx_badge_offers_price ON test_chat_schema.badge_offers USING btree (price_id); + + + +CREATE UNIQUE INDEX idx_badge_purchases_code_redemption ON test_chat_schema.badge_purchases USING btree (badge_code_redemption_id); + + + +CREATE INDEX idx_badge_purchases_user ON test_chat_schema.badge_purchases USING btree (user_id); + + + CREATE INDEX idx_calls_chat_item_id ON test_chat_schema.calls USING btree (chat_item_id); @@ -2389,6 +2618,10 @@ CREATE INDEX idx_extra_xftp_file_descriptions_user_id ON test_chat_schema.extra_ +CREATE UNIQUE INDEX idx_file_badge_proofs_file_id_kind ON test_chat_schema.file_badge_proofs USING btree (file_id, proof_kind); + + + CREATE INDEX idx_files_chat_item_id ON test_chat_schema.files USING btree (chat_item_id); @@ -2565,6 +2798,14 @@ CREATE INDEX idx_operator_usage_conditions_server_operator_id ON test_chat_schem +CREATE INDEX idx_payments_invoice ON test_chat_schema.payments USING btree (invoice_id); + + + +CREATE INDEX idx_payments_provider_ref ON test_chat_schema.payments USING btree (provider, provider_ref); + + + CREATE INDEX idx_pending_group_messages_group_member_id ON test_chat_schema.pending_group_messages USING btree (group_member_id); @@ -2693,15 +2934,7 @@ CREATE UNIQUE INDEX idx_user_contact_links_group_id ON test_chat_schema.user_con -CREATE INDEX idx_wallet_accounts_user_id ON test_chat_schema.wallet_accounts USING btree (user_id); - - - -CREATE UNIQUE INDEX idx_wallet_accounts_wallet_seed_id_account_index ON test_chat_schema.wallet_accounts USING btree (wallet_seed_id, account_index); - - - -CREATE UNIQUE INDEX idx_wallet_seeds_single_seed ON test_chat_schema.wallet_seeds USING btree (single_seed); +CREATE INDEX idx_users_shown_badge ON test_chat_schema.users USING btree (shown_badge_id); @@ -2725,6 +2958,41 @@ CREATE TRIGGER tr_group_members_update_update_summary AFTER UPDATE ON test_chat_ +ALTER TABLE ONLY test_chat_schema.badge_code_redemptions + ADD CONSTRAINT badge_code_redemptions_user_id_fkey FOREIGN KEY (user_id) REFERENCES test_chat_schema.users(user_id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY test_chat_schema.badge_issuances + ADD CONSTRAINT badge_issuances_badge_purchase_id_fkey FOREIGN KEY (badge_purchase_id) REFERENCES test_chat_schema.badge_purchases(badge_purchase_id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY test_chat_schema.badge_issuances + ADD CONSTRAINT badge_issuances_entry_id_fkey FOREIGN KEY (entry_id) REFERENCES test_chat_schema.badge_ledger(entry_id); + + + +ALTER TABLE ONLY test_chat_schema.badge_ledger + ADD CONSTRAINT badge_ledger_badge_purchase_id_fkey FOREIGN KEY (badge_purchase_id) REFERENCES test_chat_schema.badge_purchases(badge_purchase_id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY test_chat_schema.badge_offers + ADD CONSTRAINT badge_offers_price_id_fkey FOREIGN KEY (price_id) REFERENCES test_chat_schema.badge_prices(price_id); + + + +ALTER TABLE ONLY test_chat_schema.badge_purchases + ADD CONSTRAINT badge_purchases_badge_code_redemption_id_fkey FOREIGN KEY (badge_code_redemption_id) REFERENCES test_chat_schema.badge_code_redemptions(badge_code_redemption_id); + + + +ALTER TABLE ONLY test_chat_schema.badge_purchases + ADD CONSTRAINT badge_purchases_user_id_fkey FOREIGN KEY (user_id) REFERENCES test_chat_schema.users(user_id) ON DELETE CASCADE; + + + 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; @@ -3045,6 +3313,11 @@ ALTER TABLE ONLY test_chat_schema.extra_xftp_file_descriptions +ALTER TABLE ONLY test_chat_schema.file_badge_proofs + ADD CONSTRAINT file_badge_proofs_file_id_fkey FOREIGN KEY (file_id) REFERENCES test_chat_schema.files(file_id) ON DELETE CASCADE; + + + ALTER TABLE ONLY test_chat_schema.files ADD CONSTRAINT files_contact_id_fkey FOREIGN KEY (contact_id) REFERENCES test_chat_schema.contacts(contact_id) ON DELETE CASCADE; @@ -3265,6 +3538,11 @@ ALTER TABLE ONLY test_chat_schema.operator_usage_conditions +ALTER TABLE ONLY test_chat_schema.payments + ADD CONSTRAINT payments_invoice_id_fkey FOREIGN KEY (invoice_id) REFERENCES test_chat_schema.invoices(invoice_id); + + + ALTER TABLE ONLY test_chat_schema.pending_group_messages ADD CONSTRAINT pending_group_messages_group_member_id_fkey FOREIGN KEY (group_member_id) REFERENCES test_chat_schema.group_members(group_member_id) ON DELETE CASCADE; @@ -3380,13 +3658,8 @@ ALTER TABLE ONLY test_chat_schema.user_contact_links -ALTER TABLE ONLY test_chat_schema.wallet_accounts - ADD CONSTRAINT wallet_accounts_user_id_fkey FOREIGN KEY (user_id) REFERENCES test_chat_schema.users(user_id) ON DELETE SET NULL; - - - -ALTER TABLE ONLY test_chat_schema.wallet_accounts - ADD CONSTRAINT wallet_accounts_wallet_seed_id_fkey FOREIGN KEY (wallet_seed_id) REFERENCES test_chat_schema.wallet_seeds(wallet_seed_id) ON DELETE CASCADE; +ALTER TABLE ONLY test_chat_schema.users + ADD CONSTRAINT users_shown_badge_id_fkey FOREIGN KEY (shown_badge_id) REFERENCES test_chat_schema.badge_purchases(badge_purchase_id) ON DELETE SET NULL; diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index 965cbcbc78..20e05009a5 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -382,19 +382,21 @@ updateUserProfileFields_' db userId profileId Profile {displayName, fullName, sh -- store the user's own badge credential; touches only the badge columns. -- bumps user_member_profile_updated_at so groups receive the updated profile (with the badge) on the next message. -setUserBadge :: DB.Connection -> User -> Maybe LocalBadge -> IO User -setUserBadge db user@User {userId, profile = p@LocalProfile {profileId}} localBadge = do - ts <- getCurrentTime - DB.execute - db - [sql| - UPDATE contact_profiles - SET badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ?, updated_at = ? - WHERE user_id = ? AND contact_profile_id = ? - |] - (localBadgeToRow localBadge :. (ts, userId, profileId)) - DB.execute db "UPDATE users SET user_member_profile_updated_at = ? WHERE user_id = ?" (ts, userId) - pure (user :: User) {profile = p {localBadge}, userMemberProfileUpdatedAt = Just ts} +-- answers the row as stored, or a profile edit landing since the caller's read is broadcast back stale. +setUserBadge :: DB.Connection -> User -> Maybe LocalBadge -> ExceptT StoreError IO User +setUserBadge db User {userId, profile = LocalProfile {profileId}} localBadge = do + liftIO $ do + ts <- getCurrentTime + DB.execute + db + [sql| + UPDATE contact_profiles + SET badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ?, updated_at = ? + WHERE user_id = ? AND contact_profile_id = ? + |] + (localBadgeToRow localBadge :. (ts, userId, profileId)) + DB.execute db "UPDATE users SET user_member_profile_updated_at = ? WHERE user_id = ?" (ts, userId) + getUser db userId setUserSimplexDomain :: DB.Connection -> User -> Maybe SimplexDomain -> IO User setUserSimplexDomain db user@User {userId, profile = p@LocalProfile {profileId}} domain_ = do diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 86e0e5ffff..f6bea39306 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -172,7 +172,9 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260723_contact_request_rejection import Simplex.Chat.Store.SQLite.Migrations.M20260813_auto_accept_group_invitations import Simplex.Chat.Store.SQLite.Migrations.M20260822_forward_link import Simplex.Chat.Store.SQLite.Migrations.M20260828_file_expiry -import Simplex.Chat.Store.SQLite.Migrations.M20260908_wallet_seeds +import Simplex.Chat.Store.SQLite.Migrations.M20260904_file_badges +import Simplex.Chat.Store.SQLite.Migrations.M20260915_user_badges +import Simplex.Chat.Store.SQLite.Migrations.M20260921_wallet_seeds import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -345,7 +347,9 @@ schemaMigrations = ("20260813_auto_accept_group_invitations", m20260813_auto_accept_group_invitations, Just down_m20260813_auto_accept_group_invitations), ("20260822_forward_link", m20260822_forward_link, Just down_m20260822_forward_link), ("20260828_file_expiry", m20260828_file_expiry, Just down_m20260828_file_expiry), - ("20260908_wallet_seeds", m20260908_wallet_seeds, Nothing) + ("20260904_file_badges", m20260904_file_badges, Just down_m20260904_file_badges), + ("20260915_user_badges", m20260915_user_badges, Just down_m20260915_user_badges), + ("20260921_wallet_seeds", m20260921_wallet_seeds, Nothing) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260904_file_badges.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260904_file_badges.hs new file mode 100644 index 0000000000..1dec68471c --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260904_file_badges.hs @@ -0,0 +1,39 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20260904_file_badges where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20260904_file_badges :: Query +m20260904_file_badges = + [sql| +ALTER TABLE files ADD COLUMN file_max_size INTEGER; +ALTER TABLE files ADD COLUMN file_badge_status TEXT; + +CREATE TABLE file_badge_proofs( + badge_proof_id INTEGER PRIMARY KEY AUTOINCREMENT, + file_id INTEGER NOT NULL REFERENCES files ON DELETE CASCADE, + proof_kind TEXT NOT NULL, + badge_proof BLOB NOT NULL, + badge_pres_header BLOB NOT NULL, + badge_key_idx INTEGER NOT NULL, + badge_type TEXT NOT NULL, + badge_expiry TEXT NOT NULL, + badge_extra TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +) STRICT; + +CREATE UNIQUE INDEX idx_file_badge_proofs_file_id_kind ON file_badge_proofs(file_id, proof_kind); +|] + +down_m20260904_file_badges :: Query +down_m20260904_file_badges = + [sql| +DROP INDEX idx_file_badge_proofs_file_id_kind; +DROP TABLE file_badge_proofs; + +ALTER TABLE files DROP COLUMN file_badge_status; +ALTER TABLE files DROP COLUMN file_max_size; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260915_user_badges.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260915_user_badges.hs new file mode 100644 index 0000000000..3462db9bc1 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260915_user_badges.hs @@ -0,0 +1,307 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20260915_user_badges where + +import Data.Text (Text) +import qualified Data.Text as T +import Database.SQLite.Simple (Query (..)) +import Database.SQLite.Simple.QQ (sql) + +-- the same tables in the client and in the badge service; '@' is replaced with the table name prefix +badgeSchema :: Text -> Query +badgeSchema pfx = withPrefix pfx badgeSchemaTables + +badgeSchemaDown :: Text -> Query +badgeSchemaDown pfx = withPrefix pfx badgeSchemaTablesDown + +withPrefix :: Text -> Query -> Query +withPrefix pfx = Query . T.replace "@" pfx . fromQuery + +badgeSchemaTables :: Query +badgeSchemaTables = + [sql| +CREATE TABLE @invoices( + invoice_id TEXT NOT NULL PRIMARY KEY, + provider TEXT NOT NULL, + price INTEGER NOT NULL, + discount_amount INTEGER, + credit_amount INTEGER, + amount INTEGER NOT NULL, + currency TEXT NOT NULL, + payment_url TEXT, + payment_address TEXT, + payment_crypto_currency TEXT, + payment_crypto_amount TEXT, + expires_at TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +) STRICT; + +CREATE TABLE @payments( + payment_id TEXT NOT NULL PRIMARY KEY, + invoice_id TEXT REFERENCES @invoices, + provider TEXT NOT NULL, + provider_ref TEXT, + amount INTEGER, + currency TEXT, + status TEXT NOT NULL, + exception TEXT, + subscription_renews_at TEXT, + grace_until TEXT, + cancelled INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +) STRICT; + +CREATE INDEX @idx_payments_provider_ref ON @payments(provider, provider_ref); + +CREATE INDEX @idx_payments_invoice ON @payments(invoice_id); + +CREATE TABLE @badge_prices( + price_id TEXT NOT NULL PRIMARY KEY, + badge_type TEXT NOT NULL, + month_price INTEGER NOT NULL, + currency TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL +) STRICT; + +CREATE TABLE @badge_offers( + offer_id TEXT NOT NULL PRIMARY KEY, + price_id TEXT REFERENCES @badge_prices, + months INTEGER NOT NULL, + free_months INTEGER, + discount INTEGER, + status TEXT NOT NULL, + created_at TEXT NOT NULL +) STRICT; + +CREATE INDEX @idx_badge_offers_price ON @badge_offers(price_id); + +CREATE TABLE @badge_purchases( + badge_purchase_id INTEGER PRIMARY KEY AUTOINCREMENT, + purchase_key BLOB NOT NULL, + master_key BLOB NOT NULL, + initial_badge_type TEXT NOT NULL, + current_badge_type TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(purchase_key) +) STRICT; + +CREATE TABLE @badge_ledger( + entry_id INTEGER PRIMARY KEY AUTOINCREMENT, + entry_uuid TEXT NOT NULL, + badge_purchase_id INTEGER NOT NULL REFERENCES @badge_purchases ON DELETE CASCADE, + change_months INTEGER NOT NULL, + balance_months INTEGER NOT NULL, + balance_start_ts TEXT NOT NULL, + balance_anchor_ts TEXT NOT NULL, + balance_badge_type TEXT NOT NULL, + was_paused_since TEXT, + service_created_at TEXT NOT NULL, + created_at TEXT NOT NULL, + entry_type TEXT NOT NULL, + entry_credit_type TEXT, + entry_debit_type TEXT +) STRICT; + +CREATE UNIQUE INDEX @idx_badge_ledger_uuid ON @badge_ledger(entry_uuid); + +CREATE INDEX @idx_badge_ledger_purchase ON @badge_ledger(badge_purchase_id, entry_id); + +CREATE TABLE @badge_issuances( + issuance_id TEXT NOT NULL PRIMARY KEY, + badge_purchase_id INTEGER NOT NULL REFERENCES @badge_purchases ON DELETE CASCADE, + entry_id INTEGER REFERENCES @badge_ledger, + badge_type TEXT NOT NULL, + period_start TEXT NOT NULL, + period_end TEXT NOT NULL, + expiry TEXT NOT NULL, + credential BLOB NOT NULL, + created_at TEXT NOT NULL +) STRICT; + +CREATE INDEX @idx_badge_issuances_purchase ON @badge_issuances(badge_purchase_id, issuance_id); + +CREATE INDEX @idx_badge_issuances_entry ON @badge_issuances(entry_id); + +CREATE UNIQUE INDEX @idx_badge_issuances_purchase_entry ON @badge_issuances(badge_purchase_id, entry_id); +|] + +badgeSchemaTablesDown :: Query +badgeSchemaTablesDown = + [sql| +DROP INDEX @idx_badge_issuances_purchase; +DROP INDEX @idx_badge_issuances_entry; +DROP INDEX @idx_badge_issuances_purchase_entry; +DROP TABLE @badge_issuances; +DROP INDEX @idx_badge_ledger_uuid; +DROP INDEX @idx_badge_ledger_purchase; +DROP TABLE @badge_ledger; +DROP TABLE @badge_purchases; +DROP INDEX @idx_payments_provider_ref; +DROP INDEX @idx_payments_invoice; +DROP TABLE @payments; +DROP TABLE @invoices; +DROP INDEX @idx_badge_offers_price; +DROP TABLE @badge_offers; +DROP TABLE @badge_prices; +|] + +{- TODO [badges] deferred draft schema for paid purchases, subscriptions, upgrades and transfers. + +CREATE TABLE @subscription_charges( + charge_id TEXT NOT NULL PRIMARY KEY, + payment_id TEXT NOT NULL REFERENCES @payments ON DELETE CASCADE, + provider_charge_ref TEXT NOT NULL, + period_start TEXT NOT NULL, + period_end TEXT NOT NULL, + amount INTEGER NOT NULL, + currency TEXT NOT NULL, + charged_at TEXT NOT NULL, + UNIQUE(payment_id, provider_charge_ref) +) STRICT; + +ALTER TABLE @badge_purchases ADD COLUMN payment_id TEXT REFERENCES @payments; + +CREATE UNIQUE INDEX @idx_badge_purchases_payment ON @badge_purchases(payment_id); + +CREATE TABLE @badge_invoices( + invoice_id TEXT NOT NULL PRIMARY KEY REFERENCES @invoices ON DELETE CASCADE, + badge_purchase_id INTEGER NOT NULL REFERENCES @badge_purchases ON DELETE CASCADE, + price_id TEXT NOT NULL REFERENCES @badge_prices, + offer_id TEXT REFERENCES @badge_offers, + months INTEGER NOT NULL, + created_at TEXT NOT NULL +) STRICT; + +CREATE INDEX @idx_badge_invoices_purchase ON @badge_invoices(badge_purchase_id); + +CREATE INDEX @idx_badge_invoices_offer ON @badge_invoices(offer_id); + +CREATE INDEX @idx_badge_invoices_price ON @badge_invoices(price_id); + +CREATE TABLE @badge_subscription_changes( + change_id TEXT NOT NULL PRIMARY KEY, + badge_purchase_id INTEGER NOT NULL REFERENCES @badge_purchases ON DELETE CASCADE, + from_badge_type TEXT NOT NULL, + to_badge_type TEXT NOT NULL, + from_provider_ref TEXT, + to_provider_ref TEXT, + effective TEXT NOT NULL, + status TEXT NOT NULL, + effective_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +) STRICT; + +CREATE INDEX @idx_badge_subscription_changes_purchase ON @badge_subscription_changes(badge_purchase_id); + +ALTER TABLE @badge_ledger ADD COLUMN payment_id TEXT REFERENCES @payments; + +ALTER TABLE @badge_ledger ADD COLUMN charge_id TEXT REFERENCES @subscription_charges; + +ALTER TABLE @badge_ledger ADD COLUMN from_purchase_id INTEGER REFERENCES @badge_purchases; + +ALTER TABLE @badge_ledger ADD COLUMN to_purchase_id INTEGER REFERENCES @badge_purchases; + +CREATE INDEX @idx_badge_ledger_payment ON @badge_ledger(payment_id); + +CREATE INDEX @idx_badge_ledger_charge ON @badge_ledger(charge_id); + +CREATE INDEX @idx_badge_ledger_from_purchase ON @badge_ledger(from_purchase_id); + +CREATE INDEX @idx_badge_ledger_to_purchase ON @badge_ledger(to_purchase_id); + +-- down +DROP INDEX @idx_badge_ledger_payment; +DROP INDEX @idx_badge_ledger_charge; +DROP INDEX @idx_badge_ledger_from_purchase; +DROP INDEX @idx_badge_ledger_to_purchase; +ALTER TABLE @badge_ledger DROP COLUMN payment_id; +ALTER TABLE @badge_ledger DROP COLUMN charge_id; +ALTER TABLE @badge_ledger DROP COLUMN from_purchase_id; +ALTER TABLE @badge_ledger DROP COLUMN to_purchase_id; +DROP INDEX @idx_badge_subscription_changes_purchase; +DROP TABLE @badge_subscription_changes; +DROP INDEX @idx_badge_invoices_purchase; +DROP INDEX @idx_badge_invoices_offer; +DROP INDEX @idx_badge_invoices_price; +DROP TABLE @badge_invoices; +DROP INDEX @idx_badge_purchases_payment; +ALTER TABLE @badge_purchases DROP COLUMN payment_id; +DROP TABLE @subscription_charges; +-} + +m20260915_user_badges :: Query +m20260915_user_badges = + badgeSchema "" + <> [sql| +ALTER TABLE badge_purchases ADD COLUMN user_id INTEGER REFERENCES users ON DELETE CASCADE; + +ALTER TABLE badge_purchases ADD COLUMN purchase_priv_key BLOB; + +ALTER TABLE badge_purchases ADD COLUMN alert_acked_kind TEXT; + +ALTER TABLE badge_purchases ADD COLUMN alert_acked_episode TEXT; + +ALTER TABLE badge_purchases ADD COLUMN alert_snooze_until TEXT; + +ALTER TABLE badge_ledger ADD COLUMN entry_type_unknown INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE badge_ledger ADD COLUMN entry_type_value TEXT; + +ALTER TABLE badge_ledger ADD COLUMN balance_checked INTEGER; + +CREATE INDEX idx_badge_purchases_user ON badge_purchases(user_id); + +ALTER TABLE users ADD COLUMN shown_badge_id INTEGER REFERENCES badge_purchases ON DELETE SET NULL; + +CREATE INDEX idx_users_shown_badge ON users(shown_badge_id); + +CREATE TABLE badge_code_redemptions( + badge_code_redemption_id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, + code TEXT NOT NULL, + purchase_key BLOB NOT NULL, + purchase_priv_key BLOB NOT NULL, + master_key BLOB NOT NULL, + created_at TEXT NOT NULL, + UNIQUE(user_id, code) +) STRICT; + +CREATE INDEX idx_badge_code_redemptions_user ON badge_code_redemptions(user_id); + +ALTER TABLE badge_purchases ADD COLUMN badge_code_redemption_id INTEGER REFERENCES badge_code_redemptions; + +CREATE UNIQUE INDEX idx_badge_purchases_code_redemption ON badge_purchases(badge_code_redemption_id); +|] + +down_m20260915_user_badges :: Query +down_m20260915_user_badges = + [sql| +DROP INDEX idx_badge_purchases_code_redemption; +DROP INDEX idx_badge_purchases_user; +DROP INDEX idx_users_shown_badge; +ALTER TABLE users DROP COLUMN shown_badge_id; +|] + <> badgeSchemaDown "" + <> [sql| +DROP INDEX idx_badge_code_redemptions_user; +DROP TABLE badge_code_redemptions; +|] + +{- TODO [badges] deferred with the draft above, client only. + +ALTER TABLE payments ADD COLUMN evidence BLOB; + +ALTER TABLE payments ADD COLUMN receipt_code TEXT; + +-- down +ALTER TABLE payments DROP COLUMN evidence; +ALTER TABLE payments DROP COLUMN receipt_code; +-} diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260908_wallet_seeds.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260921_wallet_seeds.hs similarity index 91% rename from src/Simplex/Chat/Store/SQLite/Migrations/M20260908_wallet_seeds.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20260921_wallet_seeds.hs index 18acfcef24..0f0054f334 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20260908_wallet_seeds.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260921_wallet_seeds.hs @@ -1,12 +1,12 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Store.SQLite.Migrations.M20260908_wallet_seeds where +module Simplex.Chat.Store.SQLite.Migrations.M20260921_wallet_seeds where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) -m20260908_wallet_seeds :: Query -m20260908_wallet_seeds = +m20260921_wallet_seeds :: Query +m20260921_wallet_seeds = [sql| CREATE TABLE wallet_seeds ( wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT, 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 7372e51b6b..e887bb2548 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -115,8 +115,8 @@ SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (conta Query: INSERT INTO groups (group_profile_id, local_display_name, inv_queue_info, user_id, enable_ntfs, - created_at, updated_at, chat_ts, user_member_profile_sent_at, business_chat, business_member_id, customer_member_id) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + created_at, updated_at, chat_ts, user_member_profile_sent_at, member_priv_key, business_chat, business_member_id, customer_member_id) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: @@ -288,8 +288,8 @@ SEARCH users USING INTEGER PRIMARY KEY (rowid=?) Query: INSERT INTO group_members ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, - user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) + user_id, local_display_name, contact_id, contact_profile_id, member_pub_key, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?) @@ -395,8 +395,8 @@ SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (conta Query: INSERT INTO groups (group_profile_id, local_display_name, user_id, enable_ntfs, - created_at, updated_at, chat_ts, user_member_profile_sent_at, business_chat) - VALUES (?,?,?,?,?,?,?,?,?) + created_at, updated_at, chat_ts, user_member_profile_sent_at, business_chat, member_priv_key) + VALUES (?,?,?,?,?,?,?,?,?,?) Plan: @@ -1203,6 +1203,30 @@ Query: Plan: SEARCH chat_item_reactions USING INDEX idx_chat_item_reactions_group (group_id=? AND shared_msg_id=?) +Query: + INSERT INTO badge_issuances (issuance_id, badge_purchase_id, entry_id, badge_type, period_start, period_end, expiry, credential, created_at) + VALUES (?,?,?,?,?,?,?,?,?) + ON CONFLICT (badge_purchase_id, entry_id) DO NOTHING + +Plan: + +Query: + INSERT INTO badge_ledger + (entry_uuid, badge_purchase_id, change_months, balance_months, balance_start_ts, balance_anchor_ts, balance_badge_type, + was_paused_since, service_created_at, created_at, entry_type, entry_credit_type, entry_debit_type, + entry_type_unknown, entry_type_value, balance_checked) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT (entry_uuid) DO NOTHING + +Plan: + +Query: + INSERT INTO badge_purchases + (user_id, purchase_key, purchase_priv_key, master_key, initial_badge_type, current_badge_type, status, badge_code_redemption_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?) + +Plan: + Query: INSERT INTO chat_item_reactions (contact_id, shared_msg_id, reaction_sent, reaction, created_by_msg_id, reaction_ts) @@ -1312,8 +1336,8 @@ Query: INSERT INTO groups (group_profile_id, local_display_name, user_id, enable_ntfs, created_at, updated_at, chat_ts, user_member_profile_sent_at, conn_full_link_to_connect, conn_short_link_to_connect, welcome_shared_msg_id, - business_chat, business_member_id, customer_member_id, use_relays, relay_own_status, public_member_count) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + business_chat, business_member_id, customer_member_id, use_relays, relay_own_status, public_member_count, member_priv_key) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: @@ -1357,7 +1381,7 @@ Query: i.fwd_from_group_type, i.fwd_from_group_link, i.fwd_from_public_group_id, i.fwd_from_member_id, i.fwd_from_shared_msg_id, i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link, i.msg_signed, -- CIFile - f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, f.file_expires_at + f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, f.file_expires_at, f.file_max_size, f.file_badge_status FROM chat_items i LEFT JOIN files f ON f.chat_item_id = i.chat_item_id WHERE i.user_id = ? AND i.note_folder_id = ? AND i.chat_item_id = ? @@ -1375,7 +1399,7 @@ Query: i.fwd_from_group_type, i.fwd_from_group_link, i.fwd_from_public_group_id, i.fwd_from_member_id, i.fwd_from_shared_msg_id, i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link, i.msg_signed, -- CIFile - f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, f.file_expires_at, + f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, f.file_expires_at, f.file_max_size, f.file_badge_status, -- CIMeta forwardedByMember, showGroupAsSender i.forwarded_by_group_member_id, i.show_group_as_sender, -- GroupMember @@ -1432,7 +1456,7 @@ Query: i.fwd_from_group_type, i.fwd_from_group_link, i.fwd_from_public_group_id, i.fwd_from_member_id, i.fwd_from_shared_msg_id, i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link, i.msg_signed, -- CIFile - f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, f.file_expires_at, + f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, f.file_expires_at, f.file_max_size, f.file_badge_status, -- DirectQuote ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent FROM chat_items i @@ -1505,6 +1529,15 @@ Plan: SEARCH connections USING INDEX idx_connections_via_contact_uri_hash (user_id=? AND via_contact_uri_hash=?) USE TEMP B-TREE FOR ORDER BY +Query: + SELECT badge_purchase_id FROM badge_purchases + WHERE user_id = ? AND purchase_priv_key IS NOT NULL + ORDER BY badge_purchase_id DESC + LIMIT 1 + +Plan: +SEARCH badge_purchases USING INDEX idx_badge_purchases_user (user_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.user_contact_link_id, @@ -1721,7 +1754,8 @@ 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, g.local_display_name, f.file_type, f.file_digest + r.agent_rcv_file_id, r.agent_rcv_file_deleted, r.user_approved_relays, g.local_display_name, f.file_type, f.file_digest, + f.file_max_size, f.file_badge_status FROM rcv_files r JOIN files f USING (file_id) LEFT JOIN contacts cs ON cs.contact_id = f.contact_id @@ -1836,6 +1870,15 @@ Query: Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE groups + SET member_priv_key = COALESCE(member_priv_key, ?), updated_at = ? + WHERE group_id = ? + RETURNING member_priv_key + +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE groups SET relay_request_inv_id = ?, @@ -2004,6 +2047,20 @@ Query: Plan: +Query: + SELECT + (SELECT prev.balance_start_ts FROM badge_ledger prev + WHERE prev.badge_purchase_id = issued.badge_purchase_id AND prev.entry_id < issued.entry_id + ORDER BY prev.entry_id DESC LIMIT 1), + issued.balance_start_ts + FROM badge_ledger issued + WHERE issued.badge_purchase_id = ? AND issued.entry_id = ? + +Plan: +SEARCH issued USING INTEGER PRIMARY KEY (rowid=?) +CORRELATED SCALAR SUBQUERY 1 +SEARCH prev USING INDEX idx_badge_ledger_purchase (badge_purchase_id=? AND entry_id StoreCxt -> Int64 -> [ChatTagId] -> GroupInfoRow -> GroupInfo +toGroupInfo :: UTCTime -> StoreCxt -> Int64 -> [ChatTagId] -> GroupInfoRow -> (GroupInfo, GroupKeysRow) toGroupInfo now cxt userContactId chatTags ((groupId, localDisplayName, displayName, fullName, shortDescr, localAlias, description, image, groupType_, groupLink_, publicGroupId_) :. accessRow :. (enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. preparedGroupRow :. businessRow :. (BI useRelays, relayOwnStatus, uiThemes, currentMembers, publicMemberCount, rosterVersion, customData, chatItemTTL, membersRequireAttention, viaGroupLinkUri, groupDomainVerified) :. groupKeysRow :. userMemberRow) = let membership = (toGroupMember now userContactId userMemberRow) {memberChatVRange = vr cxt} chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} fullGroupPreferences = mergeGroupPreferences groupPreferences publicGroup = toPublicGroupProfile groupType_ groupLink_ publicGroupId_ (toPublicGroupAccess accessRow) - groupKeys = toGroupKeys publicGroupId_ groupKeysRow groupProfile = GroupProfile {displayName, fullName, shortDescr, description, image, publicGroup, groupPreferences, memberAdmission} businessChat = toBusinessChatInfo (toPublicGroupAccess accessRow >>= groupDomainClaim) businessRow preparedGroup = toPreparedGroup preparedGroupRow groupSummary = GroupSummary {currentMembers, publicMemberCount} - in GroupInfo {groupId, useRelays = BoolDef useRelays, relayOwnStatus, localDisplayName, groupProfile, localAlias, businessChat, fullGroupPreferences, membership, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, preparedGroup, chatTags, chatItemTTL, uiThemes, groupSummary, rosterVersion, customData, membersRequireAttention, viaGroupLinkUri, groupKeys, groupDomainVerified = unBI <$> groupDomainVerified} + gInfo = GroupInfo {groupId, useRelays = BoolDef useRelays, relayOwnStatus, localDisplayName, groupProfile, localAlias, businessChat, fullGroupPreferences, membership, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, preparedGroup, chatTags, chatItemTTL, uiThemes, groupSummary, rosterVersion, customData, membersRequireAttention, viaGroupLinkUri, groupDomainVerified = unBI <$> groupDomainVerified} + in (gInfo, groupKeysRow) + +toGroupInfo_ :: UTCTime -> StoreCxt -> Int64 -> [ChatTagId] -> GroupInfoRow -> GroupInfo +toGroupInfo_ now cxt userContactId chatTags row = fst $ toGroupInfo now cxt userContactId chatTags row toPreparedGroup :: PreparedGroupRow -> Maybe PreparedGroup toPreparedGroup = \case @@ -732,11 +736,35 @@ toPublicGroupAccess (groupWebPage, groupDomain_, domainWebPage_, allowEmbedding_ domainWebPage = maybe False unBI domainWebPage_ allowEmbedding = maybe False unBI allowEmbedding_ -toGroupKeys :: Maybe B64UrlByteString -> GroupKeysRow -> Maybe GroupKeys -toGroupKeys (Just publicGroupId) (rootPrivKey_, rootPubKey_, Just memberPrivKey) = - (\grk -> GroupKeys {publicGroupId, groupRootKey = grk, memberPrivKey}) - <$> (GRKPrivate <$> rootPrivKey_ <|> GRKPublic <$> rootPubKey_) -toGroupKeys _ _ = Nothing +mkGroupKeys :: DB.Connection -> StoreCxt -> GroupInfo -> GroupKeysRow -> ExceptT StoreError IO GroupKeys +mkGroupKeys db cxt g@GroupInfo {groupId, groupProfile = GroupProfile {publicGroup}, membership} (rootPrivKey, rootPubKey, memberPrivKey_) = do + memberPrivKey <- case memberPrivKey_ of + Just k -> pure k + Nothing -> do + (_, k) <- atomically $ C.generateKeyPair (drg cxt) + setUserMemberKey db groupId (groupMemberId' membership) k + pure $ case (useRelays' g, isJust publicGroup, GRKPrivate <$> rootPrivKey <|> GRKPublic <$> rootPubKey) of + (False, _, _) -> GKGroup {memberPrivKey} + (True, True, Just groupRootKey) -> GKPublicGroup {groupRootKey, memberPrivKey} + (True, True, Nothing) -> GKPreparedPublicGroup {memberPrivKey} + (True, False, _) -> GKRelayRequest {memberPrivKey} + +setUserMemberKey :: DB.Connection -> GroupId -> GroupMemberId -> C.PrivateKeyEd25519 -> ExceptT StoreError IO C.PrivateKeyEd25519 +setUserMemberKey db groupId membershipId newKey = do + currentTs <- liftIO getCurrentTime + memberPrivKey <- + ExceptT . firstRow fromOnly (SEGroupNotFound groupId) $ + DB.query + db + [sql| + UPDATE groups + SET member_priv_key = COALESCE(member_priv_key, ?), updated_at = ? + WHERE group_id = ? + RETURNING member_priv_key + |] + (newKey, currentTs, groupId) + liftIO $ DB.execute db "UPDATE group_members SET member_pub_key = ?, updated_at = ? WHERE group_member_id = ?" (C.publicKey memberPrivKey, currentTs, membershipId) + pure memberPrivKey toGroupMember :: UTCTime -> Int64 -> GroupMemberRow -> GroupMember toGroupMember now userContactId ((groupMemberId, groupId, indexInGroup, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, BI showMessages, memberRestriction_) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. profileRow :. (createdAt, updatedAt) :. (supportChatTs_, supportChatUnread, supportChatMemberAttention, supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey, relayLink, memberCode_, memberCodeVerifiedAt_)) = @@ -898,8 +926,18 @@ addGroupChatTags db g@GroupInfo {groupId} = do chatTags <- getGroupChatTags db groupId pure (g :: GroupInfo) {chatTags} +getGroupInfoKeys :: DB.Connection -> StoreCxt -> User -> Int64 -> ExceptT StoreError IO GroupInfoKeys +getGroupInfoKeys db cxt user groupId = do + (g@GroupInfo {membership}, keysData) <- getGroupInfoRow db cxt user groupId + gks <- mkGroupKeys db cxt g keysData + let membership' = membership {memberPubKey = Just $ C.publicKey $ memberPrivKey gks} :: GroupMember + pure $ GIK (g :: GroupInfo) {membership = membership'} gks + getGroupInfo :: DB.Connection -> StoreCxt -> User -> Int64 -> ExceptT StoreError IO GroupInfo -getGroupInfo db cxt User {userId, userContactId} groupId = ExceptT $ do +getGroupInfo db cxt user groupId = fst <$> getGroupInfoRow db cxt user groupId + +getGroupInfoRow :: DB.Connection -> StoreCxt -> User -> Int64 -> ExceptT StoreError IO (GroupInfo, GroupKeysRow) +getGroupInfoRow db cxt User {userId, userContactId} groupId = ExceptT $ do currentTs <- getCurrentTime chatTags <- getGroupChatTags db groupId firstRow (toGroupInfo currentTs cxt userContactId chatTags) (SEGroupNotFound groupId) $ diff --git a/src/Simplex/Chat/Terminal/Input.hs b/src/Simplex/Chat/Terminal/Input.hs index 746b1e137f..fa5efd0354 100644 --- a/src/Simplex/Chat/Terminal/Input.hs +++ b/src/Simplex/Chat/Terminal/Input.hs @@ -80,7 +80,7 @@ runInputLoop ct@ChatTerminal {termState, liveMessageState} cc = forever $ do CRChatItemUpdated u (AChatItem _ SMDSnd cInfo _) -> whenCurrUser cc u $ setActiveChat ct cInfo CRChatItemsDeleted u ((ChatItemDeletion (AChatItem _ _ cInfo _) _) : _) _ _ -> whenCurrUser cc u $ setActiveChat ct cInfo CRContactDeleted u c -> whenCurrUser cc u $ unsetActiveContact ct c - CRGroupDeletedUser u g _ -> whenCurrUser cc u $ unsetActiveGroup ct g + CRGroupDeletedUser u g _ _ -> whenCurrUser cc u $ unsetActiveGroup ct g CRSentGroupInvitation u g _ _ -> whenCurrUser cc u $ setActiveGroup ct g CRCmdOk _ -> case cmd of Right APIDeleteUser {} -> setActive ct "" diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index c0e44277a4..282d6b2fda 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -30,7 +30,9 @@ module Simplex.Chat.Types where import Control.Applicative ((<|>)) +import Control.Concurrent.STM (TVar) import Crypto.Number.Serialize (os2ip) +import Crypto.Random (ChaChaDRG) import Data.Aeson (FromJSON (..), ToJSON (..)) import qualified Data.Aeson as J import qualified Data.Aeson.Encoding as JE @@ -437,7 +439,7 @@ instance ToJSON ConnReqUriHash where data RequestEntity = REContact Contact - | REBusinessChat GroupInfo GroupMember + | REBusinessChat GroupInfoKeys GroupMember type RepeatRequest = Bool @@ -479,13 +481,31 @@ groupRootPubKey :: GroupRootKey -> C.PublicKeyEd25519 groupRootPubKey (GRKPrivate pk) = C.publicKey pk groupRootPubKey (GRKPublic pk) = pk -data GroupKeys = GroupKeys - { publicGroupId :: B64UrlByteString, - groupRootKey :: GroupRootKey, - memberPrivKey :: C.PrivateKeyEd25519 - } +data GroupKeys + = GKGroup + { memberPrivKey :: C.PrivateKeyEd25519 + } + | GKPublicGroup + { groupRootKey :: GroupRootKey, + memberPrivKey :: C.PrivateKeyEd25519 + } + | GKRelayRequest + { memberPrivKey :: C.PrivateKeyEd25519 + } + | GKPreparedPublicGroup + { memberPrivKey :: C.PrivateKeyEd25519 + } deriving (Eq, Show) +isPublicGroup :: GroupKeys -> Bool +isPublicGroup = \case + GKGroup {} -> False + GKPublicGroup {} -> True + GKRelayRequest {} -> True + GKPreparedPublicGroup {} -> True + +data GroupInfoKeys = GIK GroupInfo GroupKeys + data GroupInfo = GroupInfo { groupId :: GroupId, useRelays :: BoolDef, @@ -510,7 +530,6 @@ data GroupInfo = GroupInfo rosterVersion :: Maybe VersionRoster, membersRequireAttention :: Int, viaGroupLinkUri :: Maybe ConnReqContact, - groupKeys :: Maybe GroupKeys, groupDomainVerified :: Maybe Bool } deriving (Eq, Show) @@ -518,6 +537,9 @@ data GroupInfo = GroupInfo useRelays' :: GroupInfo -> Bool useRelays' GroupInfo {useRelays} = isTrue useRelays +publicGroup' :: GroupInfo -> Maybe PublicGroupProfile +publicGroup' g@GroupInfo {groupProfile = GroupProfile {publicGroup}} = if useRelays' g then publicGroup else Nothing + relayServesGroup :: GroupInfo -> Bool relayServesGroup GroupInfo {relayOwnStatus} = case relayOwnStatus of Just RSInactive -> False @@ -590,7 +612,7 @@ data GroupLink = GroupLink data ContactOrGroup = CGContact Contact | CGGroup GroupInfo [GroupMember] -data PreparedChatEntity = PCEContact Contact | PCEGroup {groupInfo :: GroupInfo, hostMember :: GroupMember} +data PreparedChatEntity = PCEContact Contact | PCEGroup {groupInfo :: GroupInfoKeys, hostMember :: GroupMember} contactAndGroupIds :: ContactOrGroup -> (Maybe ContactId, Maybe GroupId) contactAndGroupIds = \case @@ -943,6 +965,7 @@ instance ToJSON GroupLinkId where data GroupInvitation = GroupInvitation { fromMember :: MemberIdRole, + fromMemberKey :: Maybe MemberKey, invitedMember :: MemberIdRole, connRequest :: ConnReqInvitation, groupProfile :: GroupProfile, @@ -955,6 +978,7 @@ data GroupInvitation = GroupInvitation data GroupLinkInvitation = GroupLinkInvitation { fromMember :: MemberIdRole, fromMemberName :: ContactName, + fromMemberKey :: Maybe MemberKey, invitedMember :: MemberIdRole, groupProfile :: GroupProfile, accepted :: Maybe GroupAcceptance, @@ -1138,7 +1162,8 @@ memberRestrictions m data ReceivedGroupInvitation = ReceivedGroupInvitation { fromMember :: GroupMember, connRequest :: ConnReqInvitation, - groupInfo :: GroupInfo + groupInfo :: GroupInfo, + groupKeys :: GroupKeys } deriving (Eq, Show) @@ -1551,7 +1576,8 @@ data FileInvitation = FileInvitation fileDigest :: Maybe FileDigest, fileConnReq :: Maybe ConnReqInvitation, fileInline :: Maybe InlineFileMode, - fileDescr :: Maybe FileDescr + fileDescr :: Maybe FileDescr, + fileBadge :: Maybe BadgeProof } deriving (Eq, Show) @@ -1566,7 +1592,8 @@ xftpFileInvitation fileName fileSize fileDescr = fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, - fileDescr = Just fileDescr + fileDescr = Just fileDescr, + fileBadge = Nothing } data InlineFileMode @@ -1620,10 +1647,14 @@ instance ToJSON FileType where toJSON = J.String . textEncode toEncoding = JE.text . textEncode +data FileProhibited = FileProhibited {maxSize :: Integer, badgeStatus :: Maybe BadgeStatus} + deriving (Eq, Show) + data RcvFileTransfer = RcvFileTransfer { fileId :: FileTransferId, xftpRcvFile :: Maybe XFTPRcvFile, fileInvitation :: FileInvitation, + fileProhibited :: Maybe FileProhibited, fileStatus :: RcvFileStatus, fileType :: FileType, rcvFileInline :: Maybe InlineFileMode, @@ -2227,8 +2258,8 @@ type VersionChat = Version ChatVersion type VersionRangeChat = VersionRange ChatVersion -- | Store-wide context passed to store functions in place of the bare `vr` --- parameter. Built from config by mkStoreCxt; more fields are added here over time. -data StoreCxt = StoreCxt {vr :: VersionRangeChat, badgeKeys :: Map Int BBSPublicKey} +-- parameter. Built from config by storeCxt; more fields are added here over time. +data StoreCxt = StoreCxt {vr :: VersionRangeChat, badgeKeys :: Map Int BBSPublicKey, drg :: TVar ChaChaDRG} pattern VersionChat :: Word16 -> VersionChat pattern VersionChat v = Version v @@ -2334,10 +2365,6 @@ instance FromJSON GroupSummary where parseJSON = $(JQ.mkParseJSON defaultJSON ''GroupSummary) omittedField = Just GroupSummary {currentMembers = 0, publicMemberCount = Nothing} -$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "GRK") ''GroupRootKey) - -$(JQ.deriveJSON defaultJSON ''GroupKeys) - $(JQ.deriveJSON defaultJSON ''GroupInfo) $(JQ.deriveJSON defaultJSON ''Group) @@ -2370,6 +2397,8 @@ $(JQ.deriveJSON defaultJSON ''GroupMemberRef) $(JQ.deriveJSON defaultJSON ''FileDescr) +$(JQ.deriveJSON defaultJSON ''FileProhibited) + $(JQ.deriveJSON defaultJSON ''FileInvitation) $(JQ.deriveJSON defaultJSON ''SndFileTransfer) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 6655b9e8b0..051848e40f 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -41,9 +41,12 @@ import Numeric (showFFloat) import Simplex.Chat.Call import Simplex.Chat.Controller import Simplex.Chat.Help -import Simplex.Chat.Library.Commands (maxImageSize) +import Simplex.Chat.Library.Commands (badgeServiceErrorText, maxImageSize) import Simplex.Chat.Markdown import Simplex.Chat.Badges (BadgeInfo (..), BadgeStatus (..), BadgeType (..), LocalBadge, localBadgeInfo, localBadgeStatus) +import Simplex.Chat.Badges.Ledger (creditTypeTag, debitTypeTag) +import Simplex.Chat.Badges.Service (StatementEntry (..), StatementEntryType (..)) +import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeState (..)) import Simplex.Chat.Messages hiding (NewChatItem (..)) import Simplex.Chat.Messages.CIContent import Simplex.Chat.Operators @@ -189,6 +192,10 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, showFullLinks, te CRContactRequestRejected u UserContactRequest {localDisplayName = c} _ct_ -> ttyUser u [ttyContact c <> ": contact request rejected"] CRServiceResponse u resp -> ttyUser u ["service response: " <> viewJSON resp] CRServiceReplyAccepted u (AgentConnId cId) -> ttyUser u [plain $ "service reply accepted, connection id: " <> safeDecodeUtf8 (strEncode cId)] + -- the badge is only shown when it is the one now on the profile; a replayed code's badge may not be + CRBadgeRedeemed u badge newBadge _ -> ttyUser u $ if newBadge then "badge redeemed" : viewContactBadge (Just badge) else ["badge already redeemed"] + CRBadgeState u st -> ttyUser u $ viewUserBadgeState st + CRBadgeLedger u entries -> ttyUser u $ viewBadgeLedger entries CRWallet u accounts_ -> ttyUser u $ case accounts_ of Nothing -> ["no wallet on this device"] Just [] -> ["wallet, no accounts for this profile"] @@ -249,7 +256,7 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, showFullLinks, te "use " <> highlight ("/d #" <> viewGroupName g) <> " to delete the group (also clears the rejection)" ] | otherwise -> ttyUser u $ [ttyGroup' g <> ": you left the group"] <> groupPreserved g - CRGroupDeletedUser u g signed -> ttyUser u [ttyGroup' g <> ": you deleted the group" <> signedStr signed] + CRGroupDeletedUser u g signed local -> ttyUser u [ttyGroup' g <> (if local then ": you deleted your local copy of the group" else ": you deleted the group" <> signedStr signed)] CRForwardPlan u count itemIds fc -> ttyUser u $ viewForwardPlan count itemIds fc CRChatMsgContent u mc -> ttyUser u $ ttyMsgContent mc <> viewMsgTestInfo testView mc CRRcvFileAccepted u ci -> ttyUser u $ savingFile' ci @@ -482,6 +489,8 @@ chatEventToView hu ChatConfig {logLevel, showReactions, showReceipts, testView} <> maybe [] (\k -> [plain $ "signed by " <> safeDecodeUtf8 (strEncode k)]) sigKey_ <> ["request: " <> viewJSON req] CEvtServiceReplySent (AgentConnId cId) -> [plain $ "service reply sent, connection id: " <> safeDecodeUtf8 (strEncode cId)] + CEvtBadgeChanged u st -> ttyUser u $ viewUserBadgeState st + CEvtBadgeAlert u alert -> ttyUser u $ viewBadgeAlert alert CEvtContactRequestRejected u Contact {localDisplayName = c} _reason -> ttyUser u [ttyContact c <> ": contact request rejected"] CEvtRcvFileStart u ci -> ttyUser u $ receivingFile_' hu testView "started" ci CEvtRcvFileComplete u ci -> ttyUser u $ receivingFile_' hu testView "completed" ci @@ -1850,9 +1859,41 @@ viewContactBadge = maybe [] $ \lb -> BSExpiredOld -> "expired (old)" BSFailed -> "verification failed" BSUnknownKey -> "unknown key" - expiry = "expires " <> T.pack (formatTime defaultTimeLocale "%Y-%m-%d" badgeExpiry) + expiry = "expires " <> day badgeExpiry in [plain (textEncode badgeType <> " badge - " <> st), plain expiry] +viewUserBadgeState :: Maybe BadgeState -> [StyledString] +viewUserBadgeState = maybe [] viewBadge + where + viewBadge BadgeState {badgePurchaseId, badgeType, monthsLeft, paidThrough, alert} = + plain + ( tshow badgePurchaseId + <> ": " + <> textEncode badgeType + <> ", " + <> tshow monthsLeft + <> " months left, paid through " + <> day paidThrough + ) + : maybe [] viewBadgeAlert alert + +viewBadgeAlert :: BadgeAlert -> [StyledString] +viewBadgeAlert BadgeAlert {kind, date} = [plain $ "badge alert: " <> textEncode kind <> " " <> day date] + +viewBadgeLedger :: [StatementEntry] -> [StyledString] +viewBadgeLedger [] = ["no ledger entries"] +viewBadgeLedger entries = map viewEntry entries + where + viewEntry StatementEntry {createdAt, entryType, changeMonths, balanceMonths, balanceStartTs} = + plain $ day createdAt <> " " <> entryKind entryType <> " " <> withSign changeMonths <> " -> " <> tshow balanceMonths <> ", from " <> day balanceStartTs + entryKind = \case + SECredit c -> creditTypeTag c + SEDebit d -> debitTypeTag d + withSign n = (if n >= 0 then "+" else "") <> tshow n + +day :: UTCTime -> Text +day = T.pack . formatTime defaultTimeLocale "%Y-%m-%d" + viewContactInfo :: Contact -> Maybe ConnectionStats -> Maybe Profile -> [StyledString] viewContactInfo ct@Contact {contactId, profile = LocalProfile {localAlias, contactLink, localBadge, contactDomain, contactDomainVerified, description}, activeConn, uiThemes, customData} stats incognitoProfile = ["contact ID: " <> sShow contactId] @@ -2476,11 +2517,25 @@ viewReceivedFileInvitation :: StyledString -> CIFile d -> CurrentTime -> TimeZon viewReceivedFileInvitation from file ts tz meta = receivedWithTime_ ts tz from [] meta (receivedFileInvitation_ file) False receivedFileInvitation_ :: CIFile d -> [StyledString] -receivedFileInvitation_ CIFile {fileId, fileName, fileSize, fileStatus} = +receivedFileInvitation_ CIFile {fileId, fileName, fileSize, fileStatus, fileProhibited} = ["sends file " <> ttyFilePath fileName <> " (" <> humanReadableSize fileSize <> " / " <> sShow fileSize <> " bytes)"] - <> case fileStatus of - CIFSRcvAccepted -> [] - _ -> ["use " <> highlight ("/fr " <> show fileId <> " [/ | ]") <> " to receive it"] + <> case fileProhibited of + Just fp -> [prohibitedFileReason fp] + Nothing -> case fileStatus of + CIFSRcvAccepted -> [] + _ -> ["use " <> highlight ("/fr " <> show fileId <> " [/ | ]") <> " to receive it"] + +prohibitedFileReason :: FileProhibited -> StyledString +prohibitedFileReason FileProhibited {maxSize, badgeStatus} = + "file is above the limit of " <> sShow maxSize <> " bytes: " <> reason + where + reason = case badgeStatus of + Nothing -> "sender has no badge" + Just BSActive -> "above the limit of the sender badge" + Just BSExpired -> "sender badge expired" + Just BSExpiredOld -> "sender badge expired" + Just BSFailed -> "sender badge did not verify" + Just BSUnknownKey -> "sender badge key is not known" humanReadableSize :: Integer -> StyledString humanReadableSize size @@ -2824,6 +2879,16 @@ viewChatError isCmd logLevel testView = \case CEAgentNoSubResult connId -> ["no subscription result for connection: " <> sShow connId] CEServerProtocol p -> [plain $ "Servers for protocol " <> strEncode p <> " cannot be configured by the users"] CECommandError e -> ["bad chat command: " <> plain e] + CEBadgeRedeemError e -> + let reason = case e of + BREInvalidCode -> "invalid code" + BREServiceNotConfigured -> "badge service not configured" + BREBadgeActive -> "badge already active" + BREServiceError code -> "badge service error: " <> T.unpack (badgeServiceErrorText code) + BREInvalidResponse m -> "invalid service response: " <> m + BREUnknownKeyIndex -> "credential names an unknown badge key index" + BRECredentialNotVerified -> "credential does not verify against configured key" + in ["cannot redeem badge code: " <> plain reason] CEAgentCommandError e -> ["agent command error: " <> plain e] CEInvalidFileDescription e -> ["invalid file description: " <> plain e] CEConnectionIncognitoChangeProhibited -> ["incognito mode change prohibited"] diff --git a/src/Simplex/Chat/Web.hs b/src/Simplex/Chat/Web.hs index fc3e4b2a26..30d674d55f 100644 --- a/src/Simplex/Chat/Web.hs +++ b/src/Simplex/Chat/Web.hs @@ -24,7 +24,7 @@ module Simplex.Chat.Web where import Control.Concurrent.STM (check, flushTQueue) -import Control.Exception (SomeException, catch) +import Control.Exception (SomeException) import Control.Logger.Simple import Control.Monad import Control.Monad.Except (runExceptT) @@ -42,7 +42,7 @@ import Data.Text (Text) import qualified Data.Text as T import qualified Data.Text.IO as TIO import Data.Time.Clock (UTCTime, getCurrentTime) -import Simplex.Chat.Controller (ChatController (..), CorsOrigin (..), PublishableGroup (..), WebPreviewConfig (..), WebPreviewState (..), mkStoreCxt) +import Simplex.Chat.Controller (ChatController (..), CorsOrigin (..), PublishableGroup (..), WebPreviewConfig (..), WebPreviewState (..), storeCxt) import Simplex.Chat.Markdown (FormattedText (..), MarkdownList, parseMaybeMarkdownList) import Simplex.Chat.Messages ( CChatItem (..), @@ -75,7 +75,7 @@ import Simplex.Chat.Types ) import Simplex.Messaging.Agent.Store.Common (withTransaction) import Simplex.Messaging.Encoding.String (strEncode) -import Simplex.Messaging.Util (catchOwn, eitherToMaybe, safeDecodeUtf8, tshow) +import Simplex.Messaging.Util (catchOwn, catchOwn', eitherToMaybe, safeDecodeUtf8, tshow) import Simplex.Messaging.Parsers (defaultJSON) import System.Directory (createDirectoryIfMissing, listDirectory, removeFile, renameFile) import System.FilePath (dropExtension, takeExtension, ()) @@ -137,7 +137,7 @@ webPreviewWorker cfg@WebPreviewConfig {webJsonDir, webCorsFile, webUpdateInterva seedRoutinePending wps forever $ workerLoop wps `catchOwn` \e -> logError ("web preview worker error: " <> tshow e) where - cxt = mkStoreCxt (config cc) + cxt = storeCxt cc workerLoop wps@WebPreviewState {priorityRender, filesToRemove, corsNeeded, routinePending, wakeSignal} = do drainRemovals @@ -150,7 +150,7 @@ webPreviewWorker cfg@WebPreviewConfig {webJsonDir, webCorsFile, webUpdateInterva drainRemovals = atomically (tryReadTQueue filesToRemove) >>= \case Nothing -> pure () Just f -> do - removeFile (webJsonDir f) `catch` \(_ :: SomeException) -> pure () + removeFile (webJsonDir f) `catchOwn'` \(_ :: SomeException) -> pure () drainRemovals -- flush the whole queue and render each group once: a burst of changes in one @@ -202,7 +202,7 @@ webPreviewWorker cfg@WebPreviewConfig {webJsonDir, webCorsFile, webUpdateInterva renderOneGroup WebPreviewState {publishableGroupIds} gId = do publishable <- atomically $ M.member gId <$> readTVar publishableGroupIds when publishable $ - renderOrRemoveStale `catch` \(e :: SomeException) -> + renderOrRemoveStale `catchOwn'` \(e :: SomeException) -> logError $ "web preview: error rendering group " <> T.pack (show gId) <> ": " <> T.pack (show e) where renderOrRemoveStale = do @@ -217,7 +217,7 @@ webPreviewWorker cfg@WebPreviewConfig {webJsonDir, webCorsFile, webUpdateInterva modifyTVar' publishableGroupIds (M.delete gId) pure $ pgFileName <$> pg forM_ fName $ \f -> - removeFile (webJsonDir f) `catch` \(_ :: SomeException) -> pure () + removeFile (webJsonDir f) `catchOwn'` \(_ :: SomeException) -> pure () logInfo $ "web preview: group " <> T.pack (show gId) <> " no longer publishable" findUser f = go users @@ -262,7 +262,7 @@ renderGroupPreview WebPreviewConfig {webJsonDir, webPreviewItemCount} cc user gI pure $ corsEntry publicGroupId <$> publicGroupAccess Nothing -> pure Nothing where - cxt = mkStoreCxt (config cc) + cxt = storeCxt cc channelContentChanged :: ChatController -> Int64 -> STM () channelContentChanged cc gId = diff --git a/tests/BadgeTests.hs b/tests/BadgeTests.hs index 19ebd2818e..06c5ccfe86 100644 --- a/tests/BadgeTests.hs +++ b/tests/BadgeTests.hs @@ -1,19 +1,41 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DisambiguateRecordFields #-} +{-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE GADTs #-} +{-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} module BadgeTests (badgeTests) where +import BadgeService.Service (badgeErrorRetryAfter) +import Control.Concurrent.STM (atomically) +import Data.ByteString.Char8 (ByteString) import Data.Map.Strict (Map) import qualified Data.Map.Strict as M -import Data.Time.Clock (UTCTime, addUTCTime, getCurrentTime, nominalDay) +import qualified Data.Text as T +import Data.Time.Calendar (fromGregorian) +import Data.Time.Calendar.WeekDate (toWeekDate) +import Data.Time.Clock (NominalDiffTime, UTCTime (..), addUTCTime, diffUTCTime, getCurrentTime, nominalDay) import Data.Time.Clock.POSIX (posixSecondsToUTCTime) import qualified Data.Aeson as J +import qualified Data.Aeson.KeyMap as KM +import Data.Maybe (fromMaybe, isNothing, maybeToList) import qualified Simplex.Messaging.Crypto as C import Simplex.Chat.Badges +import Simplex.Chat.Badges.Code +import Simplex.Chat.Badges.Ledger +import Simplex.Chat.Badges.Service +import Simplex.Chat (defaultChatConfig) +import Simplex.Chat.Controller (ChatError (..), ChatErrorType (..), badgeRetryInterval, chatErrorAgent) +import Simplex.Chat.Library.Commands (badgeErrorRetry, badgeRetryAfter, badgeStalledInterval) +import Simplex.Messaging.Agent.Protocol (AgentErrorType (..), AgentServiceError (..), SMPAgentError (..)) +import Simplex.Messaging.Agent.RetryInterval (RetryInterval (..), nextRetryDelay) import Simplex.Messaging.Crypto.BBS +import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Protocol (BrokerErrorType (..), NetworkError (..)) +import Simplex.Messaging.Version.Internal (Version (..)) import Test.Hspec badgeTests :: Spec @@ -26,10 +48,53 @@ badgeTests = do it "should compute badge status correctly" testExpiryCheck it "should accept unknown badge types" testUnknownBadgeType it "credential serializes to a paste-able token and back" testCredentialSerialization + it "presentation headers encode and decode" testPresHeaderEncoding + it "should reject a proof presented under another chat binding" testOtherChatBinding + describe "redemption codes" $ do + it "a generated code reads back" testCodeRoundTrip + it "reads a code as typed - any case, separators, ambiguous characters" testCodeNormalisation + it "rejects a code whose check character does not match" testCodeCheckCharacter + it "hashes the canonical form, whatever was typed" testCodeHash + describe "ledger transitions" $ do + it "issues a twelve month code one month at a time, and no thirteenth" testTwelveMonths + it "lapses only the elapsed months after a gap, leaving paidThrough unchanged" testLapseAfterGap + it "keeps the balance non-negative and the start non-decreasing" testLedgerInvariants + it "credits an exhausted balance from the grant, not from the date it ran out" testGrantAfterExhausted + it "does not issue months topped up inside an issued period" testGrantInsideIssuedPeriod + it "clips month ends without losing the issued period start" testMonthEndClipping + it "counts the elapsed months of an absurd run in one step" testElapsedFarAnchor + it "expires at the end of the Monday after the period" testMondayExpiry + it "stores the wire tag of every entry type, and rebuilds each from its stored JSON" testEntryTypeColumns + describe "checking a statement" $ do + it "accepts a chain of entries, each against the one before it" testChecksChain + it "checks the second entry against the first, not against the tip" testChecksAgainstStatement + it "accepts an opening credit with no predecessor, and rejects anything else" testChecksOpening + it "rejects an opening credit naming a badge type the purchase is not for" testChecksOpeningBadgeType + it "accepts an opening credit restating the balance over a tip it does not follow" testChecksOpeningRestatement + it "rejects a lapse writing off months that had not elapsed" testChecksOverLapse + it "rejects a debit whose start or anchor moved" testChecksMovedStart + it "rejects a grant restarting a run the predecessor still funds" testChecksGrantRestart + it "rejects a credit of negative months" testChecksNegativeCredit + it "leaves an entry ahead of the clock unjudged, and rejects one behind the entry it follows" testChecksTimestamps + it "leaves an entry type it cannot derive unchecked, its months still checked" testChecksUnknownType + it "rejects an entry it cannot rebuild whose coverage or months contradict the ledger" testChecksUncheckedInvariants + describe "worker retry" $ do + it "repeats a failure that can clear on its own, and no other" testRetryClassification + it "backs off to the cap" testRetryBackoff + it "floors the wait a service asks for, and honours anything above it" testServiceRetryFloor + it "sends retryAfter with the transient service codes and no other" testServiceRetryAfter + describe "service protocol JSON" $ do + it "redeemBadgeCode request matches the schema" testRedeemRequestJSON + it "badgeCredential response matches the schema" testCredentialResponseJSON + it "error response matches the schema" testErrorResponseJSON + it "statement entries round-trip, unknown entry types verbatim" testStatementJSON proofOf :: BadgeProof -> BBSProof proofOf (BadgeProof _ _ p _) = p +nonDecreasing :: Ord a => [a] -> Bool +nonDecreasing xs = and $ zipWith (<=) xs (drop 1 xs) + testKeyIdx :: Int testKeyIdx = 1 @@ -125,12 +190,605 @@ futureTime = posixSecondsToUTCTime 4102444800 -- 2099-12-31 pastTime :: UTCTime pastTime = posixSecondsToUTCTime 1577836800 -- 2020-01-01 +testPresHeaderEncoding :: IO () +testPresHeaderEncoding = + mapM_ + (\ph -> strDecode (strEncode ph) `shouldBe` Right ph) + [ PHTest "nonce", + PHChat aliceBinding, + PHFileInv {chatBinding = aliceBinding, fileSize = 139737}, + PHFileDescr {chatBinding = aliceBinding, fileSize = 139737, descrHash = "descr-hash", fileExpires = Nothing}, + PHFileDescr {chatBinding = aliceBinding, fileSize = 139737, descrHash = "descr-hash", fileExpires = Just futureTime}, + PHUnknown 'Z' "payload" + ] + +testOtherChatBinding :: IO () +testOtherChatBinding = do + let ph = PHFileInv {chatBinding = aliceBinding, fileSize = 139737} + otherPh = PHFileInv {chatBinding = bobBinding, fileSize = 139737} + (pk, BadgeProof idx _ p info) <- issueBadgeProofHeader BTSupporter futureTime ph + verifyBadge (keysFor pk) (BadgeProof idx (BBSPresHeader $ strEncode ph) p info) >>= (`shouldBe` Just True) + verifyBadge (keysFor pk) (BadgeProof idx (BBSPresHeader $ strEncode otherPh) p info) >>= (`shouldBe` Just False) + +aliceBinding :: ByteString +aliceBinding = "Galice-member-id" + +bobBinding :: ByteString +bobBinding = "Gbob-member-id" + issueBadgeProof :: BadgeType -> UTCTime -> IO (BBSPublicKey, BadgeProof) -issueBadgeProof bt expiry = do +issueBadgeProof bt expiry = issueBadgeProofHeader bt expiry (PHTest "test-nonce") + +issueBadgeProofHeader :: BadgeType -> UTCTime -> ProofPresHeader -> IO (BBSPublicKey, BadgeProof) +issueBadgeProofHeader bt expiry ph = do Right (pk, sk) <- bbsKeyGen drg <- C.newRandom mk <- generateMasterKey drg let vreq = VerifiedBadgeRequest BadgeRequest {masterKey = mk, badgeInfo = BadgeInfo {badgeType = bt, badgeExpiry = expiry, badgeExtra = ""}} Right cred <- issueBadge testKeyIdx sk vreq - Right badge <- generateBadgeProof pk cred (BBSPresHeader "test-nonce") + Right badge <- badgeProof pk cred ph pure (pk, badge) + +-- Redemption codes + +testCodeRoundTrip :: IO () +testCodeRoundTrip = do + drg <- C.newRandom + code <- randomBadgeCode drg + let formatted = formatBadgeCode code + T.length formatted `shouldBe` 26 -- SB-XXXXX-XXXXX-XXXXX-XXXXX + T.take 3 formatted `shouldBe` "SB-" + T.length (badgeCodeText code) `shouldBe` 22 -- the canonical form drops the separators + parseBadgeCode formatted `shouldBe` Just code + parseBadgeCode (badgeCodeText code) `shouldBe` Just code + +testCodeNormalisation :: IO () +testCodeNormalisation = do + drg <- C.newRandom + code <- randomBadgeCode drg + parseBadgeCode (T.toLower $ badgeCodeText code) `shouldBe` Just code + parseBadgeCode (T.replace "-" " " $ formatBadgeCode code) `shouldBe` Just code + -- a fixed code, because a random one contains no 0 or 1 about a quarter of the time and the + -- folding would then be asserted against nothing + let folded = T.map ambiguous fixedCode + folded `shouldNotBe` fixedCode + parseBadgeCode folded `shouldBe` parseBadgeCode fixedCode + parseBadgeCode fixedCode `shouldNotBe` Nothing + where + fixedCode = "SB-0C0QS-XAQW1-N1VSA-R00Y3" + ambiguous = \case + '1' -> 'I' + '0' -> 'O' + c -> c + +testCodeCheckCharacter :: IO () +testCodeCheckCharacter = do + drg <- C.newRandom + code <- randomBadgeCode drg + let canonical = badgeCodeText code + -- every other value for the last character fails the check + wrong = T.init canonical <> T.singleton (if T.last canonical == 'Z' then 'Y' else 'Z') + parseBadgeCode wrong `shouldBe` Nothing + parseBadgeCode "" `shouldBe` Nothing + parseBadgeCode "SB-00000-00000-00000-0000" `shouldBe` Nothing + parseBadgeCode (T.drop 2 canonical) `shouldBe` Nothing + +testCodeHash :: IO () +testCodeHash = do + drg <- C.newRandom + code <- randomBadgeCode drg + Just typed <- pure $ parseBadgeCode $ T.toLower $ formatBadgeCode code + badgeCodeHash typed `shouldBe` badgeCodeHash code + +-- Ledger transitions, against plans/2026-07-30-supporter-badges-v3-ux.md §3 + +at :: Integer -> Int -> Int -> UTCTime +at y m d = UTCTime (fromGregorian y m d) (11 * 3600) + +newBalance :: UTCTime -> StatementEntry +newBalance t = emptyEntry t BTSupporter + +-- these tests never write, so the id each operation stamps on its entry is never read +grant :: UTCTime -> Int -> StatementEntry -> StatementEntry +grant t n = grantEntry t "" n SCCode + +lapse :: UTCTime -> StatementEntry -> Maybe StatementEntry +lapse t = lapseEntry t "" + +issue :: UTCTime -> StatementEntry -> Maybe StatementEntry +issue t = issueEntry t "" + +-- StatementEntry and BadgeInfo carry fields of the same names, so the selectors are ambiguous here +bMonths :: StatementEntry -> Int +bMonths StatementEntry {balanceMonths} = balanceMonths + +bStart :: StatementEntry -> UTCTime +bStart StatementEntry {balanceStartTs} = balanceStartTs + +-- the moment the service claims it wrote the row, which is what the check reads it against +stampedAt :: UTCTime -> StatementEntry -> StatementEntry +stampedAt t e = e {createdAt = t} + +-- one service pass: lapse what elapsed, then issue if a month is due, as the service chains them. +-- The period an issue covers is the previous entry's balance start to its own, so a run of starts +-- is what the period assertions read. +pass :: UTCTime -> StatementEntry -> [StatementEntry] +pass t e0 = maybeToList lapsed <> maybeToList (issue t $ fromMaybe e0 lapsed) + where + lapsed = lapse t e0 + +finalBalance :: StatementEntry -> [StatementEntry] -> StatementEntry +finalBalance e0 rows = last (e0 : rows) + +-- each pass issues exactly one month, so the entries returned are the months issued, in order +issueAll :: StatementEntry -> [StatementEntry] +issueAll e = case pass (bStart e) e of + [] -> [] + rows -> let e' = finalBalance e rows in e' : issueAll e' + +testTwelveMonths :: IO () +testTwelveMonths = do + let start = at 2026 3 10 + granted = grant start 12 (newBalance start) + -- each month is issued as soon as it falls due + issued = issueAll granted + spent = finalBalance granted issued + length issued `shouldBe` 12 + bMonths spent `shouldBe` 0 + -- no month was skipped or issued twice: consecutive starts, so the periods tile the whole year + map bStart (granted : issued) `shouldBe` map (\m -> addMonths m start) [0 .. 12] + bStart spent `shouldBe` at 2027 3 10 + -- a thirteenth request issues nothing, whenever it is made + issue (bStart spent) spent `shouldSatisfy` isNothing + issue (at 2030 1 1) spent `shouldSatisfy` isNothing + lapse (at 2030 1 1) spent `shouldSatisfy` isNothing + +-- 3 months bought 10 Mar, first issued the same day, no pass until 20 May: April lapses unissued +testLapseAfterGap :: IO () +testLapseAfterGap = do + let start = at 2026 3 10 + granted = grant start 3 (newBalance start) + rows1 = pass start granted + afterFirst = finalBalance granted rows1 + rows2 = pass (at 2026 5 20) afterFirst + afterSecond = finalBalance afterFirst rows2 + map bMonths rows1 `shouldBe` [2] + -- March is issued: from the granting entry's start to the issued entry's own + bStart granted `shouldBe` at 2026 3 10 + map bStart rows1 `shouldBe` [at 2026 4 10] + -- one lapse row for April, then May is issued: two rows, not one and not three. The lapse row's + -- start is where May begins, so it is also the start of the period the issue that follows covers + map bMonths rows2 `shouldBe` [1, 0] + map bStart rows2 `shouldBe` [at 2026 5 10, at 2026 6 10] + -- a lapse moves months from unused to gone; it never changes what was paid for + map paidThrough (granted : rows1 <> rows2) `shouldBe` replicate 4 (at 2026 6 10) + bMonths afterSecond `shouldBe` 0 + +data LedgerStep = Grant UTCTime Int | Pass UTCTime + +testLedgerInvariants :: IO () +testLedgerInvariants = do + let start = at 2026 1 15 + reopened = at 2027 9 9 + -- the April grant lands exactly where coverage ended and continues the run, the 2027 one + -- lands past it and restarts, so both branches of grantEntry run under the invariants + steps = + [ Grant start 3, + Pass start, + Pass (at 2026 4 15), + Grant (at 2026 4 15) 2, + Pass (at 2026 5 1), + Pass reopened, + Grant reopened 1, + Pass reopened + ] + step (e, rows) = \case + Grant t n -> let e' = grant t n e in (e', rows <> [e']) + Pass t -> let rs = pass t e in (finalBalance e rs, rows <> rs) + (_, allRows) = foldl step (newBalance start, []) steps + map bMonths allRows `shouldSatisfy` all (>= 0) + map bStart allRows `shouldSatisfy` nonDecreasing + +testGrantAfterExhausted :: IO () +testGrantAfterExhausted = do + -- the balance ran out on 10 Feb; the next code is redeemed on 1 Jun + let spent = newBalance (at 2026 2 10) + granted = grant (at 2026 6 1) 2 spent + bStart granted `shouldBe` at 2026 6 1 + paidThrough granted `shouldBe` at 2026 8 1 + -- the four unsupported months are not backfilled, so nothing lapses immediately + lapse (at 2026 6 1) granted `shouldSatisfy` isNothing + +testGrantInsideIssuedPeriod :: IO () +testGrantInsideIssuedPeriod = do + let start = at 2026 1 10 + granted = grant start 1 (newBalance start) + issued = finalBalance granted $ pass start granted + -- topped up on 20 Jan, while the month issued on 10 Jan still runs + toppedUp = grant (at 2026 1 20) 3 issued + -- February is where the next period starts, the top-up having been spent on neither January nor a gap + bStart toppedUp `shouldBe` at 2026 2 10 + paidThrough toppedUp `shouldBe` at 2026 5 10 + -- the balance starts in the future, so no second credential is issued for January + issue (at 2026 1 20) toppedUp `shouldSatisfy` isNothing + fmap bStart (issue (at 2026 2 10) toppedUp) `shouldBe` Just (at 2026 3 10) + +testMonthEndClipping :: IO () +testMonthEndClipping = do + let start = at 2027 1 31 + granted = grant start 3 (newBalance start) + issued = issueAll granted + -- February clips to the 28th, and March goes back to the 31st: clipping does not accumulate. + -- Each period runs from one start to the next, so these bounds are the three periods + map bStart (granted : issued) `shouldBe` [at 2027 1 31, at 2027 2 28, at 2027 3 31, at 2027 4 30] + -- the issued period start is the previous balance start, never periodEnd minus a month, which + -- clipping would answer as 28 Jan + addMonths (-1) (bStart (head issued)) `shouldNotBe` bStart granted + -- across a leap day + let leap = grant (at 2028 1 29) 2 (newBalance (at 2028 1 29)) + map bStart (issueAll leap) `shouldBe` [at 2028 2 29, at 2028 3 29] + -- a month that ends on the leap day counts as elapsed the moment it ends, and not before + fmap bMonths (lapse (at 2028 2 29) leap) `shouldBe` Just 1 + lapse (addUTCTime (-1) (at 2028 2 29)) leap `shouldSatisfy` isNothing + -- buying a month at a time keeps the day of month that buying three at once keeps + let jan = grant (at 2027 1 31) 1 (newBalance (at 2027 1 31)) + case issue (at 2027 1 31) jan of + Just issuedJan -> do + let feb = grant (at 2027 2 20) 1 issuedJan + fmap bStart (issue (at 2027 2 28) feb) `shouldBe` Just (at 2027 3 31) + Nothing -> expectationFailure "January was not issued" + +-- The anchor and the month count are the service's, and a run claiming to have started a thousand +-- years ago with maxBound months is answered the same way as any other. +testElapsedFarAnchor :: IO () +testElapsedFarAnchor = do + let far = at 1000 1 10 + now = at 2026 1 10 + elapsed = (2026 - 1000) * 12 + huge = (newBalance far) {balanceMonths = maxBound} + three = (newBalance far) {balanceMonths = 3} + fmap bMonths (lapse now huge) `shouldBe` Just (maxBound - elapsed) + fmap bStart (lapse now huge) `shouldBe` Just now + -- and never writes off more months than the balance holds, however long ago it started + fmap bMonths (lapse now three) `shouldBe` Just 0 + fmap bStart (lapse now three) `shouldBe` Just (at 1000 4 10) + +testMondayExpiry :: IO () +testMondayExpiry = do + -- the end of Monday 13 Apr is Tuesday 14 Apr 00:00 + endOfMondayAfter (at 2026 4 10) `shouldBe` UTCTime (fromGregorian 2026 4 14) 0 + endOfMondayAfter (at 2026 6 10) `shouldBe` UTCTime (fromGregorian 2026 6 16) 0 + -- a period ending on a Monday still runs to the end of the following Monday, never to zero days + endOfMondayAfter (at 2026 4 13) `shouldBe` UTCTime (fromGregorian 2026 4 21) 0 + let periodEnds = map (\d -> at 2026 4 d) [1 .. 30] + expiries = map endOfMondayAfter periodEnds + -- every expiry is a Tuesday midnight more than a day after its period, and at most eight + expiries `shouldSatisfy` all (\(UTCTime d t) -> t == 0 && (\(_, _, wd) -> wd == 2) (toWeekDate d)) + zipWith diffUTCTime expiries periodEnds `shouldSatisfy` all (\d -> d > nominalDay && d <= 8 * nominalDay) + +verdicts :: UTCTime -> Maybe StatementEntry -> [StatementEntry] -> [Maybe Bool] +verdicts now tip = map snd . balanceChecked now BTSupporter tip + +testChecksChain :: IO () +testChecksChain = do + let opened = newBalance (at 2026 1 10) + oneMonth = grant (at 2026 1 10) 1 opened + Just spent <- pure $ issue (at 2026 1 10) oneMonth + let granted = grant (at 2026 3 10) 3 spent + rows1 = pass (at 2026 3 10) granted + afterFirst = finalBalance granted rows1 + rows2 = pass (at 2026 5 20) afterFirst + statement = granted : rows1 <> rows2 + -- grant, issue, lapse, issue - the first checked against the stored tip + verdicts (at 2026 5 20) (Just spent) statement `shouldBe` replicate 4 (Just True) + +-- The client stores what it received, and the next row is what the service computed from the row it +-- sent - never from the tip, which that row has already superseded. +testChecksAgainstStatement :: IO () +testChecksAgainstStatement = do + let start = at 2026 3 10 + granted = grant start 3 (newBalance start) + Just firstRow <- pure $ issue start granted + Just secondRow <- pure $ issue (at 2026 4 10) firstRow + verdicts (at 2026 4 10) (Just granted) [firstRow, secondRow] `shouldBe` [Just True, Just True] + -- against the tip it followed, the second entry does not add up + verdicts (at 2026 4 10) (Just granted) [secondRow] `shouldBe` [Just False] + +-- The seed is what redeemCode grants onto, so the row it authors is the one that verifies here. +testChecksOpening :: IO () +testChecksOpening = do + let t = at 2026 3 10 + opening = grant t 12 (newBalance t) + verdicts t Nothing [opening] `shouldBe` [Just True] + Just issued <- pure $ issue t opening + verdicts t Nothing [issued] `shouldBe` [Just False] + +-- The seed takes the purchase's badge type, not the statement's, so an opening row cannot assert a +-- badge the purchase was never for - the one field on that path with something to check it against. +testChecksOpeningBadgeType :: IO () +testChecksOpeningBadgeType = do + let t = at 2026 3 10 + opening = grant t 12 (newBalance t) + verdicts t Nothing [opening] `shouldBe` [Just True] + verdicts t Nothing [opening {balanceBadgeType = BTLegend}] `shouldBe` [Just False] + +-- An opening credit resets the ledger to the amount it states, so it is the one entry whose +-- balance owes nothing to the row before it - a new device, or history discarded into a balance +-- brought forward. It still cannot state a balance other than the months it credits. +testChecksOpeningRestatement :: IO () +testChecksOpeningRestatement = do + let start = at 2026 3 10 + granted = grant start 3 (newBalance start) + restated = granted {entryType = SECredit SCOpening, changeMonths = 3, balanceMonths = 3} + -- the tip funds two months from April; the opening restates three from March and still holds + Just spent <- pure $ issue start granted + verdicts start (Just spent) [restated] `shouldBe` [Just True] + verdicts start (Just spent) [restated {balanceMonths = 9}] `shouldBe` [Just False] + verdicts start (Just spent) [restated {balanceBadgeType = BTLegend}] `shouldBe` [Just False] + +-- Over-lapsing empties the balance while paidThrough stays where it was: the badge stops renewing +-- and the ledger still reads as paid up. The row is self-consistent with the one before it, so only +-- re-running the lapse against its own timestamp catches it. +testChecksOverLapse :: IO () +testChecksOverLapse = do + let start = at 2026 3 10 + granted = grant start 3 (newBalance start) + Just issued <- pure $ issue start granted + Just lapsed <- pure $ lapse (at 2026 5 20) issued + Just overLapsed <- pure $ lapse (at 2026 8 20) issued + verdicts (at 2026 5 20) (Just issued) [lapsed] `shouldBe` [Just True] + verdicts (at 2026 5 20) (Just issued) [stampedAt (at 2026 5 20) overLapsed] `shouldBe` [Just False] + bMonths overLapsed `shouldBe` bMonths lapsed - 1 + paidThrough overLapsed `shouldBe` paidThrough lapsed + -- and a lapse claiming a month before any had elapsed: lapseEntry declines it altogether + verdicts start (Just issued) [stampedAt start lapsed] `shouldBe` [Just False] + +testChecksMovedStart :: IO () +testChecksMovedStart = do + let start = at 2026 3 10 + granted = grant start 3 (newBalance start) + Just issued <- pure $ issue start granted + verdicts start (Just granted) [issued] `shouldBe` [Just True] + verdicts start (Just granted) [issued {balanceStartTs = addMonths 1 (bStart issued)}] `shouldBe` [Just False] + verdicts start (Just granted) [issued {balanceAnchorTs = addMonths 1 start}] `shouldBe` [Just False] + +testChecksGrantRestart :: IO () +testChecksGrantRestart = do + let t = at 2026 2 10 + opened = newBalance t + oneMonth = grant t 1 opened + funded = grant t 2 opened + Just spent <- pure $ issue t oneMonth + let restarted = grant (at 2026 6 1) 2 spent + verdicts (at 2026 6 1) (Just spent) [restarted] `shouldBe` [Just True] + -- the same entry after a predecessor with months left is a run moved to a later start + verdicts (at 2026 6 1) (Just funded) [restarted] `shouldBe` [Just False] + +testChecksNegativeCredit :: IO () +testChecksNegativeCredit = do + let t = at 2026 2 10 + funded = grant t 2 (newBalance t) + negativeCredit = grant (at 2026 6 1) (-2) funded + verdicts (at 2026 6 1) (Just funded) [negativeCredit] `shouldBe` [Just False] + -- the sign is what rejects it: the row itself adds up, and the recompute would confirm it + bMonths negativeCredit `shouldBe` bMonths funded - 2 + +testChecksTimestamps :: IO () +testChecksTimestamps = do + let start = at 2026 3 10 + granted = grant start 3 (newBalance start) + Just issued <- pure $ issue start granted + -- the two clocks are not the same clock, so a row from just ahead of this one is not evidence + verdicts start (Just granted) [stampedAt (addUTCTime (30 * 60) start) issued] `shouldBe` [Just True] + -- further ahead than that, we cannot tell their clock from ours, so the row is left unjudged + verdicts start (Just granted) [stampedAt (addUTCTime (2 * 3600) start) issued] `shouldBe` [Nothing] + -- behind the row it follows is the service against itself, with no clock of ours in it + verdicts start (Just granted) [stampedAt (at 2026 3 1) issued] `shouldBe` [Just False] + +-- Marking a row this version has no operation for as broken would report a newer service as +-- tampering, which is the opposite of the forward compatibility the rest of this code keeps. +testChecksUnknownType :: IO () +testChecksUnknownType = do + let start = at 2026 3 10 + granted = grant start 3 (newBalance start) + Just issued <- pure $ issue start granted + let unknown = issued {entryType = SEDebit SDUnknown {tag = "future", json = KM.empty}} + verdicts start (Just granted) [unknown] `shouldBe` [Nothing] + verdicts start (Just granted) [issued {entryType = SEDebit SDRefund}] `shouldBe` [Nothing] + verdicts start (Just granted) [unknown {balanceMonths = 5}] `shouldBe` [Just False] + verdicts start (Just granted) [stampedAt (at 2026 3 1) unknown] `shouldBe` [Just False] + -- an unknown credit takes the same path: fall through to grantEntry and its negative count, + -- which issued carries, would be rejected instead + let unknownCredit = issued {entryType = SECredit SCUnknown {tag = "future", json = KM.empty}} + verdicts start (Just granted) [unknownCredit] `shouldBe` [Nothing] + +-- A tag with no operation behind it escapes the recompute, leaving only the months identity - which +-- holds while coverage moves back, or while the balance goes into debt. +testChecksUncheckedInvariants :: IO () +testChecksUncheckedInvariants = do + let start = at 2026 3 10 + granted = grant start 3 (newBalance start) + Just issued <- pure $ issue start granted + let shortened = issued {entryType = SEDebit SDRefund, changeMonths = 0, balanceStartTs = addMonths (-1) (bStart issued)} + owing = issued {entryType = SEDebit SDRefund, changeMonths = -3, balanceMonths = -1} + verdicts start (Just issued) [shortened] `shouldBe` [Just False] + paidThrough shortened `shouldBe` addMonths (-1) (paidThrough issued) + verdicts start (Just issued) [owing] `shouldBe` [Just False] + bMonths owing `shouldBe` bMonths issued - 3 + +-- A failed renewal is otherwise left until the next chat start or activate, which on a desktop +-- left running can be days - long enough for a funded badge to lapse. +testRetryClassification :: IO () +testRetryClassification = do + let retryFor = badgeErrorRetry . chatErrorAgent + -- an unanswered request is the likeliest renewal failure, and it is the agent's own error + retryFor (AGENT (A_SERVICE ASETimeout)) `shouldBe` True + retryFor (BROKER "localhost" TIMEOUT) `shouldBe` True + retryFor (BROKER "localhost" (NETWORK NETimeoutError)) `shouldBe` True + -- terminal: the same request would fail the same way, and repeating it would spin + retryFor (AGENT (A_SERVICE ASEBadSignature)) `shouldBe` False + retryFor (AGENT (A_SERVICE (ASERejected "no"))) `shouldBe` False + badgeErrorRetry (ChatError (CECommandError "unexpected badge service response")) `shouldBe` False + +-- A failure that never clears is repeated for as long as the balance funds a month, so the wait +-- has to grow: at a fixed interval an annual code would ask hundreds of times a day, all year. +testRetryBackoff :: IO () +testRetryBackoff = do + let ri@RetryInterval {initialInterval, maxInterval} = badgeRetryInterval defaultChatConfig + advance (elapsed, delay) = + let elapsed' = elapsed + delay + in (elapsed', nextRetryDelay elapsed' delay ri) + delays = map snd $ take 40 $ iterate advance (0, initialInterval) + head delays `shouldBe` initialInterval + delays `shouldSatisfy` all (\d -> d >= initialInterval && d <= maxInterval) + delays `shouldSatisfy` nonDecreasing + -- it reaches the cap rather than creeping towards it, and stays there + last delays `shouldBe` maxInterval + +-- A service answering retryAfter 0 would put the next attempt at now, and the worker would ask +-- again as fast as the round trip allows, for as long as the service kept answering that way. +testServiceRetryFloor :: IO () +testServiceRetryFloor = do + let ri@RetryInterval {initialInterval, maxInterval} = badgeRetryInterval defaultChatConfig + floorWait = fromIntegral initialInterval / 1000000 :: NominalDiffTime + aboveCap = 2 * fromIntegral maxInterval / 1000000 :: NominalDiffTime + -- a code carrying no wait is terminal for this request, and waits what any stalled month waits + badgeRetryAfter ri Nothing `shouldBe` badgeStalledInterval + -- nothing the service names brings the wait below where a retry of its own would start + badgeRetryAfter ri (Just 0) `shouldBe` floorWait + badgeRetryAfter ri (Just 1) `shouldBe` floorWait + badgeRetryAfter ri (Just $ round floorWait) `shouldBe` floorWait + -- above that it is honoured as sent, and not capped: a service may know it is down for the day + badgeRetryAfter ri (Just 600) `shouldBe` 600 + badgeRetryAfter ri (Just $ round aboveCap) `shouldBe` aboveCap + +-- badges-rpc.md defines retryAfter as marking the transient codes, and every other code as +-- terminal for the command attempted. The client repeats a code that carries one on the service's +-- schedule, so the set is the protocol's and not a judgement to make per call site. +testServiceRetryAfter :: IO () +testServiceRetryAfter = do + badgeErrorRetryAfter BSEPaymentPending `shouldBe` Just 300 + badgeErrorRetryAfter BSEProviderUnavailable `shouldBe` Just 300 + badgeErrorRetryAfter BSERateLimited `shouldBe` Just 60 + -- internal is the one most likely to clear on its own, and is still terminal: repeating it on + -- the service's cadence presses a service already failing, and the client has its own floor + badgeErrorRetryAfter BSEInternal `shouldBe` Nothing + mapM_ + (\code -> badgeErrorRetryAfter code `shouldBe` Nothing) + [BSEBadRequest, BSEUnsupportedVersion, BSEUnknownPurchaseKey, BSECodeInvalid, BSECodeUsed, BSECodeExpired, BSEUnknown "future_code"] + +-- The client replicates entry_credit_type / entry_debit_type verbatim, so a stored tag that +-- disagreed with the wire tag would put a different row on each side. +testEntryTypeColumns :: IO () +testEntryTypeColumns = do + k <- fst <$> (C.newRandom >>= \g -> atomically (C.generateKeyPair g) :: IO (C.KeyPair 'C.Ed25519)) + let credits = [SCPayment Nothing, SCCode, SCCharge "ch1", SCSupport, SCTransferIn k, SCOpening] + debits = [SDRefund, SDUpgrade k, SDTransferOut k, SDSupport, SDBadge, SDLapse] + mapM_ (\c -> wireTag (J.toJSON (SECredit c)) "credit" `shouldBe` Just (creditTypeTag c)) credits + mapM_ (\d -> wireTag (J.toJSON (SEDebit d)) "debit" `shouldBe` Just (debitTypeTag d)) debits + -- the three types this version writes survive a round trip through the columns + mapM_ + (\t -> uncurry3 entryTypeFromColumns (entryTypeColumns t) `shouldSatisfy` sameEntryType t) + [SECredit SCCode, SEDebit SDBadge, SEDebit SDLapse] + -- a type that needs a reference column is not silently read back as something else + uncurry3 entryTypeFromColumns (entryTypeColumns (SECredit (SCCharge "ch1"))) `shouldSatisfy` isNothing + -- which is why every type is stored as its own JSON as well, and read from that first: the + -- columns alone would answer a row naming an invoice or a purchase as no row at all + mapM_ roundTrips credits + mapM_ roundTrips debits + where + uncurry3 f (a, b, c) = f a b c + sameEntryType t = maybe False ((J.toJSON t ==) . J.toJSON) + wireTag v fld = case v of + J.Object o | Just (J.Object inner) <- KM.lookup fld o, Just (J.String t) <- KM.lookup "type" inner -> Just t + _ -> Nothing + +-- Service protocol JSON, against docs/protocol/badges-rpc.schema.json + +testRedeemRequestJSON :: IO () +testRedeemRequestJSON = do + drg <- C.newRandom + mk <- generateMasterKey drg + (k, _) <- atomically $ C.generateKeyPair drg :: IO (C.KeyPair 'C.Ed25519) + code <- randomBadgeCode drg + let req = BadgeServiceRequest {version = Version 1, purchaseKey = Just k, request = BSCRedeemBadgeCode {masterKey = mk, code = badgeCodeText code}} + J.toJSON req + `shouldBe` J.object + [ "version" J..= (1 :: Int), + "purchaseKey" J..= k, + "request" J..= J.object ["type" J..= ("redeemBadgeCode" :: T.Text), "masterKey" J..= mk, "code" J..= badgeCodeText code] + ] + -- purchaseKey is optional in the schema, and a nullary command is a bare tagged object + J.toJSON BadgeServiceRequest {version = Version 1, purchaseKey = Nothing, request = BSCGetBadgeCatalog} + `shouldBe` J.object ["version" J..= (1 :: Int), "request" J..= J.object ["type" J..= ("getBadgeCatalog" :: T.Text)]] + roundTrips req + +testCredentialResponseJSON :: IO () +testCredentialResponseJSON = do + Right (_, sk) <- bbsKeyGen + drg <- C.newRandom + mk <- generateMasterKey drg + let info = BadgeInfo {badgeType = BTSupporter, badgeExpiry = futureTime, badgeExtra = ""} + Right cred <- issueBadge testKeyIdx sk (VerifiedBadgeRequest BadgeRequest {masterKey = mk, badgeInfo = info}) + let resp = BSPBadgeCredential {credential = Just cred, receipt = Nothing, statement = BadgeStatement {entries = [], previousEntryId = Nothing}} + J.toJSON resp + `shouldBe` J.object + [ "type" J..= ("badgeCredential" :: T.Text), + "credential" J..= cred, + "statement" J..= J.object ["entries" J..= ([] :: [J.Value])] + ] + roundTrips resp + +testErrorResponseJSON :: IO () +testErrorResponseJSON = do + let resp = BSPError {code = BSECodeInvalid, message = Nothing, retryAfter = Nothing} + J.toJSON resp `shouldBe` J.object ["type" J..= ("error" :: T.Text), "code" J..= ("code_invalid" :: T.Text)] + J.toJSON BSPError {code = BSERateLimited, message = Just "slow down", retryAfter = Just 30} + `shouldBe` J.object ["type" J..= ("error" :: T.Text), "code" J..= ("rate_limited" :: T.Text), "message" J..= ("slow down" :: T.Text), "retryAfter" J..= (30 :: Int)] + +testStatementJSON :: IO () +testStatementJSON = do + let entry = + StatementEntry + { entryId = "e1", + changeMonths = 3, + balanceMonths = 3, + balanceStartTs = futureTime, + balanceAnchorTs = futureTime, + balanceBadgeType = BTSupporter, + wasPausedSince = Nothing, + createdAt = futureTime, + entryType = SECredit {credit = SCPayment {invoiceId = Nothing}} + } + -- the whole entry: the required fields, and wasPausedSince omitted rather than sent as null + J.toJSON entry + `shouldBe` J.object + [ "entryId" J..= ("e1" :: T.Text), + "changeMonths" J..= (3 :: Int), + "balanceMonths" J..= (3 :: Int), + "balanceStartTs" J..= futureTime, + "balanceAnchorTs" J..= futureTime, + "balanceBadgeType" J..= ("supporter" :: T.Text), + "createdAt" J..= futureTime, + "entryType" J..= entryType entry + ] + J.toJSON entry {wasPausedSince = Just pastTime} `shouldNotBe` J.toJSON entry + J.toJSON (entryType entry) `shouldBe` J.object ["type" J..= ("credit" :: T.Text), "credit" J..= J.object ["type" J..= ("payment" :: T.Text)]] + J.toJSON SEDebit {debit = SDBadge} `shouldBe` J.object ["type" J..= ("debit" :: T.Text), "debit" J..= J.object ["type" J..= ("badge" :: T.Text)]] + J.toJSON SEDebit {debit = SDLapse} `shouldBe` J.object ["type" J..= ("debit" :: T.Text), "debit" J..= J.object ["type" J..= ("lapse" :: T.Text)]] + -- a code grant is its own credit type, not a payment whose invoiceId happens to be absent + J.toJSON SECredit {credit = SCCode} `shouldBe` J.object ["type" J..= ("credit" :: T.Text), "credit" J..= J.object ["type" J..= ("code" :: T.Text)]] + J.toJSON SECredit {credit = SCCode} `shouldNotBe` J.toJSON SECredit {credit = SCPayment {invoiceId = Nothing}} + -- an entry type from a newer service is stored and re-emitted unchanged + let futureCredit = J.object ["type" J..= ("grant" :: T.Text), "grantedBy" J..= ("operator" :: T.Text)] + case J.fromJSON futureCredit of + J.Success c@SCUnknown {tag} -> do + tag `shouldBe` "grant" + J.toJSON c `shouldBe` futureCredit + r -> expectationFailure $ "expected SCUnknown, got " <> show (fmap (const ()) r) + +-- decoding and re-encoding reproduces the encoding, without Eq on the protocol types +roundTrips :: (HasCallStack, J.ToJSON a, J.FromJSON a) => a -> IO () +roundTrips x = case J.eitherDecode (J.encode x) of + Right x' -> J.toJSON (x' `asTypeOf` x) `shouldBe` J.toJSON x + Left e -> expectationFailure e diff --git a/tests/Bots/BadgeServiceTests.hs b/tests/Bots/BadgeServiceTests.hs new file mode 100644 index 0000000000..4897722683 --- /dev/null +++ b/tests/Bots/BadgeServiceTests.hs @@ -0,0 +1,1110 @@ +{-# LANGUAGE CPP #-} +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TupleSections #-} +{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} + +module Bots.BadgeServiceTests where + +import BadgeService.Options +import BadgeService.Service +import ChatClient +import ChatTests.DBUtils +import ChatTests.Utils +import Control.Concurrent (forkIO, killThread, threadDelay) +import Control.Concurrent.STM (atomically, readTMVar) +import Control.Monad (forM_, void, when) +import Control.Exception (finally) +import qualified Data.Aeson as J +import qualified Data.ByteString.Char8 as B +import Data.Char (toLower) +import Data.Either (isLeft, isRight) +import Data.Int (Int64) +import Data.IORef (IORef, newIORef, readIORef, writeIORef) +import qualified Data.Map.Strict as M +import Data.Maybe (isJust, isNothing) +import Data.String (fromString) +import System.Timeout (timeout) +import Data.Text (Text) +import qualified Data.Text as T +import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime, diffUTCTime, getCurrentTime, nominalDay) +import Data.Time.Format (defaultTimeLocale, formatTime) +import Simplex.Chat.Badges (BadgeCredential (..), BadgeInfo (..), BadgeMasterKey, BadgeType (..), generateMasterKey) +import Simplex.Chat.Badges.Code (BadgeCode, badgeCodeText, formatBadgeCode, parseBadgeCode, randomBadgeCode) +import Simplex.Chat.Badges.Ledger (addMonths, creditTypeTag, debitTypeTag, endOfMondayAfter) +import Simplex.Chat.Badges.Service +import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), ChatResponse (CRCustomChatResponse)) +import Simplex.Chat.Core (sendChatCmdStr) +import Simplex.Chat.Options (CoreChatOpts (..)) +import Simplex.Chat.Options.DB +import Simplex.Messaging.Agent.Store.Common (withTransaction) +import Simplex.Messaging.Agent.Store.DB (BoolInt (..)) +import qualified Simplex.Messaging.Agent.Store.DB as DB +import Simplex.Chat.Types (ChatPeerType (..), Profile (..)) +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.BBS (BBSSecretKey, bbsKeyGen) +import Simplex.Messaging.Encoding.String (strDecode, strEncode, textEncode) +import Simplex.Messaging.Util (safeDecodeUtf8) +import System.FilePath (()) +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (Only (..)) +#else +import Database.SQLite.Simple (Only (..)) +#endif +import Test.Hspec hiding (it) + +badgeServiceTests :: SpecWith TestParams +badgeServiceTests = do + it "should answer unsupported_version to unsupported command" testBadgeServiceUnsupported + it "should redeem an issued code into a badge a contact sees" testRedeemBadgeCode + it "should return the same badge when the same code is redeemed twice" testRedeemBadgeCodeTwice + it "should answer code_invalid to an unknown code, indistinguishably from a malformed one" testRedeemUnknownCode + it "should tell a second profile redeeming the same code that it is used" testRedeemSameCodeOtherProfile + it "should refuse a second code while a badge is held, leaving it unspent" testRedeemSecondCode + it "should refuse to issue a code with an unknown badge type or a nonsense month count" testIssueRejectsBadArguments + it "should refuse a request whose purchaseKey is not the verified signer" testPurchaseKeyMismatch + it "should refuse to start unless the issuer secret is the key trusted at its index" testIssuerKeyMustMatchConfig + it "should credit a code's months and issue one credential per month" testCodeMonthsRenew + it "should return the stored credential for a repeat inside an issued period" testRepeatInsideIssuedPeriod + it "should lapse only the months that elapsed while the client was away" testLapseWhileAway + it "should round the last month's expiry up to the end of the Monday after it" testLastMonthExpiryRounds + it "should sign a renewal with the master key stored on the purchase" testRenewalSignsWithStoredMasterKey + it "should leave the client holding the same ledger rows as the service" testClientReplicatesLedger + it "should renew a badge whose credential is lapsing, with no command" testWorkerRenews + it "should request from the wake it set a day before the credential lapses" testRequestWakeFires + it "should present from the wake it set at the credential's expiry" testPresentWakeFires + it "should renew a badge whose newest ledger row is of an unknown type" testRenewsAfterUnknownEntry + it "should catch up the months that lapsed while the client was stopped" testRenewsAfterRestart + it "should stop showing a badge whose balance ran out, and tell contacts" testWorkerRetiresExpired + it "should retire when entitlement ends, not when the credential expires" testRetiresWhenEntitlementEnds + it "should alert that support ended, survive a restart, and go silent once acknowledged" testEndedAlert + it "should raise a snoozed alert once more when the snooze lapses" testSnoozedAlertReturns + it "should renew a badge on a profile that is not active, without switching to it" testRenewalKeepsActiveProfile + it "should broadcast the current profile when a renewal presents a badge" testRenewalKeepsProfileEdits + it "should present the month already issued when a previous pass did not" testPresentationCatchesUp + +badgeProfile :: Profile +badgeProfile = Profile {displayName = "SimpleX Badges", fullName = "", shortDescr = Nothing, description = Nothing, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences = Nothing, badge = Nothing, contactDomain = Nothing} + +serviceDbPrefix :: FilePath +serviceDbPrefix = "badge_service" + +testIssuerKeyIdx :: Int +testIssuerKeyIdx = 1 + +mkBadgeServiceOpts :: TestParams -> BBSSecretKey -> BadgeServiceOpts +mkBadgeServiceOpts TestParams {tmpPath = ps} secretKey = + BadgeServiceOpts + { coreOptions = + testCoreOpts + { dbOptions = + (dbOptions testCoreOpts) +#if defined(dbPostgres) + {dbSchemaPrefix = "client_" <> serviceDbPrefix} +#else + {dbFilePrefix = ps serviceDbPrefix} +#endif + }, + serviceName = "SimpleX Badges", + clientService = True, + noAddress = False, + runCLI = False, + issuerKey = Just BadgeIssuerKey {keyIdx = testIssuerKeyIdx, secretKey}, + testing = True + } + +-- | A clock the service and the client both read: real time plus an offset the test moves. It +-- tracks real time rather than freezing it, so a sleeper still sleeps the right real duration. +newtype TestClock = TestClock (IORef NominalDiffTime) + +newTestClock :: IO TestClock +newTestClock = TestClock <$> newIORef 0 + +testClockTime :: TestClock -> IO UTCTime +testClockTime (TestClock r) = do + offset <- readIORef r + addUTCTime offset <$> getCurrentTime + +-- | Move the clock so that "now" becomes exactly the given time - months are calendar months, so +-- a test crosses a boundary by naming the date rather than adding a duration. +setClockAt :: TestClock -> UTCTime -> IO () +setClockAt (TestClock r) t = getCurrentTime >>= \real -> writeIORef r (diffUTCTime t real) + +-- | Everything a badge test may need from a running service. +data BadgeServiceEnv = BadgeServiceEnv + { bsIssuerKey :: BadgeIssuerKey, + bsClock :: TestClock, + bsClientCfg :: ChatConfig, + bsAddress :: String, + bsController :: ChatController + } + +-- | Start the badge service on a fresh issuer key, and hand the test body what depends on it: +-- the client config trusting that key and addressing the service, the address, and the controller. +withBadgeService :: HasCallStack => TestParams -> (ChatConfig -> String -> ChatController -> IO ()) -> IO () +withBadgeService ps test = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsAddress, bsController} -> test bsClientCfg bsAddress bsController + +withBadgeServiceEnv :: HasCallStack => TestParams -> (BadgeServiceEnv -> IO ()) -> IO () +withBadgeServiceEnv ps test = do + Right (pk, sk) <- bbsKeyGen + clock <- newTestClock + let opts = mkBadgeServiceOpts ps sk + -- the service refuses to start unless its secret is the key trusted at its index + svcCfg = testCfg {badgePublicKeys = M.singleton testIssuerKeyIdx pk, badgeCurrentTime = testClockTime clock} + withNewTestChatCfg ps testCfg serviceDbPrefix badgeProfile $ \_ -> pure () + -- First start: badge service takes the CreateMyAddress branch. + runBadgeService svcCfg opts $ \_ -> pure () + -- Reopen the DB to read the link the service created. + bsLink <- withTestChat ps serviceDbPrefix $ \bs -> do + bs <## "subscribed 1 connections on server localhost" + bs ##> "/sa" + (sLink, _) <- getContactLinks bs False + bs <## "auto_accept off" + pure sLink + let clientCfg = + svcCfg {badgeServiceAddress = Just $ either (error . ("bad badge service address: " <>)) id $ strDecode (B.pack bsLink)} + -- Second start: badge service takes the ShowMyAddress branch, then serves the test body. + runBadgeService svcCfg opts $ \env -> do + cc <- atomically $ readTMVar $ serviceCC env + test BadgeServiceEnv {bsIssuerKey = BadgeIssuerKey {keyIdx = testIssuerKeyIdx, secretKey = sk}, bsClock = clock, bsClientCfg = clientCfg, bsAddress = bsLink, bsController = cc} + +-- through the operator command the service actually exposes, not the function behind it +issueCode :: HasCallStack => ChatController -> BadgeType -> Int -> IO BadgeCode +issueCode cc badgeType months = + sendChatCmdStr cc ("//issue " <> T.unpack (textEncode badgeType) <> " " <> show months) >>= \case + Right (CRCustomChatResponse _ response) -> case T.stripPrefix "code " response of + Just c | Just code <- parseBadgeCode c -> pure code + _ -> error $ "unexpected issue response: " <> T.unpack response + r -> error $ "issue failed: " <> show (() <$ r) + +-- | The post-start hook fills serviceCC once the address exists, so waiting on it is the service +-- being ready. A fixed delay here raced with startup and left the address output of one start +-- arriving during the next test. +runBadgeService :: ChatConfig -> BadgeServiceOpts -> (ServiceState -> IO ()) -> IO () +runBadgeService cfg opts action = do + env <- newServiceState + t <- forkIO $ badgeService opts cfg env + ready <- timeout 30000000 $ atomically $ readTMVar $ serviceCC env + when (isNothing ready) $ killThread t >> error "badge service did not start" + action env `finally` killThread t + +codeArg :: BadgeCode -> String +codeArg = T.unpack . formatBadgeCode + +testBadgeServiceUnsupported :: HasCallStack => TestParams -> IO () +testBadgeServiceUnsupported ps = + withBadgeService ps $ \clientCfg bsLink _ -> + withNewTestChatCfg ps clientCfg "client" bobProfile $ \client -> do + let req = "{\"version\":1,\"request\":{\"type\":\"pauseBadge\"}}" + client ##> ("/_service_request 1 " <> bsLink <> " " <> req) + client <## "service response: {\"code\":\"unsupported_version\",\"type\":\"error\"}" + +testRedeemBadgeCode :: HasCallStack => TestParams -> IO () +testRedeemBadgeCode ps = + withBadgeService ps $ \clientCfg _ cc -> + withNewTestChatCfg ps clientCfg "alice" aliceProfile $ \alice -> + withNewTestChatCfg ps clientCfg "bob" bobProfile $ \bob -> do + connectUsers alice bob + code <- issueCode cc BTSupporter 1 + -- the service has never seen this purchase key: a first redemption must still succeed + alice ##> ("/_redeem_badge_code 1 " <> codeArg code) + alice <## "badge redeemed" + alice <## "supporter badge - active" + alice <##. "expires " + alice ##> "/p" + alice <## "user profile: alice (Alice, * supporter)" + alice <## "use /p [] to change it" + alice #> "@bob hi" + bob <# "alice *> hi" + bob ##> "/i alice" + bob <## "contact ID: 2" + bob <## "supporter badge - active" + bob <##. "expires " + bob <## "receiving messages via: localhost" + bob <## "sending messages via: localhost" + bob <## "you've shared main profile with this contact" + bob <## "connection not verified, use /code command to see security code" + bob <## "quantum resistant end-to-end encryption" + bob <## currentChatVRangeInfo + +testRedeemBadgeCodeTwice :: HasCallStack => TestParams -> IO () +testRedeemBadgeCodeTwice ps = + withBadgeService ps $ \clientCfg _ cc -> + withNewTestChatCfg ps clientCfg "alice" aliceProfile $ \alice -> do + code <- issueCode cc BTSupporter 1 + alice ##> ("/_redeem_badge_code 1 " <> codeArg code) + alice <## "badge redeemed" + alice <## "supporter badge - active" + alice <##. "expires " + -- Retyped in another case and without separators, so it normalises to the same code and + -- finds the same stashed keys: a retry the service can recognise as the same signer. + alice ##> ("/_redeem_badge_code 1 " <> map toLower (T.unpack $ badgeCodeText code)) + alice <## "badge already redeemed" + alice ##> "/p" + alice <## "user profile: alice (Alice, * supporter)" + alice <## "use /p [] to change it" + +-- The service answers unknown and malformed alike; the client refuses malformed locally, which +-- is why the two reach the user differently. +testRedeemUnknownCode :: HasCallStack => TestParams -> IO () +testRedeemUnknownCode ps = + withBadgeService ps $ \clientCfg bsLink _ -> + withNewTestChatCfg ps clientCfg "alice" aliceProfile $ \alice -> do + g <- C.newRandom + unknown <- randomBadgeCode g + alice ##> ("/_redeem_badge_code 1 " <> codeArg unknown) + alice <## "cannot redeem badge code: badge service error: code_invalid" + -- a failed check character is refused before anything leaves the device + alice ##> "/_redeem_badge_code 1 SB-00000-00000-00000-00001" + alice <## "cannot redeem badge code: invalid code" + -- sent straight to the service, past the client's own check, the two are one answer + (_, redeemPriv) <- atomically $ C.generateKeyPair g :: IO (C.KeyPair 'C.Ed25519) + redeemDirect alice bsLink redeemPriv (T.unpack $ badgeCodeText unknown) + alice <## "service response: {\"code\":\"code_invalid\",\"type\":\"error\"}" + redeemDirect alice bsLink redeemPriv "SB-00000-00000-00000-00001" + alice <## "service response: {\"code\":\"code_invalid\",\"type\":\"error\"}" + +-- a signed redeemBadgeCode sent as a raw service request, bypassing the client's own checks +redeemDirect :: HasCallStack => TestCC -> String -> C.PrivateKeyEd25519 -> String -> IO () +redeemDirect cc bsLink signPriv code = do + let purchaseKey = B.unpack $ strEncode $ C.publicKey signPriv + signKey = B.unpack $ strEncode (C.StoredPrivateKey signPriv) + req = + "{\"version\":1,\"purchaseKey\":\"" <> purchaseKey + <> "\",\"request\":{\"type\":\"redeemBadgeCode\",\"masterKey\":\"" <> testMasterKeyB64 + <> "\",\"code\":\"" <> code <> "\"}}" + cc ##> ("/_service_request 1 " <> bsLink <> " sign_key=" <> signKey <> " " <> req) + +-- any 32 bytes: these requests never reach signing +testMasterKeyB64 :: String +testMasterKeyB64 = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + +-- BadgeType decodes anything to BTUnknown, so without a check at this boundary an operator +-- typo would issue a code no app can show as a badge. +testIssueRejectsBadArguments :: HasCallStack => TestParams -> IO () +testIssueRejectsBadArguments ps = + withBadgeService ps $ \_ _ cc -> do + let refuses arg = issueRaw cc arg >>= (`shouldSatisfy` isLeft) + refuses "suporter" + refuses "supporter 0" + refuses "supporter 256" + refuses "supporter 1 gratis" + refuses "" + issueRaw cc "supporter 255 paid" >>= (`shouldSatisfy` isRight) + +issueRaw :: ChatController -> String -> IO (Either () ()) +issueRaw cc args = + sendChatCmdStr cc ("//issue " <> args) >>= \case + Right CRCustomChatResponse {} -> pure $ Right () + _ -> pure $ Left () + +-- The guard is the badge on the profile, so it refuses whatever the code is and whichever type it +-- funds; nothing is sent, so the refused code is still redeemable. +testRedeemSecondCode :: HasCallStack => TestParams -> IO () +testRedeemSecondCode ps = + withBadgeService ps $ \clientCfg _ cc -> + withNewTestChatCfg ps clientCfg "alice" aliceProfile $ \alice -> do + supporter <- issueCode cc BTSupporter 1 + legend <- issueCode cc BTLegend 1 + alice ##> ("/_redeem_badge_code 1 " <> codeArg supporter) + alice <## "badge redeemed" + alice <## "supporter badge - active" + alice <##. "expires " + alice ##> ("/_redeem_badge_code 1 " <> codeArg legend) + alice <## "cannot redeem badge code: badge already active" + alice ##> "/p" + showActiveUser alice "alice (Alice, * supporter)" + alice ##> "/create user alisa" + showActiveUser alice "alisa" + alice ##> ("/_redeem_badge_code 2 " <> codeArg legend) + alice <## "badge redeemed" + alice <## "legend badge - active" + alice <##. "expires " + +-- Each profile stashes its own keys, so the second reaches the service as a different signer - +-- rather than being handed the first profile's badge, or colliding in badge_code_redemptions. +testRedeemSameCodeOtherProfile :: HasCallStack => TestParams -> IO () +testRedeemSameCodeOtherProfile ps = + withBadgeService ps $ \clientCfg _ cc -> + withNewTestChatCfg ps clientCfg "alice" aliceProfile $ \alice -> do + code <- issueCode cc BTSupporter 1 + alice ##> ("/_redeem_badge_code 1 " <> codeArg code) + alice <## "badge redeemed" + alice <## "supporter badge - active" + alice <##. "expires " + alice ##> "/create user alisa" + showActiveUser alice "alisa" + alice ##> ("/_redeem_badge_code 2 " <> codeArg code) + alice <## "cannot redeem badge code: badge service error: code_used" + alice ##> "/p" + showActiveUser alice "alisa" + alice ##> "/user alice" + showActiveUser alice "alice (Alice, * supporter)" + +-- Ledger behaviour, driven against the real service - real signing, real rows - through the +-- handler rather than the transport, so the typed response can be asserted. The transport and its +-- purchaseKey guard are covered by the tests above. + +serviceCmd :: HasCallStack => BadgeServiceEnv -> C.PublicKeyEd25519 -> BadgeServiceCommand -> IO BadgeServiceResponse +serviceCmd BadgeServiceEnv {bsIssuerKey, bsController} purchaseKey request = + badgeServiceResponse bsIssuerKey bsController (Just purchaseKey) reqObject + where + reqObject = case J.toJSON BadgeServiceRequest {version = currentBadgeServiceVersion, purchaseKey = Just purchaseKey, request} of + J.Object o -> o + _ -> error "badge service request must encode as an object" + +-- the fields a ledger assertion reads, without the ambiguity of the shared record names +entryOf :: StatementEntry -> (Int, Int, UTCTime) +entryOf StatementEntry {changeMonths, balanceMonths, balanceStartTs} = (changeMonths, balanceMonths, balanceStartTs) + +anchorOf :: StatementEntry -> UTCTime +anchorOf StatementEntry {balanceAnchorTs} = balanceAnchorTs + +entryTag :: StatementEntry -> Text +entryTag StatementEntry {entryType} = case entryType of + SECredit c -> creditTypeTag c + SEDebit d -> debitTypeTag d + +statementOf :: HasCallStack => BadgeServiceResponse -> ([StatementEntry], Maybe Text) +statementOf = \case + BSPBadgeCredential {statement = BadgeStatement {entries, previousEntryId}} -> (entries, previousEntryId) + r -> error $ "expected badgeCredential, got " <> show (J.toJSON r) + +credentialOf :: HasCallStack => BadgeServiceResponse -> Maybe BadgeCredential +credentialOf = \case + BSPBadgeCredential {credential} -> credential + r -> error $ "expected badgeCredential, got " <> show (J.toJSON r) + +-- the next month falls due when the balance start reaches it, which is the last entry's start +nextDue :: [StatementEntry] -> UTCTime +nextDue entries = let (_, _, start) = entryOf (last entries) in start + +newPurchaseKeys :: IO (C.PublicKeyEd25519, BadgeMasterKey) +newPurchaseKeys = do + g <- C.newRandom + (purchaseKey, _) <- atomically $ C.generateKeyPair g :: IO (C.KeyPair 'C.Ed25519) + (purchaseKey,) <$> generateMasterKey g + +assertBalance :: HasCallStack => BadgeServiceEnv -> C.PublicKeyEd25519 -> StatementEntry -> IO BadgeServiceResponse +assertBalance env purchaseKey lastEntry = + serviceCmd env purchaseKey BSCIssueBadge {balance = BadgeBalance {lastEntry}} + +expiryOf :: HasCallStack => BadgeServiceResponse -> Maybe UTCTime +expiryOf r = (\(BadgeCredential _ _ _ BadgeInfo {badgeExpiry}) -> badgeExpiry) <$> credentialOf r + +masterKeyOf :: HasCallStack => BadgeServiceResponse -> Maybe BadgeMasterKey +masterKeyOf r = (\(BadgeCredential _ mk _ _) -> mk) <$> credentialOf r + +-- A three month code credits three months and issues the first; a month later the second is +-- issued, and only then. Asserted against the service's own rows. +testCodeMonthsRenew :: HasCallStack => TestParams -> IO () +testCodeMonthsRenew ps = + withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsClock, bsController = cc} -> do + code <- issueCode cc BTSupporter 3 + (purchaseKey, masterKey) <- newPurchaseKeys + redeemed <- serviceCmd env purchaseKey BSCRedeemBadgeCode {masterKey, code = badgeCodeText code} + let (entries, previousEntryId) = statementOf redeemed + previousEntryId `shouldBe` Nothing + map entryTag entries `shouldBe` ["code", "badge"] + map (\e -> let (c, m, _) = entryOf e in (c, m)) entries `shouldBe` [(3, 3), (-1, 2)] + credentialOf redeemed `shouldSatisfy` isJust + let firstDue = nextDue entries + -- still inside the first month: nothing new is written + r2 <- assertBalance env purchaseKey (last entries) + map entryTag (fst $ statementOf r2) `shouldBe` [] + -- the second month falls due + setClockAt bsClock firstDue + r3 <- assertBalance env purchaseKey (last entries) + let (entries3, prev3) = statementOf r3 + prev3 `shouldBe` Just (entryIdOf $ last entries) + map entryTag entries3 `shouldBe` ["badge"] + map (\e -> let (c, m, _) = entryOf e in (c, m)) entries3 `shouldBe` [(-1, 1)] + credentialOf r3 `shouldSatisfy` isJust + -- a different credential for a different month, not the same signature returned twice + credentialOf r3 `shouldNotBe` credentialOf redeemed + where + entryIdOf StatementEntry {entryId} = entryId + +-- A repeat inside an issued month returns the credential already stored, and writes no row: +-- re-signing the same period would churn the client's credential for nothing. +testRepeatInsideIssuedPeriod :: HasCallStack => TestParams -> IO () +testRepeatInsideIssuedPeriod ps = + withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsController = cc} -> do + code <- issueCode cc BTSupporter 2 + (purchaseKey, masterKey) <- newPurchaseKeys + redeemed <- serviceCmd env purchaseKey BSCRedeemBadgeCode {masterKey, code = badgeCodeText code} + let (entries, _) = statementOf redeemed + repeated <- assertBalance env purchaseKey (last entries) + map entryTag (fst $ statementOf repeated) `shouldBe` [] + credentialOf repeated `shouldBe` credentialOf redeemed + +-- Months that passed unissued are lapsed in one row, and the month now current is issued. +testLapseWhileAway :: HasCallStack => TestParams -> IO () +testLapseWhileAway ps = + withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsClock, bsController = cc} -> do + code <- issueCode cc BTSupporter 6 + (purchaseKey, masterKey) <- newPurchaseKeys + redeemed <- serviceCmd env purchaseKey BSCRedeemBadgeCode {masterKey, code = badgeCodeText code} + let (entries, _) = statementOf redeemed + -- away past the fourth boundary of the run: three months lapse, the fourth is due. Counted + -- from the anchor - adding months to an already clipped due date would miss the boundary. + setClockAt bsClock (addMonths 4 (anchorOf (last entries))) + away <- assertBalance env purchaseKey (last entries) + let (entries', _) = statementOf away + map entryTag entries' `shouldBe` ["lapse", "badge"] + map (\e -> let (c, m, _) = entryOf e in (c, m)) entries' `shouldBe` [(-3, 2), (-1, 1)] + credentialOf away `shouldSatisfy` isJust + +-- Every badge issued in a week expires at the same moment, so the expiry says nothing about when +-- it was bought. On the last month of a balance paidThrough is the period end exactly, so a +-- client-proposed expiry capped the rounding away and put the expiry back on the anniversary. +testLastMonthExpiryRounds :: HasCallStack => TestParams -> IO () +testLastMonthExpiryRounds ps = + withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsClock, bsController = cc} -> do + code <- issueCode cc BTSupporter 2 + (purchaseKey, masterKey) <- newPurchaseKeys + redeemed <- serviceCmd env purchaseKey BSCRedeemBadgeCode {masterKey, code = badgeCodeText code} + let (entries, _) = statementOf redeemed + setClockAt bsClock (nextDue entries) + renewed <- assertBalance env purchaseKey (last entries) + let (entries', _) = statementOf renewed + map entryTag entries' `shouldBe` ["badge"] + -- the last month the balance funds: nothing is left to issue after it + map (\e -> let (c, m, _) = entryOf e in (c, m)) entries' `shouldBe` [(-1, 0)] + expiryOf renewed `shouldBe` Just (endOfMondayAfter (nextDue entries')) + +-- A renewal states nothing, so the credential carries the master key stored with the purchase. +-- The service used to sign whatever key the request supplied, checking only the badge type. +testRenewalSignsWithStoredMasterKey :: HasCallStack => TestParams -> IO () +testRenewalSignsWithStoredMasterKey ps = + withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsClock, bsController = cc} -> do + code <- issueCode cc BTSupporter 2 + (purchaseKey, masterKey) <- newPurchaseKeys + redeemed <- serviceCmd env purchaseKey BSCRedeemBadgeCode {masterKey, code = badgeCodeText code} + masterKeyOf redeemed `shouldBe` Just masterKey + let (entries, _) = statementOf redeemed + setClockAt bsClock (nextDue entries) + renewed <- assertBalance env purchaseKey (last entries) + masterKeyOf renewed `shouldBe` Just masterKey + +-- The replicated columns of a ledger, in order. service_created_at and created_at are left out: +-- the client records when it stored a row, which is not when the service wrote it. +type ReplicatedRow = (Text, Int, Int, UTCTime, Text, Maybe Text) + +ledgerRows :: ChatController -> String -> IO [ReplicatedRow] +ledgerRows ChatController {chatStore} table = + withTransaction chatStore $ \db -> + DB.query_ db . fromString $ + "SELECT entry_uuid, change_months, balance_months, balance_start_ts, balance_badge_type, " + <> "COALESCE(entry_credit_type, entry_debit_type) FROM " + <> table + <> " ORDER BY entry_id" + +-- the two dates the CLI prints for each row +ledgerTimes :: ChatController -> IO [(UTCTime, UTCTime)] +ledgerTimes ChatController {chatStore} = + withTransaction chatStore $ \db -> + DB.query_ db "SELECT service_created_at, balance_start_ts FROM badge_ledger ORDER BY entry_id" + +-- | The client's verdict on each row, in ledger order. The service has no such column: it computes +-- the rows rather than checking what someone else computed. +balanceChecks :: ChatController -> IO [Maybe Bool] +balanceChecks ChatController {chatStore} = + withTransaction chatStore $ \db -> + map (fmap unBI . fromOnly) + <$> DB.query_ db "SELECT balance_checked FROM badge_ledger ORDER BY entry_id" + +-- The client copies the statement verbatim and authors nothing, so after a redemption both sides +-- hold the same rows under the same entry ids. +testClientReplicatesLedger :: HasCallStack => TestParams -> IO () +testClientReplicatesLedger ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + code <- issueCode cc BTSupporter 3 + alice ##> ("/_redeem_badge_code 1 " <> codeArg code) + alice <## "badge redeemed" + alice <## "supporter badge - active" + alice <##. "expires " + serviceLedger <- ledgerRows cc "sx_badge_service_badge_ledger" + clientLedger <- ledgerRows (chatController alice) "badge_ledger" + -- the code credit and the first month, on both sides + map (\(_, ch, m, _, _, t) -> (ch, m, t)) serviceLedger `shouldBe` [(3, 3, Just "code"), (-1, 2, Just "badge")] + clientLedger `shouldBe` serviceLedger + -- and re-ran both operations against them: the credit from the seed, the issue from the credit + checks <- balanceChecks (chatController alice) + checks `shouldBe` [Just True, Just True] + -- redeeming again replays the statement, and must not duplicate a single row + alice ##> ("/_redeem_badge_code 1 " <> codeArg code) + alice <## "badge already redeemed" + clientLedger' <- ledgerRows (chatController alice) "badge_ledger" + clientLedger' `shouldBe` serviceLedger + -- nor a second issuance for the one month issued: the replay names a month already stored + expiries <- issuedExpiries (chatController alice) + length expiries `shouldBe` 1 + -- the CLI lists the rows oldest first, with the dates they carry + times <- ledgerTimes (chatController alice) + alice ##> "/_badge ledger 1 1" + forM_ (zip times [("code", "+3", "3"), ("badge", "-1", "2")]) $ \((createdAt, from), (kind, change, balance)) -> + alice <## (day createdAt <> " " <> kind <> " " <> change <> " -> " <> balance <> ", from " <> day from) + -- and nothing for a purchase that is another profile's + alice ##> "/create user alisa" + showActiveUser alice "alisa" + alice ##> "/_badge ledger 2 1" + alice <## "no ledger entries" + where + day = formatTime defaultTimeLocale "%Y-%m-%d" + +-- the balance start of the last row, which is when the next month falls due +dueAtOf :: [ReplicatedRow] -> UTCTime +dueAtOf rows = let (_, _, _, start, _, _) = last rows in start + +-- | The two clock positions a renewal needs: the request, a day before the shown credential +-- lapses, and the presentation, as it lapses. +renewalMoments :: [ReplicatedRow] -> (UTCTime, UTCTime) +renewalMoments rows = + let expiry = endOfMondayAfter $ dueAtOf rows + in (addUTCTime (-nominalDay) expiry, expiry) + +-- | Real seconds between arming a wake and it firing. Long enough for the arming pass to finish +-- first, since one that overruns does the work itself and the test passes without a wake at all. +badgeWakeMargin :: NominalDiffTime +badgeWakeMargin = 3 + +-- | Stand the clock just short of t and signal once. A sleeping worker cannot see the clock move, +-- so the signal is what makes it re-derive - and, standing short, it arms the wake at t rather +-- than doing the work. Whatever follows is produced by that wake. +armWakeAt :: HasCallStack => TestCC -> TestClock -> UTCTime -> IO () +armWakeAt cc clock t = do + setClockAt clock $ addUTCTime (negate badgeWakeMargin) t + cc ##> "/_app activate" + cc <## "ok" + +issuedExpiries :: ChatController -> IO [UTCTime] +issuedExpiries ChatController {chatStore} = do + rows :: [(UTCTime, Int64)] <- + withTransaction chatStore $ \db -> + DB.query_ db "SELECT expiry, badge_purchase_id FROM badge_issuances ORDER BY period_end" + pure $ map fst rows + +peerBadgeExpiry :: ChatController -> IO (Maybe UTCTime) +peerBadgeExpiry ChatController {chatStore} = do + rows :: [(Maybe UTCTime, Int64)] <- + withTransaction chatStore $ \db -> + DB.query_ db "SELECT badge_expiry, contact_profile_id FROM contact_profiles WHERE badge_proof IS NOT NULL ORDER BY contact_profile_id" + pure $ case rows of + ((t, _) : _) -> t + [] -> Nothing + +setBadgeExpiry :: ChatController -> String -> UTCTime -> IO () +setBadgeExpiry ChatController {chatStore} whichBadge t = + withTransaction chatStore $ \db -> + DB.execute db (fromString $ "UPDATE contact_profiles SET badge_expiry = ? WHERE " <> whichBadge <> " IS NOT NULL") (Only t) + +-- | Copy the newest ledger row as an entry of a type this version does not know: the service is +-- deployed ahead of app releases, so a new type first arrives at clients that predate it. +insertUnknownLedgerEntry :: ChatController -> IO () +insertUnknownLedgerEntry ChatController {chatStore} = + withTransaction chatStore $ \db -> + DB.execute_ db . fromString $ + "INSERT INTO badge_ledger" + <> " (entry_uuid, badge_purchase_id, change_months, balance_months, balance_start_ts, balance_anchor_ts," + <> " balance_badge_type, service_created_at, created_at, entry_type, entry_credit_type," + <> " entry_type_unknown, entry_type_value)" + <> " SELECT 'unknown-entry', badge_purchase_id, 0, balance_months, balance_start_ts, balance_anchor_ts," + <> " balance_badge_type, service_created_at, created_at, 'credit', 'grant', 1, '{\"type\":\"grant\"}'" + <> " FROM badge_ledger ORDER BY entry_id DESC LIMIT 1" + +-- | The month the profile is showing, and the month last issued. They must agree: a profile left +-- on an earlier month shows contacts a badge the ledger has already replaced. +shownAndIssuedExpiry :: ChatController -> IO (Maybe UTCTime, Maybe UTCTime) +shownAndIssuedExpiry ChatController {chatStore} = withTransaction chatStore $ \db -> do + shown :: [(Maybe UTCTime, Int64)] <- + DB.query_ db "SELECT badge_expiry, contact_profile_id FROM contact_profiles WHERE badge_signature IS NOT NULL ORDER BY contact_profile_id" + issued :: [(Maybe UTCTime, Int64)] <- + DB.query_ db "SELECT expiry, badge_purchase_id FROM badge_issuances ORDER BY period_end DESC LIMIT 1" + pure (firstOf shown, firstOf issued) + where + firstOf = \case + ((t, _) : _) -> t + [] -> Nothing + +waitShownIssued :: HasCallStack => ChatController -> IO () +waitShownIssued cc = loop (100 :: Int) + where + -- both absent would compare equal, so the presence of a badge is asserted separately + loop 0 = shownAndIssuedExpiry cc >>= \(shown, issued) -> do + shown `shouldSatisfy` isJust + shown `shouldBe` issued + loop i = + shownAndIssuedExpiry cc >>= \(shown, issued) -> + if isJust shown && shown == issued then pure () else threadDelay 50000 >> loop (i - 1) + +-- The worker acts on its own schedule, so the test waits for the rows rather than for a response. +waitLedgerRows :: HasCallStack => ChatController -> Int -> IO [ReplicatedRow] +waitLedgerRows cc n = loop (100 :: Int) + where + loop 0 = ledgerRows cc "badge_ledger" >>= \rows -> error $ "expected " <> show n <> " ledger rows, got " <> show (length rows) + loop i = do + rows <- ledgerRows cc "badge_ledger" + if length rows >= n then pure rows else threadDelay 50000 >> loop (i - 1) + +-- the badge the profile shows, which the worker clears when the balance has run out +shownBadgeId :: HasCallStack => ChatController -> IO (Maybe Int64) +shownBadgeId ChatController {chatStore} = do + -- two columns rather than one, so the row type needs no backend-specific Only + rows :: [(Maybe Int64, Int64)] <- + withTransaction chatStore $ \db -> + DB.query_ db "SELECT shown_badge_id, user_id FROM users WHERE user_id = 1" + -- not Nothing on an unexpected shape: waiting for Nothing would then pass without reading it + pure $ case rows of + [(i, _)] -> i + _ -> error $ "expected one users row, got " <> show rows + +-- the occurrence the user answered, which silences that alert and no other +ackedEpisode :: HasCallStack => ChatController -> IO (Maybe Text, Maybe Text) +ackedEpisode ChatController {chatStore} = do + rows :: [(Maybe Text, Maybe Text)] <- + withTransaction chatStore $ \db -> + DB.query_ db "SELECT alert_acked_kind, alert_acked_episode FROM badge_purchases" + pure $ case rows of + [r] -> r + _ -> error $ "expected one badge purchase, got " <> show rows + +waitShownBadge :: HasCallStack => ChatController -> Maybe Int64 -> IO () +waitShownBadge cc expected = loop (100 :: Int) + where + loop 0 = shownBadgeId cc >>= \actual -> actual `shouldBe` expected + loop i = + shownBadgeId cc >>= \actual -> + if actual == expected then pure () else threadDelay 50000 >> loop (i - 1) + +redeemFirstBadge :: HasCallStack => TestCC -> BadgeCode -> IO () +redeemFirstBadge alice code = do + alice ##> ("/_redeem_badge_code 1 " <> codeArg code) + alice <## "badge redeemed" + alice <## "supporter badge - active" + alice <##. "expires " + +-- A credential nears its expiry and the badge renews from the worker's own pass, with no command +-- sent by the app: chat activate only signals it, and the work is derived from stored state. +-- The request and the presentation are a day apart, so each renewal takes two passes. +testWorkerRenews :: HasCallStack => TestParams -> IO () +testWorkerRenews ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClock, bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + code <- issueCode cc BTSupporter 3 + redeemFirstBadge alice code + redeemed <- ledgerRows (chatController alice) "badge_ledger" + map (\(_, ch, m, _, _, t) -> (ch, m, t)) redeemed `shouldBe` [(3, 3, Just "code"), (-1, 2, Just "badge")] + -- the credential the profile shows is a day from lapsing, while the app is running + let (requestAt, presentAt) = renewalMoments redeemed + setClockAt bsClock requestAt + alice ##> "/_app activate" + alice <## "ok" + renewed <- waitLedgerRows (chatController alice) 3 + -- the renewal reports itself, no command having asked for it + alice <##. "1: supporter" + map (\(_, ch, m, _, _, t) -> (ch, m, t)) renewed `shouldBe` [(3, 3, Just "code"), (-1, 2, Just "badge"), (-1, 1, Just "badge")] + -- the month is issued but not yet worn: the profile still carries the one it had + (shownEarly, issuedEarly) <- shownAndIssuedExpiry (chatController alice) + shownEarly `shouldNotBe` issuedEarly + -- a day later the held credential lapses and the new month is presented + setClockAt bsClock presentAt + alice ##> "/_app activate" + alice <## "ok" + waitShownIssued (chatController alice) + -- the third month too: the state a renewal leaves must support the next one + let (requestAt2, presentAt2) = renewalMoments renewed + setClockAt bsClock requestAt2 + alice ##> "/_app activate" + alice <## "ok" + alice <##. "1: supporter" + renewed2 <- waitLedgerRows (chatController alice) 4 + map (\(_, ch, m, _, _, t) -> (ch, m, t)) renewed2 + `shouldBe` [(3, 3, Just "code"), (-1, 2, Just "badge"), (-1, 1, Just "badge"), (-1, 0, Just "badge")] + -- the client authored none of them: the service holds exactly the same rows + serviceLedger <- ledgerRows cc "sx_badge_service_badge_ledger" + renewed2 `shouldBe` serviceLedger + -- and the profile shows the month last issued, not an earlier one + setClockAt bsClock presentAt2 + alice ##> "/_app activate" + alice <## "ok" + waitShownIssued (chatController alice) + +-- The request is made by the wake the worker set for itself a day before the credential lapses. +testRequestWakeFires :: HasCallStack => TestParams -> IO () +testRequestWakeFires ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClock, bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + code <- issueCode cc BTSupporter 3 + redeemFirstBadge alice code + redeemed <- ledgerRows (chatController alice) "badge_ledger" + armWakeAt alice bsClock $ fst $ renewalMoments redeemed + -- the arming pass asked for nothing, so what follows cannot be its doing + ledgerRows (chatController alice) "badge_ledger" >>= (`shouldBe` redeemed) + renewed <- waitLedgerRows (chatController alice) 3 + alice <##. "1: supporter" + map (\(_, ch, m, _, _, t) -> (ch, m, t)) renewed + `shouldBe` [(3, 3, Just "code"), (-1, 2, Just "badge"), (-1, 1, Just "badge")] + +-- The presentation is made by the wake at the expiry itself, a day after the request that issued +-- the month it presents. +testPresentWakeFires :: HasCallStack => TestParams -> IO () +testPresentWakeFires ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClock, bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + code <- issueCode cc BTSupporter 3 + redeemFirstBadge alice code + redeemed <- ledgerRows (chatController alice) "badge_ledger" + let (requestAt, presentAt) = renewalMoments redeemed + setClockAt bsClock requestAt + alice ##> "/_app activate" + alice <## "ok" + void $ waitLedgerRows (chatController alice) 3 + alice <##. "1: supporter" + armWakeAt alice bsClock presentAt + -- the arming pass presented nothing: the profile still carries the credential it had + shownAndIssuedExpiry (chatController alice) >>= \(shown, issued) -> shown `shouldNotBe` issued + waitShownIssued (chatController alice) + +-- The newest row being of an unknown type must not stop the renewal: it is stored verbatim and +-- read back to be asserted, rather than leaving the client with no entry to assert at all. +testRenewsAfterUnknownEntry :: HasCallStack => TestParams -> IO () +testRenewsAfterUnknownEntry ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClock, bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + code <- issueCode cc BTSupporter 3 + redeemFirstBadge alice code + rows <- ledgerRows (chatController alice) "badge_ledger" + insertUnknownLedgerEntry (chatController alice) + setClockAt bsClock $ fst $ renewalMoments rows + alice ##> "/_app activate" + alice <## "ok" + renewed <- waitLedgerRows (chatController alice) 4 + alice <##. "1: supporter" + -- the unknown row is asserted, so the service answers with what it does not hold: the whole + -- ledger, which re-stores the two rows already held and adds the month it issued + map (\(_, ch, m, _, _, t) -> (ch, m, t)) renewed + `shouldBe` [(3, 3, Just "code"), (-1, 2, Just "badge"), (0, 2, Just "grant"), (-1, 1, Just "badge")] + -- the issued row follows the badge row in the statement but the unknown one in the ledger, + -- and verifies all the same - the client cannot see that the service dropped a row + checks <- balanceChecks (chatController alice) + checks `shouldBe` [Just True, Just True, Nothing, Just True] + +-- The worker driven by chat start rather than by activate, and the only test where the client is +-- given a lapse row to store: the months that passed while the app was stopped. +testRenewsAfterRestart :: HasCallStack => TestParams -> IO () +testRenewsAfterRestart ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClock, bsClientCfg, bsController = cc} -> do + rows <- withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + code <- issueCode cc BTSupporter 6 + redeemFirstBadge alice code + ledgerRows (chatController alice) "badge_ledger" + -- the credit row's start is the anchor: a grant on a run that has never lapsed moves neither + let (_, _, _, anchor, _, _) = head rows + -- stopped until past the fourth boundary of the run: three months lapse, the fourth is issued + setClockAt bsClock $ addMonths 4 anchor + withTestChatCfg ps bsClientCfg "alice" $ \alice -> do + renewed <- waitLedgerRows (chatController alice) 4 + alice <##. "1: supporter" + map (\(_, ch, m, _, _, t) -> (ch, m, t)) renewed + `shouldBe` [(6, 6, Just "code"), (-1, 5, Just "badge"), (-3, 2, Just "lapse"), (-1, 1, Just "badge")] + -- the lapse row was replicated rather than authored here + serviceLedger <- ledgerRows cc "sx_badge_service_badge_ledger" + renewed `shouldBe` serviceLedger + -- and re-run: the renewal's two rows against the tip the client held, not against a seed + checks <- balanceChecks (chatController alice) + checks `shouldBe` replicate 4 (Just True) + -- a week missed costs only the day between the two steps: one pass does both + waitShownIssued (chatController alice) + +-- When the balance is spent and the last period ends, the badge stops being shown and the profile +-- update reaches contacts - the visible half of "the badge expired". +testWorkerRetiresExpired :: HasCallStack => TestParams -> IO () +testWorkerRetiresExpired ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClock, bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> + withNewTestChatCfg ps bsClientCfg "bob" bobProfile $ \bob -> do + connectUsers alice bob + code <- issueCode cc BTSupporter 1 + redeemFirstBadge alice code + alice #> "@bob hi" + bob <# "alice *> hi" + -- bob sees it before it expires + bob ##> "/i alice" + bob <## "contact ID: 2" + bob <## "supporter badge - active" + bob <##. "expires " + bob <## "receiving messages via: localhost" + bob <## "sending messages via: localhost" + bob <## "you've shared main profile with this contact" + bob <## "connection not verified, use /code command to see security code" + bob <## "quantum resistant end-to-end encryption" + bob <## currentChatVRangeInfo + rows <- ledgerRows (chatController alice) "badge_ledger" + -- the one month it bought has ended and nothing is left to issue + setClockAt bsClock $ dueAtOf rows + alice ##> "/_app activate" + alice <## "ok" + -- support ended, and the state it changed by retiring the badge carries the same alert + alice <##. "badge alert: support_ended " + alice <##. "1: supporter" + alice <##. "badge alert: support_ended " + -- the profile stops showing it locally + waitShownBadge (chatController alice) Nothing + alice ##> "/p" + alice <## "user profile: alice (Alice)" + alice <## "use /p [] to change it" + -- The removal travels as a profile update, which prints nothing when only the badge + -- changed (viewContactUpdated compares names and links). The next message shows it + -- arrived: bob's prefix loses the badge marker it carried above. + alice #> "@bob after" + bob <# "alice> after" + -- and the badge is gone from the contact's stored profile, not merely from the prefix + bob ##> "/i alice" + bob <## "contact ID: 2" + bob <## "receiving messages via: localhost" + bob <## "sending messages via: localhost" + bob <## "you've shared main profile with this contact" + bob <## "connection not verified, use /code command to see security code" + bob <## "quantum resistant end-to-end encryption" + bob <## currentChatVRangeInfo + +-- The credential outlives the balance by up to eight days, because its expiry covers renewal and +-- not entitlement. A worker scheduled only on that expiry would leave the badge worn, and the user +-- unasked to buy again, for the whole of that week - so paidThrough is a wake of its own. +testRetiresWhenEntitlementEnds :: HasCallStack => TestParams -> IO () +testRetiresWhenEntitlementEnds ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClock, bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + code <- issueCode cc BTSupporter 1 + redeemFirstBadge alice code + rows <- ledgerRows (chatController alice) "badge_ledger" + armWakeAt alice bsClock $ dueAtOf rows + -- that pass retired nothing and printed nothing: the badge is still worn and /p says so + alice ##> "/p" + alice <## "user profile: alice (Alice, * supporter)" + alice <## "use /p [] to change it" + -- nothing signals the worker from here, and the credential has days left, so the wake that + -- produces these is the one the worker set for itself at paidThrough + alice <##. "badge alert: support_ended " + alice <##. "1: supporter" + alice <##. "badge alert: support_ended " + waitShownBadge (chatController alice) Nothing + +-- The alert is derived from stored state rather than kept pending, so it is still there after a +-- restart; acknowledging records the occurrence it answered, and the same one is not raised again. +testEndedAlert :: HasCallStack => TestParams -> IO () +testEndedAlert ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClock, bsClientCfg, bsController = cc} -> do + endsAt <- withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + code <- issueCode cc BTSupporter 1 + redeemFirstBadge alice code + rows <- ledgerRows (chatController alice) "badge_ledger" + let endsAt = dueAtOf rows + setClockAt bsClock endsAt + alice ##> "/_app activate" + alice <## "ok" + -- the pass raises the alert, and reports the state it changed by retiring the badge - which + -- carries the same alert, the alert being part of the state + alice <##. "badge alert: support_ended " + alice <##. "1: supporter" + alice <##. "badge alert: support_ended " + pure endsAt + -- nothing was stored as pending, and the alert is derived again on the next start + withTestChatCfg ps bsClientCfg "alice" $ \alice -> do + alice <##. "badge alert: support_ended " + alice ##> ("/_badge ack 1 1 support_ended off " <> T.unpack (safeDecodeUtf8 $ strEncode endsAt)) + alice <##. "1: supporter" + ackedEpisode (chatController alice) `shouldReturn` (Just "support_ended", Just (safeDecodeUtf8 $ strEncode endsAt)) + -- acknowledged: the state no longer carries the alert, and no event raises it again + alice ##> "/_badge state 1" + alice <##. "1: supporter" + alice ##> "/_app activate" + alice <## "ok" + alice ##> "/p" + alice <## "user profile: alice (Alice)" + alice <## "use /p [] to change it" + +-- A worker runs for every profile, not only the one in use, so presenting a renewed badge must not +-- make its profile active - the next message would then be sent from the wrong identity. +testRenewalKeepsActiveProfile :: HasCallStack => TestParams -> IO () +testRenewalKeepsActiveProfile ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClock, bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + code <- issueCode cc BTSupporter 3 + redeemFirstBadge alice code + rows <- ledgerRows (chatController alice) "badge_ledger" + alice ##> "/create user alisa" + showActiveUser alice "alisa" + -- alice's credential is a day from lapsing while alisa is the profile in use + let (requestAt, presentAt) = renewalMoments rows + setClockAt bsClock requestAt + alice ##> "/_app activate" + alice <## "ok" + renewed <- waitLedgerRows (chatController alice) 3 + map (\(_, ch, m, _, _, t) -> (ch, m, t)) renewed + `shouldBe` [(3, 3, Just "code"), (-1, 2, Just "badge"), (-1, 1, Just "badge")] + -- the renewal is reported for alice, and the prefix is there because alice is not active + alice <##. "[user: alice] 1: supporter" + -- presenting is the pass that writes alice's profile, and it is the one that could switch + setClockAt bsClock presentAt + alice ##> "/_app activate" + alice <## "ok" + waitShownIssued (chatController alice) + alice ##> "/p" + showActiveUser alice "alisa" + +-- A snooze silences the alert until it lapses, and then it is raised once more, without a restart. +-- A snooze changes nothing else on the purchase, so the occurrence already raised has to count it. +testSnoozedAlertReturns :: HasCallStack => TestParams -> IO () +testSnoozedAlertReturns ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClock, bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + code <- issueCode cc BTSupporter 1 + redeemFirstBadge alice code + rows <- ledgerRows (chatController alice) "badge_ledger" + let endsAt = dueAtOf rows + setClockAt bsClock endsAt + alice ##> "/_app activate" + alice <## "ok" + alice <##. "badge alert: support_ended " + alice <##. "1: supporter" + alice <##. "badge alert: support_ended " + alice ##> ("/_badge ack 1 1 support_ended on " <> T.unpack (safeDecodeUtf8 $ strEncode endsAt)) + alice <##. "1: supporter" + -- the ack signalled the worker, and the pass it ran was silent: /p prints only its own output + alice ##> "/p" + alice <## "user profile: alice (Alice)" + alice <## "use /p [] to change it" + -- the snooze lapses: nothing else about the badge changed, so only the alert is reported + setClockAt bsClock $ addUTCTime (nominalDay + 60) endsAt + alice ##> "/_app activate" + alice <## "ok" + alice <##. "badge alert: support_ended " + +-- The worker re-reads the profile each pass. Presenting a renewed badge from a copy captured when +-- the worker started would revert any edit made since and broadcast the profile in its old form. +testRenewalKeepsProfileEdits :: HasCallStack => TestParams -> IO () +testRenewalKeepsProfileEdits ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClock, bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> + withNewTestChatCfg ps bsClientCfg "bob" bobProfile $ \bob -> do + connectUsers alice bob + code <- issueCode cc BTSupporter 3 + redeemFirstBadge alice code + -- the profile is edited after the worker started + alice ##> "/p alice Alice Jones" + concurrentlyN_ + [ alice <## "user bio changed to Alice Jones (your 1 contacts are notified)", + bob <## "contact alice updated bio: Alice Jones" + ] + rows <- ledgerRows (chatController alice) "badge_ledger" + let (requestAt, presentAt) = renewalMoments rows + setClockAt bsClock requestAt + alice ##> "/_app activate" + alice <## "ok" + alice <##. "1: supporter" + renewed <- waitLedgerRows (chatController alice) 3 + map (\(_, ch, m, _, _, t) -> (ch, m, t)) renewed + `shouldBe` [(3, 3, Just "code"), (-1, 2, Just "badge"), (-1, 1, Just "badge")] + -- presenting a day later is the pass that broadcasts the profile + setClockAt bsClock presentAt + alice ##> "/_app activate" + alice <## "ok" + waitShownIssued (chatController alice) + -- The renewal's profile update carries the edited bio. Had it carried the profile the + -- worker started with, bob would print a bio change back to "Alice" here, before the + -- message - so the message arriving next is the assertion. + alice #> "@bob after renewal" + bob <# "alice *> after renewal" + bob ##> "/i alice" + bob <## "contact ID: 2" + bob <## "supporter badge - active" + bob <##. "expires " + bob <## "receiving messages via: localhost" + bob <## "sending messages via: localhost" + bob <## "you've shared main profile with this contact" + bob <## "connection not verified, use /code command to see security code" + bob <## "quantum resistant end-to-end encryption" + bob <## currentChatVRangeInfo + +-- Forces the state a crash between the issuance write and the profile write leaves behind: the +-- month is issued, unpresented, and no later pass finds it due. +testPresentationCatchesUp :: HasCallStack => TestParams -> IO () +testPresentationCatchesUp ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClock, bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> + withNewTestChatCfg ps bsClientCfg "bob" bobProfile $ \bob -> do + connectUsers alice bob + code <- issueCode cc BTSupporter 3 + redeemFirstBadge alice code + alice #> "@bob hi" + bob <# "alice *> hi" + rows <- ledgerRows (chatController alice) "badge_ledger" + let (requestAt, presentAt) = renewalMoments rows + setClockAt bsClock requestAt + alice ##> "/_app activate" + alice <## "ok" + alice <##. "1: supporter" + void $ waitLedgerRows (chatController alice) 3 + setClockAt bsClock presentAt + alice ##> "/_app activate" + alice <## "ok" + waitShownIssued (chatController alice) + expiries <- issuedExpiries (chatController alice) + length expiries `shouldBe` 2 + let firstMonth = head expiries + latestMonth = last expiries + -- without this bob can receive the presentation after the updates below and never lose it + waitPeerBadgeExpiry (chatController bob) latestMonth + -- the renewal's rows are kept; only its presentation is undone, on both sides + setBadgeExpiry (chatController alice) "badge_signature" firstMonth + setBadgeExpiry (chatController bob) "badge_proof" firstMonth + alice ##> "/_app activate" + alice <## "ok" + waitPeerBadgeExpiry (chatController bob) latestMonth + (shown, issued) <- shownAndIssuedExpiry (chatController alice) + shown `shouldBe` Just latestMonth + issued `shouldBe` Just latestMonth + alice #> "@bob after repair" + bob <# "alice *> after repair" + +waitPeerBadgeExpiry :: HasCallStack => ChatController -> UTCTime -> IO () +waitPeerBadgeExpiry cc expected = loop (100 :: Int) + where + loop 0 = peerBadgeExpiry cc >>= \actual -> actual `shouldBe` Just expected + loop i = + peerBadgeExpiry cc >>= \actual -> + if actual == Just expected then pure () else threadDelay 50000 >> loop (i - 1) + +testPurchaseKeyMismatch :: HasCallStack => TestParams -> IO () +testPurchaseKeyMismatch ps = + withBadgeService ps $ \clientCfg bsLink _ -> + withNewTestChatCfg ps clientCfg "alice" aliceProfile $ \alice -> do + g <- C.newRandom + (_, signPriv) <- atomically $ C.generateKeyPair g :: IO (C.KeyPair 'C.Ed25519) + (claimedPub, _) <- atomically $ C.generateKeyPair g :: IO (C.KeyPair 'C.Ed25519) + let signKey = B.unpack $ strEncode (C.StoredPrivateKey signPriv) + claimed = B.unpack $ strEncode claimedPub + req = "{\"version\":1,\"purchaseKey\":\"" <> claimed <> "\",\"request\":{\"type\":\"pauseBadge\"}}" + alice ##> ("/_service_request 1 " <> bsLink <> " sign_key=" <> signKey <> " " <> req) + alice <## "service response: {\"code\":\"bad_request\",\"type\":\"error\"}" + +-- A secret that is not the key clients trust at its index makes every credential unverifiable, +-- and each code redeemed against it is spent for good - so the service must not start at all. +testIssuerKeyMustMatchConfig :: HasCallStack => TestParams -> IO () +testIssuerKeyMustMatchConfig ps = do + Right (pk, sk) <- bbsKeyGen + Right (_, otherSk) <- bbsKeyGen + let optsFor sk' = mkBadgeServiceOpts ps sk' + cfg = testCfg {badgePublicKeys = M.singleton testIssuerKeyIdx pk} + checkIssuerKey (optsFor sk) cfg >>= (`shouldSatisfy` isRight) + checkIssuerKey (optsFor otherSk) cfg >>= (`shouldSatisfy` isLeft) + -- an index no client trusts is equally fatal + checkIssuerKey (optsFor sk) testCfg {badgePublicKeys = M.empty} >>= (`shouldSatisfy` isLeft) diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 9c627df90d..74688e9b4f 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -78,6 +78,7 @@ directoryServiceTests = do describe "member admission" $ do it "should require captcha by default for new groups" testCaptchaByDefault it "should require captcha in all groups with --always-captcha" testAlwaysCaptcha + it "should make joining members observers in all groups with --always-observer" testAlwaysObserver it "should require admin review in all groups with --knocking" testKnocking it "should ask member to pass captcha screen" testCapthaScreening it "should send voice captcha on /audio command" testVoiceCaptchaScreening @@ -144,6 +145,7 @@ mkDirectoryOpts TestParams {tmpPath = ps} superUsers ownersGroup webFolder = linkCheckInterval = 0, prohibitedToObserver = False, alwaysCaptcha = False, + alwaysObserver = False, knocking = False, testing = True } @@ -343,7 +345,7 @@ testDeleteGroupAdmin ps = submitGroup bob "security" "Security" bob <# "'SimpleX Directory'> The group security (Security) is already listed in the directory, please choose another name." bob ##> "/d #security" - bob <## "#security: you deleted the group" + bob <## "#security: you deleted the group (signed)" -- admin can delete the group superUser #> "@'SimpleX Directory' /delete 2:security" superUser <# "'SimpleX Directory'> > /delete 2:security" @@ -622,7 +624,7 @@ testInviteOwnerAfterLeavingOwnersGroup ps = superUser <## "#owners: new member bob is connected" -- owner leaves owners' group; GroupMember row keeps status GSMemLeft leaveGroup "owners" bob - superUser <## "#owners: bob left the group" + superUser <## "#owners: bob left the group (signed)" -- owners' group has no GroupReg, so directory service notifies admins on contact left superUser <# "'SimpleX Directory'> Error: contact left, group: 1 owners, group registration not found" -- super-user re-invites via /invite — must send a fresh invitation, not "already a member" @@ -641,7 +643,7 @@ testDelistedOwnerLeaves ps = registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath leaveGroup "privacy" bob - cath <## "#privacy: bob left the group" + cath <## "#privacy: bob left the group (signed)" bob <# "'SimpleX Directory'> You left the group ID 1 (privacy)." bob <## "" bob <## "The group is no longer listed in the directory." @@ -678,7 +680,7 @@ testNotDelistedMemberLeaves ps = registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath leaveGroup "privacy" cath - bob <## "#privacy: cath left the group" + bob <## "#privacy: cath left the group (signed)" (superUser "@'SimpleX Directory_1' privacy" @@ -743,7 +745,7 @@ testNotDelistedOwnerRejoinsViaLink ps = bob ##> "/l privacy_1" bob <## "#privacy_1: you left the group" bob <## "use /d #privacy_1 to delete the group" - bob <## "#privacy: bob_1 left the group" + bob <## "#privacy: bob_1 left the group (signed)" -- the group must remain listed: the leaving member is not the owner member (superUser "/rm #privacy 'SimpleX Directory'" - bob <## "#privacy: you removed 'SimpleX Directory' from the group" - cath <## "#privacy: bob removed 'SimpleX Directory' from the group" + bob <## "#privacy: you removed 'SimpleX Directory' from the group (signed)" + cath <## "#privacy: bob removed 'SimpleX Directory' from the group (signed)" bob <# "'SimpleX Directory'> SimpleX Directory is removed from the group ID 1 (privacy)." bob <## "" bob <## "The group is no longer listed in the directory." @@ -781,11 +783,11 @@ testDelistedGroupDeleted ps = cath <## "contact and member are merged: 'SimpleX Directory', #privacy 'SimpleX Directory_1'" cath <## "use @'SimpleX Directory' to send messages" bob ##> "/d #privacy" - bob <## "#privacy: you deleted the group" + bob <## "#privacy: you deleted the group (signed)" bob <# "'SimpleX Directory'> The group ID 1 (privacy) is deleted." bob <## "" bob <## "The group is no longer listed in the directory." - cath <## "#privacy: bob deleted the group" + cath <## "#privacy: bob deleted the group (signed)" cath <## "use /d #privacy to delete the local copy of the group" superUser <# "'SimpleX Directory'> The group ID 1 (privacy) is de-listed (group is deleted)." groupNotFound cath "privacy" @@ -804,8 +806,8 @@ testDelistedRoleChanges ps = groupFoundN 3 cath "privacy" -- de-listed if service role changed bob ##> "/mr privacy 'SimpleX Directory' member" - bob <## "#privacy: you changed the role of 'SimpleX Directory' to member" - cath <## "#privacy: bob changed the role of 'SimpleX Directory' from admin to member" + bob <## "#privacy: you changed the role of 'SimpleX Directory' to member (signed)" + cath <## "#privacy: bob changed the role of 'SimpleX Directory' from admin to member (signed)" bob <# "'SimpleX Directory'> SimpleX Directory role in the group ID 1 (privacy) is changed to member." bob <## "" bob <## "The group is no longer listed in the directory." @@ -813,8 +815,8 @@ testDelistedRoleChanges ps = groupNotFound cath "privacy" -- re-listed if service role changed back without profile changes cath ##> "/mr privacy 'SimpleX Directory' admin" - cath <## "#privacy: you changed the role of 'SimpleX Directory' to admin" - bob <## "#privacy: cath changed the role of 'SimpleX Directory' from member to admin" + cath <## "#privacy: you changed the role of 'SimpleX Directory' to admin (signed)" + bob <## "#privacy: cath changed the role of 'SimpleX Directory' from member to admin (signed)" bob <# "'SimpleX Directory'> SimpleX Directory role in the group ID 1 (privacy) is changed to admin." bob <## "" bob <## "The group is listed in the directory again." @@ -822,8 +824,8 @@ testDelistedRoleChanges ps = groupFoundN 3 cath "privacy" -- de-listed if owner role changed cath ##> "/mr privacy bob admin" - cath <## "#privacy: you changed the role of bob to admin" - bob <## "#privacy: cath changed your role from owner to admin" + cath <## "#privacy: you changed the role of bob to admin (signed)" + bob <## "#privacy: cath changed your role from owner to admin (signed)" bob <# "'SimpleX Directory'> Your role in the group ID 1 (privacy) is changed to admin." bob <## "" bob <## "The group is no longer listed in the directory." @@ -831,8 +833,8 @@ testDelistedRoleChanges ps = groupNotFound cath "privacy" -- re-listed if owner role changed back without profile changes cath ##> "/mr privacy bob owner" - cath <## "#privacy: you changed the role of bob to owner" - bob <## "#privacy: cath changed your role from admin to owner" + cath <## "#privacy: you changed the role of bob to owner (signed)" + bob <## "#privacy: cath changed your role from admin to owner (signed)" bob <# "'SimpleX Directory'> Your role in the group ID 1 (privacy) is changed to owner." bob <## "" bob <## "The group is listed in the directory again." @@ -852,8 +854,8 @@ testNotDelistedMemberRoleChanged ps = cath <## "use @'SimpleX Directory' to send messages" groupFoundN 3 cath "privacy" bob ##> "/mr privacy cath member" - bob <## "#privacy: you changed the role of cath to member" - cath <## "#privacy: bob changed your role from owner to member" + bob <## "#privacy: you changed the role of cath to member (signed)" + cath <## "#privacy: bob changed your role from owner to member (signed)" groupFoundN 3 cath "privacy" testNotSentApprovalBadRoles :: HasCallStack => TestParams -> IO () @@ -867,13 +869,13 @@ testNotSentApprovalBadRoles ps = groupAccepted bob "privacy" 1 notifySuperUser superUser bob "privacy" "Privacy" 1 bob ##> "/mr privacy 'SimpleX Directory' member" - bob <## "#privacy: you changed the role of 'SimpleX Directory' to member" + bob <## "#privacy: you changed the role of 'SimpleX Directory' to member (signed)" bob ##> "/gp privacy privacy Privacy!" bob <## "description changed to: Privacy!" groupUpdatedHidden superUser bob "privacy" "" bob <# "'SimpleX Directory'> You must grant directory service admin role to register the group" bob ##> "/mr privacy 'SimpleX Directory' admin" - bob <## "#privacy: you changed the role of 'SimpleX Directory' to admin" + bob <## "#privacy: you changed the role of 'SimpleX Directory' to admin (signed)" bob <# "'SimpleX Directory'> SimpleX Directory role in the group ID 1 (privacy) is changed to admin." bob <## "" bob <## "The group is submitted for approval." @@ -893,14 +895,14 @@ testNotApprovedBadRoles ps = groupAccepted bob "privacy" 1 notifySuperUser superUser bob "privacy" "Privacy" 1 bob ##> "/mr privacy 'SimpleX Directory' member" - bob <## "#privacy: you changed the role of 'SimpleX Directory' to member" + bob <## "#privacy: you changed the role of 'SimpleX Directory' to member (signed)" let approve = "/approve 1:privacy 1" superUser #> ("@'SimpleX Directory' " <> approve) superUser <# ("'SimpleX Directory'> > " <> approve) superUser <## " Group is not approved: SimpleX Directory is not an admin." groupNotFound cath "privacy" bob ##> "/mr privacy 'SimpleX Directory' admin" - bob <## "#privacy: you changed the role of 'SimpleX Directory' to admin" + bob <## "#privacy: you changed the role of 'SimpleX Directory' to admin (signed)" bob <# "'SimpleX Directory'> SimpleX Directory role in the group ID 1 (privacy) is changed to admin." bob <## "" bob <## "The group is submitted for approval." @@ -920,7 +922,7 @@ testRegOwnerChangedProfile ps = bob <## "description changed to: Privacy and Security" 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 <## "bob updated group #privacy: (signed)" cath <## "description changed to: Privacy and Security" cath `connectVia` dsLink cath <## "contact and member are merged: 'SimpleX Directory_1', #privacy 'SimpleX Directory'" @@ -943,7 +945,7 @@ testAnotherOwnerChangedProfile ps = cath <## "use @'SimpleX Directory' to send messages" cath ##> "/gp privacy privacy Privacy and Security" cath <## "description changed to: Privacy and Security" - bob <## "cath updated group #privacy:" + bob <## "cath updated group #privacy: (signed)" bob <## "description changed to: Privacy and Security" bob <# "'SimpleX Directory'> The group ID 1 (privacy) is updated by cath!" bob <## "It is hidden from the directory until approved." @@ -964,7 +966,7 @@ testNotConnectedOwnerChangedProfile ps = addCathAsOwner bob cath cath ##> "/gp privacy privacy Privacy and Security" cath <## "description changed to: Privacy and Security" - bob <## "cath updated group #privacy:" + bob <## "cath updated group #privacy: (signed)" bob <## "description changed to: Privacy and Security" bob <# "'SimpleX Directory'> The group ID 1 (privacy) is updated by cath!" bob <## "It is hidden from the directory until approved." @@ -1176,7 +1178,7 @@ testListUserGroups promote ps = -- with de-listed group groupFound cath "anonymity" cath ##> "/mr anonymity 'SimpleX Directory' member" - cath <## "#anonymity: you changed the role of 'SimpleX Directory' to member" + cath <## "#anonymity: you changed the role of 'SimpleX Directory' to member (signed)" cath <# "'SimpleX Directory'> SimpleX Directory role in the group ID 1 (anonymity) is changed to member." cath <## "" cath <## "The group is no longer listed in the directory." @@ -1191,7 +1193,7 @@ testListUserGroups promote ps = checkListings ["privacy", "security"] ["privacy"] bob ##> "/gp privacy privacy" bob <## "description removed" - cath <## "bob updated group #privacy:" + cath <## "bob updated group #privacy: (signed)" cath <## "description removed" groupUpdatedHidden superUser bob "privacy" "" superUser <# "'SimpleX Directory'> bob submitted the group ID 1:" @@ -1258,6 +1260,34 @@ testAlwaysCaptcha ps = bob <## "#privacy: 'SimpleX Directory' added cath (Catherine) to the group (connecting...)" bob <## "#privacy: new member cath is connected" +testAlwaysObserver :: HasCallStack => TestParams -> IO () +testAlwaysObserver ps = + withDirectoryServiceOpts ps (\o -> o {alwaysObserver = True}) $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do + bob `connectVia` dsLink + submitGroup bob "privacy" "Privacy" + groupAccepted bob "privacy" 1 + welcomeWithLink <- completeRegistration superUser bob "privacy" "Privacy" 1 + let groupLink = dropStrPrefix "Link to join the group privacy: " welcomeWithLink + cath ##> ("/c " <> groupLink) + cath <## "connection request sent!" + cath <## "#privacy: joining the group..." + cath <## "#privacy: you joined the group, pending approval" + cath <# "#privacy (support) 'SimpleX Directory'> Captcha is generated by SimpleX Directory service." + cath <## "" + cath <## "Send captcha text to join the group privacy." + captcha <- dropStrPrefix "#privacy (support) 'SimpleX Directory'> " . dropTime <$> getTermLine cath + cath #> ("#privacy (support) " <> captcha) + cath <# ("#privacy (support) 'SimpleX Directory'!> > cath " <> captcha) + cath <## " Correct, you joined the group privacy" + cath <## "#privacy: you joined the group" + cath <## "#privacy: member bob (Bob) is connected" + bob <## "#privacy: 'SimpleX Directory' added cath (Catherine) to the group (connecting...)" + bob <## "#privacy: new member cath is connected" + cath ##> "#privacy hello" + cath <## "#privacy: you don't have permission to send messages" + testKnocking :: HasCallStack => TestParams -> IO () testKnocking ps = withDirectoryServiceOpts ps (\o -> o {knocking = True}) $ \superUser dsLink -> @@ -1342,9 +1372,9 @@ testCapthaScreening ps = cath ##> "/l privacy" cath <## "#privacy: you left the group" cath <## "use /d #privacy to delete the group" - bob <## "#privacy: cath left the group" + bob <## "#privacy: cath left the group (signed)" cath ##> "/d #privacy" - cath <## "#privacy: you deleted the group" + cath <## "#privacy: you deleted your local copy of the group" -- change default role to observer bob #> "@'SimpleX Directory' /role 1 observer" bob <# "'SimpleX Directory'> > /role 1 observer" @@ -1888,7 +1918,7 @@ setWelcomeMessage u others welcome = do u <## "welcome message changed to:" u <## welcome forM_ others $ \m -> do - m <## (uName <> " updated group #privacy:") + m <## (uName <> " updated group #privacy: (signed)") m <## "welcome message changed to:" m <## welcome @@ -1926,8 +1956,8 @@ removeMember gName admin removed = do adminName <- userName admin removedName <- userName removed admin ##> ("/rm " <> gName <> " " <> removedName) - admin <## (gn <> ": you removed " <> removedName <> " from the group") - removed <## (gn <> ": " <> adminName <> " removed you from the group") + admin <## (gn <> ": you removed " <> removedName <> " from the group (signed)") + removed <## (gn <> ": " <> adminName <> " removed you from the group (signed)") removed <## ("use /d " <> gn <> " to delete the group") groupFound :: TestCC -> String -> IO () @@ -2155,7 +2185,7 @@ testDirectoryChannelName ps = withSmpServerAndNames $ \reg -> enableNamesRole bob bob `connectVia` dsLink (shortLink, _fullLink) <- prepareChannel1Relay "news" bob relay - registerName reg newsName (channelNameRecord "news" (T.pack shortLink)) + registerName reg newsName (channelNameRecord "news.simplex" (T.pack shortLink)) bob ##> "/public group access #news domain=news.simplex" bob <## "updated public group access: domain=news.simplex" relay <## "bob updated group #news: (signed)" @@ -2196,13 +2226,13 @@ testDirectoryChannelNameNotVerified ps = withSmpServerAndNames $ \reg -> enableNamesRole bob bob `connectVia` dsLink (shortLink, _fullLink) <- prepareChannel1Relay "news" bob relay - registerName reg newsName (channelNameRecord "news" (T.pack shortLink)) + registerName reg newsName (channelNameRecord "news.simplex" (T.pack shortLink)) bob ##> "/public group access #news domain=news.simplex" bob <## "updated public group access: domain=news.simplex" relay <## "bob updated group #news: (signed)" relay <## "updated public group access: domain=news.simplex" -- the name is re-pointed to a different link after the owner set it - registerName reg newsName (channelNameRecord "news" "https://simplex.chat/other") + registerName reg newsName (channelNameRecord "news.simplex" "https://simplex.chat/other") bob ##> "/share chat #news @'SimpleX Directory'" bob <# "@'SimpleX Directory' link to join channel #news (signed):" _ <- getTermLine bob -- short link diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 4f322c82d9..0a7914d366 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -157,6 +157,7 @@ testCoreOpts = logAgent = Nothing, logFile = Nothing, tbqSize = 16, + maxChats = 5000, deviceName = Nothing, chatRelay = False, webPreviewConfig = Nothing, diff --git a/tests/ChatTests/ChatList.hs b/tests/ChatTests/ChatList.hs index 889915a6e8..07b338c99f 100644 --- a/tests/ChatTests/ChatList.hs +++ b/tests/ChatTests/ChatList.hs @@ -5,11 +5,13 @@ import ChatTests.DBUtils import ChatTests.Utils import Data.Time.Clock (getCurrentTime) import Data.Time.Format.ISO8601 (iso8601Show) +import Simplex.Chat.Options (ChatOpts (..), CoreChatOpts (..)) import Test.Hspec hiding (it) chatListTests :: SpecWith TestParams chatListTests = do it "get last chats" testPaginationLast + it "get last chats with max chats option" testMaxChats it "get chats before/after timestamp" testPaginationTs it "filter by search query" testFilterSearch it "filter favorite" testFilterFavorite @@ -33,6 +35,23 @@ testPaginationLast = alice <# "bob> hey" alice <# "@cath hey" +testMaxChats :: HasCallStack => TestParams -> IO () +testMaxChats = + testChatOpts3 testOpts {coreOptions = testCoreOpts {maxChats = 1}} aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + connectUsers alice bob + alice <##> bob + connectUsers alice cath + cath <##> alice + + alice ##> "/chats all" + alice <# "@cath hey" + alice ##> "/chats 2" + alice <# "bob> hey" + alice <# "@cath hey" + alice #$> ("/_get chats 1 pcc=on", chats, [("@cath", "hey")]) + getChats_ alice "count=2" [("@cath", "hey"), ("@bob", "hey")] + testPaginationTs :: HasCallStack => TestParams -> IO () testPaginationTs = testChat3 aliceProfile bobProfile cathProfile $ diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 732bc465b4..9319f6e711 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -15,18 +15,21 @@ import ChatClient import ChatTests.DBUtils import ChatTests.Utils import Control.Concurrent (threadDelay) -import Control.Concurrent.Async (concurrently_) -import Control.Monad (forM_, void) +import Control.Concurrent.Async (concurrently_, poll) +import Control.Monad (forM_, void, (>=>)) import Data.Aeson (ToJSON) import qualified Data.Aeson as J import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy.Char8 as LB import Data.List (intercalate, stripPrefix) +import qualified Data.Map.Strict as M +import Data.Maybe (isJust, isNothing) import qualified Data.Text as T +import GHC.Conc (ThreadStatus (..), threadStatus) import Simplex.Chat.AppSettings (defaultAppSettings) import qualified Simplex.Chat.AppSettings as AS import Simplex.Chat.Call -import Simplex.Chat.Controller (ChatConfig (..), PresetServers (..)) +import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), PresetServers (..)) import Simplex.Chat.Messages (ChatItemId) import Simplex.Chat.Options import Simplex.Chat.Protocol (supportedChatVRange) @@ -35,7 +38,7 @@ import Simplex.Messaging.Agent.Env.SQLite import Simplex.Messaging.Agent.RetryInterval import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Client (NetworkTimeout (..)) -import Control.Concurrent.STM (atomically) +import Control.Concurrent.STM (atomically, readTVarIO) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String (strEncode) import Simplex.Messaging.Server.Env.STM hiding (subscriptions) @@ -43,6 +46,7 @@ import Simplex.Messaging.Transport import Simplex.Messaging.Util (safeDecodeUtf8) import Simplex.Messaging.Version import System.Directory (copyFile, doesDirectoryExist, doesFileExist) +import System.Mem.Weak (deRefWeak) import Test.Hspec hiding (it) #if defined(dbPostgres) import Database.PostgreSQL.Simple (Only (..)) @@ -101,8 +105,9 @@ chatDirectTests = do it "connect, fully asynchronous (when clients are never simultaneously online)" $ testFullAsyncFast describe "webrtc calls api" $ do it "negotiate call" testNegotiateCall -#if !defined(dbPostgres) describe "maintenance mode" $ do + it "stop chat stops all threads, start chat restarts them" testStopStartChat +#if !defined(dbPostgres) it "start/stop/export/import chat" testMaintenanceMode it "export/import chat with files" testMaintenanceModeWithFiles it "encrypt/decrypt database" testDatabaseEncryption @@ -1358,6 +1363,52 @@ testNegotiateCall = alice <## "call with bob ended" alice #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(1, "outgoing call: ended (00:00)")]) +testStopStartChat :: HasCallStack => TestParams -> IO () +testStopStartChat ps = + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do + connectUsers alice bob + alice #> "@bob hi" + bob <# "alice> hi" + alice #$> ("/_ttl 1 4", id, "ok") + alice ##> "/_set prefs @2 {\"timedMessages\": {\"allow\": \"yes\", \"ttl\": 2}}" + alice <## "you updated preferences for bob:" + alice <## "Disappearing messages: enabled (you allow: yes (2 sec), contact allows: yes)" + bob <## "alice updated preferences for you:" + bob <## "Disappearing messages: enabled (you allow: yes (2 sec), contact allows: yes (2 sec))" + alice #> "@bob hi timed" + bob <# "alice> hi timed" + let ChatController {agentAsync, cleanupManagerAsync, expireCIThreads, timedItemThreads} = chatController alice + Just (a1, Just a2) <- readTVarIO agentAsync + Just cleanupA <- readTVarIO cleanupManagerAsync + [Just expireA] <- M.elems <$> readTVarIO expireCIThreads + [Just timedTId] <- mapM (readTVarIO >=> maybe (pure Nothing) deRefWeak) . M.elems =<< readTVarIO timedItemThreads + alice ##> "/_stop" + alice <## "chat stopped" + forM_ [a1, a2, cleanupA, expireA] $ \a -> isJust <$> poll a `shouldReturn` True + threadDelay 100000 + threadStatus timedTId `shouldReturn` ThreadFinished + isNothing <$> readTVarIO agentAsync `shouldReturn` True + isNothing <$> readTVarIO cleanupManagerAsync `shouldReturn` True + M.null <$> readTVarIO expireCIThreads `shouldReturn` True + M.null <$> readTVarIO timedItemThreads `shouldReturn` True + alice ##> "/_start" + alice <## "chat started" + alice <## "subscribed 1 connections on server localhost" + bob #> "@alice hello" + alice <# "bob> hello" + alice <### ["timed message deleted: hi timed", "timed message deleted: hello"] + bob <### ["timed message deleted: hi timed", "timed message deleted: hello"] + threadDelay 3000000 + alice #$> ("/_get chat @2 count=100", chat, [(1, "chat banner")]) + Just (a1', _) <- readTVarIO agentAsync + (a1' == a1) `shouldBe` False + Just cleanupA' <- readTVarIO cleanupManagerAsync + (cleanupA' == cleanupA) `shouldBe` False + M.keys <$> readTVarIO expireCIThreads `shouldReturn` [1] + where + cfg = testCfg {initialCleanupManagerDelay = 0, cleanupManagerStepDelay = 0, ciExpirationInterval = 500000} + testMaintenanceMode :: HasCallStack => TestParams -> IO () testMaintenanceMode ps = do withNewTestChat ps "bob" bobProfile $ \bob -> do diff --git a/tests/ChatTests/Files.hs b/tests/ChatTests/Files.hs index fb94194561..14851444a7 100644 --- a/tests/ChatTests/Files.hs +++ b/tests/ChatTests/Files.hs @@ -7,19 +7,25 @@ module ChatTests.Files where import ChatClient import ChatTests.DBUtils +import ChatTests.Profiles (addTestBadge, futureDate, issueTestBadge, issueTestBadgeType, testBadgeKeys) import ChatTests.Utils import Control.Concurrent (threadDelay) import Control.Concurrent.Async (concurrently_) import Control.Logger.Simple +import Control.Monad.Except (runExceptT) +import Control.Monad.Reader (runReaderT) import qualified Data.Aeson as J import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy.Char8 as LB import Network.HTTP.Types.URI (urlEncode) +import Data.Time.Clock (addUTCTime, getCurrentTime, nominalDay) +import Simplex.Chat.Badges (BadgeProof, BadgeStatus (..), BadgeType (..), FileSizeLimits (..), ProofPresHeader (..), badgeProof, defaultFileSizeLimits) import Simplex.Chat.Controller (ChatConfig (..)) -import Simplex.Chat.Library.Internal (roundedFDCount) +import Simplex.Chat.Library.Internal (badgeProofStatus, roundedFDCount) import Simplex.Chat.Mobile.File import Simplex.Chat.Options (ChatOpts (..)) import Simplex.FileTransfer.Server.Env (XFTPServerConfig (..), XFTPStoreConfig (..)) +import Simplex.Messaging.Crypto.BBS (BBSPublicKey, bbsKeyGen) import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import Simplex.Messaging.Encoding.String import System.Directory (copyFile, createDirectoryIfMissing, doesFileExist, getFileSize) @@ -63,6 +69,14 @@ chatFileTests = do it "send and receive large standalone file using relative paths" testXFTPStandaloneRelativePaths xit "removes sent file from server" testXFTPStandaloneCancelSnd -- no error shown in tests it "removes received temporary files" testXFTPStandaloneCancelRcv + describe "send larger files with badges" $ do + it "send and receive file with badge proof" testXFTPFileBadgeProof + it "send and receive file with badge proof in group" testXFTPGroupFileBadgeProof + it "file above the limit without badge proof is not accepted" testXFTPFileNoBadgeProof + it "file above the limit the badge allows is not accepted" testXFTPFileBadgeAboveLimit + it "sending file above the limit the badge allows fails" testXFTPSndFileBadgeLimit + it "sending file with a badge expired past the send grace fails" testXFTPSndFileBadgeGrace + it "file proof is rejected under another binding, size or expired badge" testFileBadgeProofStatus runTestMessageWithFile :: HasCallStack => TestParams -> IO () runTestMessageWithFile = testChat2 aliceProfile bobProfile $ \alice bob -> withXFTPServer $ do @@ -750,6 +764,180 @@ testXFTPGroupFileTransfer = dest1 `shouldBe` src dest2 `shouldBe` src +badgeFileCfg :: BBSPublicKey -> ChatConfig +badgeFileCfg pk = badgeFileCfgLimits pk FileSizeLimits {noBadge = 100000, supporter = 300000, legend = 400000} + +badgeFileCfgLimits :: BBSPublicKey -> FileSizeLimits -> ChatConfig +badgeFileCfgLimits pk lims = testCfg {badgePublicKeys = testBadgeKeys pk, fileSizeLimits = lims} + +testXFTPFileBadgeProof :: HasCallStack => TestParams -> IO () +testXFTPFileBadgeProof ps = do + Right (pk, sk) <- bbsKeyGen + testChatCfg2 (badgeFileCfg pk) aliceProfile bobProfile (test sk) ps + where + test sk alice bob = withXFTPServer $ do + connectUsers alice bob + addTestBadge alice =<< issueTestBadge sk futureDate + + alice #> "/f @bob ./tests/fixtures/test.pdf" + alice <## "use /fc 1 to cancel sending" + bob <# "alice *> sends file test.pdf (266.0 KiB / 272376 bytes)" + bob <## "use /fr 1 [/ | ] to receive it" + bob ##> "/fr 1 ./tests/tmp" + concurrentlyN_ + [ alice <## "completed uploading file 1 (test.pdf) for bob", + bob + <### [ "saving file 1 from alice to ./tests/tmp/test.pdf", + "started receiving file 1 (test.pdf) from alice" + ] + ] + bob <## "completed receiving file 1 (test.pdf) from alice" + + src <- B.readFile "./tests/fixtures/test.pdf" + dest <- B.readFile "./tests/tmp/test.pdf" + dest `shouldBe` src + +testXFTPGroupFileBadgeProof :: HasCallStack => TestParams -> IO () +testXFTPGroupFileBadgeProof ps = do + Right (pk, sk) <- bbsKeyGen + testChatCfg3 (badgeFileCfg pk) aliceProfile bobProfile cathProfile (test sk) ps + where + test sk alice bob cath = withXFTPServer $ do + createGroup3 "team" alice bob cath + addTestBadge alice =<< issueTestBadge sk futureDate + + alice #> "/f #team ./tests/fixtures/test.pdf" + alice <## "use /fc 1 to cancel sending" + concurrentlyN_ + [ do + bob <# "#team alice> sends file test.pdf (266.0 KiB / 272376 bytes)" + bob <## "use /fr 1 [/ | ] to receive it", + do + cath <# "#team alice> sends file test.pdf (266.0 KiB / 272376 bytes)" + cath <## "use /fr 1 [/ | ] to receive it" + ] + alice <## "completed uploading file 1 (test.pdf) for #team" + + bob ##> "/fr 1 ./tests/tmp" + bob + <### [ "saving file 1 from alice to ./tests/tmp/test.pdf", + "started receiving file 1 (test.pdf) from alice" + ] + bob <## "completed receiving file 1 (test.pdf) from alice" + + src <- B.readFile "./tests/fixtures/test.pdf" + dest <- B.readFile "./tests/tmp/test.pdf" + dest `shouldBe` src + +testXFTPFileNoBadgeProof :: HasCallStack => TestParams -> IO () +testXFTPFileNoBadgeProof ps = + withNewTestChatCfg ps sndCfg "alice" aliceProfile $ \alice -> + withNewTestChatCfg ps rcvCfg "bob" bobProfile $ \bob -> withXFTPServer $ do + connectUsers alice bob + + alice #> "/f @bob ./tests/fixtures/test.pdf" + alice <## "use /fc 1 to cancel sending" + bob <# "alice> sends file test.pdf (266.0 KiB / 272376 bytes)" + bob <## "file is above the limit of 100000 bytes: sender has no badge" + bob ##> "/fr 1 ./tests/tmp" + concurrentlyN_ + [ bob <## "file size exceeds the limit: test.pdf", + alice <## "completed uploading file 1 (test.pdf) for bob" + ] + where + sndCfg = testCfg {fileSizeLimits = defaultFileSizeLimits {noBadge = 1000000}} + rcvCfg = testCfg {fileSizeLimits = defaultFileSizeLimits {noBadge = 100000}} + +testXFTPFileBadgeAboveLimit :: HasCallStack => TestParams -> IO () +testXFTPFileBadgeAboveLimit ps = do + Right (pk, sk) <- bbsKeyGen + withNewTestChatCfg ps (badgeFileCfg pk) "alice" aliceProfile $ \alice -> + withNewTestChatCfg ps (rcvCfg pk) "bob" bobProfile $ \bob -> withXFTPServer $ do + connectUsers alice bob + addTestBadge alice =<< issueTestBadge sk futureDate + + alice #> "/f @bob ./tests/fixtures/test.pdf" + alice <## "use /fc 1 to cancel sending" + bob <# "alice *> sends file test.pdf (266.0 KiB / 272376 bytes)" + bob <## "file is above the limit of 150000 bytes: above the limit of the sender badge" + bob ##> "/fr 1 ./tests/tmp" + concurrentlyN_ + [ bob <## "file size exceeds the limit: test.pdf", + alice <## "completed uploading file 1 (test.pdf) for bob" + ] + where + rcvCfg pk = badgeFileCfgLimits pk FileSizeLimits {noBadge = 100000, supporter = 150000, legend = 400000} + +testXFTPSndFileBadgeLimit :: HasCallStack => TestParams -> IO () +testXFTPSndFileBadgeLimit ps = do + Right (pk, sk) <- bbsKeyGen + testChatCfg2 (cfg pk) aliceProfile bobProfile (test sk) ps + where + cfg pk = badgeFileCfgLimits pk FileSizeLimits {noBadge = 100000, supporter = 150000, legend = 300000} + test sk alice bob = withXFTPServer $ do + connectUsers alice bob + + addTestBadge alice =<< issueTestBadgeType sk BTSupporter futureDate + alice ##> "/f @bob ./tests/fixtures/test.pdf" + alice <## "file size exceeds the limit: ./tests/fixtures/test.pdf" + + addTestBadge alice =<< issueTestBadgeType sk BTLegend futureDate + alice #> "/f @bob ./tests/fixtures/test.pdf" + alice <## "use /fc 1 to cancel sending" + concurrentlyN_ + [ alice <## "completed uploading file 1 (test.pdf) for bob", + do + bob <# "alice *> sends file test.pdf (266.0 KiB / 272376 bytes)" + bob <## "use /fr 1 [/ | ] to receive it" + ] + +testXFTPSndFileBadgeGrace :: HasCallStack => TestParams -> IO () +testXFTPSndFileBadgeGrace ps = do + Right (pk, sk) <- bbsKeyGen + testChatCfg2 (badgeFileCfg pk) aliceProfile bobProfile (test sk) ps + where + test sk alice bob = withXFTPServer $ do + connectUsers alice bob + now <- getCurrentTime + + addTestBadge alice =<< issueTestBadge sk (addUTCTime (-3 * nominalDay) now) + alice ##> "/f @bob ./tests/fixtures/test.pdf" + alice <## "file size exceeds the limit: ./tests/fixtures/test.pdf" + + addTestBadge alice =<< issueTestBadge sk (addUTCTime (-3600) now) + alice #> "/f @bob ./tests/fixtures/test.pdf" + alice <## "use /fc 1 to cancel sending" + concurrentlyN_ + [ alice <## "completed uploading file 1 (test.pdf) for bob", + do + bob <# "alice *> sends file test.pdf (266.0 KiB / 272376 bytes)" + bob <## "use /fr 1 [/ | ] to receive it" + ] + +testFileBadgeProofStatus :: HasCallStack => TestParams -> IO () +testFileBadgeProofStatus ps = do + Right (pk, sk) <- bbsKeyGen + withNewTestChatCfg ps (badgeFileCfg pk) "alice" aliceProfile $ \alice -> do + now <- getCurrentTime + let ph = PHFileInv {chatBinding = "Dalice-binding", fileSize = 272376} + otherBinding = PHFileInv {chatBinding = "Dbob-binding", fileSize = 272376} + otherSize = PHFileInv {chatBinding = "Dalice-binding", fileSize = 1} + proofFor expiry = do + cred <- issueTestBadge sk expiry + Right badge <- badgeProof pk cred ph + pure badge + statusOf expected badge = do + Right st <- runExceptT (badgeProofStatus expected badge) `runReaderT` chatController alice + pure st + badge <- proofFor futureDate + statusOf (Just ph) badge `shouldReturn` BSActive + statusOf (Just otherBinding) badge `shouldReturn` BSFailed + statusOf (Just otherSize) badge `shouldReturn` BSFailed + -- the receiver has no binding for the sender, so no header can be expected + statusOf Nothing badge `shouldReturn` BSFailed + expired <- proofFor $ addUTCTime (-10 * nominalDay) now + statusOf (Just ph) expired `shouldReturn` BSExpired + testXFTPDeleteUploadedFile :: HasCallStack => TestParams -> IO () testXFTPDeleteUploadedFile = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -1037,11 +1225,11 @@ testProhibitFiles = alice <## "Files and media: off" concurrentlyN_ [ do - bob <## "alice updated group #team:" + bob <## "alice updated group #team: (signed)" bob <## "updated group preferences:" bob <## "Files and media: off", do - cath <## "alice updated group #team:" + cath <## "alice updated group #team: (signed)" cath <## "updated group preferences:" cath <## "Files and media: off" ] diff --git a/tests/ChatTests/Forward.hs b/tests/ChatTests/Forward.hs index 91ad46ba43..1654ce5335 100644 --- a/tests/ChatTests/Forward.hs +++ b/tests/ChatTests/Forward.hs @@ -128,7 +128,7 @@ testForwardChannelLinkRemoved ps = cath ##> "/set links #club off" cath <## "updated group preferences:" cath <## "SimpleX links: off" - dan <## "cath updated group #club:" + dan <## "cath updated group #club: (signed)" dan <## "updated group preferences:" dan <## "SimpleX links: off" alice #> "#team hi" diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 2c01f379ad..66deaae225 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -14,6 +14,7 @@ module ChatTests.Groups where import ChatClient import ChatTests.DBUtils +import ChatTests.Profiles (addTestBadge, futureDate, issueTestBadge, testBadgeKeys) import ChatTests.Utils import Control.Concurrent (threadDelay) import Control.Concurrent.Async (concurrently_) @@ -29,6 +30,7 @@ import Data.Int (Int64) import Data.List (intercalate, isInfixOf, isSuffixOf) import qualified Data.Map.Strict as M import qualified Data.Text as T +import Simplex.Chat.Badges (FileSizeLimits (..)) import Simplex.Chat.Controller (ChatController (ChatController, smpAgent), ChatConfig (..), ChatHooks (..), ChatLogLevel (..), defaultChatHooks) import Simplex.Chat.Library.Internal (uniqueMsgMentions, updatedMentionNames) import Simplex.Chat.Markdown (parseMaybeMarkdownList) @@ -46,6 +48,7 @@ import Simplex.Messaging.Agent.RetryInterval import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Agent.Store.DB (Binary (..)) import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.BBS (bbsKeyGen) import Simplex.Messaging.Crypto.Ratchet (pattern PQEncOff) import Simplex.Messaging.Protocol (MsgFlags (..)) import Simplex.Messaging.Server.Env.STM hiding (subscriptions) @@ -108,6 +111,10 @@ chatGroupTests = do it "send multiple messages (many chat batches)" testSendMultiManyBatches it "shared message body is reused" testSharedMessageBody it "shared batch body is reused" testSharedBatchBody + it "shared batch body reference across binary and json members" testGroupSharedBatchBodyMixedModes + it "shared batch body reused across binary and json members" testSharedBatchBodyMixed + it "all old members group upgrades to current version" testGroupAllOldThenUpgrade + it "member key is generated at the first read of a group created without one" testGroupMemberKeyGenerated describe "async group connections" $ do xit "create and join group when clients go offline" testGroupAsync describe "group links" $ do @@ -185,6 +192,8 @@ chatGroupTests = do describe "group history" $ do it "text messages" testGroupHistory it "history is sent when joining via group link" testGroupHistoryGroupLink + it "file with badge proof is received from history" testGroupHistoryFileBadgeProof + it "file received from member with badge proof is received from history" testGroupHistoryRcvFileBadgeProof it "history is not sent if preference is disabled" testGroupHistoryPreferenceOff it "host's file" testGroupHistoryHostFile it "member's file" testGroupHistoryMemberFile @@ -329,6 +338,7 @@ chatGroupTests = do it "should update channel message sent as member" testChannelOwnerUpdateAsMember it "should delete channel message sent as member" testChannelOwnerDeleteAsMember it "should send and receive file sent as member" testChannelOwnerFileTransferAsMember + it "should send and receive file with badge proof" testChannelFileBadgeProof it "should cancel file sent as member" testChannelOwnerFileCancelAsMember it "should attribute reactions to member" testChannelReactionAttribution it "should recreate deleted item with correct sendAsGroup from update" testChannelUpdateFallbackSendAsGroup @@ -429,9 +439,9 @@ testGroupShared alice bob cath checkMessages = do -- test observer role alice ##> "/mr team bob observer" concurrentlyN_ - [ alice <## "#team: you changed the role of bob to observer", - bob <## "#team: alice changed your role from admin to observer", - cath <## "#team: alice changed the role of bob from admin to observer" + [ alice <## "#team: you changed the role of bob to observer (signed)", + bob <## "#team: alice changed your role from admin to observer (signed)", + cath <## "#team: alice changed the role of bob from admin to observer (signed)" ] bob ##> "#team hello" bob <## "#team: you don't have permission to send messages" @@ -439,9 +449,9 @@ testGroupShared alice bob cath checkMessages = do bob <## "#team: you have insufficient permissions for this action, the required role is admin" 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" + [ alice <## "#team: you changed the role of bob to admin (signed)", + bob <## "#team: alice changed your role from observer to admin (signed)", + cath <## "#team: alice changed the role of bob from observer to admin (signed)" ] -- delete contact alice ##> "/d bob" @@ -548,7 +558,7 @@ testGroupLargeMessage = alice `send` ("/_group_profile #1 {\"displayName\": \"team\", \"fullName\": \"\", \"image\": \"" <> profileImage <> "\", \"groupPreferences\": {\"directMessages\": {\"enable\": \"on\"}, \"history\": {\"enable\": \"on\"}}}") _trimmedCmd1 <- getTermLine alice alice <## "profile image updated" - bob <## "alice updated group #team:" + bob <## "alice updated group #team: (signed)" bob <## "profile image updated" testNewGroupIncognito :: HasCallStack => TestParams -> IO () @@ -760,11 +770,11 @@ testGroup2 = -- remove member cath ##> "/rm club dan" concurrentlyN_ - [ cath <## "#club: you removed dan from the group", - alice <## "#club: cath removed dan from the group", - bob <## "#club: cath removed dan from the group", + [ cath <## "#club: you removed dan from the group (signed)", + alice <## "#club: cath removed dan from the group (signed)", + bob <## "#club: cath removed dan from the group (signed)", do - dan <## "#club: cath removed you from the group" + dan <## "#club: cath removed you from the group (signed)" dan <## "use /d #club to delete the group" ] alice #> "#club hello" @@ -788,7 +798,7 @@ testGroup2 = dan ##> "#club how is it going?" dan <## "bad chat command: not current member" dan ##> "/d #club" - dan <## "#club: you deleted the group" + dan <## "#club: you deleted your local copy of the group" dan <##> alice -- member leaves bob ##> "/l club" @@ -796,8 +806,8 @@ testGroup2 = [ do bob <## "#club: you left the group" bob <## "use /d #club to delete the group", - alice <## "#club: bob left the group", - cath <## "#club: bob left the group" + alice <## "#club: bob left the group (signed)", + cath <## "#club: bob left the group (signed)" ] alice #> "#club hello" concurrently_ @@ -810,7 +820,7 @@ testGroup2 = bob ##> "#club how is it going?" bob <## "bad chat command: not current member" bob ##> "/d #club" - bob <## "#club: you deleted the group" + bob <## "#club: you deleted your local copy of the group" bob <##> alice testGroupDelete :: HasCallStack => TestParams -> IO () @@ -820,22 +830,22 @@ testGroupDelete = createGroup3' "team" alice (bob, GRMember) (cath, GRMember) alice ##> "/d #team" concurrentlyN_ - [ alice <## "#team: you deleted the group", + [ alice <## "#team: you deleted the group (signed)", do - bob <## "#team: alice deleted the group" + bob <## "#team: alice deleted the group (signed)" bob <## "use /d #team to delete the local copy of the group", do - cath <## "#team: alice deleted the group" + cath <## "#team: alice deleted the group (signed)" cath <## "use /d #team to delete the local copy of the group" ] alice ##> "#team hi" alice <## "no group #team" bob ##> "/d #team" - bob <## "#team: you deleted the group" + bob <## "#team: you deleted your local copy of the group" cath ##> "#team hi" cath <## "bad chat command: not current member" cath ##> "/d #team" - cath <## "#team: you deleted the group" + cath <## "#team: you deleted your local copy of the group" alice <##> bob alice <##> cath -- unused group contacts are deleted @@ -877,7 +887,7 @@ testGroupDeleteWhenInvited = bob <## "use /j team to accept" ] bob ##> "/d #team" - bob <## "#team: you deleted the group" + bob <## "#team: you deleted your local copy of the group" -- alice doesn't receive notification that bob deleted group, -- but she can re-add bob alice ##> "/a team bob" @@ -953,15 +963,15 @@ testGroupReAddInvitedChangeRole = (bob <## "#team: you joined the group") bob ##> "/d #team" concurrentlyN_ - [ bob <## "#team: you deleted the group", + [ bob <## "#team: you deleted the group (signed)", do - alice <## "#team: bob deleted the group" + alice <## "#team: bob deleted the group (signed)" alice <## "use /d #team to delete the local copy of the group" ] bob ##> "#team hi" bob <## "no group #team" alice ##> "/d #team" - alice <## "#team: you deleted the group" + alice <## "#team: you deleted your local copy of the group" testGroupDeleteInvitedContact :: HasCallStack => TestParams -> IO () testGroupDeleteInvitedContact = @@ -1066,15 +1076,15 @@ testDeleteGroupMemberProfileKept = -- delete group 1 alice ##> "/d #team" concurrentlyN_ - [ alice <## "#team: you deleted the group", + [ alice <## "#team: you deleted the group (signed)", do - bob <## "#team: alice deleted the group" + bob <## "#team: alice deleted the group (signed)" bob <## "use /d #team to delete the local copy of the group" ] alice ##> "#team hi" alice <## "no group #team" bob ##> "/d #team" - bob <## "#team: you deleted the group" + bob <## "#team: you deleted your local copy of the group" -- group 2 still works alice #> "#club checking connection" bob <# "#club alice> checking connection" @@ -1092,11 +1102,11 @@ testGroupRemoveAdd = -- remove member alice ##> "/rm team bob" concurrentlyN_ - [ alice <## "#team: you removed bob from the group", + [ alice <## "#team: you removed bob from the group (signed)", do - bob <## "#team: alice removed you from the group" + bob <## "#team: alice removed you from the group (signed)" bob <## "use /d #team to delete the group", - cath <## "#team: alice removed bob from the group" + cath <## "#team: alice removed bob from the group (signed)" ] threadDelay 100000 @@ -1154,7 +1164,7 @@ testGroupList = ] -- after deleting invitation bob sees only one group bob ##> "/d #tennis" - bob <## "#tennis: you deleted the group" + bob <## "#tennis: you deleted your local copy of the group" bob ##> "/gs" bob <## "#team (2 members)" @@ -1589,10 +1599,10 @@ testUpdateGroupProfile = alice <## "changed to #my_team" concurrentlyN_ [ do - bob <## "alice updated group #team:" + bob <## "alice updated group #team: (signed)" bob <## "changed to #my_team", do - cath <## "alice updated group #team:" + cath <## "alice updated group #team: (signed)" cath <## "changed to #my_team" ] bob #> "#my_team hi" @@ -1603,20 +1613,20 @@ testUpdateGroupProfile = alice <## "description changed to: My team" concurrentlyN_ [ do - bob <## "alice updated group #my_team:" + bob <## "alice updated group #my_team: (signed)" bob <## "description changed to: My team", do - cath <## "alice updated group #my_team:" + cath <## "alice updated group #my_team: (signed)" cath <## "description changed to: My team" ] alice ##> "/gp my_team my_team My team updated" alice <## "description changed to: My team updated" concurrentlyN_ [ do - bob <## "alice updated group #my_team:" + bob <## "alice updated group #my_team: (signed)" bob <## "description changed to: My team updated", do - cath <## "alice updated group #my_team:" + cath <## "alice updated group #my_team: (signed)" cath <## "description changed to: My team updated" ] @@ -1642,8 +1652,8 @@ testUpdateMemberRole = bob <## "#team: you have insufficient permissions for this action, the required role is admin" alice ##> "/mr team bob admin" concurrently_ - (alice <## "#team: you changed the role of bob to admin") - (bob <## "#team: alice changed your role from member to admin") + (alice <## "#team: you changed the role of bob to admin (signed)") + (bob <## "#team: alice changed your role from member to admin (signed)") bob ##> "/a team cath owner" bob <## "#team: you have insufficient permissions for this action, the required role is owner" addMember "team" bob cath GRMember @@ -1678,7 +1688,7 @@ testOwnerRoleChange = |] cath ##> "/mr #team bob owner" - cath <## "#team: you changed the role of bob to owner" + cath <## "#team: you changed the role of bob to owner (signed)" concurrentlyN_ [ alice <## "error: x.grp.mem.role with insufficient member permissions", bob <## "error: x.grp.mem.role with insufficient member permissions" @@ -1712,7 +1722,7 @@ testGroupDescription = testChat4 aliceProfile bobProfile cathProfile danProfile alice ##> "/set welcome team Welcome to the team!" alice <## "welcome message changed to:" alice <## "Welcome to the team!" - bob <## "alice updated group #team:" + bob <## "alice updated group #team: (signed)" bob <## "welcome message changed to:" bob <## "Welcome to the team!" alice ##> "/group_profile team" @@ -1775,9 +1785,9 @@ testGroupModerate = -- disableFullDeletion3 "team" alice bob cath alice ##> "/mr team cath member" concurrentlyN_ - [ alice <## "#team: you changed the role of cath to member", - bob <## "#team: alice changed the role of cath from admin to member", - cath <## "#team: alice changed your role from admin to member" + [ alice <## "#team: you changed the role of cath to member (signed)", + bob <## "#team: alice changed the role of cath from admin to member (signed)", + cath <## "#team: alice changed your role from admin to member (signed)" ] alice #> "#team hello" concurrently_ @@ -1858,20 +1868,20 @@ testGroupModerateFullDelete = -- disableFullDeletion3 "team" alice bob cath alice ##> "/mr team cath member" concurrentlyN_ - [ alice <## "#team: you changed the role of cath to member", - bob <## "#team: alice changed the role of cath from admin to member", - cath <## "#team: alice changed your role from admin to member" + [ alice <## "#team: you changed the role of cath to member (signed)", + bob <## "#team: alice changed the role of cath from admin to member (signed)", + cath <## "#team: alice changed your role from admin to member (signed)" ] alice ##> "/set delete #team on" alice <## "updated group preferences:" alice <## "Full deletion: on" concurrentlyN_ [ do - bob <## "alice updated group #team:" + bob <## "alice updated group #team: (signed)" bob <## "updated group preferences:" bob <## "Full deletion: on", do - cath <## "alice updated group #team:" + cath <## "alice updated group #team: (signed)" cath <## "updated group preferences:" cath <## "Full deletion: on" ] @@ -1965,13 +1975,13 @@ testGroupDelayedModerationFullDelete ps = do alice ##> "/set delete #team on" alice <## "updated group preferences:" alice <## "Full deletion: on" - cath <## "alice updated group #team:" + cath <## "alice updated group #team: (signed)" cath <## "updated group preferences:" cath <## "Full deletion: on" withTestChatCfg ps cfg "bob" $ \bob -> do bob <## "subscribed 2 connections on server localhost" bob <## "#team: alice added cath (Catherine) to the group (connecting...)" - bob <## "alice updated group #team:" + bob <## "alice updated group #team: (signed)" bob <## "updated group preferences:" bob <## "Full deletion: on" withTestChatCfg ps cfg "cath" $ \cath -> do @@ -2002,11 +2012,11 @@ testDeleteMemberWithMessages = threadDelay 750000 concurrentlyN_ [ do - bob <## "alice updated group #team:" + bob <## "alice updated group #team: (signed)" bob <## "updated group preferences:" bob <## "Full deletion: on", do - cath <## "alice updated group #team:" + cath <## "alice updated group #team: (signed)" cath <## "updated group preferences:" cath <## "Full deletion: on" ] @@ -2053,19 +2063,19 @@ testDeleteMemberWithMessages = threadDelay 1000000 alice ##> "/rm #team bob messages=on" - alice <## "#team: you removed bob from the group with all messages" - bob <## "#team: alice removed you from the group with all messages" + alice <## "#team: you removed bob from the group with all messages (signed)" + bob <## "#team: alice removed you from the group with all messages (signed)" bob <## "use /d #team to delete the group" - cath <## "#team: alice removed bob from the group with all messages" + cath <## "#team: alice removed bob from the group with all messages (signed)" doesFileExist "./tests/tmp/alice_app_files/test.jpg" `shouldReturn` False doesFileExist "./tests/tmp/bob_app_files/test.jpg" `shouldReturn` False doesFileExist "./tests/tmp/cath_app_files/test.jpg" `shouldReturn` False -- Under fullDelete, bob's items are physically deleted on all sides; only the system event remains. - alice #$> ("/_get chat #1 count=1", chat, [(1, "removed bob")]) - bob #$> ("/_get chat #1 count=1", chat, [(0, "removed you")]) - cath #$> ("/_get chat #1 count=1", chat, [(0, "removed bob")]) + alice #$> ("/_get chat #1 count=1", chat, [(1, "removed bob (signed)")]) + bob #$> ("/_get chat #1 count=1", chat, [(0, "removed you (signed)")]) + cath #$> ("/_get chat #1 count=1", chat, [(0, "removed bob (signed)")]) testDeleteMemberMarkMessagesDeleted :: HasCallStack => TestParams -> IO () testDeleteMemberMarkMessagesDeleted = @@ -2082,13 +2092,13 @@ testDeleteMemberMarkMessagesDeleted = cath #$> ("/_get chat #1 count=1", chat, [(0, "hello")]) threadDelay 1000000 alice ##> "/rm #team bob messages=on" - alice <## "#team: you removed bob from the group with all messages" - bob <## "#team: alice removed you from the group with all messages" + alice <## "#team: you removed bob from the group with all messages (signed)" + bob <## "#team: alice removed you from the group with all messages (signed)" bob <## "use /d #team to delete the group" - cath <## "#team: alice removed bob from the group with all messages" - alice #$> ("/_get chat #1 count=2", chat, [(0, "hello [marked deleted by you]"), (1, "removed bob")]) - bob #$> ("/_get chat #1 count=2", chat, [(1, "hello [marked deleted by alice]"), (0, "removed you")]) - cath #$> ("/_get chat #1 count=2", chat, [(0, "hello [marked deleted by alice]"), (0, "removed bob")]) + cath <## "#team: alice removed bob from the group with all messages (signed)" + alice #$> ("/_get chat #1 count=2", chat, [(0, "hello [marked deleted by you]"), (1, "removed bob (signed)")]) + bob #$> ("/_get chat #1 count=2", chat, [(1, "hello [marked deleted by alice]"), (0, "removed you (signed)")]) + cath #$> ("/_get chat #1 count=2", chat, [(0, "hello [marked deleted by alice]"), (0, "removed bob (signed)")]) testDeleteMemberMessagesLeftRemoved :: HasCallStack => TestParams -> IO () testDeleteMemberMessagesLeftRemoved = @@ -2115,33 +2125,33 @@ testDeleteMemberMessagesLeftRemoved = [ do cath <## "#team: you left the group" cath <## "use /d #team to delete the group", - alice <## "#team: cath left the group", - bob <## "#team: cath left the group", - dan <## "#team: cath left the group" + alice <## "#team: cath left the group (signed)", + bob <## "#team: cath left the group (signed)", + dan <## "#team: cath left the group (signed)" ] threadDelay 1000000 alice ##> "/rm team dan" concurrentlyN_ - [ alice <## "#team: you removed dan from the group", + [ alice <## "#team: you removed dan from the group (signed)", do - dan <## "#team: alice removed you from the group" + dan <## "#team: alice removed you from the group (signed)" dan <## "use /d #team to delete the group", - bob <## "#team: alice removed dan from the group" + bob <## "#team: alice removed dan from the group (signed)" ] alice ##> "/rm #team cath messages=on" - alice <## "#team: you removed cath from the group with all messages" - bob <## "#team: alice removed cath from the group with all messages" + alice <## "#team: you removed cath from the group with all messages (signed)" + bob <## "#team: alice removed cath from the group with all messages (signed)" alice ##> "/rm #team dan messages=on" - alice <## "#team: you removed dan from the group with all messages" - bob <## "#team: alice removed dan from the group with all messages" + alice <## "#team: you removed dan from the group with all messages (signed)" + bob <## "#team: alice removed dan from the group with all messages (signed)" - alice #$> ("/_get chat #1 count=4", chat, [(0, "1 [marked deleted by you]"), (0, "2 [marked deleted by you]"), (0, "left [marked deleted by you]"), (1, "removed dan")]) - bob #$> ("/_get chat #1 count=4", chat, [(0, "1 [marked deleted by alice]"), (0, "2 [marked deleted by alice]"), (0, "left [marked deleted by alice]"), (0, "removed dan")]) - cath #$> ("/_get chat #1 count=3", chat, [(1, "1"), (0, "2"), (1, "left")]) - dan #$> ("/_get chat #1 count=4", chat, [(0, "1"), (1, "2"), (0, "left"), (0, "removed you")]) + alice #$> ("/_get chat #1 count=4", chat, [(0, "1 [marked deleted by you]"), (0, "2 [marked deleted by you]"), (0, "left (signed) [marked deleted by you]"), (1, "removed dan (signed)")]) + bob #$> ("/_get chat #1 count=4", chat, [(0, "1 [marked deleted by alice]"), (0, "2 [marked deleted by alice]"), (0, "left (signed) [marked deleted by alice]"), (0, "removed dan (signed)")]) + cath #$> ("/_get chat #1 count=3", chat, [(1, "1"), (0, "2"), (1, "left (signed)")]) + dan #$> ("/_get chat #1 count=4", chat, [(0, "1"), (1, "2"), (0, "left (signed)"), (0, "removed you (signed)")]) testSendMulti :: HasCallStack => TestParams -> IO () testSendMulti = @@ -2166,10 +2176,10 @@ testSendMultiTimed = alice ##> "/set disappear #team on 1" alice <## "updated group preferences:" alice <## "Disappearing messages: on (1 sec)" - bob <## "alice updated group #team:" + bob <## "alice updated group #team: (signed)" bob <## "updated group preferences:" bob <## "Disappearing messages: on (1 sec)" - cath <## "alice updated group #team:" + cath <## "alice updated group #team: (signed)" cath <## "updated group preferences:" cath <## "Disappearing messages: on (1 sec)" @@ -2322,6 +2332,323 @@ testSharedBatchBody ps = } } +testGroupSharedBatchBodyMixedModes :: HasCallStack => TestParams -> IO () +testGroupSharedBatchBodyMixedModes ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do + withNewTestChatCfg ps oldCfg "dan" danProfile $ \dan -> + withNewTestChatCfg ps oldCfg "eve" eveProfile $ \eve -> do + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + alice <## "bob (Bob): accepting request to join group #team..." + concurrentlyN_ + [ alice <## "#team: bob joined the group", + do + bob <## "#team: joining the group..." + bob <## "#team: you joined the group" + ] + cath ##> ("/c " <> gLink) + cath <## "connection request sent!" + concurrentlyN_ + [ do + alice <## "cath (Catherine): accepting request to join group #team..." + alice <## "#team: cath joined the group", + cath + <### [ "#team: joining the group...", + "#team: you joined the group", + "#team: member bob (Bob) is connected" + ], + bob + <### [ "#team: alice added cath (Catherine) to the group (connecting...)", + "#team: new member cath is connected" + ] + ] + dan ##> ("/c " <> gLink) + dan <## "connection request sent!" + concurrentlyN_ + [ do + alice <## "dan (Daniel): accepting request to join group #team..." + alice <## "#team: dan joined the group", + dan + <### [ "#team: joining the group...", + "#team: you joined the group", + "#team: member bob (Bob) is connected", + "#team: member cath (Catherine) is connected" + ], + bob + <### [ "#team: alice added dan (Daniel) to the group (connecting...)", + "#team: new member dan is connected" + ], + cath + <### [ "#team: alice added dan (Daniel) to the group (connecting...)", + "#team: new member dan is connected" + ] + ] + eve ##> ("/c " <> gLink) + eve <## "connection request sent!" + concurrentlyN_ + [ do + alice <## "eve (Eve): accepting request to join group #team..." + alice <## "#team: eve joined the group", + eve + <### [ "#team: joining the group...", + "#team: you joined the group", + "#team: member bob (Bob) is connected", + "#team: member cath (Catherine) is connected", + "#team: member dan (Daniel) is connected" + ], + bob + <### [ "#team: alice added eve (Eve) to the group (connecting...)", + "#team: new member eve is connected" + ], + cath + <### [ "#team: alice added eve (Eve) to the group (connecting...)", + "#team: new member eve is connected" + ], + dan + <### [ "#team: alice added eve (Eve) to the group (connecting...)", + "#team: new member eve is connected" + ] + ] + cath ##> "/p kate" + cath <## "user profile is changed to kate (your 0 contacts are notified)" + cath #> "#team hi" + alice <# "#team kate> hi" + bob <# "#team kate> hi" + dan <# "#team kate> hi" + eve <# "#team kate> hi" + alice ##> "/_get chat #1 count=100" + ra <- chat <$> getTermLine alice + ra `shouldContain` [(0, "updated profile (signed)")] + bob ##> "/_get chat #1 count=100" + rb <- chat <$> getTermLine bob + rb `shouldContain` [(0, "updated profile (signed)")] + dan ##> "/_get chat #1 count=100" + rd <- chat <$> getTermLine dan + rd `shouldContain` [(0, "updated profile")] + eve ##> "/_get chat #1 count=100" + re <- chat <$> getTermLine eve + re `shouldContain` [(0, "updated profile")] + dan #> "#team hello from dan" + [alice, bob, cath, eve] *<# "#team dan> hello from dan" + alice ##> "/mr team dan admin" + concurrentlyN_ + [ alice <## "#team: you changed the role of dan to admin (signed)", + dan <## "#team: alice changed your role from member to admin", + bob <## "#team: alice changed the role of dan from member to admin (signed)", + cath <## "#team: alice changed the role of dan from member to admin (signed)", + eve <## "#team: alice changed the role of dan from member to admin" + ] + withTestChat ps "dan" $ \dan -> + withTestChat ps "eve" $ \eve -> do + dan <## "subscribed 4 connections on server localhost" + eve <## "subscribed 4 connections on server localhost" + dan #> "#team d1" + [alice, bob, cath, eve] *<# "#team dan> d1" + dan #> "#team d2" + [alice, bob, cath, eve] *<# "#team dan> d2" + eve #> "#team e1" + [alice, bob, cath, dan] *<# "#team eve> e1" + eve #> "#team e2" + [alice, bob, cath, dan] *<# "#team eve> e2" + dan ##> "/p dan2" + dan <## "user profile is changed to dan2 (your 0 contacts are notified)" + dan #> "#team d3" + [alice, bob, cath, eve] *<# "#team dan2> d3" + eve ##> "/_get chat #1 count=100" + re2 <- chat <$> getTermLine eve + re2 `shouldContain` [(0, "updated profile (signed)")] + where + oldCfg = testCfg {chatVRange = mkVersionRange (VersionChat 9) (VersionChat 17)} + +testSharedBatchBodyMixed :: HasCallStack => TestParams -> IO () +testSharedBatchBodyMixed ps = + withNewTestChatOpts ps opts' "alice" aliceProfile $ \alice -> do + withSmpServer' serverCfg' $ + withNewTestChatOpts ps opts' "bob" bobProfile $ \bob -> + withNewTestChatOpts ps opts' "cath" cathProfile $ \cath -> + withNewTestChatCfgOpts ps oldCfg opts' "dan" danProfile $ \dan -> + withNewTestChatCfgOpts ps oldCfg opts' "eve" eveProfile $ \eve -> do + createGroup4 "team" alice (bob, GRMember) (cath, GRMember) (dan, GRMember) + connectUsers alice eve + addMember "team" alice eve GRMember + eve ##> "/j team" + concurrentlyN_ + [ alice <## "#team: eve joined the group", + do + eve <## "#team: you joined the group" + eve + <### [ "#team: member bob (Bob) is connected", + "#team: member cath (Catherine) is connected", + "#team: member dan (Daniel) is connected" + ], + do + bob <## "#team: alice added eve (Eve) to the group (connecting...)" + bob <## "#team: new member eve is connected", + do + cath <## "#team: alice added eve (Eve) to the group (connecting...)" + cath <## "#team: new member eve is connected", + do + dan <## "#team: alice added eve (Eve) to the group (connecting...)" + dan <## "#team: new member eve is connected" + ] + alice <##. "disconnected " + let cm i = "{\"msgContent\": {\"type\": \"text\", \"text\": \"message " <> show i <> "\"}}" + cms = intercalate ", " (map cm [1 .. 300 :: Int]) + alice `send` ("/_send #1 json [" <> cms <> "]") + _ <- getTermLine alice + alice <## "300 messages sent" + checkMsgBodyCount alice 6 + withSmpServer' serverCfg' $ + withTestChatOpts ps opts' "bob" $ \bob -> + withTestChatOpts ps opts' "cath" $ \cath -> + withTestChatCfgOpts ps oldCfg opts' "dan" $ \dan -> + withTestChatCfgOpts ps oldCfg opts' "eve" $ \eve -> do + concurrentlyN_ + [ alice <##. "subscribed ", + bob <##. "subscribed ", + cath <##. "subscribed ", + dan <##. "subscribed ", + eve <##. "subscribed " + ] + forM_ [(1 :: Int) .. 300] $ \i -> + [bob, cath, dan, eve] *<# ("#team alice> message " <> show i) + checkMsgBodyCount alice 0 + alice <##. "disconnected " + where + oldCfg = testCfg {chatVRange = mkVersionRange (VersionChat 9) (VersionChat 17)} + tmp = tmpPath ps + serverCfg' = + smpServerCfg + { transports = [("7003", transport @TLS, False)], + serverStoreCfg = persistentServerStoreCfg tmp + } + opts' = + testOpts + { coreOptions = + testCoreOpts + { smpServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7003"] + } + } + +testGroupAllOldThenUpgrade :: HasCallStack => TestParams -> IO () +testGroupAllOldThenUpgrade ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChatCfg ps oldCfg "bob" bobProfile $ \bob -> + withNewTestChatCfg ps oldCfg "cath" cathProfile $ \cath -> do + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + alice <## "bob (Bob): accepting request to join group #team..." + concurrentlyN_ + [ alice <## "#team: bob joined the group", + do + bob <## "#team: joining the group..." + bob <## "#team: you joined the group" + ] + cath ##> ("/c " <> gLink) + cath <## "connection request sent!" + concurrentlyN_ + [ do + alice <## "cath (Catherine): accepting request to join group #team..." + alice <## "#team: cath joined the group", + cath + <### [ "#team: joining the group...", + "#team: you joined the group", + "#team: member bob (Bob) is connected" + ], + bob + <### [ "#team: alice added cath (Catherine) to the group (connecting...)", + "#team: new member cath is connected" + ] + ] + alice #> "#team hi1" + [bob, cath] *<# "#team alice> hi1" + withTestChat ps "bob" $ \bob -> + withTestChat ps "cath" $ \cath -> do + bob <##. "subscribed " + cath <##. "subscribed " + bob #> "#team b1" + [alice, cath] *<# "#team bob> b1" + cath #> "#team c1" + [alice, bob] *<# "#team cath> c1" + alice ##> "/p alisa" + alice <## "user profile is changed to alisa (your 0 contacts are notified)" + alice #> "#team hi2" + [bob, cath] *<# "#team alisa> hi2" + bob ##> "/_get chat #1 count=100" + rb <- chat <$> getTermLine bob + rb `shouldContain` [(0, "updated profile (signed)")] + cath ##> "/_get chat #1 count=100" + rc <- chat <$> getTermLine cath + rc `shouldContain` [(0, "updated profile (signed)")] + where + oldCfg = testCfg {chatVRange = mkVersionRange (VersionChat 9) (VersionChat 17)} + +testGroupMemberKeyGenerated :: HasCallStack => TestParams -> IO () +testGroupMemberKeyGenerated = + testChat2 aliceProfile bobProfile $ \alice bob -> do + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + alice <## "bob (Bob): accepting request to join group #team..." + concurrentlyN_ + [ alice <## "#team: bob joined the group", + do + bob <## "#team: joining the group..." + bob <## "#team: you joined the group" + ] + alice #> "#team hi0" + bob <# "#team alice> hi0" + void $ withCCTransaction alice $ \db -> do + DB.execute_ db "UPDATE groups SET member_priv_key = NULL" + DB.execute_ db "UPDATE group_members SET member_pub_key = NULL WHERE member_category = 'user'" + void $ withCCTransaction bob $ \db -> + DB.execute_ db "UPDATE group_members SET member_pub_key = NULL WHERE member_category = 'host'" + alice ##> "/p alisa" + alice <## "user profile is changed to alisa (your 0 contacts are notified)" + alice #> "#team hi1" + bob <# "#team alisa> hi1" + bob ##> "/_get chat #1 count=100" + r <- chat <$> getTermLine bob + r `shouldContain` [(0, "updated profile (signed, no key to verify)")] + privKey1 <- alicePrivKey alice + pubKey1 <- alicePubKey alice + bobKnownKey <- withCCTransaction bob $ \db -> + DB.query_ db "SELECT member_pub_key FROM group_members WHERE member_category = 'host'" :: IO [Only (Maybe C.PublicKeyEd25519)] + (C.publicKey <$> privKey1) `shouldBe` pubKey1 + bobKnownKey `shouldBe` [Only pubKey1] + alice ##> "/p alisa2" + alice <## "user profile is changed to alisa2 (your 0 contacts are notified)" + alice #> "#team hi2" + bob <# "#team alisa2> hi2" + bob ##> "/_get chat #1 count=100" + r' <- chat <$> getTermLine bob + r' `shouldContain` [(0, "updated profile (signed)")] + privKey2 <- alicePrivKey alice + privKey2 `shouldBe` privKey1 + where + alicePrivKey alice = do + [Only k] <- withCCTransaction alice $ \db -> DB.query_ db "SELECT member_priv_key FROM groups" :: IO [Only (Maybe C.PrivateKeyEd25519)] + pure k + alicePubKey alice = do + [Only k] <- withCCTransaction alice $ \db -> DB.query_ db "SELECT member_pub_key FROM group_members WHERE member_category = 'user'" :: IO [Only (Maybe C.PublicKeyEd25519)] + pure k + testGroupAsync :: HasCallStack => TestParams -> IO () testGroupAsync ps = do withNewTestChat ps "alice" aliceProfile $ \alice -> do @@ -2472,10 +2799,10 @@ testGroupLinkDeleteGroupRejoin = [ do bob <## "#team: you left the group" bob <## "use /d #team to delete the group", - alice <## "#team: bob left the group" + alice <## "#team: bob left the group (signed)" ] bob ##> "/d #team" - bob <## "#team: you deleted the group" + bob <## "#team: you deleted your local copy of the group" -- re-join via same link bob ##> ("/c " <> gLink) bob <## "connection request sent!" @@ -2666,7 +2993,7 @@ testPlanGroupLinkLeaveRejoin = [ do bob <## "#team: you left the group" bob <## "use /d #team to delete the group", - alice <## "#team: bob left the group" + alice <## "#team: bob left the group (signed)" ] threadDelay 100000 @@ -2795,8 +3122,8 @@ testGroupLink = [ do alice <## "#team: you left the group" alice <## "use /d #team to delete the group", - bob <## "#team: alice left the group", - cath <## "#team: alice left the group" + bob <## "#team: alice left the group (signed)", + cath <## "#team: alice left the group (signed)" ] alice ##> "/show link #team" alice <## "no group link, to create: /create link #team" @@ -2805,7 +3132,7 @@ testGroupLink = alice ##> "/contacts" alice <## "cath (Catherine)" alice ##> "/d #team" - alice <## "#team: you deleted the group" + alice <## "#team: you deleted your local copy of the group" alice ##> "/contacts" alice <## "cath (Catherine)" @@ -3010,8 +3337,8 @@ testGroupLinkMemberRole = bob <## "#team: you don't have permission to send messages" alice ##> "/mr #team bob member" - alice <## "#team: you changed the role of bob to member" - bob <## "#team: alice changed your role from observer to member" + alice <## "#team: you changed the role of bob to member (signed)" + bob <## "#team: alice changed your role from observer to member (signed)" bob #> "#team hey now" alice <# "#team bob> hey now" @@ -3040,18 +3367,18 @@ testGroupLinkMemberRole = cath <## "#team: you don't have permission to send messages" alice ##> "/mr #team cath admin" - alice <## "#team: you changed the role of cath to admin" - cath <## "#team: alice changed your role from observer to admin" - bob <## "#team: alice changed the role of cath from observer to admin" + alice <## "#team: you changed the role of cath to admin (signed)" + cath <## "#team: alice changed your role from observer to admin (signed)" + bob <## "#team: alice changed the role of cath from observer to admin (signed)" cath #> "#team hey" alice <# "#team cath> hey" bob <# "#team cath> hey" cath ##> "/mr #team bob admin" - cath <## "#team: you changed the role of bob to admin" - bob <## "#team: cath changed your role from member to admin" - alice <## "#team: cath changed the role of bob from member to admin" + cath <## "#team: you changed the role of bob to admin (signed)" + bob <## "#team: cath changed your role from member to admin (signed)" + alice <## "#team: cath changed the role of bob from member to admin (signed)" testGroupLinkDemotedAdmin :: HasCallStack => TestParams -> IO () testGroupLinkDemotedAdmin = @@ -3064,8 +3391,8 @@ testGroupLinkDemotedAdmin = alice ##> "/mr #team bob member" concurrentlyN_ - [ alice <## "#team: you changed the role of bob to member", - bob <## "#team: alice changed your role from admin to member" + [ alice <## "#team: you changed the role of bob to member (signed)", + bob <## "#team: alice changed your role from admin to member (signed)" ] -- demotion does not remove bob's group link (it is preserved, usable again on re-promotion) @@ -3162,7 +3489,7 @@ testGroupLinkHostProfileReceived = bob <## "#team: you joined the group" ] - threadDelay 100000 + threadDelay 250000 aliceImage <- getProfilePictureByName bob "alice" aliceImage `shouldBe` Just profileImage @@ -3317,13 +3644,13 @@ testGLinkReviewMember = alice <## "changed member admission rules" concurrentlyN_ [ do - bob <## "alice updated group #team:" + bob <## "alice updated group #team: (signed)" bob <## "changed member admission rules", do - cath <## "alice updated group #team:" + cath <## "alice updated group #team: (signed)" cath <## "changed member admission rules", do - dan <## "alice updated group #team:" + dan <## "alice updated group #team: (signed)" dan <## "changed member admission rules" ] @@ -3444,13 +3771,13 @@ testGLinkApproveThenReviewMember = alice <## "changed member admission rules" concurrentlyN_ [ do - bob <## "alice updated group #team:" + bob <## "alice updated group #team: (signed)" bob <## "changed member admission rules", do - cath <## "alice updated group #team:" + cath <## "alice updated group #team: (signed)" cath <## "changed member admission rules", do - dan <## "alice updated group #team:" + dan <## "alice updated group #team: (signed)" dan <## "changed member admission rules" ] @@ -3615,8 +3942,8 @@ testGLinkDeletePendingApprovalMember = ] alice ##> "/rm team cath" - alice <## "#team: you removed cath from the group" - cath <## "#team: alice removed you from the group" + alice <## "#team: you removed cath from the group (signed)" + cath <## "#team: alice removed you from the group (signed)" cath <## "use /d #team to delete the group" where cfg = testCfg {chatHooks = defaultChatHooks {acceptMember = Just (\_ _ _ -> pure $ Right (GAPendingApproval, GRObserver))}} @@ -3651,23 +3978,23 @@ testGLinkReviewIntroduce = alice ##> "/mr team dan admin" concurrentlyN_ - [ alice <## "#team: you changed the role of dan to admin", - bob <## "#team: alice changed the role of dan from member to admin", - cath <## "#team: alice changed the role of dan from member to admin", - dan <## "#team: alice changed your role from member to admin" + [ alice <## "#team: you changed the role of dan to admin (signed)", + bob <## "#team: alice changed the role of dan from member to admin (signed)", + cath <## "#team: alice changed the role of dan from member to admin (signed)", + dan <## "#team: alice changed your role from member to admin (signed)" ] alice ##> "/set admission review #team all" alice <## "changed member admission rules" concurrentlyN_ [ do - bob <## "alice updated group #team:" + bob <## "alice updated group #team: (signed)" bob <## "changed member admission rules", do - cath <## "alice updated group #team:" + cath <## "alice updated group #team: (signed)" cath <## "changed member admission rules", do - dan <## "alice updated group #team:" + dan <## "alice updated group #team: (signed)" dan <## "changed member admission rules" ] @@ -4918,12 +5245,12 @@ testMemberContactAccept = -- if group is deleted, bob and cath keep contact with each other alice ##> "/d #team" concurrentlyN_ - [ alice <## "#team: you deleted the group", + [ alice <## "#team: you deleted the group (signed)", do - bob <## "#team: alice deleted the group" + bob <## "#team: alice deleted the group (signed)" bob <## "use /d #team to delete the local copy of the group", do - cath <## "#team: alice deleted the group" + cath <## "#team: alice deleted the group (signed)" cath <## "use /d #team to delete the local copy of the group" ] @@ -5018,12 +5345,12 @@ testMemberContactAcceptIncognito = -- if group is deleted, bob and cath keep contact with each other alice ##> "/d #team" concurrentlyN_ - [ alice <## "#team: you deleted the group", + [ alice <## "#team: you deleted the group (signed)", do - bob <## "#team: alice deleted the group" + bob <## "#team: alice deleted the group (signed)" bob <## "use /d #team to delete the local copy of the group", do - cath <## "#team: alice deleted the group" + cath <## "#team: alice deleted the group (signed)" cath <## "use /d #team to delete the local copy of the group" ] @@ -5129,16 +5456,16 @@ testGroupMsgForwardReport = alice ##> "/mr team bob moderator" concurrentlyN_ - [ alice <## "#team: you changed the role of bob to moderator", - bob <## "#team: alice changed your role from admin to moderator", - cath <## "#team: alice changed the role of bob from admin to moderator" + [ alice <## "#team: you changed the role of bob to moderator (signed)", + bob <## "#team: alice changed your role from admin to moderator (signed)", + cath <## "#team: alice changed the role of bob from admin to moderator (signed)" ] alice ##> "/mr team cath member" concurrentlyN_ - [ alice <## "#team: you changed the role of cath to member", - bob <## "#team: alice changed the role of cath from admin to member", - cath <## "#team: alice changed your role from admin to member" + [ alice <## "#team: you changed the role of cath to member (signed)", + bob <## "#team: alice changed the role of cath from admin to member (signed)", + cath <## "#team: alice changed your role from admin to member (signed)" ] cath ##> "/report #team content hi there" cath <# "#team (support) > bob hi there" @@ -5154,9 +5481,9 @@ testGroupMsgForwardReport = alice ##> "/mr team bob member" concurrentlyN_ - [ alice <## "#team: you changed the role of bob to member", - bob <## "#team: alice changed your role from moderator to member", - cath <## "#team: alice changed the role of bob from moderator to member" + [ alice <## "#team: you changed the role of bob to member (signed)", + bob <## "#team: alice changed your role from moderator to member (signed)", + cath <## "#team: alice changed the role of bob from moderator to member (signed)" ] cath ##> "/report #team content hi there" @@ -5373,9 +5700,9 @@ testGroupMsgForwardChangeRole = setupGroupForwarding alice bob cath cath ##> "/mr #team bob member" - cath <## "#team: you changed the role of bob to member" - alice <## "#team: cath changed the role of bob from admin to member" - bob <## "#team: cath changed your role from admin to member" -- TODO show as forwarded + cath <## "#team: you changed the role of bob to member (signed)" + alice <## "#team: cath changed the role of bob from admin to member (signed)" + bob <## "#team: cath changed your role from admin to member (signed)" -- TODO show as forwarded testGroupMsgForwardNewMember :: HasCallStack => TestParams -> IO () testGroupMsgForwardNewMember = @@ -5429,8 +5756,8 @@ testGroupMsgForwardLeave = bob ##> "/leave #team" bob <## "#team: you left the group" bob <## "use /d #team to delete the group" - alice <## "#team: bob left the group" - cath <## "#team: bob left the group" + alice <## "#team: bob left the group (signed)" + cath <## "#team: bob left the group (signed)" testGroupMsgForwardMemberRemoval :: HasCallStack => TestParams -> IO () testGroupMsgForwardMemberRemoval = @@ -5442,10 +5769,10 @@ testGroupMsgForwardMemberRemoval = -- remove member bob ##> "/rm team cath" concurrentlyN_ - [ bob <## "#team: you removed cath from the group", - alice <## "#team: bob removed cath from the group", + [ bob <## "#team: you removed cath from the group (signed)", + alice <## "#team: bob removed cath from the group (signed)", do - cath <## "#team: bob removed you from the group" + cath <## "#team: bob removed you from the group (signed)" cath <## "use /d #team to delete the group" ] bob #> "#team hi" @@ -5478,11 +5805,11 @@ testGroupMsgForwardAdminRemoval = -- if alice is removed, she forwards message of her own removal bob ##> "/rm team alice" concurrentlyN_ - [ bob <## "#team: you removed alice from the group", + [ bob <## "#team: you removed alice from the group (signed)", do - alice <## "#team: bob removed you from the group" + alice <## "#team: bob removed you from the group (signed)" alice <## "use /d #team to delete the group", - cath <## "#team: bob removed alice from the group" + cath <## "#team: bob removed alice from the group (signed)" ] -- there is no forwarding admin anymore between bob and cath, so messages don't get delivered @@ -5517,12 +5844,12 @@ testGroupMsgForwardGroupDeletion = -- if bob deletes the group, alice forwards it to cath bob ##> "/d #team" concurrentlyN_ - [ bob <## "#team: you deleted the group", + [ bob <## "#team: you deleted the group (signed)", do - alice <## "#team: bob deleted the group" + alice <## "#team: bob deleted the group (signed)" alice <## "use /d #team to delete the local copy of the group", do - cath <## "#team: bob deleted the group" + cath <## "#team: bob deleted the group (signed)" cath <## "use /d #team to delete the local copy of the group" ] @@ -5667,11 +5994,11 @@ testGroupHistoryPreferenceOff = alice <## "Recent history: off" concurrentlyN_ [ do - bob <## "alice updated group #team:" + bob <## "alice updated group #team: (signed)" bob <## "updated group preferences:" bob <## "Recent history: off", do - cath <## "alice updated group #team:" + cath <## "alice updated group #team: (signed)" cath <## "updated group preferences:" cath <## "Recent history: off" ] @@ -6203,7 +6530,7 @@ testGroupHistoryDisappearingMessage = alice ##> "/set disappear #team on 4" alice <## "updated group preferences:" alice <## "Disappearing messages: on (4 sec)" - bob <## "alice updated group #team:" + bob <## "alice updated group #team: (signed)" bob <## "updated group preferences:" bob <## "Disappearing messages: on (4 sec)" @@ -6220,7 +6547,7 @@ testGroupHistoryDisappearingMessage = alice ##> "/set disappear #team off" alice <## "updated group preferences:" alice <## "Disappearing messages: off" - bob <## "alice updated group #team:" + bob <## "alice updated group #team: (signed)" bob <## "updated group preferences:" bob <## "Disappearing messages: off" @@ -6280,7 +6607,7 @@ testGroupHistoryWelcomeMessage = alice <## "welcome message changed to:" alice <## "welcome to team" - bob <## "alice updated group #team:" + bob <## "alice updated group #team: (signed)" bob <## "welcome message changed to:" bob <## "welcome to team" @@ -6353,8 +6680,8 @@ testGroupHistoryUnknownMember = [ do bob <## "#team: you left the group" bob <## "use /d #team to delete the group", - alice <## "#team: bob left the group", - cath <## "#team: bob left the group" + alice <## "#team: bob left the group (signed)", + cath <## "#team: bob left the group (signed)" ] connectUsers alice dan @@ -6467,7 +6794,7 @@ testMembershipProfileUpdateNextGroupMessage = bob ##> "/_get chat #1 count=100" rb <- chat <$> getTermLine bob - rb `shouldContain` [(0, "updated profile")] + rb `shouldContain` [(0, "updated profile (signed)")] -- update profile in group 2 @@ -6491,7 +6818,7 @@ testMembershipProfileUpdateNextGroupMessage = cath ##> "/_get chat #1 count=100" rc <- chat <$> getTermLine cath - rc `shouldContain` [(0, "updated profile")] + rc `shouldContain` [(0, "updated profile (signed)")] testMembershipProfileUpdateSameMember :: HasCallStack => TestParams -> IO () testMembershipProfileUpdateSameMember = @@ -6545,7 +6872,7 @@ testMembershipProfileUpdateSameMember = bob ##> "/_get chat #1 count=100" rTeam <- chat <$> getTermLine bob - rTeam `shouldContain` [(0, "updated profile")] + rTeam `shouldContain` [(0, "updated profile (signed)")] bob ##> "/_get chat #2 count=100" rClub <- chat <$> getTermLine bob @@ -6691,7 +7018,7 @@ testMembershipProfileUpdateContactDeleted = bob ##> "/_get chat #1 count=100" rGrp <- chat <$> getTermLine bob - rGrp `shouldContain` [(0, "updated profile")] + rGrp `shouldContain` [(0, "updated profile (signed)")] checkAliceNoProfileLink bob name = do bob ##> ("/info #team " <> name) bob <## "group ID: 1" @@ -6754,7 +7081,7 @@ testMembershipProfileUpdateContactDisabled = bob ##> "/_get chat #1 count=100" rGrp <- chat <$> getTermLine bob - rGrp `shouldContain` [(0, "updated profile")] + rGrp `shouldContain` [(0, "updated profile (signed)")] testMembershipProfileUpdateNoChangeIgnored :: HasCallStack => TestParams -> IO () testMembershipProfileUpdateNoChangeIgnored = @@ -6852,8 +7179,8 @@ testBlockForAllMarkedBlocked = threadDelay 1000000 alice ##> "/block for all #team bob" - alice <## "#team: you blocked bob" - cath <## "#team: alice blocked bob" + alice <## "#team: you blocked bob (signed)" + cath <## "#team: alice blocked bob (signed)" bob "/ms team" @@ -6892,8 +7219,8 @@ testBlockForAllMarkedBlocked = threadDelay 1000000 alice ##> "/unblock for all #team bob" - alice <## "#team: you unblocked bob" - cath <## "#team: alice unblocked bob" + alice <## "#team: you unblocked bob (signed)" + cath <## "#team: alice unblocked bob (signed)" bob ( "/_get chat #1 count=6", chat, [ (0, "1"), - (1, "blocked bob"), + (1, "blocked bob (signed)"), (0, "2 [blocked by admin]"), (0, "3 [blocked by admin]"), - (1, "unblocked bob"), + (1, "unblocked bob (signed)"), (0, "4") ] ) @@ -6916,10 +7243,10 @@ testBlockForAllMarkedBlocked = #$> ( "/_get chat #1 count=6", chat, [ (0, "1"), - (0, "blocked bob"), + (0, "blocked bob (signed)"), (0, "2 [blocked by admin]"), (0, "3 [blocked by admin]"), - (0, "unblocked bob"), + (0, "unblocked bob (signed)"), (0, "4") ] ) @@ -6941,8 +7268,8 @@ testBlockForAllMentionsIgnored = threadDelay 1000000 alice ##> "/block for all #team bob" - alice <## "#team: you blocked bob" - cath <## "#team: alice blocked bob" + alice <## "#team: you blocked bob (signed)" + cath <## "#team: alice blocked bob (signed)" bob "/block for all #team bob" - alice <## "#team: you blocked bob" - cath <## "#team: alice blocked bob" + alice <## "#team: you blocked bob (signed)" + cath <## "#team: alice blocked bob (signed)" bob "/unblock for all #team bob" - alice <## "#team: you unblocked bob" - cath <## "#team: alice unblocked bob" + alice <## "#team: you unblocked bob (signed)" + cath <## "#team: alice unblocked bob (signed)" bob ( "/_get chat #1 count=6", chat, [ (0, "1"), - (1, "blocked bob"), + (1, "blocked bob (signed)"), (0, "blocked [blocked by admin]"), (0, "blocked [blocked by admin]"), - (1, "unblocked bob"), + (1, "unblocked bob (signed)"), (0, "4") ] ) @@ -7064,10 +7391,10 @@ testBlockForAllFullDelete = #$> ( "/_get chat #1 count=6", chat, [ (0, "1"), - (0, "blocked bob"), + (0, "blocked bob (signed)"), (0, "blocked [blocked by admin]"), (0, "blocked [blocked by admin]"), - (0, "unblocked bob"), + (0, "unblocked bob (signed)"), (0, "4") ] ) @@ -7084,8 +7411,8 @@ testBlockForAllAnotherAdminUnblocks = [alice, cath] *<# "#team bob> 1" alice ##> "/block for all #team bob" - alice <## "#team: you blocked bob" - cath <## "#team: alice blocked bob" + alice <## "#team: you blocked bob (signed)" + cath <## "#team: alice blocked bob (signed)" bob "#team 2" @@ -7093,8 +7420,8 @@ testBlockForAllAnotherAdminUnblocks = cath <# "#team bob> 2 [blocked by admin] " cath ##> "/unblock for all #team bob" - cath <## "#team: you unblocked bob" - alice <## "#team: cath unblocked bob" + cath <## "#team: you unblocked bob (signed)" + alice <## "#team: cath unblocked bob (signed)" bob "#team 3" @@ -7113,8 +7440,8 @@ testBlockForAllBeforeJoining = [alice, cath] *<# "#team bob> 1" alice ##> "/block for all #team bob" - alice <## "#team: you blocked bob" - cath <## "#team: alice blocked bob" + alice <## "#team: you blocked bob (signed)" + cath <## "#team: alice blocked bob (signed)" bob "#team 2" @@ -7148,9 +7475,9 @@ testBlockForAllBeforeJoining = threadDelay 1000000 alice ##> "/unblock for all #team bob" - alice <## "#team: you unblocked bob" - cath <## "#team: alice unblocked bob" - dan <## "#team: alice unblocked bob" + alice <## "#team: you unblocked bob (signed)" + cath <## "#team: alice unblocked bob (signed)" + dan <## "#team: alice unblocked bob (signed)" bob "/_get chat #1 count=100" r <- chat <$> getTermLine dan - r `shouldContain` [(0, "3 [blocked by admin]"), (0, "4 [blocked by admin]"), (0, "unblocked bob"), (0, "5")] + r `shouldContain` [(0, "3 [blocked by admin]"), (0, "4 [blocked by admin]"), (0, "unblocked bob (signed)"), (0, "5")] r `shouldNotContain` [(0, "1")] r `shouldNotContain` [(0, "1 [blocked by admin]")] r `shouldNotContain` [(0, "2")] @@ -7182,30 +7509,30 @@ testBlockForAllRepeat = [alice, cath] *<# "#team bob> 1" alice ##> "/block for all #team bob" - alice <## "#team: you blocked bob" - cath <## "#team: alice blocked bob" + alice <## "#team: you blocked bob (signed)" + cath <## "#team: alice blocked bob (signed)" bob "/block for all #team bob" - alice <## "#team: you blocked bob" + alice <## "#team: you blocked bob (signed)" cath ##> "/block for all #team bob" - cath <## "#team: you blocked bob" + cath <## "#team: you blocked bob (signed)" bob #> "#team 2" alice <# "#team bob> 2 [blocked by admin] " cath <# "#team bob> 2 [blocked by admin] " cath ##> "/unblock for all #team bob" - cath <## "#team: you unblocked bob" - alice <## "#team: cath unblocked bob" + cath <## "#team: you unblocked bob (signed)" + alice <## "#team: cath unblocked bob (signed)" bob "/unblock for all #team bob" - alice <## "#team: you unblocked bob" + alice <## "#team: you unblocked bob (signed)" cath ##> "/unblock for all #team bob" - cath <## "#team: you unblocked bob" + cath <## "#team: you unblocked bob (signed)" bob #> "#team 3" [alice, cath] *<# "#team bob> 3" @@ -7240,17 +7567,17 @@ testBlockForAllMultipleMembers = -- lower roles to for batch block to be allowed (can't batch block if admins are selected) alice ##> "/mr team bob member" concurrentlyN_ - [ alice <## "#team: you changed the role of bob to member", - bob <## "#team: alice changed your role from admin to member", - cath <## "#team: alice changed the role of bob from admin to member", - dan <## "#team: alice changed the role of bob from admin to member" + [ alice <## "#team: you changed the role of bob to member (signed)", + bob <## "#team: alice changed your role from admin to member (signed)", + cath <## "#team: alice changed the role of bob from admin to member (signed)", + dan <## "#team: alice changed the role of bob from admin to member (signed)" ] alice ##> "/mr team cath member" concurrentlyN_ - [ alice <## "#team: you changed the role of cath to member", - bob <## "#team: alice changed the role of cath from admin to member", - cath <## "#team: alice changed your role from admin to member", - dan <## "#team: alice changed the role of cath from admin to member" + [ alice <## "#team: you changed the role of cath to member (signed)", + bob <## "#team: alice changed the role of cath from admin to member (signed)", + cath <## "#team: alice changed your role from admin to member (signed)", + dan <## "#team: alice changed the role of cath from admin to member (signed)" ] bob #> "#team 1" @@ -7260,9 +7587,9 @@ testBlockForAllMultipleMembers = [alice, bob, dan] *<# "#team cath> 2" alice ##> "/_block #1 2,3 blocked=on" - alice <## "#team: you blocked 2 members" - dan <## "#team: alice blocked bob" - dan <## "#team: alice blocked cath" + alice <## "#team: you blocked 2 members (signed)" + dan <## "#team: alice blocked bob (signed)" + dan <## "#team: alice blocked cath (signed)" bob 4" alice ##> "/_block #1 2,3 blocked=off" - alice <## "#team: you unblocked 2 members" - dan <## "#team: alice unblocked bob" - dan <## "#team: alice unblocked cath" + alice <## "#team: you unblocked 2 members (signed)" + dan <## "#team: alice unblocked bob (signed)" + dan <## "#team: alice unblocked cath (signed)" bob "/rm team dan" concurrentlyN_ - [ alice <## "#team: you removed dan from the group", + [ alice <## "#team: you removed dan from the group (signed)", do - dan <## "#team: alice removed you from the group" + dan <## "#team: alice removed you from the group (signed)" dan <## "use /d #team to delete the group", - bob <## "#team: alice removed dan from the group" + bob <## "#team: alice removed dan from the group (signed)" ] alice ##> "/block for all #team cath" - alice <## "#team: you blocked cath" - bob <## "#team: alice blocked cath" + alice <## "#team: you blocked cath (signed)" + bob <## "#team: alice blocked cath (signed)" alice ##> "/block for all #team dan" - alice <## "#team: you blocked dan" - bob <## "#team: alice blocked dan" + alice <## "#team: you blocked dan (signed)" + bob <## "#team: alice blocked dan (signed)" testGroupMemberInactive :: HasCallStack => TestParams -> IO () testGroupMemberInactive ps = do @@ -7398,15 +7725,15 @@ testGroupMemberReports = -- disableFullDeletion3 "jokes" alice bob cath alice ##> "/mr jokes bob moderator" concurrentlyN_ - [ alice <## "#jokes: you changed the role of bob to moderator", - bob <## "#jokes: alice changed your role from admin to moderator", - cath <## "#jokes: alice changed the role of bob from admin to moderator" + [ alice <## "#jokes: you changed the role of bob to moderator (signed)", + bob <## "#jokes: alice changed your role from admin to moderator (signed)", + cath <## "#jokes: alice changed the role of bob from admin to moderator (signed)" ] alice ##> "/mr jokes cath member" concurrentlyN_ - [ alice <## "#jokes: you changed the role of cath to member", - bob <## "#jokes: alice changed the role of cath from admin to member", - cath <## "#jokes: alice changed your role from admin to member" + [ alice <## "#jokes: you changed the role of cath to member (signed)", + bob <## "#jokes: alice changed the role of cath from admin to member (signed)", + cath <## "#jokes: alice changed your role from admin to member (signed)" ] alice ##> "/create link #jokes" gLink <- getGroupLink alice "jokes" GRMember True @@ -7843,13 +8170,13 @@ testScopedSupportForwardWhileReview = alice <## "changed member admission rules" concurrentlyN_ [ do - bob <## "alice updated group #team:" + bob <## "alice updated group #team: (signed)" bob <## "changed member admission rules", do - cath <## "alice updated group #team:" + cath <## "alice updated group #team: (signed)" cath <## "changed member admission rules", do - dan <## "alice updated group #team:" + dan <## "alice updated group #team: (signed)" dan <## "changed member admission rules" ] @@ -7926,13 +8253,13 @@ testScopedSupportForwardAll = alice <## "changed member admission rules" concurrentlyN_ [ do - bob <## "alice updated group #team:" + bob <## "alice updated group #team: (signed)" bob <## "changed member admission rules", do - cath <## "alice updated group #team:" + cath <## "alice updated group #team: (signed)" cath <## "changed member admission rules", do - dan <## "alice updated group #team:" + dan <## "alice updated group #team: (signed)" dan <## "changed member admission rules" ] @@ -7980,16 +8307,16 @@ testScopedSupportForwardAll = dan <## "changed to #my_team" concurrentlyN_ [ do - alice <## "dan updated group #team:" + alice <## "dan updated group #team: (signed)" alice <## "changed to #my_team", do - bob <## "dan updated group #team:" + bob <## "dan updated group #team: (signed)" bob <## "changed to #my_team", do - cath <## "dan updated group #team:" + cath <## "dan updated group #team: (signed)" cath <## "changed to #my_team", do - eve <## "dan updated group #team:" + eve <## "dan updated group #team: (signed)" eve <## "changed to #my_team" ] @@ -8063,11 +8390,11 @@ testScopedSupportForwardMemberRemoval = -- bob removes eve, eve and dan receive member removal message bob ##> "/_remove #1 5" concurrentlyN_ - [ bob <## "#team: you removed eve from the group", - alice <## "#team: bob removed eve from the group", - dan <## "#team: bob removed eve from the group", + [ bob <## "#team: you removed eve from the group (signed)", + alice <## "#team: bob removed eve from the group (signed)", + dan <## "#team: bob removed eve from the group (signed)", do - eve <## "#team: bob removed you from the group" + eve <## "#team: bob removed you from the group (signed)" eve <## "use /d #team to delete the group" ] @@ -8086,13 +8413,13 @@ setupReviewForward alice bob cath dan eve = do alice <## "changed member admission rules" concurrentlyN_ [ do - bob <## "alice updated group #team:" + bob <## "alice updated group #team: (signed)" bob <## "changed member admission rules", do - cath <## "alice updated group #team:" + cath <## "alice updated group #team: (signed)" cath <## "changed member admission rules", do - dan <## "alice updated group #team:" + dan <## "alice updated group #team: (signed)" dan <## "changed member admission rules" ] @@ -8146,13 +8473,13 @@ testScopedSupportForwardAdminRemoval = -- bob removes eve, eve and dan receive member removal message bob ##> "/rm team alice" concurrentlyN_ - [ bob <## "#team: you removed alice from the group", + [ bob <## "#team: you removed alice from the group (signed)", do - alice <## "#team: bob removed you from the group" + alice <## "#team: bob removed you from the group (signed)" alice <## "use /d #team to delete the group", - cath <## "#team: bob removed alice from the group", - dan <## "#team: bob removed alice from the group", - eve <## "#team: bob removed alice from the group" + cath <## "#team: bob removed alice from the group (signed)", + dan <## "#team: bob removed alice from the group (signed)", + eve <## "#team: bob removed alice from the group (signed)" ] -- there is no forwarding admin anymore between bob and cath, @@ -8193,9 +8520,9 @@ testScopedSupportForwardLeave = eve ##> "/leave #team" eve <## "#team: you left the group" eve <## "use /d #team to delete the group" - alice <## "#team: eve left the group" - bob <## "#team: eve left the group" - dan <## "#team: eve left the group" + alice <## "#team: eve left the group (signed)" + bob <## "#team: eve left the group (signed)" + dan <## "#team: eve left the group (signed)" alice ##> "#team (support: eve) hi" alice <## "bad chat command: support member not current or pending" @@ -8216,18 +8543,18 @@ testScopedSupportForwardGroupDeletion = -- if bob deletes the group, alice forwards it to eve and dan bob ##> "/d #team" concurrentlyN_ - [ bob <## "#team: you deleted the group", + [ bob <## "#team: you deleted the group (signed)", do - alice <## "#team: bob deleted the group" + alice <## "#team: bob deleted the group (signed)" alice <## "use /d #team to delete the local copy of the group", do - cath <## "#team: bob deleted the group" + cath <## "#team: bob deleted the group (signed)" cath <## "use /d #team to delete the local copy of the group", do - dan <## "#team: bob deleted the group" + dan <## "#team: bob deleted the group (signed)" dan <## "use /d #team to delete the local copy of the group", do - eve <## "#team: bob deleted the group" + eve <## "#team: bob deleted the group (signed)" eve <## "use /d #team to delete the local copy of the group" ] @@ -8433,7 +8760,7 @@ testScopedSupportUnreadStatsOnDelete = alice ##> "/set delete #team on" alice <## "updated group preferences:" alice <## "Full deletion: on" - bob <## "alice updated group #team:" + bob <## "alice updated group #team: (signed)" bob <## "updated group preferences:" bob <## "Full deletion: on" @@ -8588,11 +8915,11 @@ testScopedSupportMemberRemoved = cath ##> "/rm team bob" concurrentlyN_ - [ cath <## "#team: you removed bob from the group", + [ cath <## "#team: you removed bob from the group (signed)", do - bob <## "#team: cath removed you from the group" + bob <## "#team: cath removed you from the group (signed)" bob <## "use /d #team to delete the group", - alice <## "#team: cath removed bob from the group" + alice <## "#team: cath removed bob from the group (signed)" ] alice ##> "/member support chats #team" @@ -8620,9 +8947,9 @@ testScopedSupportUserRemovesMember = alice ##> "/rm team bob" concurrentlyN_ - [ alice <## "#team: you removed bob from the group", + [ alice <## "#team: you removed bob from the group (signed)", do - bob <## "#team: alice removed you from the group" + bob <## "#team: alice removed you from the group (signed)" bob <## "use /d #team to delete the group" ] @@ -8655,7 +8982,7 @@ testScopedSupportMemberLeaves = [ do bob <## "#team: you left the group" bob <## "use /d #team to delete the group", - alice <## "#team: bob left the group" + alice <## "#team: bob left the group (signed)" ] alice ##> "/member support chats #team" @@ -8687,11 +9014,11 @@ testSupportPreferenceGroup = alice <## "Chat with admins: off" concurrentlyN_ [ do - bob <## "alice updated group #team:" + bob <## "alice updated group #team: (signed)" bob <## "updated group preferences:" bob <## "Chat with admins: off", do - cath <## "alice updated group #team:" + cath <## "alice updated group #team: (signed)" cath <## "updated group preferences:" cath <## "Chat with admins: off" ] @@ -11502,7 +11829,7 @@ testRelayRejectRaceConcurrentInvitations ps = -- first rejection alice ##> "/rm #team bob" - alice .<##. ("#team: you removed bob from the group", "") + alice .<##. ("#team: you removed bob from the group (signed)", "") threadDelay 100000 alice ##> "/_add relays #1 1" alice <## "#team: group relays:" @@ -11517,7 +11844,7 @@ testRelayRejectRaceConcurrentInvitations ps = -- second rejection alice ##> "/rm #team bob" - alice .<##. ("#team: you removed bob from the group", "") + alice .<##. ("#team: you removed bob from the group (signed)", "") threadDelay 100000 alice ##> "/_add relays #1 1" alice <## "#team: group relays:" @@ -11954,6 +12281,174 @@ testChannelOwnerFileTransferAsMember ps = cc <## ("completed receiving file " <> show fileId <> " (test.jpg) from alice") B.readFile path >>= (`shouldBe` src) +testGroupHistoryFileBadgeProof :: HasCallStack => TestParams -> IO () +testGroupHistoryFileBadgeProof ps = do + Right (pk, sk) <- bbsKeyGen + let cfg = testCfg {badgePublicKeys = testBadgeKeys pk, fileSizeLimits = FileSizeLimits {noBadge = 100000, supporter = 300000, legend = 400000}} + testChatCfg3 cfg aliceProfile bobProfile cathProfile (test sk) ps + where + test sk alice bob cath = withXFTPServer $ do + createGroup2 "team" alice bob + addTestBadge alice =<< issueTestBadge sk futureDate + + alice #> "/f #team ./tests/fixtures/test.pdf" + alice <## "use /fc 1 to cancel sending" + bob <# "#team alice> sends file test.pdf (266.0 KiB / 272376 bytes)" + bob <## "use /fr 1 [/ | ] to receive it" + alice <## "completed uploading file 1 (test.pdf) for #team" + + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + cath ##> ("/c " <> gLink) + cath <## "connection request sent!" + alice <## "cath (Catherine): accepting request to join group #team..." + concurrentlyN_ + [ alice <## "#team: cath joined the group", + cath + <### [ "#team: joining the group...", + "#team: you joined the group", + WithTime "#team alice> sends file test.pdf (266.0 KiB / 272376 bytes) [>>]", + "use /fr 1 [/ | ] to receive it [>>]", + "#team: member bob (Bob) is connected" + ], + do + bob <## "#team: alice added cath (Catherine) to the group (connecting...)" + bob <## "#team: new member cath is connected" + ] + + cath ##> "/fr 1 ./tests/tmp" + cath + <### [ "saving file 1 from alice to ./tests/tmp/test.pdf", + "started receiving file 1 (test.pdf) from alice" + ] + cath <## "completed receiving file 1 (test.pdf) from alice" + src <- B.readFile "./tests/fixtures/test.pdf" + dest <- B.readFile "./tests/tmp/test.pdf" + dest `shouldBe` src + +testGroupHistoryRcvFileBadgeProof :: HasCallStack => TestParams -> IO () +testGroupHistoryRcvFileBadgeProof ps = do + Right (pk, sk) <- bbsKeyGen + let cfg = testCfg {badgePublicKeys = testBadgeKeys pk, fileSizeLimits = FileSizeLimits {noBadge = 100000, supporter = 300000, legend = 400000}} + testChatCfg3 cfg aliceProfile bobProfile cathProfile (test sk) ps + where + test sk alice bob cath = withXFTPServer $ do + createGroup2 "team" alice bob + addTestBadge bob =<< issueTestBadge sk futureDate + + bob #> "/f #team ./tests/fixtures/test.pdf" + bob <## "use /fc 1 to cancel sending" + alice <# "#team bob> sends file test.pdf (266.0 KiB / 272376 bytes)" + alice <## "use /fr 1 [/ | ] to receive it" + alice ##> "/fr 1 ./tests/tmp" + concurrentlyN_ + [ bob <## "completed uploading file 1 (test.pdf) for #team", + alice + <### [ "saving file 1 from bob to ./tests/tmp/test.pdf", + "started receiving file 1 (test.pdf) from bob" + ] + ] + alice <## "completed receiving file 1 (test.pdf) from bob" + + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + cath ##> ("/c " <> gLink) + cath <## "connection request sent!" + alice <## "cath (Catherine): accepting request to join group #team..." + concurrentlyN_ + [ alice <## "#team: cath joined the group", + cath + <### [ "#team: joining the group...", + "#team: you joined the group", + WithTime "#team bob> sends file test.pdf (266.0 KiB / 272376 bytes) [>>]", + "use /fr 1 [/ | ] to receive it [>>]", + "#team: member bob (Bob) is connected" + ], + do + bob <## "#team: alice added cath (Catherine) to the group (connecting...)" + bob <## "#team: new member cath is connected" + ] + + cath ##> "/fr 1 ./tests/tmp" + cath + <### [ "saving file 1 from bob to ./tests/tmp/test_1.pdf", + "started receiving file 1 (test.pdf) from bob" + ] + cath <## "completed receiving file 1 (test.pdf) from bob" + src <- B.readFile "./tests/fixtures/test.pdf" + dest <- B.readFile "./tests/tmp/test_1.pdf" + dest `shouldBe` src + +testChannelFileBadgeProof :: HasCallStack => TestParams -> IO () +testChannelFileBadgeProof ps = do + Right (pk, sk) <- bbsKeyGen + let cfg = testCfg {badgePublicKeys = testBadgeKeys pk, fileSizeLimits = FileSizeLimits {noBadge = 100000, supporter = 300000, legend = 400000}} + withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> + withNewTestChatCfgOpts ps cfg relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChatCfg ps cfg "cath" cathProfile $ \cath -> + withNewTestChatCfg ps cfg "dan" danProfile $ \dan -> + withNewTestChatCfg ps cfg "eve" eveProfile $ \eve -> withXFTPServer $ do + createChannel1Relay "team" alice bob cath dan eve + addTestBadge alice =<< issueTestBadge sk futureDate +#if defined(dbPostgres) + let rcvFileId = 2 :: Int +#else + let rcvFileId = 1 :: Int +#endif + alice ##> "/_send #1(as_group=off) json [{\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"type\": \"file\", \"text\": \"\"}}]" + alice <# "/f #team ./tests/fixtures/test.jpg" + alice <## "use /fc 1 to cancel sending" + alice <## "completed uploading file 1 (test.jpg) for #team" + bob <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes)" + bob <## ("use /fr " <> show rcvFileId <> " [/ | ] to receive it") + concurrentlyN_ + [ do + cath <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" + cath <## ("use /fr " <> show rcvFileId <> " [/ | ] to receive it [>>]"), + do + dan <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" + dan <## ("use /fr " <> show rcvFileId <> " [/ | ] to receive it [>>]"), + do + eve <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" + eve <## ("use /fr " <> show rcvFileId <> " [/ | ] to receive it [>>]") + ] + + src <- B.readFile "./tests/fixtures/test.jpg" + let path = "./tests/tmp/test_cath.jpg" + cath ##> ("/fr " <> show rcvFileId <> " " <> path) + cath + <### [ ConsoleString ("saving file " <> show rcvFileId <> " from alice to " <> path), + ConsoleString ("started receiving file " <> show rcvFileId <> " (test.jpg) from alice") + ] + cath <## ("completed receiving file " <> show rcvFileId <> " (test.jpg) from alice") + B.readFile path >>= (`shouldBe` src) + + alice ##> "/_send #1(as_group=on) json [{\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"type\": \"file\", \"text\": \"\"}}]" + alice <# "/f #team ./tests/fixtures/test.jpg" + alice <## "use /fc 2 to cancel sending" + alice <## "completed uploading file 2 (test.jpg) for #team" + bob <# "#team> sends file test.jpg (136.5 KiB / 139737 bytes)" + bob <## ("use /fr " <> show (rcvFileId + 1) <> " [/ | ] to receive it") + concurrentlyN_ + [ do + cath <# "#team> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" + cath <## ("use /fr " <> show (rcvFileId + 1) <> " [/ | ] to receive it [>>]"), + do + dan <# "#team> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" + dan <## ("use /fr " <> show (rcvFileId + 1) <> " [/ | ] to receive it [>>]"), + do + eve <# "#team> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" + eve <## ("use /fr " <> show (rcvFileId + 1) <> " [/ | ] to receive it [>>]") + ] + let path2 = "./tests/tmp/test_cath_2.jpg" + cath ##> ("/fr " <> show (rcvFileId + 1) <> " " <> path2) + cath + <### [ ConsoleString ("saving file " <> show (rcvFileId + 1) <> " from #team to " <> path2), + ConsoleString ("started receiving file " <> show (rcvFileId + 1) <> " (test.jpg) from #team") + ] + cath <## ("completed receiving file " <> show (rcvFileId + 1) <> " (test.jpg) from #team") + B.readFile path2 >>= (`shouldBe` src) + testChannelOwnerFileCancelAsMember :: HasCallStack => TestParams -> IO () testChannelOwnerFileCancelAsMember ps = withNewTestChat ps "alice" aliceProfile $ \alice -> diff --git a/tests/ChatTests/Names.hs b/tests/ChatTests/Names.hs index 46b62801fe..8e37c5e2d2 100644 --- a/tests/ChatTests/Names.hs +++ b/tests/ChatTests/Names.hs @@ -36,7 +36,7 @@ testConnectByName ps = withSmpServerAndNames $ \reg -> mapM_ enableNamesRole [alice, bob] alice ##> "/ad" (shortLink, _) <- getContactLinks alice True - registerName reg aliceName (contactNameRecord "alice" (T.pack shortLink)) + registerName reg aliceName (contactNameRecord "alice.simplex" (T.pack shortLink)) alice ##> "/_set domain 1 alice.simplex" alice <## "new contact address set" bob ##> "/c @alice.simplex" @@ -71,7 +71,7 @@ testConnectByNameNotClaimed ps = withSmpServerAndNames $ \reg -> mapM_ enableNamesRole [alice, bob] alice ##> "/ad" (shortLink, _) <- getContactLinks alice True - registerName reg aliceName (contactNameRecord "alice" (T.pack shortLink)) + registerName reg aliceName (contactNameRecord "alice.simplex" (T.pack shortLink)) bob ##> "/c @alice.simplex" bob <## "SimpleX name alice.simplex is not included in the connection link's profile" @@ -94,7 +94,7 @@ testConnectByNameKnownContactNotClaimed ps = withSmpServerAndNames $ \reg -> concurrently_ (bob <## "alice (Alice): contact is connected") (alice <## "bob (Bob): contact is connected") - registerName reg aliceName (contactNameRecord "alice" (T.pack shortLink)) + registerName reg aliceName (contactNameRecord "alice.simplex" (T.pack shortLink)) bob ##> "/c @alice.simplex" bob <## "SimpleX name alice.simplex is not included in the connection link's profile" @@ -116,7 +116,7 @@ testSetNameNotOwnAddress ps = withSmpServerAndNames $ \reg -> mapM_ enableNamesRole [alice, bob] bob ##> "/ad" (bobShortLink, _) <- getContactLinks bob True - registerName reg aliceName (contactNameRecord "alice" (T.pack bobShortLink)) + registerName reg aliceName (contactNameRecord "alice.simplex" (T.pack bobShortLink)) alice ##> "/ad" _ <- getContactLinks alice True alice ##> "/_set domain 1 alice.simplex" @@ -130,7 +130,7 @@ testChannelDomainLinkJoinUnverified ps = withSmpServerAndNames $ \reg -> withNewTestChat ps "bob" bobProfile $ \bob -> do mapM_ enableNamesRole [alice, cath, bob] (shortLink, fullLink) <- prepareChannel1Relay "team" alice cath - registerName reg teamName (channelNameRecord "team" (T.pack shortLink)) + registerName reg teamName (channelNameRecord "team.simplex" (T.pack shortLink)) alice ##> "/public group access #team domain=team.simplex" alice <## "updated public group access: domain=team.simplex" cath <## "alice updated group #team: (signed)" @@ -150,7 +150,7 @@ testChannelDomainVerify ps = withSmpServerAndNames $ \reg -> withNewTestChat ps "bob" bobProfile $ \bob -> do mapM_ enableNamesRole [alice, cath, bob] (shortLink, fullLink) <- prepareChannel1Relay "team" alice cath - registerName reg teamName (channelNameRecord "team" (T.pack shortLink)) + registerName reg teamName (channelNameRecord "team.simplex" (T.pack shortLink)) alice ##> "/public group access #team domain=team.simplex" alice <## "updated public group access: domain=team.simplex" cath <## "alice updated group #team: (signed)" @@ -162,7 +162,7 @@ testChannelDomainVerify ps = withSmpServerAndNames $ \reg -> bob ##> "/_verify domain #1" bob <## "SimpleX name #team verified" -- the name is re-pointed to a different link: verification fails - registerName reg teamName (channelNameRecord "team" "https://simplex.chat/other") + registerName reg teamName (channelNameRecord "team.simplex" "https://simplex.chat/other") bob ##> "/_verify domain #1" bob <## "SimpleX name #team not verified: the name does not resolve to the link in the group profile" -- a link-data refresh keeps the failed status, not overwritten with verified @@ -180,7 +180,7 @@ testConnectByChannelName ps = withSmpServerAndNames $ \reg -> withNewTestChat ps "bob" bobProfile $ \bob -> do mapM_ enableNamesRole [alice, cath, bob] (shortLink, _) <- prepareChannel1Relay "team" alice cath - registerName reg teamName (channelNameRecord "team" (T.pack shortLink)) + registerName reg teamName (channelNameRecord "team.simplex" (T.pack shortLink)) alice ##> "/public group access #team domain=team.simplex" alice <## "updated public group access: domain=team.simplex" cath <## "alice updated group #team: (signed)" @@ -216,7 +216,7 @@ testConnectByNameChannelAndContact ps = withSmpServerAndNames $ \reg -> (channelLink, _) <- prepareChannel1Relay "team" alice cath alice ##> "/ad" (contactLink, _) <- getContactLinks alice True - registerName reg teamName (contactAndChannelNameRecord "team" (T.pack contactLink) (T.pack channelLink)) + registerName reg teamName (contactAndChannelNameRecord "team.simplex" (T.pack contactLink) (T.pack channelLink)) alice ##> "/public group access #team domain=team.simplex" alice <## "updated public group access: domain=team.simplex" cath <## "alice updated group #team: (signed)" @@ -255,7 +255,7 @@ testConnectByNameContactAndChannel ps = withSmpServerAndNames $ \reg -> (channelLink, _) <- prepareChannel1Relay "acme" alice cath alice ##> "/ad" (contactLink, _) <- getContactLinks alice True - registerName reg acmeName (contactAndChannelNameRecord "acme" (T.pack contactLink) (T.pack channelLink)) + registerName reg acmeName (contactAndChannelNameRecord "acme.simplex" (T.pack contactLink) (T.pack channelLink)) alice ##> "/_set domain 1 acme.simplex" alice <## "new contact address set" bob ##> "/_connect plan 1 acme.simplex" @@ -274,7 +274,7 @@ testConnectByNameBusinessAndChannel ps = withSmpServerAndNames $ \reg -> (channelLink, _) <- prepareChannel1Relay "biz" alice cath alice ##> "/ad" (contactLink, fullLink) <- getContactLinks alice True - registerName reg bizName (contactAndChannelNameRecord "biz" (T.pack contactLink) (T.pack channelLink)) + registerName reg bizName (contactAndChannelNameRecord "biz.simplex" (T.pack contactLink) (T.pack channelLink)) alice ##> "/auto_accept on business" alice <## "auto_accept on, business" alice ##> "/_set domain 1 biz.simplex" diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index efe2097504..10cb6e45ff 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -24,7 +24,7 @@ import Data.Time.Clock.POSIX (posixSecondsToUTCTime) import Data.Time.Format (defaultTimeLocale, formatTime) import qualified Data.Map.Strict as M import Simplex.Chat.Badges (BadgeCredential, BadgeInfo (..), BadgePurchase (..), BadgeRequest (..), BadgeType (..), generateMasterKey, issueBadge, verifyPayment) -import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), ChatHooks (..), defaultChatHooks, mkStoreCxt) +import Simplex.Chat.Controller (ChatConfig (..), ChatHooks (..), defaultChatHooks, storeCxt) import Simplex.Chat.Options (ChatOpts (..), CoreChatOpts (..)) import Simplex.Chat.Protocol (LinkOwnerSig, MsgChatLink (..), MsgContent (..)) import Simplex.Chat.Store.Shared (createContact) @@ -281,10 +281,13 @@ futureDate = posixSecondsToUTCTime 4102444800 -- 2100-01-01 -- issue a supporter badge credential with the given expiry (test issuer) issueTestBadge :: BBSSecretKey -> UTCTime -> IO BadgeCredential -issueTestBadge sk badgeExpiry = do +issueTestBadge sk = issueTestBadgeType sk BTSupporter + +issueTestBadgeType :: BBSSecretKey -> BadgeType -> UTCTime -> IO BadgeCredential +issueTestBadgeType sk badgeType badgeExpiry = do drg <- C.newRandom mk <- generateMasterKey drg - let info = BadgeInfo {badgeType = BTSupporter, badgeExpiry, badgeExtra = ""} + let info = BadgeInfo {badgeType, badgeExpiry, badgeExtra = ""} Just vreq <- verifyPayment (BPRedeemCode "TEST") BadgeRequest {masterKey = mk, badgeInfo = info} Right cred <- issueBadge 1 sk vreq pure cred @@ -1299,13 +1302,13 @@ testBusinessUpdateProfiles = testChat4 businessProfile aliceProfile bobProfile c alice ##> "/p alisa" alice <## "user profile is changed to alisa (your 0 contacts are notified)" alice #> "#biz hello again" -- profile update is sent with message - biz <## "alice_1 updated group #alice:" + biz <## "alice_1 updated group #alice: (signed)" biz <## "changed to #alisa" biz <# "#alisa alisa_1> hello again" -- customer can invite members too, if business allows biz ##> "/mr alisa alisa_1 admin" - biz <## "#alisa: you changed the role of alisa_1 to admin" - alice <## "#biz: biz_1 changed your role from member to admin" + biz <## "#alisa: you changed the role of alisa_1 to admin (signed)" + alice <## "#biz: biz_1 changed your role from member to admin (signed)" connectUsers alice bob alice ##> "/a #biz bob" alice <## "invitation to join the group #biz sent to bob" @@ -1370,11 +1373,11 @@ testBusinessUpdateProfiles = testChat4 businessProfile aliceProfile bobProfile c biz #> "#alisa hey" concurrentlyN_ [ do - alice <## "biz_1 updated group #biz:" + alice <## "biz_1 updated group #biz: (signed)" alice <## "changed to #business" alice <# "#business business_1> hey", do - bob <## "biz_1 updated group #biz:" + bob <## "biz_1 updated group #biz: (signed)" bob <## "changed to #business" bob <# "#business business_1> hey", do @@ -1387,15 +1390,15 @@ testBusinessUpdateProfiles = testChat4 businessProfile aliceProfile bobProfile c biz <## "Full deletion: on" concurrentlyN_ [ do - alice <## "business_1 updated group #business:" + alice <## "business_1 updated group #business: (signed)" alice <## "updated group preferences:" alice <## "Full deletion: on", do - bob <## "business_1 updated group #business:" + bob <## "business_1 updated group #business: (signed)" bob <## "updated group preferences:" bob <## "Full deletion: on", do - cath <## "business updated group #alisa:" + cath <## "business updated group #alisa: (signed)" cath <## "updated group preferences:" cath <## "Full deletion: on" ] @@ -1608,13 +1611,13 @@ testPlanAddressContactViaAddress = Left _ -> error "error parsing contact link" Right cReq -> do let profile = aliceProfile {contactLink = Just cReq} - void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> let TestCC {chatController = ChatController {config}} = bob in runExceptT $ createContact db (mkStoreCxt config) user profile + void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> runExceptT $ createContact db (storeCxt $ chatController bob) user profile bob @@@ [("@alice", "")] bob ##> "/delete @alice" bob <## "alice: contact is deleted" - void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> let TestCC {chatController = ChatController {config}} = bob in runExceptT $ createContact db (mkStoreCxt config) user profile + void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> runExceptT $ createContact db (storeCxt $ chatController bob) user profile bob @@@ [("@alice", "")] bob ##> ("/_connect plan 1 " <> cLink) @@ -1629,7 +1632,7 @@ testPlanAddressContactViaAddress = alice ##> "/delete @bob" alice <## "bob: contact is deleted" - void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> let TestCC {chatController = ChatController {config}} = bob in runExceptT $ createContact db (mkStoreCxt config) user profile + void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> runExceptT $ createContact db (storeCxt $ chatController bob) user profile bob @@@ [("@alice", "")] -- GUI api @@ -1670,13 +1673,13 @@ testPlanAddressContactViaShortAddress = Left _ -> error "error parsing contact link" Right shortLink -> do let profile = aliceProfile {contactLink = Just shortLink} - void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> let TestCC {chatController = ChatController {config}} = bob in runExceptT $ createContact db (mkStoreCxt config) user profile + void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> runExceptT $ createContact db (storeCxt $ chatController bob) user profile bob @@@ [("@alice", "")] bob ##> "/delete @alice" bob <## "alice: contact is deleted" - void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> let TestCC {chatController = ChatController {config}} = bob in runExceptT $ createContact db (mkStoreCxt config) user profile + void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> runExceptT $ createContact db (storeCxt $ chatController bob) user profile bob @@@ [("@alice", "")] bob ##> ("/_connect plan 1 " <> sLink) @@ -1691,7 +1694,7 @@ testPlanAddressContactViaShortAddress = alice ##> "/delete @bob" alice <## "bob: contact is deleted" - void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> let TestCC {chatController = ChatController {config}} = bob in runExceptT $ createContact db (mkStoreCxt config) user profile + void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> runExceptT $ createContact db (storeCxt $ chatController bob) user profile bob @@@ [("@alice", "")] -- GUI api @@ -2093,11 +2096,11 @@ testJoinGroupIncognito = -- remove member alice ##> ("/rm secret_club " <> cathIncognito) concurrentlyN_ - [ 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"), + [ alice <## ("#secret_club: you removed " <> cathIncognito <> " from the group (signed)"), + bob <## ("#secret_club: alice removed " <> cathIncognito <> " from the group (signed)"), + dan <## ("#secret_club: alice removed " <> cathIncognito <> " from the group (signed)"), do - cath <## "#secret_club: alice removed you from the group" + cath <## "#secret_club: alice removed you from the group (signed)" cath <## "use /d #secret_club to delete the group" ] bob #> "#secret_club hi" @@ -2236,10 +2239,10 @@ testDeleteContactThenGroupDeletesIncognitoProfile = testChat2 aliceProfile bobPr [ do bob <## "#team: you left the group" bob <## "use /d #team to delete the group", - alice <## ("#team: " <> bobIncognito <> " left the group") + alice <## ("#team: " <> bobIncognito <> " left the group (signed)") ] bob ##> "/d #team" - bob <## "#team: you deleted the group" + bob <## "#team: you deleted your local copy of the group" bob `hasContactProfiles` ["bob"] testDeleteGroupThenContactDeletesIncognitoProfile :: HasCallStack => TestParams -> IO () @@ -2281,10 +2284,10 @@ testDeleteGroupThenContactDeletesIncognitoProfile = testChat2 aliceProfile bobPr [ do bob <## "#team: you left the group" bob <## "use /d #team to delete the group", - alice <## ("#team: " <> bobIncognito <> " left the group") + alice <## ("#team: " <> bobIncognito <> " left the group (signed)") ] bob ##> "/d #team" - bob <## "#team: you deleted the group" + bob <## "#team: you deleted your local copy of the group" bob `hasContactProfiles` ["alice", "bob", T.pack bobIncognito] -- delete contact bob ##> "/d alice" @@ -2653,7 +2656,7 @@ testUpdateGroupPrefs = alice <## "updated group preferences:" alice <## "Full deletion: on" alice #$> ("/_get chat #1 count=100", chat, sndGroupFeatures <> [(0, "connected"), (1, "Full deletion: on")]) - bob <## "alice updated group #team:" + bob <## "alice updated group #team: (signed)" bob <## "updated group preferences:" bob <## "Full deletion: on" threadDelay 500000 @@ -2663,7 +2666,7 @@ testUpdateGroupPrefs = alice <## "Full deletion: off" alice <## "Voice messages: off" alice #$> ("/_get chat #1 count=100", chat, sndGroupFeatures <> [(0, "connected"), (1, "Full deletion: on"), (1, "Full deletion: off"), (1, "Voice messages: off")]) - bob <## "alice updated group #team:" + bob <## "alice updated group #team: (signed)" bob <## "updated group preferences:" bob <## "Full deletion: off" bob <## "Voice messages: off" @@ -2673,7 +2676,7 @@ testUpdateGroupPrefs = alice <## "updated group preferences:" alice <## "Voice messages: on" alice #$> ("/_get chat #1 count=100", chat, sndGroupFeatures <> [(0, "connected"), (1, "Full deletion: on"), (1, "Full deletion: off"), (1, "Voice messages: off"), (1, "Voice messages: on")]) - bob <## "alice updated group #team:" + bob <## "alice updated group #team: (signed)" bob <## "updated group preferences:" bob <## "Voice messages: on" threadDelay 500000 @@ -2726,7 +2729,7 @@ testAllowFullDeletionGroup = alice ##> "/set delete #team on" alice <## "updated group preferences:" alice <## "Full deletion: on" - bob <## "alice updated group #team:" + bob <## "alice updated group #team: (signed)" bob <## "updated group preferences:" bob <## "Full deletion: on" alice #$> ("/_get chat #1 count=100", chat, sndGroupFeatures <> [(0, "connected"), (1, "hi"), (0, "hey"), (1, "Full deletion: on")]) @@ -2790,7 +2793,7 @@ testProhibitDirectMessages = where directProhibited :: HasCallStack => TestCC -> IO () directProhibited cc = do - cc <## "alice updated group #team:" + cc <## "alice updated group #team: (signed)" cc <## "updated group preferences:" cc <## "Direct messages: off" @@ -2846,7 +2849,7 @@ testEnableTimedMessagesGroup = alice ##> "/_group_profile #1 {\"displayName\": \"team\", \"fullName\": \"\", \"groupPreferences\": {\"timedMessages\": {\"enable\": \"on\", \"ttl\": 1}, \"directMessages\": {\"enable\": \"on\"}, \"history\": {\"enable\": \"on\"}}}" alice <## "updated group preferences:" alice <## "Disappearing messages: on (1 sec)" - bob <## "alice updated group #team:" + bob <## "alice updated group #team: (signed)" bob <## "updated group preferences:" bob <## "Disappearing messages: on (1 sec)" threadDelay 1000000 @@ -2864,7 +2867,7 @@ testEnableTimedMessagesGroup = alice ##> "/set disappear #team off" alice <## "updated group preferences:" alice <## "Disappearing messages: off" - bob <## "alice updated group #team:" + bob <## "alice updated group #team: (signed)" bob <## "updated group preferences:" bob <## "Disappearing messages: off" threadDelay 1000000 @@ -2877,13 +2880,13 @@ testEnableTimedMessagesGroup = alice ##> "/set disappear #team on 30s" alice <## "updated group preferences:" alice <## "Disappearing messages: on (30 sec)" - bob <## "alice updated group #team:" + bob <## "alice updated group #team: (signed)" bob <## "updated group preferences:" bob <## "Disappearing messages: on (30 sec)" alice ##> "/set disappear #team week" -- "on" is optional alice <## "updated group preferences:" alice <## "Disappearing messages: on (1 week)" - bob <## "alice updated group #team:" + bob <## "alice updated group #team: (signed)" bob <## "updated group preferences:" bob <## "Disappearing messages: on (1 week)" @@ -3001,7 +3004,7 @@ testGroupPrefsDirectForRole = testChat4 aliceProfile bobProfile cathProfile danP where directForOwners :: HasCallStack => TestCC -> IO () directForOwners cc = do - cc <## "alice updated group #team:" + cc <## "alice updated group #team: (signed)" cc <## "updated group preferences:" cc <## "Direct messages: on for owners" @@ -3036,7 +3039,7 @@ testGroupPrefsFilesForRole = testChat3 aliceProfile bobProfile cathProfile $ where filesForOwners :: HasCallStack => TestCC -> IO () filesForOwners cc = do - cc <## "alice updated group #team:" + cc <## "alice updated group #team: (signed)" cc <## "updated group preferences:" cc <## "Files and media: on for owners" @@ -3078,7 +3081,7 @@ testGroupPrefsSimplexLinksForRole = testChat3 aliceProfile bobProfile cathProfil where linksForOwners :: HasCallStack => TestCC -> IO () linksForOwners cc = do - cc <## "alice updated group #team:" + cc <## "alice updated group #team: (signed)" cc <## "updated group preferences:" cc <## "SimpleX links: on for owners" @@ -3724,10 +3727,10 @@ testShortLinkAddressPrepareBusiness = testChat3 businessProfile aliceProfile {fu bob <## "business address: known business #biz" bob <## "use #biz to send messages" biz ##> "/d #bob" - biz <## "#bob: you deleted the group" - alice <## "#bob: biz deleted the group" + biz <## "#bob: you deleted the group (signed)" + alice <## "#bob: biz deleted the group (signed)" alice <## "use /d #bob to delete the local copy of the group" - bob <## "#biz: biz_1 deleted the group" + bob <## "#biz: biz_1 deleted the group (signed)" bob <## "use /d #biz to delete the local copy of the group" bob ##> ("/_connect plan 1 " <> shortLink) bob <## "business address: ok to connect" @@ -3820,8 +3823,8 @@ testShortLinkPrepareGroup = testChat3 aliceProfile bobProfile cathProfile test bob ##> "/l #team" bob <## "#team: you left the group" bob <## "use /d #team to delete the group" - alice <## "#team: bob left the group" - cath <## "#team: bob left the group" + alice <## "#team: bob left the group (signed)" + cath <## "#team: bob left the group (signed)" bob ##> ("/_connect plan 1 " <> shortLink) bob <## "group link: ok to connect directly" void $ getTermLine bob @@ -4483,7 +4486,7 @@ testShortLinkGroupChangeProfile = testChat3 aliceProfile bobProfile cathProfile alice ##> "/gp team club" alice <## "changed to #club" - cath <## "alice updated group #team:" + cath <## "alice updated group #team: (signed)" cath <## "changed to #club" bob ##> ("/_connect plan 1 " <> shortLink) @@ -4521,7 +4524,7 @@ testShortLinkGroupChangeProfileReceived = testChat3 aliceProfile bobProfile cath cath ##> "/gp team club" cath <## "changed to #club" - alice <## "cath updated group #team:" + alice <## "cath updated group #team: (signed)" alice <## "changed to #club" threadDelay 250000 diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index f16b3ac090..305207fef6 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -23,7 +23,7 @@ import Data.List (isPrefixOf, isSuffixOf) import Data.Maybe (fromMaybe) import Data.String import qualified Data.Text as T -import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), mkStoreCxt) +import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), storeCxt) import Simplex.Chat.Library.Commands (maxProfileImageSize) import Simplex.Chat.Markdown (viewName) import Simplex.Chat.Messages.CIContent (e2eInfoNoPQText, e2eInfoPQText) @@ -709,10 +709,10 @@ getCtConn cc contactId = getTestCCContact cc contactId >>= maybe (fail "no conne getTestCCContact :: TestCC -> ContactId -> IO Contact getTestCCContact cc contactId = do - let TestCC {chatController = ChatController {config}} = cc + let TestCC {chatController} = cc withCCTransaction cc $ \db -> withCCUser cc $ \user -> - runExceptT (getContact db (mkStoreCxt config) user contactId) >>= either (fail . show) pure + runExceptT (getContact db (storeCxt chatController) user contactId) >>= either (fail . show) pure lastItemId :: HasCallStack => TestCC -> IO String lastItemId cc = do diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index e6aecfd295..b93e1c8c8c 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -25,7 +25,7 @@ import qualified Data.ByteString.Lazy.Char8 as LB import Data.Time.Clock (getCurrentTime) import Data.Word (Word8, Word32) import Foreign.C -import Foreign.Marshal.Alloc (mallocBytes) +import Foreign.Marshal.Alloc (alloca, mallocBytes) import Foreign.Marshal.Utils (copyBytes) import Foreign.Ptr import Foreign.StablePtr @@ -34,7 +34,7 @@ import GHC.IO.Encoding (setLocaleEncoding, setFileSystemEncoding, setForeignEnco import JSONFixtures import Simplex.Chat import Simplex.Chat.Badges (BadgeInfo (..), BadgeRequest (..), BadgeType (..), generateMasterKey, verifyCredential) -import Simplex.Chat.Controller (ChatController (..), ChatDatabase (..)) +import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), ChatDatabase (..)) import Simplex.Chat.Mobile hiding (error) import Simplex.Chat.Mobile.Badges hiding (error) import Simplex.Chat.Mobile.File @@ -44,6 +44,8 @@ import Simplex.Chat.Options.DB import Simplex.Chat.Store import Simplex.Chat.Store.Profiles import Simplex.Chat.Types (AgentUserId (..), Profile (..)) +import Simplex.Messaging.Agent.Client (AgentClient (..)) +import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), Env (..)) 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 @@ -65,6 +67,7 @@ mobileTests = do setForeignEncoding utf8 it "start new chat without user" testChatApiNoUser it "start new chat with existing user" testChatApi + it "should set queue size via C API" testChatMigrateInitQueueCApi it "should encrypt/decrypt WebRTC frames" testMediaApi it "should encrypt/decrypt WebRTC frames via C API" testMediaCApi describe "should read/write encrypted files via C API" $ do @@ -165,6 +168,22 @@ testChatApi ps = do chatParseMarkdown "hello" `shouldBe` "{}" chatParseMarkdown "*hello*" `shouldBe` parsedMarkdown +testChatMigrateInitQueueCApi :: TestParams -> IO () +testChatMigrateInitQueueCApi ps = do + cPath <- newCString $ tmpPath ps "1" + cKey <- newCString "" + cConfirm <- newCString "yesUp" + alloca $ \ctrlPtr -> do + let migrateInit queueSize = peekCAString =<< cChatMigrateInitQueue cPath cKey cConfirm queueSize ctrlPtr + migrateInit 0 `shouldReturn` jsonStr DBMInvalidQueueSize + migrateInit (-1) `shouldReturn` jsonStr DBMInvalidQueueSize + migrateInit 65536 `shouldReturn` jsonStr DBMOk + ChatController {config = ChatConfig {tbqSize}, smpAgent = AgentClient {agentEnv = Env {config = AgentConfig {tbqSize = agentQSize}}}} <- deRefStablePtr =<< peek ctrlPtr + tbqSize `shouldBe` 65536 + agentQSize `shouldBe` 65536 + where + jsonStr = LB.unpack . J.encode + testMediaApi :: HasCallStack => TestParams -> IO () testMediaApi ps = do let tmp = tmpPath ps diff --git a/tests/NameResolver.hs b/tests/NameResolver.hs index 35f5cd2496..3020e95833 100644 --- a/tests/NameResolver.hs +++ b/tests/NameResolver.hs @@ -2,7 +2,7 @@ {-# LANGUAGE OverloadedStrings #-} -- | Local HTTP names resolver for chat tests, copied from simplexmq's --- NamesResolverServer and made dynamic: it answers /resolve/ from a +-- NamesResolverServer and made dynamic: it answers /v2/resolve/ from a -- mutable name -> NameRecord registry, so a test can resolve a name to the -- address it just created. module NameResolver @@ -21,17 +21,19 @@ import qualified Data.Aeson as J import Data.Map.Strict (Map) import qualified Data.Map.Strict as M import Data.Text (Text) +import Data.Text.Encoding (decodeLatin1) import Network.HTTP.Types (hContentType, notFound404, ok200) import Network.Wai (Application, pathInfo, responseLBS) import qualified Network.Wai.Handler.Warp as Warp -import Simplex.Messaging.Names.Record (NameRecord (..)) +import Simplex.Messaging.Encoding.String (strEncode) +import Simplex.Messaging.Names.Record (NamePricing (..), NameRecord (..), NameRegistration (..), NameResponse (..), USDCents (..)) import Simplex.Messaging.Server.Names (NamesConfig (..)) -import Simplex.Messaging.SimplexName (SimplexNameInfo (..), fullDomainName) +import Simplex.Messaging.SimplexName (SimplexDomain (..), SimplexNameInfo (..), labelHash) type NameRegistry = TVar (Map Text NameRecord) -- | Run an action with a local resolver on a free port and its registry (keyed --- by full domain name, what the resolver looks the name up by). +-- by the query the resolver looks the name up by). withNameResolver :: (Int -> TVar (Map Text NameRecord) -> IO a) -> IO a withNameResolver action = do reg <- newTVarIO M.empty @@ -41,14 +43,16 @@ withNameResolver action = do app reg req send = do (st, body) <- case pathInfo req of ["health"] -> pure (ok200, "{}") - ["resolve", d] -> maybe (notFound404, "{}") (\r -> (ok200, J.encode r)) . M.lookup d <$> readTVarIO reg + ["v2", "resolve", q] -> (\r -> (ok200, J.encode $ nameResponse r)) . M.lookup q <$> readTVarIO reg _ -> pure (notFound404, "{}") send $ responseLBS st [(hContentType, "application/json")] body + nameResponse (Just nameRecord) = NameResponse {lastBlockTs = Nothing, registration = NRRegistered {expires = Nothing, graceUntil = Nothing, reservedReason_ = Nothing, nameRecord}} + nameResponse Nothing = NameResponse {lastBlockTs = Nothing, registration = NRAvailable {pricing = NamePricing {registrationPrices = M.empty, basePrice = USDCents 1000, minLabelLength = 1}}} -- | Register a name's domain to resolve to the given record. registerName :: TVar (Map Text NameRecord) -> SimplexNameInfo -> NameRecord -> IO () -registerName reg SimplexNameInfo {nameDomain} r = - atomically $ modifyTVar' reg $ M.insert (fullDomainName nameDomain) r +registerName reg SimplexNameInfo {nameDomain = SimplexDomain {nameTLD, domain}} r = + atomically $ modifyTVar' reg $ M.insert (decodeLatin1 $ strEncode (labelHash domain) <> strEncode nameTLD) r contactNameRecord :: Text -> Text -> NameRecord contactNameRecord name link = (emptyRecord name) {nrSimplexContact = [link]} diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index cf613a1b26..633bbe876c 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -188,7 +188,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"9\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (mcSimple (MCText "hello"))) it "x.msg.new chat message with chat version range" $ - "{\"v\":\"9-19\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" + "{\"v\":\"9-20\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage supportedChatVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (mcSimple (MCText "hello"))) it "x.msg.new quote" $ "{\"v\":\"9\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}}}}" @@ -225,10 +225,10 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ mcForward (Just (testForwardLink {memberId = Nothing} :: ForwardLink)) (MCText "hello")) it "x.msg.new simple text with file" $ "{\"v\":\"9\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" - #==# XMsgNew ((mcSimple (MCText "hello")) {file = Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing}}) + #==# XMsgNew ((mcSimple (MCText "hello")) {file = Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing, fileBadge = Nothing}}) it "x.msg.new simple file with file" $ "{\"v\":\"9\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"\",\"type\":\"file\"},\"file\":{\"fileSize\":12345,\"fileName\":\"file.txt\"}}}" - #==# XMsgNew ((mcSimple (MCFile "")) {file = Just FileInvitation {fileName = "file.txt", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing}}) + #==# XMsgNew ((mcSimple (MCFile "")) {file = Just FileInvitation {fileName = "file.txt", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing, fileBadge = Nothing}}) it "x.msg.new quote with file" $ "{\"v\":\"9\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}}}}" ##==## ChatMessage @@ -236,7 +236,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do (Just $ SharedMsgId "\1\2\3\4") ( XMsgNew (mcQuote quotedMsg (MCText "hello to you too")) - {file = Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing}} + {file = Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing, fileBadge = Nothing}} ) it "x.msg.new report" $ "{\"v\":\"9\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"\",\"reason\":\"spam\",\"type\":\"report\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}}}}" @@ -246,7 +246,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do (XMsgNew (mcQuote quotedMsg (MCReport "" RRSpam))) it "x.msg.new forward with file" $ "{\"v\":\"9\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"},\"forward\":true}}" - ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ (mcForward Nothing (MCText "hello")) {file = Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing}}) + ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ (mcForward Nothing (MCText "hello")) {file = Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing, fileBadge = Nothing}}) it "x.msg.update" $ "{\"v\":\"9\",\"event\":\"x.msg.update\",\"params\":{\"msgId\":\"AQIDBA==\", \"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" #==# XMsgUpdate (SharedMsgId "\1\2\3\4") (MCText "hello") [] Nothing Nothing Nothing Nothing @@ -258,10 +258,10 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do #==# XMsgDeleted it "x.file" $ "{\"v\":\"9\",\"event\":\"x.file\",\"params\":{\"file\":{\"fileConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" - #==# XFile FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Just testConnReq, fileInline = Nothing, fileDescr = Nothing} + #==# XFile FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Just testConnReq, fileInline = Nothing, fileDescr = Nothing, fileBadge = Nothing} it "x.file without file invitation" $ "{\"v\":\"9\",\"event\":\"x.file\",\"params\":{\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" - #==# XFile FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing} + #==# XFile FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing, fileBadge = Nothing} it "x.file.acpt" $ "{\"v\":\"9\",\"event\":\"x.file.acpt\",\"params\":{\"fileName\":\"photo.jpg\"}}" #==# XFileAcpt "photo.jpg" @@ -276,42 +276,42 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do #==# XFileCancel (SharedMsgId "\1\2\3\4") it "x.info" $ "{\"v\":\"9\",\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" - #==# XInfo testProfile + #==# XInfo testProfile Nothing it "x.info with empty full name" $ "{\"v\":\"9\",\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"\",\"displayName\":\"alice\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" - #==# XInfo Profile {displayName = "alice", fullName = "", shortDescr = Nothing, description = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = testChatPreferences, badge = Nothing, contactDomain = Nothing} + #==# XInfo Profile {displayName = "alice", fullName = "", shortDescr = Nothing, description = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = testChatPreferences, badge = Nothing, contactDomain = Nothing} Nothing it "x.contact with xContactId" $ "{\"v\":\"9\",\"event\":\"x.contact\",\"params\":{\"contactReqId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" - #==# XContact testProfile (Just $ XContactId "\1\2\3\4") Nothing Nothing + #==# XContact testProfile Nothing (Just $ XContactId "\1\2\3\4") Nothing Nothing it "x.contact without XContactId" $ "{\"v\":\"9\",\"event\":\"x.contact\",\"params\":{\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" - #==# XContact testProfile Nothing Nothing Nothing + #==# XContact testProfile Nothing Nothing Nothing Nothing it "x.contact with content null" $ "{\"v\":\"9\",\"event\":\"x.contact\",\"params\":{\"content\":null,\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" - ==# XContact testProfile Nothing Nothing Nothing + ==# XContact testProfile Nothing Nothing Nothing Nothing it "x.contact with content" $ "{\"v\":\"9\",\"event\":\"x.contact\",\"params\":{\"msgId\":\"AQIDBA==\",\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" - ==# XContact testProfile Nothing Nothing (Just (SharedMsgId "\1\2\3\4", MCText {text = "hello"})) + ==# XContact testProfile Nothing Nothing Nothing (Just (SharedMsgId "\1\2\3\4", MCText {text = "hello"})) it "x.grp.inv" $ "{\"v\":\"9\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}}}}" - #==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, business = Nothing, groupLinkId = Nothing, groupSize = Nothing} + #==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, fromMemberKey = Nothing, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, business = Nothing, groupLinkId = Nothing, groupSize = Nothing} it "x.grp.inv with group link id" $ "{\"v\":\"9\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}, \"groupLinkId\":\"AQIDBA==\"}}}" - #==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, business = Nothing, groupLinkId = Just $ GroupLinkId "\1\2\3\4", groupSize = Nothing} + #==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, fromMemberKey = Nothing, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, business = Nothing, groupLinkId = Just $ GroupLinkId "\1\2\3\4", groupSize = Nothing} it "x.grp.acpt without incognito profile" $ "{\"v\":\"9\",\"event\":\"x.grp.acpt\",\"params\":{\"memberId\":\"AQIDBA==\"}}" - #==# XGrpAcpt (MemberId "\1\2\3\4") + #==# XGrpAcpt (MemberId "\1\2\3\4") Nothing it "x.grp.mem.new" $ "{\"v\":\"9\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile, memberKey = Nothing} Nothing it "x.grp.mem.new with member chat version range" $ - "{\"v\":\"9\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"9-19\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"9\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"9-20\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile, memberKey = Nothing} Nothing it "x.grp.mem.intro" $ "{\"v\":\"9\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile, memberKey = Nothing} Nothing it "x.grp.mem.intro with member chat version range" $ - "{\"v\":\"9\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"9-19\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"9\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"9-20\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile, memberKey = Nothing} Nothing it "x.grp.mem.intro with member restrictions" $ "{\"v\":\"9\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberRestrictions\":{\"restriction\":\"blocked\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" @@ -326,7 +326,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"9\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile, memberKey = Nothing} IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq} it "x.grp.mem.fwd with member chat version range and w/t directConnReq" $ - "{\"v\":\"9\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"9-19\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"9\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"9-20\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile, memberKey = Nothing} IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} it "x.grp.mem.info" $ "{\"v\":\"9\",\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" diff --git a/tests/Test.hs b/tests/Test.hs index eb82eaaa9a..580eea5944 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -3,6 +3,7 @@ {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE TupleSections #-} +import Bots.BadgeServiceTests import Bots.BroadcastTests import Bots.DirectoryTests import ChatClient @@ -91,6 +92,7 @@ main = do describe "Wallet" walletTests xdescribe'' "SimpleX Broadcast bot" broadcastBotTests xdescribe'' "SimpleX Directory service bot" directoryServiceTests + xdescribe'' "SimpleX Badge service bot" badgeServiceTests describe "Remote session" remoteTests #if !defined(dbPostgres) xdescribe'' "Save query plans" saveQueryPlans diff --git a/website/package.json b/website/package.json index 6ad5deab6b..5e4e947bcf 100644 --- a/website/package.json +++ b/website/package.json @@ -7,7 +7,7 @@ "build": "npm run build:js && npm run build:eleventy && npm run build:tailwind", "start": "npx eleventy --serve", "test": "echo \"Error: no test specified\" && exit 1", - "build:js": "cp ./node_modules/qrcode/build/qrcode.js ./src/js/ && ./copy_call.sh", + "build:js": "cp ./node_modules/qrcode/build/qrcode.js ./src/js/ && cp ./node_modules/@plausible-analytics/tracker/plausible.js ./src/js/page.js && ./copy_call.sh", "build:eleventy": "eleventy", "build:tailwind": "npx tailwindcss -i tailwind.css -o _site/css/tailwind.css", "watch:tailwind": "npx tailwindcss -i tailwind.css -o _site/css/tailwind.css --watch" @@ -29,6 +29,7 @@ "tailwindcss": "3.3.1" }, "dependencies": { + "@plausible-analytics/tracker": "0.4.6", "@simplex-chat/xftp-web": "^0.3.0", "eleventy-plugin-i18n": "^0.1.3", "fs": "^0.0.1-security", diff --git a/website/src/_includes/blog_previews/20260919.html b/website/src/_includes/blog_previews/20260919.html new file mode 100644 index 0000000000..d6333c3c62 --- /dev/null +++ b/website/src/_includes/blog_previews/20260919.html @@ -0,0 +1,5 @@ +

Supporter badges are available in v7.1 beta: a badge on your profile, larger files and longer file storage — and the purchase cannot be linked to your profile.

+ +

Investors in our equity crowdfunding receive badges as perks.

+ +

If you invest $500 or more by September 22, you will also receive a public SimpleX name for 7 years. Learn more and invest on Wefunder.

diff --git a/website/src/_includes/navbar.html b/website/src/_includes/navbar.html index b9c6313968..6de0c372e3 100644 --- a/website/src/_includes/navbar.html +++ b/website/src/_includes/navbar.html @@ -210,6 +210,8 @@ + + + + + + + + + + {% macro wfLogo() %}{% endmacro %} + + + +
+ + +
+
+
+

The first and the only messaging network without any user IDs

+

SimpleX Chat is building a messaging network unlike every major platform — without phone numbers, usernames, emails, or any user identifiers.

+

No phone numbers, emails, or accounts.

+ +
+
+ +
3MDownloads
+
+
+ +
520KMonthly users
+
+
+
+

SimpleX name for 7 years

+

Invest $500+ by the end of September 22, AoE.

+
+
+ + +
+
+ Subscribe for updates +
+ + +
+
+
+

520,000
monthly users

+

Grown with zero marketing spend: hundreds of user posts, podcasts and videos, and $650,000 in user donations, before any paid features.

+

Only $1.7M investment over 4 years — more capital efficient than most startups.

+
+
+
+ + +
+
+
+

Every other network can identify you

+

Surveillance, censorship and online crime all depend on user identifiers. AI made all three affect everyone.

+

Removing them by design, as SimpleX did, is the only solution — so you control who can reach you.

+ Why AI increases demand for privacy +
+
+
+ + +
+
+ +
+

SimpleX is a network, not an app

+

All five kinds of users arrived on their own — the cold start problem solved.

+

Each group makes the network more valuable to others, accelerating growth.

+
+
+
+ + +
+
+ +
+

Pre-revenue stage ends this year

+

Additional file capacity and SimpleX public names launch this year, followed by business messaging and paid servers for big channels.

+

Demand is proven — users donated $650,000 while everything was free.

+
+
+
+ + +
+
+
+

Design advantage that can't be copied

+

Only SimpleX has every property in this comparison — together they need a network without user identifiers.

+

A network with user IDs can't remove them — rivals would have to rebuild, and the ad giants would lose revenue that depends on user IDs.

+
+
+
+ + +
+
+
+

A network others build on

+

Developers already built many different services on SimpleX. In 2027, we plan to add support for user-created features inside chats.

+

Features and services users build would bring new users and increase revenue.

+ How custom UX helps unify messaging +
+
+
+ + +
+
+
+

Get a stake
in SimpleX Chat

+

We are building a network that people own.
We invite you to invest and become part of it.

+ +
+
+

Invest $500+ by September 22 and get a SimpleX public name for your channel or business for 7 years, ahead of public launch.

+
+
+
+ + + + +
+ + + + + + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + + + + + + diff --git a/website/src/crowdfunding.md b/website/src/crowdfunding.md deleted file mode 100644 index df386ea4ac..0000000000 --- a/website/src/crowdfunding.md +++ /dev/null @@ -1,125 +0,0 @@ ---- -layout: layouts/page.html -title: "Get a Stake in SimpleX Chat" -description: "SimpleX Chat equity crowdfunding on Wefunder - invest in the first messaging network without any user IDs." -permalink: "/crowdfunding/index.html" ---- - - - -# Get a Stake in SimpleX Chat - - - -SimpleX Chat — the company that builds the first and the only messaging network without any user identifiers — is raising its community round. By investing, you can benefit from the company growth, and help us build the future of private and secure communications. [Learn more and invest on Wefunder](https://wefunder.com/simplex.chat?utm_source=website). - - -Live event -SimpleX Chat: Foundation for the Future -Livestream and Q&A about SimpleX Chat roadmap and crowdfunding. Tuesday, September 15, 2026 at 5:00 PM UTC. -Open event page - - -## Every other network can identify users - -For 150 years, all networks required user identifiers — phone numbers, usernames, random IDs or public keys. To be reachable, you had to be identifiable. User identification gives networks power over users: to surveil their private life, to sell their data, and to revoke access. Even encrypted platforms see who you talk to, when, and where — and pseudonyms don't help, because your contacts are a fingerprint. - -Identification also gives platforms the power to censor public speech without due process. Telegram blocked 44 million groups and channels in 2025 alone. Publishers and content creators build audiences on borrowed infrastructure — one policy change, and the audience that took years to build can be gone. - -The root cause of these problems is user identifiers. The solution is not better policies or stronger encryption with the same architecture — it is a different architecture, without any user IDs. - -## The first network without any user identifiers - - - -We invented and built SimpleX Network. It uses no phone numbers, emails, or user accounts — every conversation has its own network address, removing the need for any user IDs. There is no user data on the servers that could be breached or sold — all user data exists only on user devices. The network runs on multiple independent operators — SimpleX Chat and Flux are preconfigured in the app, and anyone can run their own servers. - -SimpleX Chat also supports public groups and channels, where owners fully control them, and nobody can take their work, because only the users hold the keys. SimpleX software is open-source and based on open protocols, audited by Trail of Bits in 2022, 2024, and 2026 (to be published). - -## 480,000+ users joined on their own - - - -Over 480,000 people use SimpleX Chat every month — more than doubling every year — and all of them found it without any paid marketing. 3 million people have downloaded the app, and users send 20 million messages a day via pre-configured servers. - -Users have donated over $650,000, paying for something they could use for free. All of it was built on a total investment of just $1.7M by 5 people team over 4.5 years. - -## A network others build on - - - -In addition to people who use SimpleX Chat for private messaging, four other kinds of participants came to the network: creators with thousands of public groups and channels, businesses supporting customers over SimpleX, developers building on the open protocols (19K+ GitHub stars), and ~1,000 servers run by volunteers, plus Flux and StormyCloud. Each group makes the network more valuable to the rest, driving organic growth. - -Independent developers created moderation and AI bots, Telegram bridges, and a public server registry. Every service developers build on SimpleX Network may increase its value, and bring new users to SimpleX Chat. And the [SimpleX Network Consortium](https://simplexnetwork.org/consortium.html) — an agreement between a non-profit foundation and SimpleX Chat — prevents any single company from controlling the network. - -## Why SimpleX cannot be copied - - - -No other communication system combines scalable one-to-many delivery, sovereign ownership, infrastructure independence, and participation privacy — and removing user identifiers from an existing network is hard for three reasons: - -- **Technically**: other networks rely on user IDs to route messages — they would have to rebuild from scratch. -- **Economically**: large platforms monetize user identifiers — removing IDs would destroy their revenue model. -- **Cold start**: a newcomer would need users, creators, businesses, developers, and servers, all at the same time — while competing with the SimpleX Network. - -## Why now: three trends that may grow SimpleX - - - -- **Growing surveillance of private life**: big centralized platforms are keen to scan private messages to train their AI models, supported by proposed laws. More people would move to private messaging that keeps no record of who they talk to. -- **Accelerating deplatforming**: more and more creators are removed from centralized platforms, without any due process. An audience built over years can disappear with one policy change. -- **AI agents acting for people**: if a platform controls an agent's identity, the platform can shut the agent down or turn it against the person it works for. User-controlled IDs are a basic security for the agentic Internet, not a preference. - -All three trends increase the demand for identity-free messaging. None of the existing networks can provide it. - -## Revenue plan: free for users, channels & businesses pay - - - -Private messaging should remain free for the users. Instead, we plan to earn from the infrastructure and services that channels and businesses need: - -- **SimpleX public names** — globally unique names for a public channel or business, paid yearly and controlled by the owner's key, so no one can seize them. If you invest $500 or more, you would receive a name as a perk for 7 years, if you invest by September 22, for 5 years during early bird, and for 3 years after that. -- **Business messaging** — a web widget that adds encrypted chat to any website with one line of code, built for privacy-first businesses and sectors where anonymity is required. -- **Paid servers for big channels** — channels that have grown beyond the free tier pay for the servers that deliver their messages, with server operators earning most of the revenue. - -Read about how we plan to make SimpleX Chat and network profitable, and about all the investment terms, on Wefunder. - -## Get a stake - -We are building a network that people own. Businesses, creators, and publishers keep the audiences and communities they build, and no company can take them away or unilaterally shut them down. By investing, you benefit from the company growth, and become part of building that future. - - - - diff --git a/website/src/css/livestream.css b/website/src/css/livestream.css index 9928664c99..831f4a0569 100644 --- a/website/src/css/livestream.css +++ b/website/src/css/livestream.css @@ -63,7 +63,7 @@ main .section-bg { color: #ffffff; } -.livestream .text-container .event-utc, +.livestream .text-container .event-date, .livestream .text-container .event-join { font-family: "Manrope", sans-serif; font-weight: 300; @@ -73,7 +73,7 @@ main .section-bg { color: #000000; } -.dark .livestream .text-container .event-utc, +.dark .livestream .text-container .event-date, .dark .livestream .text-container .event-join { color: #ffffff; } @@ -86,14 +86,6 @@ main .section-bg { font-weight: inherit; } -.livestream .text-container .event-join .join-count { - margin-right: 0.3em; -} - -.livestream .text-container .event-join.counting .join-where { - display: block; -} - .livestream .text-container .event-join a { color: #009df7; font-family: inherit; @@ -114,12 +106,7 @@ main .section-bg { margin-top: calc(var(--block-gap) * 0.5); } -.livestream .text-container .invest-link { - max-width: none; - font-size: calc(var(--sec-vwu) * 1.35); -} - -.livestream .text-container .register-btn { +.livestream .text-container .invest-btn { display: inline-flex; align-items: center; justify-content: center; @@ -134,7 +121,7 @@ main .section-bg { text-decoration: none; } -.dark .livestream .text-container .register-btn { +.dark .livestream .text-container .invest-btn { background: #70f0f9; color: #000000; } @@ -175,7 +162,7 @@ main .section-bg { max-width: calc(var(--sec-vwu) * 80); } - .livestream .text-container .event-utc, + .livestream .text-container .event-date, .livestream .text-container .event-join { font-size: calc(var(--sec-vwu) * 3.9); max-width: calc(var(--sec-vwu) * 80); @@ -185,204 +172,8 @@ main .section-bg { font-size: calc(var(--sec-vwu) * 3.3); } - .livestream .text-container .register-btn { + .livestream .text-container .invest-btn { padding: calc(var(--sec-vhu) * 1.4) calc(var(--sec-vwu) * 5); font-size: calc(var(--sec-vwu) * 3.7); } - - .livestream .text-container .invest-link { - font-size: calc(var(--sec-vwu) * 3.4); - } -} - -.register-overlay { - position: fixed; - inset: 0; - z-index: 60000; - display: none; - align-items: center; - justify-content: center; - padding: 16px; - background: rgba(3, 12, 40, 0.6); -} - -.register-overlay.open { - display: flex; -} - -.register-card { - position: relative; - width: 100%; - max-width: 580px; - padding: 48px 44px 44px; - border-radius: 12px; - background: #ffffff; - box-shadow: 0 3px 12px rgba(0, 0, 0, 0.2); -} - -.dark .register-card { - background: #071c46; -} - -.register-card h2 { - font-family: "GT-Walsheim", sans-serif; - font-weight: 500; - font-size: 28px; - line-height: 1.2; - color: #023789; -} - -.dark .register-card h2 { - color: #ffffff; -} - -.register-card p { - margin-top: 14px; - font-family: "Manrope", sans-serif; - font-weight: 300; - font-size: 16px; - line-height: 1.5; - color: #3f484b; -} - -.dark .register-card p { - color: #ffffff; -} - -.register-card form { - display: flex; - flex-wrap: wrap; - gap: 12px; - margin-top: 28px; -} - -.register-card input[type="email"] { - flex: 1; - min-width: 260px; - height: 52px; - padding: 0 22px; - border: 1px solid #0053d0; - border-radius: 9999px; - background: transparent; - font-family: "Manrope", sans-serif; - font-size: 16px; - color: #0d0e12; -} - -.dark .register-card input[type="email"] { - border-color: #70f0f9; - color: #ffffff; -} - -.register-card .bot-field { - position: absolute; - left: -5000px; -} - -.register-card input[type="submit"] { - height: 52px; - min-width: 164px; - padding: 0 34px; - border: none; - border-radius: 9999px; - background: #0053d0; - font-family: "Manrope", sans-serif; - font-weight: 500; - font-size: 16px; - color: #ffffff; - cursor: pointer; -} - -.dark .register-card input[type="submit"] { - background: #70f0f9; - color: #000000; -} - -.register-card .register-note { - margin-top: 10px; - font-size: 16px; - color: #6b7478; -} - -.dark .register-card .register-note { - color: #9fb2c9; -} - -.register-card .channel-link { - display: inline-flex; - align-items: center; - margin-top: 18px; - font-family: "Manrope", sans-serif; - font-weight: 500; - font-size: 16px; - color: #0053d0; - text-decoration: none; -} - -.register-card .channel-link:hover { - text-decoration: underline; - text-underline-offset: 3px; -} - -.dark .register-card .channel-link { - color: #70f0f9; -} - -.register-card .channel-link svg { - width: 0.82em; - height: 0.82em; - margin-left: 0.4em; - fill: currentColor; - flex: none; -} - -.register-card .close-register { - position: absolute; - top: 16px; - right: 16px; - background: transparent; - border: none; - cursor: pointer; - line-height: 0; -} - -.register-card .close-register svg { - width: 13px; - height: auto; - fill: #023789; -} - -.dark .register-card .close-register svg { - fill: #ffffff; -} - -.mc-frame { - position: absolute; - left: -5000px; - width: 0; - height: 0; - border: 0; -} - -.mc-frame-card { - max-width: 640px; - padding: 44px 0 0; - overflow: hidden; - background: #ffffff; -} - -.dark .mc-frame-card { - background: #ffffff; -} - -.mc-frame-card .close-register svg, -.dark .mc-frame-card .close-register svg { - fill: #023789; -} - -#subscribe-failed.open .mc-frame { - position: static; - display: block; - width: 100%; - height: min(520px, calc(100svh - 140px)); } diff --git a/website/src/img/crowdfunding/ai.png b/website/src/img/crowdfunding/ai.png new file mode 100644 index 0000000000..c207b4d24a Binary files /dev/null and b/website/src/img/crowdfunding/ai.png differ diff --git a/website/src/img/crowdfunding/cloud-download.png b/website/src/img/crowdfunding/cloud-download.png new file mode 100644 index 0000000000..a5ab510952 Binary files /dev/null and b/website/src/img/crowdfunding/cloud-download.png differ diff --git a/website/src/img/crowdfunding/comparison.png b/website/src/img/crowdfunding/comparison.png new file mode 100644 index 0000000000..6d7e4caa77 Binary files /dev/null and b/website/src/img/crowdfunding/comparison.png differ diff --git a/website/src/img/crowdfunding/github-stars.png b/website/src/img/crowdfunding/github-stars.png new file mode 100644 index 0000000000..1629e1b5c6 Binary files /dev/null and b/website/src/img/crowdfunding/github-stars.png differ diff --git a/website/src/img/crowdfunding/p2p.png b/website/src/img/crowdfunding/p2p.png new file mode 100644 index 0000000000..a5a0f938b6 Binary files /dev/null and b/website/src/img/crowdfunding/p2p.png differ diff --git a/website/src/img/crowdfunding/phone-mask.png b/website/src/img/crowdfunding/phone-mask.png new file mode 100644 index 0000000000..93d675f770 Binary files /dev/null and b/website/src/img/crowdfunding/phone-mask.png differ diff --git a/website/src/img/crowdfunding/phone.png b/website/src/img/crowdfunding/phone.png new file mode 100644 index 0000000000..18116a8c0c Binary files /dev/null and b/website/src/img/crowdfunding/phone.png differ diff --git a/website/src/img/crowdfunding/problem.png b/website/src/img/crowdfunding/problem.png new file mode 100644 index 0000000000..b76f36ea0d Binary files /dev/null and b/website/src/img/crowdfunding/problem.png differ diff --git a/website/src/img/crowdfunding/simplex-chat-pitch-deck.pdf b/website/src/img/crowdfunding/simplex-chat-pitch-deck.pdf new file mode 100644 index 0000000000..9b539bbc5f Binary files /dev/null and b/website/src/img/crowdfunding/simplex-chat-pitch-deck.pdf differ diff --git a/website/src/img/crowdfunding/simplex-growth-2.png b/website/src/img/crowdfunding/simplex-growth-2.png new file mode 100644 index 0000000000..671865cbe6 Binary files /dev/null and b/website/src/img/crowdfunding/simplex-growth-2.png differ diff --git a/website/src/img/crowdfunding/simplex-growth.png b/website/src/img/crowdfunding/simplex-growth.png new file mode 100644 index 0000000000..87382641a4 Binary files /dev/null and b/website/src/img/crowdfunding/simplex-growth.png differ diff --git a/website/src/js/livestream.js b/website/src/js/livestream.js index a0375f722f..fde531a93d 100644 --- a/website/src/js/livestream.js +++ b/website/src/js/livestream.js @@ -1,100 +1,3 @@ -function showLocalTime() { - const utc = document.querySelector('.event-utc'); - if (!utc) return; - - const start = new Date(utc.dateTime); - if (isNaN(start.getTime())) return; - - const format = new Intl.DateTimeFormat('en-US', { - weekday: 'long', - month: 'long', - day: 'numeric', - hour: 'numeric', - minute: '2-digit', - timeZoneName: 'short' - }); - - utc.textContent = format.format(start); -} - -function startCountdown() { - const utc = document.querySelector('.event-utc'); - const join = document.querySelector('.event-join'); - const count = join && join.querySelector('.join-count'); - if (!utc || !count) return; - - const start = new Date(utc.dateTime); - if (isNaN(start.getTime())) return; - - const pad = (n) => String(n).padStart(2, '0'); - - function tick() { - const left = Math.floor((start.getTime() - Date.now()) / 1000); - if (left <= 0) { - count.textContent = 'now'; - join.classList.remove('counting'); - } else { - const days = Math.floor(left / 86400); - const dayPart = days ? days + (days === 1 ? ' day ' : ' days ') : ''; - count.textContent = 'in ' + dayPart - + pad(Math.floor((left % 86400) / 3600)) + ' hrs ' - + pad(Math.floor((left % 3600) / 60)) + ' min ' - + pad(left % 60) + ' sec'; - join.classList.add('counting'); - } - count.removeAttribute('hidden'); - } - - tick(); - setInterval(tick, 1000); -} - -function setSignupSource() { - const field = document.querySelector('input[name="SOURCE"]'); - if (!field) return; - - const inHash = new URLSearchParams(location.hash.replace(/^#\??/, '')).get('utm_source'); - const source = inHash ?? new URLSearchParams(location.search).get('utm_source'); - if (source && /^[\w.-]{1,40}$/.test(source)) field.value = source; -} - -function setupRegisterOverlay() { - const overlay = document.getElementById('register'); - const openBtn = document.querySelector('.register-btn'); - if (!overlay || !openBtn) return; - - const email = overlay.querySelector('input[type="email"]'); - const form = overlay.querySelector('form'); - - function openOverlay(e) { - e.preventDefault(); - overlay.classList.add('open'); - document.documentElement.classList.add('lock-scroll'); - email.focus(); - } - - function closeOverlay() { - overlay.classList.remove('open'); - document.documentElement.classList.remove('lock-scroll'); - } - - openBtn.addEventListener('click', openOverlay); - form.addEventListener('submit', () => { - const submit = form.querySelector('[type="submit"]'); - if (!submit) return; - setTimeout(() => { - submit.disabled = true; - submit.value = 'Submitting...'; - }, 0); - }); - overlay.addEventListener('click', (e) => { - if (e.target === overlay || e.target.closest('.close-register')) closeOverlay(); - }); - document.addEventListener('keydown', (e) => { - if (e.key === 'Escape' && overlay.classList.contains('open')) closeOverlay(); - }); -} - function trackNavColor() { const footer = document.querySelector('.footer.page'); const mobileHeader = document.getElementById('mobile-header'); @@ -112,8 +15,4 @@ function trackNavColor() { observer.observe(footer); } -showLocalTime(); -startCountdown(); -setSignupSource(); -setupRegisterOverlay(); trackNavColor(); diff --git a/website/src/livestream.html b/website/src/livestream.html index 262a2cf4af..ee1a242730 100644 --- a/website/src/livestream.html +++ b/website/src/livestream.html @@ -1,6 +1,6 @@ --- -title: "SimpleX Chat: Foundation for the Future - livestream and Q&A" -description: "Livestream and Q&A for SimpleX Chat equity crowdfunding investors, on Tuesday, September 15, 2026 at 5:00 PM UTC. Register for event updates." +title: "SimpleX Chat: Foundation for the Future - livestream recording" +description: "Recording of the livestream and Q&A about SimpleX Chat roadmap and crowdfunding, September 15, 2026." permalink: /livestream/ templateEngineOverride: njk --- @@ -73,13 +73,12 @@ templateEngineOverride: njk

Livestream and Q&A about SimpleX Chat roadmap and crowdfunding

- -

Join us on X, YouTube or our page

+

Recorded on September 15, 2026

+

Watch on X or YouTube

@@ -89,107 +88,7 @@ templateEngineOverride: njk {% include "footer.html" %} -
- -
- -
- -
- -
- -
- -
- -
- - - -