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/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/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index 85963202c4..432ac44511 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -192,6 +192,10 @@ 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 apiAckBadgeAlert(userId: Int64, badgePurchaseId: Int64, alertKind: BadgeAlertKind, snooze: Bool, episode: String) // misc case showVersion case getAgentSubsTotal(userId: Int64) @@ -417,6 +421,10 @@ 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 .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)" @@ -605,6 +613,9 @@ enum ChatCommand: ChatCmdProtocol { case .apiUploadStandaloneFile: return "apiUploadStandaloneFile" case .apiDownloadStandaloneFile: return "apiDownloadStandaloneFile" case .apiStandaloneFileInfo: return "apiStandaloneFileInfo" + case .apiRedeemBadgeCode: return "apiRedeemBadgeCode" + case .apiGetBadgeState: return "apiGetBadgeState" + case .apiAckBadgeAlert: return "apiAckBadgeAlert" case .showVersion: return "showVersion" case .getAgentSubsTotal: return "getAgentSubsTotal" case .getAgentServersSummary: return "getAgentServersSummary" @@ -658,6 +669,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 } } @@ -684,6 +698,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 { "" @@ -1026,6 +1051,10 @@ 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?) var responseType: String { switch self { @@ -1077,6 +1106,8 @@ enum ChatResponse2: Decodable, ChatAPIResult { case .archiveExported: "archiveExported" case .archiveImported: "archiveImported" case .appSettings: "appSettings" + case .badgeRedeemed: "badgeRedeemed" + case .badgeState: "badgeState" } } @@ -1130,6 +1161,8 @@ 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)) } } } @@ -1212,6 +1245,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 { @@ -1284,6 +1320,8 @@ enum ChatEvent: Decodable, ChatAPIResult { case .remoteCtrlConnected: "remoteCtrlConnected" case .remoteCtrlStopped: "remoteCtrlStopped" case .contactPQEnabled: "contactPQEnabled" + case .badgeChanged: "badgeChanged" + case .badgeAlert: "badgeAlert" } } @@ -1366,6 +1404,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 91b098c482..5aae0755bf 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -376,6 +376,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 20efa8e461..a63f6e3cb9 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 } } @@ -495,9 +499,9 @@ func loadChat(chatId: ChatId, im: ItemsModel, contentTag: MsgContentTag? = nil, ) } -func apiGetChatItemInfo(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64) async throws -> ChatItemInfo { +func apiGetChatItemInfo(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64) async throws -> (AChatItem, ChatItemInfo) { let r: ChatResponse0 = try await chatSendCmd(.apiGetChatItemInfo(type: type, id: id, scope: scope, itemId: itemId)) - if case let .chatItemInfo(_, _, chatItemInfo) = r { return chatItemInfo } + if case let .chatItemInfo(_, aci, chatItemInfo) = r { return (aci, chatItemInfo) } throw r.unexpected } @@ -2199,6 +2203,68 @@ 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 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 @@ -2369,12 +2435,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() @@ -2387,6 +2475,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 @@ -2965,6 +3054,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..bca1f7da40 --- /dev/null +++ b/apps/ios/Shared/Views/Badges/BadgesHowItWorksView.swift @@ -0,0 +1,46 @@ +// +// 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 private badges work") + .font(.largeTitle) + .bold() + .foregroundColor(theme.colors.primary) + .padding(.top, 8) + .padding(.bottom, 16) + ScrollView { + VStack(alignment: .leading, spacing: 12) { + Text("A badge is not an account. It is a signed credential stored on your device. It does not identify you, and no one keeps a record of who holds which badge.") + Text("Your contacts see the badge and its expiry date, and nothing else. The badge carries no identifier, so it cannot be used to find out who you are or to match you across chats.") + Text("Payment and badge are kept apart. Paying is one step; the badge is issued in another, under a key that exists only for that badge. Whoever handles the payment cannot see where the badge ends up.") + } + .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/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..16e1796e96 --- /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 from your receipt.") + .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..2fc904197d --- /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("SimpleX doesn't sell ads or data. It's funded by its users and by investors who share the mission. You can support the project and show a badge 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 private badges work").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..07b6d7a3dc --- /dev/null +++ b/apps/ios/Shared/Views/Badges/BadgesYourBadgeView.swift @@ -0,0 +1,85 @@ +// +// 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 + 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 private badges work") + } + } + } + } + } + .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..eb2396c655 --- /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 private badges work").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..dad3bb1b0a --- /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 + files up to 5GB" + 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 fc46669cee..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 { @@ -126,7 +121,7 @@ struct CIFileView: View { } case let .rcvError(rcvFileError): logger.debug("CIFileView fileAction - in .rcvError") - showFileErrorAlert(rcvFileError) + showFileErrorAlert(rcvFileError, file) case let .rcvWarning(rcvFileError): logger.debug("CIFileView fileAction - in .rcvWarning") showFileErrorAlert(rcvFileError, temporary: true) @@ -171,10 +166,12 @@ 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) { - fileIcon("arrow.down.doc.fill", color: theme.colors.primary) - } else { + if !fileSizeValid(file) { fileIcon("doc.fill", color: .orange, innerIcon: "exclamationmark", innerIconSize: 12) + } else if file.expired { + fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10) + } else { + fileIcon("arrow.down.doc.fill", color: theme.colors.primary) } case .rcvAccepted: fileIcon("doc.fill", innerIcon: "ellipsis", innerIconSize: 12) case let .rcvTransfer(rcvProgress, rcvTotal): @@ -233,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) @@ -264,7 +275,14 @@ func saveCryptoFile(_ fileSource: CryptoFile) { } } -func showFileErrorAlert(_ err: FileError, temporary: Bool = false) { +func showFileErrorAlert(_ err: FileError, _ file: CIFile? = nil, temporary: Bool = false) { + if let file, file.expired, let fileExpires = file.fileExpires, err == .auth || err == .noFile { + showAlert( + NSLocalizedString("File expired", comment: "file error alert title"), + message: String.localizedStringWithFormat(NSLocalizedString("File was available until %@.", comment: "file error text"), localTimestamp(fileExpires)) + ) + return + } let title: String = if temporary { NSLocalizedString("Temporary file error", comment: "file error alert title") } else { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift index 972e9c4ec6..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 { @@ -83,7 +78,7 @@ struct CIImageView: View { case .rcvComplete: () // ? case .rcvCancelled: () // TODO case let .rcvError(rcvFileError): - showFileErrorAlert(rcvFileError) + showFileErrorAlert(rcvFileError, file) case let .rcvWarning(rcvFileError): showFileErrorAlert(rcvFileError, temporary: true) case let .sndError(sndFileError): @@ -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("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 912fde4043..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("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 { @@ -382,7 +380,7 @@ struct CIVideoView: View { case let .rcvError(rcvFileError): fileIcon("xmark", 10, 13) .simultaneousGesture(TapGesture().onEnded { - showFileErrorAlert(rcvFileError) + showFileErrorAlert(rcvFileError, file) }) case let .rcvWarning(rcvFileError): fileIcon("exclamationmark.triangle.fill", 10, 13) @@ -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/CIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift index 820074542f..64fa2a4dd3 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift @@ -178,7 +178,7 @@ struct VoiceMessagePlayer: View { .simultaneousGesture(TapGesture().onEnded { showFileErrorAlert(sndFileError, temporary: true) }) - case .rcvInvitation: downloadButton(recordingFile, "play.fill") + case .rcvInvitation: downloadButton(recordingFile, recordingFile.expired ? "multiply" : "play.fill") case .rcvAccepted: loadingIcon() case .rcvTransfer: loadingIcon() case .rcvAborted: downloadButton(recordingFile, "exclamationmark.arrow.circlepath") @@ -187,7 +187,7 @@ struct VoiceMessagePlayer: View { case let .rcvError(rcvFileError): fileStatusIcon("multiply", 14) .simultaneousGesture(TapGesture().onEnded { - showFileErrorAlert(rcvFileError) + showFileErrorAlert(rcvFileError, recordingFile) }) case let .rcvWarning(rcvFileError): fileStatusIcon("exclamationmark.triangle.fill", 16) 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/ChatItemInfoView.swift b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift index 2213b34586..15effaeea6 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift @@ -162,6 +162,9 @@ struct ChatItemInfoView: View { if let deleteAt = meta.itemTimed?.deleteAt { infoRow("Disappears at", localTimestamp(deleteAt)) } + if let file = ci.file, let fileExpires = file.fileExpires { + infoRow(file.expired ? "File was available until" : "File available until", localTimestamp(fileExpires)) + } if meta.msgVerified?.verified == true { let signedText: LocalizedStringKey = ci.chatDir.sent ? "Signed" : "Signed & verified" HStack { diff --git a/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift b/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift index 9987fb4697..5213b5509b 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift @@ -31,11 +31,11 @@ func apiLoadMessages( let chatModel = ChatModel.shared - // For .initial allow the chatItems to be empty as well as chatModel.chatId to not match this chat because these values become set after .initial finishes let paginationIsInitial = switch pagination { case .initial: true; default: false } let paginationIsLast = switch pagination { case .last: true; default: false } - // When openAroundItemId is provided, chatId can be different too - if ((chatModel.chatId != chat.id || chat.chatItems.isEmpty) && !paginationIsInitial && !paginationIsLast && openAroundItemId == nil) || Task.isCancelled { + // For .initial allow the chatItems to be empty, as well as for .last that is used for searching + let allowEmptyItems = paginationIsInitial || paginationIsLast || openAroundItemId != nil + if chatWasSwitched(chat, pagination, openAroundItemId) || (!allowEmptyItems && chat.chatItems.isEmpty) || Task.isCancelled { return } @@ -79,6 +79,7 @@ func apiLoadMessages( newItems.insert(contentsOf: chat.chatItems, at: insertAt) let newReversed: [ChatItem] = newItems.reversed() await MainActor.run { + if chatWasSwitched(chat, pagination, openAroundItemId) { return } im.reversedChatItems = newReversed im.chatState.splits = modifiedSplits.newSplits im.chatState.moveUnreadAfterItem(modifiedSplits.oldUnreadSplitIndex, modifiedSplits.newUnreadSplitIndex, oldItems) @@ -99,6 +100,7 @@ func apiLoadMessages( let new: [ChatItem] = newItems let newReversed: [ChatItem] = newItems.reversed() await MainActor.run { + if chatWasSwitched(chat, pagination, openAroundItemId) { return } im.reversedChatItems = newReversed im.chatState.splits = newSplits im.chatState.moveUnreadAfterItem(im.chatState.splits.first ?? new.last!.id, new) @@ -122,6 +124,7 @@ func apiLoadMessages( let newReversed: [ChatItem] = newItems.reversed() let orderedSplits = newSplits await MainActor.run { + if chatWasSwitched(chat, pagination, openAroundItemId) { return } im.reversedChatItems = newReversed im.chatState.splits = orderedSplits im.chatState.unreadAfterItemId = chat.chatItems.last!.id @@ -147,6 +150,7 @@ func apiLoadMessages( newItems.append(contentsOf: chat.chatItems) let items = newItems await MainActor.run { + if chatWasSwitched(chat, pagination, openAroundItemId) { return } im.reversedChatItems = items.reversed() im.chatState.splits = newSplits if im.secondaryIMFilter == nil { @@ -158,6 +162,14 @@ func apiLoadMessages( } +/// .initial pagination and opening around item set ChatModel.chatId themselves after the items are loaded. +/// In other cases the chat could be switched while the items were loading, and the items of the previously opened chat +/// must not be added to the items of the currently opened one +private func chatWasSwitched(_ chat: Chat, _ pagination: ChatPagination, _ openAroundItemId: ChatItem.ID?) -> Bool { + let paginationIsInitial = switch pagination { case .initial: true; default: false } + return !paginationIsInitial && openAroundItemId == nil && ChatModel.shared.chatId != chat.id +} + private class ModifiedSplits { let oldUnreadSplitIndex: Int let newUnreadSplitIndex: Int diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index bc55fc6174..d18d8ae145 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -1719,6 +1719,7 @@ struct ChatView: View { @State private var showArchivingReports = false @State private var showChatItemInfoSheet: Bool = false @State private var chatItemInfo: ChatItemInfo? + @State private var chatItemInfoItem: ChatItem? @State private var msgWidth: CGFloat = 0 @State private var touchInProgress: Bool = false @@ -2230,8 +2231,9 @@ struct ChatView: View { .frame(minWidth: 0, maxWidth: .infinity, alignment: alignment) .sheet(isPresented: $showChatItemInfoSheet, onDismiss: { chatItemInfo = nil + chatItemInfoItem = nil }) { - ChatItemInfoView(ci: ci, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, chatItemInfo: $chatItemInfo) + ChatItemInfoView(ci: chatItemInfoItem ?? ci, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, chatItemInfo: $chatItemInfo) } } @@ -2320,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 { @@ -2574,8 +2576,9 @@ struct ChatView: View { Task { do { let cInfo = chat.chatInfo - let ciInfo = try await apiGetChatItemInfo(type: cInfo.chatType, id: cInfo.apiId, scope: cInfo.groupChatScope(), itemId: ci.id) + let (aci, ciInfo) = try await apiGetChatItemInfo(type: cInfo.chatType, id: cInfo.apiId, scope: cInfo.groupChatScope(), itemId: ci.id) await MainActor.run { + chatItemInfoItem = aci.chatItem chatItemInfo = ciInfo } if case let .group(gInfo, _) = chat.chatInfo { 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 712c4cba2e..30f7f1bfb0 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -154,6 +154,7 @@ 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 @@ -175,7 +176,9 @@ 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_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial + @State private var showBadgesSheet = false // Spec: spec/client/chat-list.md#body var body: some View { @@ -212,6 +215,12 @@ struct ChatListView: View { NewChatSheet() .environment(\EnvironmentValues.refresh as! WritableKeyPath, nil) } + .appSheet(isPresented: $showBadgesSheet) { + NavigationView { + BadgesView(showsAsSheet: true) + .modifier(ThemedBackground()) + } + } .onChange(of: activeUserPickerSheet) { if $0 != nil { DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { @@ -364,8 +373,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("Support ended", 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 { @@ -459,6 +502,30 @@ 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: "Support ended", + subtitle: "Your support ended 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) + } if #available(iOS 16.0, *) { ForEach(cs, id: \.viewId) { chat in ChatListNavLink(chat: chat, parentSheet: $sheet) @@ -605,7 +672,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/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 67f6d6d6b2..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) { @@ -162,8 +161,8 @@ func showBadgeInfoAlert(_ name: String, _ badge: LocalBadge) { } else { // supporter, legend and unknown types use the supporter wording let supports = - if badge.status == .expired, let expiry = badge.badge.badgeExpiry { - String.localizedStringWithFormat(NSLocalizedString("%1$@ supported SimpleX Chat. The badge expired on %2$@.", comment: "badge alert"), name, expiry.formatted(date: .abbreviated, time: .omitted)) + if badge.status == .expired { + String.localizedStringWithFormat(NSLocalizedString("%1$@ supported SimpleX Chat. The badge expired on %2$@.", comment: "badge alert"), name, badge.badge.badgeExpiry.formatted(date: .abbreviated, time: .omitted)) } else { String.localizedStringWithFormat(NSLocalizedString("%@ supports SimpleX Chat.", comment: "badge alert"), name) } @@ -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..2fd21e39dd 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) { diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index f8340543fd..7b86329eb0 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -57,6 +57,7 @@ let DEFAULT_CHAT_ITEM_TAIL = "chatItemTail" let DEFAULT_ONE_HAND_UI_CARD_SHOWN = "oneHandUICardShown" let DEFAULT_ADDRESS_CREATION_CARD_SHOWN = "addressCreationCardShown" let DEFAULT_DIRECTORY_SEARCH_ALERT_SHOWN = "directorySearchAlertShown" +let DEFAULT_SUPPORTER_BANNER_SHOWN = "supporterBannerShown" let DEFAULT_TOOLBAR_MATERIAL = "toolbarMaterial" let DEFAULT_CONNECT_VIA_LINK_TAB = "connectViaLinkTab" let DEFAULT_LIVE_MESSAGE_ALERT_SHOWN = "liveMessageAlertShown" @@ -119,6 +120,7 @@ let appDefaults: [String: Any] = [ DEFAULT_ONE_HAND_UI_CARD_SHOWN: false, DEFAULT_ADDRESS_CREATION_CARD_SHOWN: false, DEFAULT_DIRECTORY_SEARCH_ALERT_SHOWN: false, + DEFAULT_SUPPORTER_BANNER_SHOWN: false, DEFAULT_TOOLBAR_MATERIAL: ToolbarMaterial.defaultMaterial, DEFAULT_CONNECT_VIA_LINK_TAB: ConnectViaLinkTab.scan.rawValue, DEFAULT_LIVE_MESSAGE_ALERT_SHOWN: false, @@ -151,6 +153,7 @@ let hintDefaults = [ DEFAULT_ONE_HAND_UI_CARD_SHOWN, DEFAULT_ADDRESS_CREATION_CARD_SHOWN, DEFAULT_DIRECTORY_SEARCH_ALERT_SHOWN, + DEFAULT_SUPPORTER_BANNER_SHOWN, DEFAULT_LIVE_MESSAGE_ALERT_SHOWN, DEFAULT_SIGN_MESSAGE_ALERT_SHOWN, DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE, @@ -344,6 +347,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() @@ -378,7 +397,7 @@ struct SettingsView: View { NavigationLink { VersionView() .navigationBarTitle("App version") - .modifier(ThemedBackground()) + .modifier(ThemedBackground(grouped: true)) } label: { Text(verbatim: "v\(appVersion ?? "?")") } diff --git a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift index b9048becda..9711f3d6f8 100644 --- a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift @@ -802,6 +802,8 @@ private func saveAddressSettings(_ settings: AddressSettingsState, _ savedSettin } } +private let simplexNameSaleStart = Calendar(identifier: .gregorian).date(from: DateComponents(timeZone: TimeZone(identifier: "UTC"), year: 2026, month: 12, day: 12, hour: 18))! + struct SetSimplexDomainView: View { let title: LocalizedStringKey let footer: LocalizedStringKey @@ -815,6 +817,8 @@ struct SetSimplexDomainView: View { @State private var original = "" @State private var didSave = false @State private var editing = false + @State private var timeToSaleStart = simplexNameSaleStart.timeIntervalSinceNow + @State private var saleTimer: Timer? = nil @FocusState private var nameFocused: Bool init(title: LocalizedStringKey, footer: LocalizedStringKey, prompt: String, simplexName: String, broadcastWarning: String? = nil, save: @escaping (String?) async -> Bool) { @@ -873,7 +877,7 @@ struct SetSimplexDomainView: View { Section { if editing { Button { - openBrowserAlert(uri: "https://github.com/simplex-chat/simplex-chat/blob/master/docs/guide/register-simplex-name.md") + openBrowserAlert(uri: "https://simplex.domains/#testing") } label: { HStack { Text("How to register a test name") @@ -901,15 +905,39 @@ struct SetSimplexDomainView: View { } } } + Section { + VStack(alignment: .leading, spacing: 4) { + Text(verbatim: saleCountdown(timeToSaleStart)) + Text(timeToSaleStart > 0 ? "until you can register a SimpleX domain" : "Update the app to register a SimpleX domain") + .font(.caption) + .foregroundColor(theme.colors.secondary) + } + } header: { + Text("SimpleX name sale starts in") + .foregroundColor(theme.colors.secondary) + } footer: { + Text("Crowdfunding investors can reserve names before the sale starts: [simplex.domains](https://simplex.domains/)") + .foregroundColor(theme.colors.secondary) + .padding(.bottom) + } } + .modifier(ThemedBackground(grouped: true)) .navigationTitle(title) .navigationBarTitleDisplayMode(.large) .onAppear { if editing { DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { nameFocused = true } } + if timeToSaleStart > 0 { + saleTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { t in + timeToSaleStart = simplexNameSaleStart.timeIntervalSinceNow + if timeToSaleStart <= 0 { t.invalidate() } + } + } } .onDisappear { + saleTimer?.invalidate() + saleTimer = nil if !didSave, !saving, changed, isValid { let domain = normalized(simplexName) let saveName = save @@ -948,6 +976,21 @@ struct SetSimplexDomainView: View { : addSimplexTLD((t.hasPrefix("@") || t.hasPrefix("#") ? String(t.dropFirst()) : t).lowercased()) } + private func saleCountdown(_ remaining: TimeInterval) -> String { + let total = max(0, Int(remaining)) + let days = total / 86400 + let dayStr = String.localizedStringWithFormat( + days == 1 + ? NSLocalizedString("%d day", comment: "time interval") + : NSLocalizedString("%d days", comment: "time interval"), + days + ) + return dayStr + " " + String.localizedStringWithFormat( + NSLocalizedString("%02d hrs %02d min %02d sec", comment: "countdown"), + total / 3600 % 24, total / 60 % 60, total % 60 + ) + } + private func addSimplexTLD(_ d: String) -> String { if d.contains(".") { d } else { "\(d).simplex" } } 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 Localizations/az.xcloc/Localized Contents/az.xliff b/apps/ios/SimpleX Localizations/az.xcloc/Localized Contents/az.xliff new file mode 100644 index 0000000000..b8e7c44a67 --- /dev/null +++ b/apps/ios/SimpleX Localizations/az.xcloc/Localized Contents/az.xliff @@ -0,0 +1,10951 @@ + + + +
+ +
+ + + (can be copied) + (kopyalana bilər) + No comment provided by engineer. + + + !1 colored! + !1 rəngli! + No comment provided by engineer. + + + # %@ + # %@ + copied message info title, # <title> + + + ## History + ## Tarixçə + copied message info + + + ## In reply to + ## Cavab olaraq + copied message info + + + #secret# + #gizli# + No comment provided by engineer. + + + %1$@ supported SimpleX Chat. The badge expired on %2$@. + %1$@ SimpleX Chat-ı dəstəklədi. Nişanın müddəti %2$@ tarixində bitdi. + badge alert + + + %@ + %@ + No comment provided by engineer. + + + %@ %@ + %@ %@ + No comment provided by engineer. + + + %@ (current) + %@ (cari) + No comment provided by engineer. + + + %@ (current): + %@ (cari): + copied message info + + + %@ / %@ + %@ / %@ + No comment provided by engineer. + + + %@ and %@ + %@ və %@ + No comment provided by engineer. + + + %@ and %@ connected + %@ və %@ qoşuldu + No comment provided by engineer. + + + %1$@ at %2$@: + %2$@ tarixində %1$@: + copied message info, <sender> at <time> + + + %@ connected + %@ qoşuldu + No comment provided by engineer. + + + %@ downloaded + %@ yüklənib + No comment provided by engineer. + + + %@ invested in SimpleX Chat crowdfunding. + SimpleX Chat-ın kraudfandinqinə %@ sərmayə qoyulub. + badge alert + + + %@ is connected! + %@ qoşulub! + notification title + + + %@ is not verified + %@ təsdiqlənməyib + No comment provided by engineer. + + + %@ is verified + %@ təsdiqlənib + No comment provided by engineer. + + + %@ server + %@ server + No comment provided by engineer. + + + %@ servers + %@ serverlər + No comment provided by engineer. + + + %@ supports SimpleX Chat. + %@ SimpleX Chat-ı dəstəkləyir. + badge alert + + + %@ uploaded + %@ yükləndi + No comment provided by engineer. + + + %@ wants to connect! + %@ qoşulmaq istəyir! + notification title + + + %1$@, %2$@ + %1$@, %2$@ + format for date separator in chat + + + %@, %@ and %lld members + %@, %@ və %lld üzv + No comment provided by engineer. + + + %@, %@ and %lld other members connected + %@, %@ və digər %lld üzv qoşuldu + No comment provided by engineer. + + + %@: + %@: + copied message info + + + %d days + %d gün + time interval + + + %d file(s) are still being downloaded. + %d fayl hələ də yüklənir. + forward confirmation reason + + + %d file(s) failed to download. + %d faylın endirilməsi uğursuz oldu. + forward confirmation reason + + + %d file(s) were deleted. + %d fayl silindi. + forward confirmation reason + + + %d file(s) were not downloaded. + %d fayl yüklənmədi. + forward confirmation reason + + + %d hours + %d saat + time interval + + + %d messages not forwarded + %d mesaj yönləndirilməyib + alert title + + + %d min + %d dəq + time interval + + + %d months + %d ay + time interval + + + %d owner + %d sahib + channel owners count + + + %d owners + %d sahibləri + channel owners count + + + %d owners & contributors + %d sahib və töhfəçi + channel members count + + + %d relays failed + %d rele sıradan çıxdı + channel relay bar +channel subscriber relay bar + + + %d relays not active + %d rele aktiv deyil + channel relay bar +channel subscriber relay bar + + + %d relays removed + %d rele silindi + channel relay bar +channel subscriber relay bar + + + %d sec + %d san + time interval + + + %d seconds(s) + %d saniyə + delete after time + + + %d skipped message(s) + %d mesaj ötürüldü + integrity error chat item + + + %d subscriber + %d abunəçi + channel subscriber count + + + %d subscribers + %d abunəçilər + channel subscriber count + + + %d weeks + %d həftə + time interval + + + %1$d/%2$d relays active + %1$d/%2$d ötürücü aktivdir + channel creation progress +channel relay bar progress + + + %1$d/%2$d relays active, %3$d errors + %1$d/%2$d ötürücü aktiv, %3$d xəta + channel relay bar + + + %1$d/%2$d relays active, %3$d failed + %1$d/%2$d ötürücü aktiv, %3$d uğursuz + channel creation progress with errors +channel relay bar + + + %1$d/%2$d relays active, %3$d removed + %1$d/%2$d ötürücü aktivdir, %3$d-ü silinib + channel relay bar + + + %1$d/%2$d relays connected + %1$d/%2$d ötürücü qoşulub + channel subscriber relay bar progress + + + %1$d/%2$d relays connected, %3$d errors + %1$d/%2$d ötürücü qoşulub, %3$d xəta + channel subscriber relay bar + + + %1$d/%2$d relays connected, %3$d failed + %1$d/%2$d ötürücü qoşulub, %3$d-si uğursuz olub + channel subscriber relay bar + + + %1$d/%2$d relays connected, %3$d removed + %1$d/%2$d ötürücü qoşulub, %3$d silinib + channel subscriber relay bar + + + %lld + %lld + No comment provided by engineer. + + + %lld %@ + %lld %@ + No comment provided by engineer. + + + %lld channel events + %lld kanal hadisəsi + No comment provided by engineer. + + + %lld contact(s) selected + %lld kontakt seçildi + No comment provided by engineer. + + + %lld file(s) with total size of %@ + Ümumi həcmi %@ olan %lld fayl + No comment provided by engineer. + + + %lld group events + %lld qrup tədbiri + No comment provided by engineer. + + + %lld members + %lld üzv + No comment provided by engineer. + + + %lld messages blocked + %lld mesaj bloklandı + No comment provided by engineer. + + + %lld messages blocked by admin + İnzibatçı tərəfindən %lld mesaj bloklanıb + No comment provided by engineer. + + + %lld messages marked deleted + %lld mesaj silinmiş kimi işarələndi + No comment provided by engineer. + + + %lld messages moderated by %@ + %@ tərəfindən moderasiya edilmiş %lld mesaj + No comment provided by engineer. + + + %lld minutes + %lld dəqiqə + No comment provided by engineer. + + + %lld new interface languages + %lld yeni interfeys dili + No comment provided by engineer. + + + %lld seconds + %lld saniyə + No comment provided by engineer. + + + %lldd + %lldd + No comment provided by engineer. + + + %lldh + %lldh + No comment provided by engineer. + + + %lldk + %lldk + No comment provided by engineer. + + + %lldm + %lldm + No comment provided by engineer. + + + %lldmth + %lldmth + No comment provided by engineer. + + + %llds + %llds + No comment provided by engineer. + + + %lldw + %lldw + No comment provided by engineer. + + + %u messages failed to decrypt. + %u mesajın şifrəsi açıla bilmədi. + No comment provided by engineer. + + + %u messages skipped. + %u mesaj ötürüldü. + No comment provided by engineer. + + + (from owner) + (sahibindən) + chat link info line + + + (new) + (yeni) + No comment provided by engineer. + + + (this device v%@) + (bu cihaz v%@) + No comment provided by engineer. + + + **Create 1-time link**: to create and share a new invitation link. + **Birdəfəlik keçid yarat**: yeni dəvət keçidi yaratmaq və paylaşmaq üçün. + No comment provided by engineer. + + + **Create group**: to create a new group. + **Qrup yarat**: yeni qrup yaratmaq. + No comment provided by engineer. + + + **More private**: check new messages every 20 minutes. Only device token is shared with our push server. It doesn't see how many contacts you have, or any message metadata. + **Daha çox məxfilik**: yeni mesajları hər 20 dəqiqədən bir yoxlayın. Push serverimizlə yalnız cihazın tokeni paylaşılır; server kontaktlarınızın sayını və ya mesajlarla bağlı hər hansı metadanatı görmür. + No comment provided by engineer. + + + **Most private**: do not use SimpleX Chat push server. The app will check messages in background, when the system allows it, depending on how often you use the app. + **Ən yüksək məxfilik**: SimpleX Chat-ın push serverindən istifadə etmir. Tətbiq, ondan nə qədər tez-tez istifadə etdiyinizdən asılı olaraq, sistem icazə verdikdə mesajları arxa fonda yoxlayacaq. + No comment provided by engineer. + + + **Please note**: using the same database on two devices will break the decryption of messages from your connections, as a security protection. + **Diqqət**: Təhlükəsizlik tədbiri olaraq, eyni verilənlər bazasından iki cihazda istifadə etmək əlaqədə olduğunuz şəxslərdən gələn mesajların şifrəsinin açılmasını mümkünsüz edəcək. + No comment provided by engineer. + + + **Please note**: you will NOT be able to recover or change passphrase if you lose it. + **Diqqət**: Əgər parol ifadəsini itirsəniz, onu bərpa edə və ya dəyişdirə bilməyəcəksiniz. + No comment provided by engineer. + + + **Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from. + **Tövsiyə olunur**: cihaz tokeni və uçdan-uca şifrələnmiş bildirişlər SimpleX Chat-ın push serverinə göndərilir, lakin server mesajın məzmununu, həcmini və ya kimdən gəldiyini görmür. + No comment provided by engineer. + + + **Scan / Paste link**: to connect via a link you received. + **Skan edin / Keçidi yapışdırın**: aldığınız keçid vasitəsilə qoşulmaq üçün. + No comment provided by engineer. + + + **Test relay** to retrieve its name. + Adını əldə etmək üçün **ötürücünü yoxlayın**. + No comment provided by engineer. + + + **Warning**: Instant push notifications require passphrase saved in Keychain. + **Xəbərdarlıq**: Ani təkan bildirişləri üçün "Keychain"də yadda saxlanılmış parol ifadəsi tələb olunur. + No comment provided by engineer. + + + **Warning**: the archive will be removed. + **Xəbərdarlıq**: arxiv silinəcək. + No comment provided by engineer. + + + **e2e encrypted** audio call + **Ucdan-uca şifrələnmiş** səsli zəng + No comment provided by engineer. + + + **e2e encrypted** video call + **Ucdan-uca şifrələnmiş** videozəng + No comment provided by engineer. + + + \*bold* + \*qalın* + No comment provided by engineer. + + + - connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! +- delivery receipts (up to 20 members). +- faster and more stable. + - [kataloq xidmətinə](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) qoşulun (BETA)! - çatdırılma bildirişləri (20 üzvə qədər). - daha sürətli və daha stabil. + No comment provided by engineer. + + + - more stable message delivery. +- a bit better groups. +- and more! + - daha sabit mesaj çatdırılması. - bir az daha yaxşı qruplar. - və daha çoxu! + No comment provided by engineer. + + + - opt-in to send link previews. +- prevent hyperlink phishing. +- remove link tracking. + - keçid önizləmələrinin göndərilməsinə razılıq verin. - hiperkeçid vasitəsilə fişinqin qarşısını alın. - keçid izləmə funksiyasını ləğv edin. + No comment provided by engineer. + + + - optionally notify deleted contacts. +- profile names with spaces. +- and more! + - silinmiş kontaktlara seçimli bildiriş göndərmə. - boşluq işarəsi olan profil adları. - və daha çoxu! + No comment provided by engineer. + + + - voice messages up to 5 minutes. +- custom time to disappear. +- editing history. + - 5 dəqiqəyə qədər səsli mesajlar. - fərdi yoxa çıxma müddəti. - redaktə tarixçəsi. + No comment provided by engineer. + + + 0 sec + 0 saniyə + time to disappear + + + 0s + 0s + No comment provided by engineer. + + + 1 day + 1 gün + delete after time +time interval + + + 1 hour + 1 saat + time interval + + + 1 minute + 1 dəqiqə + No comment provided by engineer. + + + 1 month + 1 ay + delete after time +time interval + + + 1 week + 1 həftə + delete after time +time interval + + + 1 year + 1 il + delete after time + + + 1-time link + Bir dəfəlik keçid + No comment provided by engineer. + + + 1-time link can be used *with one contact only* - share in person or via any messenger. + Birdəfəlik keçid *yalnız bir kontaktla* istifadə oluna bilər – onu şəxsən və ya hər hansı messencer vasitəsilə paylaşın. + No comment provided by engineer. + + + 5 minutes + 5 dəqiqə + No comment provided by engineer. + + + 6 + 6 + No comment provided by engineer. + + + 30 seconds + 30 saniyə + No comment provided by engineer. + + + <p>Hi!</p> +<p><a href="%@">Connect to me via SimpleX Chat</a></p> + <p>Salam!</p> <p><a href="%@">SimpleX Chat vasitəsilə mənimlə əlaqə saxlayın</a></p> + email text + + + A few more things + Daha bir neçə məqam + No comment provided by engineer. + + + A link for one person to connect + Bir nəfərin qoşulması üçün keçid + No comment provided by engineer. + + + A new contact + Yeni əlaqə + notification title + + + A new random profile will be shared. + Yeni və təsadüfi bir profil paylaşılacaq. + No comment provided by engineer. + + + A separate TCP connection will be used **for each chat profile you have in the app**. + Tətbiqdəki hər bir söhbət profili üçün ayrıca TCP bağlantısı istifadə olunacaq. + No comment provided by engineer. + + + A separate TCP connection will be used **for each contact and group member**. +**Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail. + **Hər bir kontakt və qrup üzvü üçün** ayrıca TCP bağlantısı istifadə olunacaq. **Diqqət**: Əgər çox sayda bağlantınız varsa, batareya və trafik sərfiyyatı əhəmiyyətli dərəcədə arta bilər və bəzi bağlantılar uğursuz ola bilər. + No comment provided by engineer. + + + Abort + Ləğv et + No comment provided by engineer. + + + Abort changing address + Ünvan dəyişikliyini ləğv et + No comment provided by engineer. + + + Abort changing address? + Ünvan dəyişikliyi dayandırılsın? + No comment provided by engineer. + + + About SimpleX Chat + SimpleX Chat haqqında + No comment provided by engineer. + + + About operators + Operatorlar haqqında + No comment provided by engineer. + + + Accent + Aksent + No comment provided by engineer. + + + Accept + Qəbul et + accept contact request via notification +accept incoming call via notification +alert action +swipe action + + + Accept as member + Üzv kimi qəbul et + alert action + + + Accept as observer + Müşahidəçi kimi qəbul et + alert action + + + Accept conditions + Şərtləri qəbul edin + No comment provided by engineer. + + + Accept connection request? + Əlaqə istəyi qəbul edilsin? + No comment provided by engineer. + + + Accept contact request + Əlaqə sorğusunu qəbul et + alert title + + + Accept contact request from %@? + %@ əlaqə sorğusunu qəbul edirsiniz? + notification body + + + Accept incognito + Gizli rejimdə qəbul et + alert action +swipe action + + + Accept member + Üzvü qəbul et + alert title + + + Accepted conditions + Qəbul edilmiş şərtlər + No comment provided by engineer. + + + Acknowledged + Qəbul edildi + No comment provided by engineer. + + + Acknowledgement errors + Təsdiq xətaları + No comment provided by engineer. + + + Active + Aktiv + token status text + + + Active connections + Aktiv bağlantılar + No comment provided by engineer. + + + Add + Əlavə et + No comment provided by engineer. + + + 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. + Profilinizə ünvan əlavə edin ki, SimpleX kontaktlarınızı onu başqaları ilə paylaşa bilsinlər. Profil yeniləməsi SimpleX kontaktlarınıza göndəriləcək. + No comment provided by engineer. + + + Add contributors. + Töhfəçiləri əlavə edin. + No comment provided by engineer. + + + Add description + Təsvir əlavə edin + No comment provided by engineer. + + + Add friends + Dostlar əlavə et + No comment provided by engineer. + + + Add list + Siyahı əlavə et + No comment provided by engineer. + + + Add message + Mesaj əlavə et + placeholder for sending contact request + + + Add profile + Profil əlavə et + No comment provided by engineer. + + + Add relay + Ötürücü əlavə et + No comment provided by engineer. + + + Add relays + Ötürücülər əlavə edin + No comment provided by engineer. + + + Add relays to restore message delivery. + Mesaj çatdırılmasını bərpa etmək üçün relelər əlavə edin. + No comment provided by engineer. + + + Add server + Server əlavə et + No comment provided by engineer. + + + Add servers by scanning QR codes. + QR kodları skan edərək serverlər əlavə edin. + No comment provided by engineer. + + + Add team members + Komanda üzvlərini əlavə edin + No comment provided by engineer. + + + Add this code to your webpage. It will display the preview of your channel / group. + Bu kodu veb-səhifənizə əlavə edin. O, kanalınızın və ya qrupunuzun önizləməsini göstərəcək. + No comment provided by engineer. + + + Add to another device + Başqa cihaza əlavə et + No comment provided by engineer. + + + Add to list + Siyahıya əlavə et + No comment provided by engineer. + + + Add welcome message + Salamlama mesajı əlavə edin + No comment provided by engineer. + + + Add your team members to the conversations. + Komanda üzvlərinizi söhbətlərə əlavə edin. + No comment provided by engineer. + + + Added media & file servers + Media və fayl serverləri əlavə edildi + No comment provided by engineer. + + + Added message servers + Mesaj serverləri əlavə edildi + No comment provided by engineer. + + + Additional accent + Əlavə vurğu + No comment provided by engineer. + + + Additional accent 2 + Əlavə vurğu 2 + No comment provided by engineer. + + + Additional secondary + Əlavə ikinci dərəcəli + No comment provided by engineer. + + + Address + Ünvan + No comment provided by engineer. + + + Address change will be aborted. Old receiving address will be used. + Ünvan dəyişikliyi ləğv ediləcək. Köhnə qəbul ünvanı istifadə olunacaq. + No comment provided by engineer. + + + Address or 1-time link? + Ünvan, yoxsa birdəfəlik keçid? + No comment provided by engineer. + + + Address settings + Ünvan parametrləri + No comment provided by engineer. + + + Admins can block a member for all. + İnzibatçılar üzvü hamı üçün bloklaya bilərlər. + No comment provided by engineer. + + + Admins can create the links to join groups. + İnzibatçılar qruplara qoşulmaq üçün keçidlər yarada bilərlər. + No comment provided by engineer. + + + Advanced network settings + Qabaqcıl şəbəkə parametrləri + No comment provided by engineer. + + + Advanced options + Əlavə seçimlər + No comment provided by engineer. + + + Advanced settings + Əlavə parametrlər + No comment provided by engineer. + + + All + Hamısı + No comment provided by engineer. + + + All app data is deleted. + Tətbiqin bütün məlumatları silinir. + No comment provided by engineer. + + + All chats and messages will be deleted - this cannot be undone! + Bütün söhbətlər və mesajlar silinəcək – bunu geri qaytarmaq mümkün deyil! + No comment provided by engineer. + + + All chats will be removed from the list %@, and the list deleted. + Bütün söhbətlər %@ siyahısından çıxarılacaq və siyahı silinəcək. + alert message + + + All data is erased when it is entered. + Bütün məlumatlar daxil edildikdən sonra silinir. + No comment provided by engineer. + + + All data is kept private on your device. + Bütün məlumatlar cihazınızda məxfi saxlanılır. + No comment provided by engineer. + + + All group members will remain connected. + Qrupun bütün üzvləri əlaqədə qalacaqlar. + No comment provided by engineer. + + + All messages + Bütün mesajlar + No comment provided by engineer. + + + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. + Bütün mesajlar və fayllar **uçdan-uca şifrələnmiş** şəkildə göndərilir, birbaşa mesajlarda isə post-kvant təhlükəsizliyi təmin edilir. + No comment provided by engineer. + + + All messages will be deleted - this cannot be undone! + Bütün mesajlar silinəcək – bunu geri qaytarmaq mümkün deyil! + No comment provided by engineer. + + + All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you. + Bütün mesajlar silinəcək – bunu geri qaytarmaq mümkün deyil! Mesajlar yalnız sizin üçün silinəcək. + No comment provided by engineer. + + + All new messages from %@ will be hidden! + %@ gələn bütün yeni mesajlar gizlədiləcək! + No comment provided by engineer. + + + All profiles + Bütün profillər + profile dropdown + + + All relays failed + Bütün ötürücü sıradan çıxdı. + No comment provided by engineer. + + + All relays removed + Bütün ötürücü çıxarılıb + No comment provided by engineer. + + + All reports will be archived for you. + Bütün hesabatlar sizin üçün arxivləşdiriləcək. + No comment provided by engineer. + + + All servers + Bütün serverlər + No comment provided by engineer. + + + All your contacts will remain connected. + Bütün kontaktlarınız əlaqədə qalacaq. + No comment provided by engineer. + + + All your contacts will remain connected. Profile update will be sent to your contacts. + Bütün kontaktlarınızla əlaqə qorunub saxlanılacaq. Profil yeniləməsi kontaktlarınıza göndəriləcək. + No comment provided by engineer. + + + All your contacts, conversations and files will be securely encrypted and uploaded in chunks to configured XFTP relays. + Bütün kontaktlarınız, söhbətləriniz və fayllarınız təhlükəsiz şəkildə şifrələnəcək və hissələr halında konfiqurasiya edilmiş XFTP ötürücülərinə yüklənəcək. + No comment provided by engineer. + + + Allow + İcazə verin + No comment provided by engineer. + + + Allow anyone to embed + Hər kəsin yerləşdirməsinə icazə verin + No comment provided by engineer. + + + Allow calls only if your contact allows them. + Zənglərə yalnız əlaqə saxladığınız şəxs icazə verdikdə icazə verin. + No comment provided by engineer. + + + Allow calls? + Zənglərə icazə verilsin? + No comment provided by engineer. + + + Allow disappearing messages only if your contact allows it to you. + Yoxa çıxan mesajlara yalnız qarşı tərəf buna icazə verdiyi təqdirdə icazə verin. + No comment provided by engineer. + + + Allow downgrade + Endirməyə icazə verin + No comment provided by engineer. + + + Allow files and media only if your contact allows them. + Fayllara və mediaya yalnız əlaqə saxladığınız şəxs icazə verdiyi təqdirdə icazə verin. + No comment provided by engineer. + + + Allow irreversible message deletion only if your contact allows it to you. (24 hours) + Kontaktunuz buna icazə verdiyi halda, geri qaytarılması mümkün olmayan mesaj silinməsinə icazə verin. (24 saat) + No comment provided by engineer. + + + Allow members to chat with admins. + Üzvlərə administratorlarla söhbət etməyə icazə verin. + No comment provided by engineer. + + + Allow message reactions only if your contact allows them. + Mesaj reaksiyalarına yalnız əlaqə saxladığınız şəxs buna icazə verdikdə icazə verin. + No comment provided by engineer. + + + Allow message reactions. + Mesaj reaksiyalarına icazə verin. + No comment provided by engineer. + + + Allow sending direct messages to members. + Üzvlərə birbaşa mesaj göndərilməsinə icazə verin. + No comment provided by engineer. + + + Allow sending direct messages to subscribers. + Abunəçilərə birbaşa mesaj göndərilməsinə icazə verin. + No comment provided by engineer. + + + Allow sending disappearing messages. + Yoxa çıxan mesajların göndərilməsinə icazə verin. + No comment provided by engineer. + + + Allow sharing + Paylaşmağa icazə verin + No comment provided by engineer. + + + Allow subscribers to chat with admins. + Abunəçilərə administratorlarla söhbət etməyə icazə verin. + No comment provided by engineer. + + + Allow to irreversibly delete sent messages. (24 hours) + Göndərilmiş mesajların geri qaytarıla bilməyəcək şəkildə silinməsinə icazə verin. (24 saat) + No comment provided by engineer. + + + Allow to report messsages to moderators. + İstifadəçilərə mesajları moderatorlara şikayət etmək imkanı verin. + No comment provided by engineer. + + + Allow to send SimpleX links. + SimpleX linklərinin göndərilməsinə icazə verin. + No comment provided by engineer. + + + Allow to send files and media. + Faylların və medianın göndərilməsinə icazə verin. + No comment provided by engineer. + + + Allow to send voice messages. + Səsli mesajların göndərilməsinə icazə verin. + No comment provided by engineer. + + + Allow voice messages only if your contact allows them. + Səsli mesajlara yalnız əlaqə saxladığınız şəxs icazə verdiyi təqdirdə icazə verin. + No comment provided by engineer. + + + Allow voice messages? + Səsli mesajlara icazə verilsin? + No comment provided by engineer. + + + Allow your contacts adding message reactions. + Kontaktlarınıza mesajlara reaksiya əlavə etməyə icazə verin. + No comment provided by engineer. + + + Allow your contacts to call you. + Kontaktlarınızın sizə zəng etməsinə icazə verin. + No comment provided by engineer. + + + Allow your contacts to irreversibly delete sent messages. (24 hours) + Kontaktlarınıza göndərilmiş mesajları geri qaytarıla bilməyəcək şəkildə silməyə icazə verin. (24 saat) + No comment provided by engineer. + + + Allow your contacts to send disappearing messages. + Kontaktlarınıza yoxa çıxan mesajlar göndərməyə icazə verin. + No comment provided by engineer. + + + Allow your contacts to send files and media. + Kontaktlarınıza fayl və media göndərməyə icazə verin. + No comment provided by engineer. + + + Allow your contacts to send voice messages. + Kontaktlarınıza səsli mesajlar göndərməyə icazə verin. + No comment provided by engineer. + + + Already connected? + Artıq qoşulmusunuz? + No comment provided by engineer. + + + Already connecting! + Artıq qoşulur! + new chat sheet title + + + Already joining the group! + Artıq qrupa qoşuluram! + new chat sheet title + + + Always use private routing. + Həmişə şəxsi marşrutlaşdırmadan istifadə edin. + No comment provided by engineer. + + + Always use relay + Həmişə ötürücünü istifadə edin. + No comment provided by engineer. + + + An empty chat profile with the provided name is created, and the app opens as usual. + Təqdim olunmuş adla boş bir çat profili yaradılır və tətbiq adi qaydada açılır. + No comment provided by engineer. + + + Another reason + Başqa bir səbəb + report reason + + + Answer call + Zəngə cavab verin + No comment provided by engineer. + + + Any webpage can show the preview. + İstənilən veb-səhifə önizləməni göstərə bilər. + No comment provided by engineer. + + + App build: %@ + Tətbiq versiyası: %@ + No comment provided by engineer. + + + App data migration + Tətbiq məlumatlarının miqrasiyası + No comment provided by engineer. + + + App encrypts new local files (except videos). + Tətbiq yeni yerli faylları (videolar istisna olmaqla) şifrələyir. + No comment provided by engineer. + + + App group: + Tətbiq qrupu: + No comment provided by engineer. + + + App icon + Tətbiq ikonu + No comment provided by engineer. + + + App passcode + Tətbiq üçün giriş kodu + No comment provided by engineer. + + + App passcode is replaced with self-destruct passcode. + Tətbiqin giriş kodu öz-özünə silinən giriş kodu ilə əvəz olunur. + No comment provided by engineer. + + + App session + Tətbiq sessiyası + No comment provided by engineer. + + + App update required + Tətbiq yeniləməsi tələb olunur + alert title + + + App version + Tətbiq versiyası + No comment provided by engineer. + + + App version: v%@ + Tətbiq versiyası: v%@ + No comment provided by engineer. + + + Appearance + Görünüş + No comment provided by engineer. + + + Apply + Müraciət edin + No comment provided by engineer. + + + Apply to + Müraciət edin + No comment provided by engineer. + + + Archive + Arxiv + No comment provided by engineer. + + + Archive %lld reports? + %lld hesabatı arxivlə? + No comment provided by engineer. + + + Archive all reports? + Bütün hesabatları arxivləyək? + No comment provided by engineer. + + + Archive and upload + Arxivləyin və yükləyin + No comment provided by engineer. + + + Archive contacts to chat later. + Daha sonra yazışmaq üçün kontaktları arxivləşdirin. + No comment provided by engineer. + + + Archive report + Arxiv hesabatı + No comment provided by engineer. + + + Archive report? + Hesabatı arxivləşdirək? + No comment provided by engineer. + + + Archive reports + Arxiv hesabatları + swipe action + + + Archived contacts + Arxivlənmiş kontaktlar + No comment provided by engineer. + + + Archiving database + Verilənlər bazasının arxivləşdirilməsi + No comment provided by engineer. + + + Attach + Əlavə et + No comment provided by engineer. + + + Audio & video calls + Audio və video zənglər + No comment provided by engineer. + + + Audio and video calls + Audio və video zənglər + No comment provided by engineer. + + + Audio call + Səsli zəng + No comment provided by engineer. + + + Audio/video calls + Audio/video zənglər + chat feature + + + Audio/video calls are prohibited. + Audio və video zənglər qadağandır. + No comment provided by engineer. + + + Authentication cancelled + Autentifikasiya ləğv edildi + PIN entry + + + Authentication failed + Autentifikasiya uğursuz oldu + No comment provided by engineer. + + + Authentication is required before the call is connected, but you may miss calls. + Zəng birləşdirilməzdən əvvəl autentifikasiya tələb olunur, lakin zəngləri qaçıra bilərsiniz. + No comment provided by engineer. + + + Authentication unavailable + Autentifikasiya mümkün deyil + No comment provided by engineer. + + + Auto-accept + Avtomatik qəbul + No comment provided by engineer. + + + Auto-accept contact requests + Əlaqə sorğularını avtomatik qəbul et + No comment provided by engineer. + + + Auto-accept images + Şəkilləri avtomatik qəbul et + No comment provided by engineer. + + + Back + Geri + No comment provided by engineer. + + + Background + Arxa plan + No comment provided by engineer. + + + Bad desktop address + Yanlış masaüstü ünvanı + No comment provided by engineer. + + + Bad message ID + Yanlış mesaj ID + No comment provided by engineer. + + + Bad message hash + Yanlış mesaj heşi + No comment provided by engineer. + + + Badge cannot be verified + Nişan təsdiqlənə bilmir + badge alert title + + + Be free +in your network + Şəbəkənizdə sərbəst olun + No comment provided by engineer. + + + Be free in your network. + Şəbəkənizdə sərbəst olun. + No comment provided by engineer. + + + Because we destroyed the power to know who you are. So that your power can never be taken. + Çünki biz kim olduğunuzu bilmək gücünü məhv etdik. Ta ki gücünüz heç vaxt əlinizdən alınmasın deyə. + No comment provided by engineer. + + + Better calls + Daha yaxşı zənglər + No comment provided by engineer. + + + Better channels 📢 + Daha yaxşı kanallar 📢 + No comment provided by engineer. + + + Better groups + Daha yaxşı qruplar + No comment provided by engineer. + + + Better groups performance + Qrupların daha yaxşı performansı + No comment provided by engineer. + + + Better message dates. + Daha yaxşı mesaj tarixləri. + No comment provided by engineer. + + + Better messages + Daha yaxşı mesajlar + No comment provided by engineer. + + + Better networking + Daha yaxşı şəbəkə + No comment provided by engineer. + + + Better notifications + Daha yaxşı bildirişlər + No comment provided by engineer. + + + Better privacy and security + Daha yaxşı məxfilik və təhlükəsizlik + No comment provided by engineer. + + + Better security ✅ + Daha yaxşı təhlükəsizlik ✅ + No comment provided by engineer. + + + Better user experience + Daha yaxşı istifadəçi təcrübəsi + No comment provided by engineer. + + + Bio + Haqqında + No comment provided by engineer. + + + Bio too large + Bioqrafiya çox uzundur + alert title + + + Black + Qara + No comment provided by engineer. + + + Block + Blok + No comment provided by engineer. + + + Block for all + Hamı üçün blokla + No comment provided by engineer. + + + Block group members + Blok qrupu üzvləri + No comment provided by engineer. + + + Block member + Blok üzvü + No comment provided by engineer. + + + Block member for all? + Hamı üçün üzvü blokla? + No comment provided by engineer. + + + Block member? + İstifadəçini blokla? + No comment provided by engineer. + + + Block subscriber for all? + Abunəçini hamı üçün blokla? + No comment provided by engineer. + + + Blocked by admin + İnzibatçı tərəfindən bloklanıb + No comment provided by engineer. + + + Blur for better privacy. + Daha yaxşı məxfilik üçün bulanıqlaşdırın. + No comment provided by engineer. + + + Blur media + Medianı bulandır + No comment provided by engineer. + + + Bot + Bot + No comment provided by engineer. + + + Both you and your contact can add message reactions. + Həm siz, həm də əlaqə saxladığınız şəxs mesajlara reaksiya əlavə edə bilərsiniz. + No comment provided by engineer. + + + Both you and your contact can irreversibly delete sent messages. (24 hours) + Həm siz, həm də həmsöhbətiniz göndərilən mesajları geri qaytarıla bilməyəcək şəkildə silə bilərsiniz. (24 saat) + No comment provided by engineer. + + + Both you and your contact can make calls. + Həm siz, həm də əlaqə saxladığınız şəxs zəng edə bilərsiniz. + No comment provided by engineer. + + + Both you and your contact can send disappearing messages. + Həm siz, həm də əlaqə saxladığınız şəxs yoxa çıxan mesajlar göndərə bilərsiniz. + No comment provided by engineer. + + + Both you and your contact can send files and media. + Həm siz, həm də əlaqə saxladığınız şəxs fayllar və media göndərə bilərsiniz. + No comment provided by engineer. + + + Both you and your contact can send voice messages. + Həm siz, həm də əlaqə saxladığınız şəxs səsli mesajlar göndərə bilərsiniz. + No comment provided by engineer. + + + Bottom bar + Alt panel + No comment provided by engineer. + + + Broadcast + Yayımlamaq + compose placeholder for channel owner + + + Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! + Bolqar, fin, tay və ukrayna dilləri – istifadəçilərə və [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)-ə təşəkkürlər! + No comment provided by engineer. + + + Business address + Biznes ünvanı + chat link info line + + + Business chats + Biznes söhbətləri + No comment provided by engineer. + + + Business connection + İşgüzar əlaqə + No comment provided by engineer. + + + Businesses + Bizneslər + No comment provided by engineer. + + + By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). + Çat profili (standart) və ya [əlaqə vasitəsilə](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). + No comment provided by engineer. + + + Call already ended! + Zəng artıq bitib! + No comment provided by engineer. + + + Calls prohibited! + Zəng etmək qadağandır! + No comment provided by engineer. + + + Camera not available + Kamera mövcud deyil + No comment provided by engineer. + + + Can't call contact + Kontaktla əlaqə yaratmaq mümkün deyil + No comment provided by engineer. + + + Can't call member + Üzvü çağırmaq mümkün deyil + No comment provided by engineer. + + + Can't change profile + Profili dəyişmək mümkün deyil + alert title + + + Can't invite contact! + Kontaktı dəvət etmək mümkün deyil! + No comment provided by engineer. + + + Can't invite contacts! + Kontaktları dəvət etmək mümkün deyil! + No comment provided by engineer. + + + Can't message member + İstifadəçiyə mesaj göndərmək mümkün deyil + No comment provided by engineer. + + + Cancel + Ləğv et + alert action +alert button +new chat action + + + Cancel and delete channel + Kanalı ləğv et və sil + No comment provided by engineer. + + + Cancel creating channel? + Kanalın yaradılmasını ləğv etmək? + alert title + + + Cancel migration + Köçürməni ləğv et + No comment provided by engineer. + + + Cannot access keychain to save database password + Verilənlər bazası parolunu saxlamaq üçün açar zəncirinə daxil olmaq mümkün deyil + No comment provided by engineer. + + + Cannot forward message + Mesajı yönləndirmək mümkün deyil + No comment provided by engineer. + + + Cannot receive file + Fayl qəbul edilə bilmir + alert title + + + Capacity exceeded - recipient did not receive previously sent messages. + Tutum həddi aşıldı – alıcı əvvəllər göndərilmiş mesajları qəbul etməyib. + snd error text + + + Cellular + Hüceyrəvi + No comment provided by engineer. + + + Change + Dəyişiklik + No comment provided by engineer. + + + Change automatic message deletion? + Avtomatik mesaj silinməsini dəyişdirmək? + alert title + + + Change chat profiles + Çat profillərini dəyişdirin + authentication reason + + + Change database passphrase? + Verilənlər bazasının parolunu dəyişdirmək? + No comment provided by engineer. + + + Change lock mode + Kilid rejimini dəyişdirin + authentication reason + + + Change passcode + Kilid kodunu dəyişdirin + authentication reason + + + Change receiving address + Qəbul ünvanını dəyişdirin + No comment provided by engineer. + + + Change receiving address? + Qəbul ünvanını dəyişdirmək? + No comment provided by engineer. + + + Change role + Rolu dəyişdirin + No comment provided by engineer. + + + Change role? + Rolunuzu dəyişin? + No comment provided by engineer. + + + Change self-destruct mode + Öz-özünü məhv etmə rejimini dəyişdirin + authentication reason + + + Change self-destruct passcode + Öz-özünü məhv etmə kodunu dəyişdirin + authentication reason +set passcode view + + + Channel + Kanal + No comment provided by engineer. + + + Channel SimpleX name + SimpleX kanalı adı + No comment provided by engineer. + + + Channel display name + Kanalın görünən adı + No comment provided by engineer. + + + Channel full name (optional) + Kanalın tam adı (isteğe bağlı) + No comment provided by engineer. + + + Channel has no active relays. Please try to join later. + Kanalda aktiv ötürücü yoxdur. Zəhmət olmasa, daha sonra qoşulmağa cəhd edin. + alert message +alert subtitle + + + Channel image + Kanal şəkli + No comment provided by engineer. + + + Channel link + Kanalın linki + chat link info line + + + Channel preferences + Kanal seçimləri + No comment provided by engineer. + + + Channel profile + Kanal profili + No comment provided by engineer. + + + Channel profile is stored on subscribers' devices and on the chat relays. + Kanal profili abunəçilərin cihazlarında və çat ötürücülərində saxlanılır. + No comment provided by engineer. + + + Channel profile was changed. If you save it, the updated profile will be sent to channel subscribers. + Kanal profili dəyişdirildi. Əgər onu yaddaşda saxlasanız, yenilənmiş profil kanal abunəçilərinə göndəriləcək. + alert message + + + Channel temporarily unavailable + Kanal müvəqqəti olaraq mövcud deyil + alert title + + + Channel webpage + Kanalın veb-səhifəsi + No comment provided by engineer. + + + Channel will be deleted for all subscribers - this cannot be undone! + Kanal bütün abunəçilər üçün silinəcək – bunu geri qaytarmaq mümkün deyil! + No comment provided by engineer. + + + Channel will be deleted for you - this cannot be undone! + Kanal sizin üçün silinəcək – bunu geri qaytarmaq mümkün olmayacaq! + No comment provided by engineer. + + + Channel will start working with %1$d of %2$d relays. Continue? + Kanal %2$d ötürücüdən %1$d-i ilə işləməyə başlayacaq. Davam edilsin? + alert message + + + Channels + Kanallar + No comment provided by engineer. + + + Chat + Çat + No comment provided by engineer. + + + Chat already exists + Çat artıq mövcuddur + No comment provided by engineer. + + + Chat already exists! + Çat artıq mövcuddur! + new chat sheet title + + + Chat colors + Çat rəngləri + No comment provided by engineer. + + + Chat console + Çat konsolu + No comment provided by engineer. + + + Chat data + Çat məlumatları + No comment provided by engineer. + + + Chat database + Çat məlumat bazası + No comment provided by engineer. + + + Chat database deleted + Çat məlumat bazası silindi + No comment provided by engineer. + + + Chat database exported + Çat məlumat bazası ixrac edildi + No comment provided by engineer. + + + Chat database imported + Çat məlumat bazası idxal edildi + No comment provided by engineer. + + + Chat is running + Çat işləyir + No comment provided by engineer. + + + Chat is stopped + Çat dayandırılıb + No comment provided by engineer. + + + Chat is stopped. If you already used this database on another device, you should transfer it back before starting chat. + Çat dayandırılıb. Əgər bu verilənlər bazasından artıq başqa bir cihazda istifadə etmisinizsə, çata başlamazdan əvvəl onu geri köçürməlisiniz. + No comment provided by engineer. + + + Chat list + Çat siyahısı + No comment provided by engineer. + + + Chat migrated! + Çat köçürüldü! + No comment provided by engineer. + + + Chat preferences + Çat seçimləri + No comment provided by engineer. + + + Chat preferences were changed. + Çat parametrləri dəyişdirildi. + alert message + + + Chat profile + Çat profili + No comment provided by engineer. + + + Chat relay + Çat ötürücüsü + No comment provided by engineer. + + + Chat relays + Çat ötürücüləri + No comment provided by engineer. + + + Chat relays forward messages in channels you create. + Çat ötürücüləri yaratdığınız kanallarda mesajları ötürür. + No comment provided by engineer. + + + Chat relays forward messages to channel subscribers. + Çat ötürücüləri mesajları kanal abunəçilərinə yönləndirir. + No comment provided by engineer. + + + Chat theme + Söhbət mövzusu + No comment provided by engineer. + + + Chat will be deleted for all members - this cannot be undone! + Çat bütün üzvlər üçün silinəcək – bunu geri qaytarmaq mümkün deyil! + No comment provided by engineer. + + + Chat will be deleted for you - this cannot be undone! + Çat sizin üçün silinəcək – bunu geri qaytarmaq mümkün deyil! + No comment provided by engineer. + + + Chat with admins + Adminlərlə söhbət + chat feature +chat toolbar + + + Chat with member + Üzvlə söhbət edin + No comment provided by engineer. + + + Chat with members before they join. + Üzvlər qoşulmazdan əvvəl onlarla söhbət edin. + No comment provided by engineer. + + + Chats + Çatlar + No comment provided by engineer. + + + Chats with admins are prohibited. + Adminlərlə söhbət etmək qadağandır. + No comment provided by engineer. + + + Chats with admins in public channels have no E2E encryption - use only with trusted chat relays. + İctimai kanallarda administratorlarla aparılan söhbətlərdə uçdan-uca şifrələmə (E2E) yoxdur; yalnız etibarlı söhbət ötürücülərindən (relays) istifadə edin. + alert message + + + Chats with members + Üzvlərlə söhbətlər + No comment provided by engineer. + + + Chats with members are disabled + Üzvlərlə söhbət deaktiv edilib + No comment provided by engineer. + + + Check messages every 20 min. + Mesajları hər 20 dəqiqədən bir yoxlayın. + No comment provided by engineer. + + + Check messages when allowed. + İcazə verildikdə mesajları yoxlayın. + No comment provided by engineer. + + + Check relay address and try again. + Ötürücünün ünvanını yoxlayın və yenidən cəhd edin. + alert message + + + Check relay name and try again. + Ötürücünün adını yoxlayın və yenidən cəhd edin. + alert message + + + Check server address and try again. + Server ünvanını yoxlayın və yenidən cəhd edin. + alert title + + + Chinese and Spanish interface + Çin və İspan dillərində interfeys + No comment provided by engineer. + + + Choose _Migrate from another device_ on the new device and scan QR code. + Yeni cihazda "Başqa cihazdan keçid et" seçimini edin və QR kodu skan edin. + No comment provided by engineer. + + + Choose file + Fayl seçin + No comment provided by engineer. + + + Choose from library + Kitabxanadan seçin + No comment provided by engineer. + + + Chunks deleted + Hissələr silindi + No comment provided by engineer. + + + Chunks downloaded + Hissələr yükləndi + No comment provided by engineer. + + + Chunks uploaded + Hissələr yükləndi + No comment provided by engineer. + + + Clear + Aydın + swipe action + + + Clear conversation + Aydın söhbət + No comment provided by engineer. + + + Clear conversation? + Aydın söhbət? + No comment provided by engineer. + + + Clear group? + Qrupu təmizləyin? + No comment provided by engineer. + + + Clear or delete group? + Qrupu təmizləyin və ya silin? + No comment provided by engineer. + + + Clear private notes? + Şəxsi qeydləri təmizləyin? + No comment provided by engineer. + + + Clear verification + Aydın təsdiqləmə + No comment provided by engineer. + + + Color chats with the new themes. + Yeni mövzularla rəngli söhbətlər. + No comment provided by engineer. + + + Color mode + Rəng rejimi + No comment provided by engineer. + + + Community guidelines violation + İcma qaydalarının pozulması + report reason + + + Compare file + Faylı müqayisə et + server test step + + + Compare security codes with your contacts. + Təhlükəsizlik kodlarını kontaktlarınızla müqayisə edin. + No comment provided by engineer. + + + Completed + Tamamlanıb + No comment provided by engineer. + + + Conditions accepted on: %@. + Şərtlər %@ tarixində qəbul edilib. + No comment provided by engineer. + + + Conditions are accepted for the operator(s): **%@**. + Operator(lar) üçün şərtlər qəbul edilir: **%@**. + No comment provided by engineer. + + + Conditions are already accepted for these operator(s): **%@**. + Bu operator(lar) üçün şərtlər artıq qəbul edilib: **%@**. + No comment provided by engineer. + + + Conditions of use + İstifadə şərtləri + alert button + + + Conditions will be accepted for the operator(s): **%@**. + Şərtlər operator(lar) üçün qəbul ediləcək: **%@**. + No comment provided by engineer. + + + Conditions will be accepted on: %@. + Şərtlər %@ tarixində qəbul ediləcək. + No comment provided by engineer. + + + Conditions will be automatically accepted for enabled operators on: %@. + Şərtlər %@ üzərində aktivləşdirilmiş operatorlar üçün avtomatik olaraq qəbul ediləcək. + No comment provided by engineer. + + + Configure ICE servers + ICE serverlərini konfiqurasiya edin + No comment provided by engineer. + + + Configure relays + Ötürücüləri konfiqurasiya edin + No comment provided by engineer. + + + Confirm + Təsdiqləyin + No comment provided by engineer. + + + Confirm Passcode + Kilid kodunu təsdiqləyin + No comment provided by engineer. + + + Confirm contact deletion? + Kontaktın silinməsini təsdiqləyin? + No comment provided by engineer. + + + Confirm database upgrades + Verilənlər bazası təkmilləşdirmələrini təsdiqləyin + No comment provided by engineer. + + + Confirm files from unknown servers. + Naməlum serverlərdən gələn faylları təsdiqləyin. + No comment provided by engineer. + + + Confirm network settings + Şəbəkə parametrlərini təsdiqləyin + No comment provided by engineer. + + + Confirm new passphrase… + Yeni parolu təsdiqləyin… + No comment provided by engineer. + + + Confirm password + Parolu təsdiqləyin + No comment provided by engineer. + + + Confirm that you remember database passphrase to migrate it. + Verilənlər bazasını miqrasiya etmək üçün onun şifrə ifadəsini xatırladığınızı təsdiqləyin. + No comment provided by engineer. + + + Confirm upload + Yükləməni təsdiqləyin + No comment provided by engineer. + + + Confirmed + Təsdiqlənib + token status text + + + Connect + Qoşulun + relay test step +server test step + + + Connect automatically + Avtomatik qoşul + No comment provided by engineer. + + + Connect faster! 🚀 + Daha sürətlə qoşulun! 🚀 + No comment provided by engineer. + + + Connect to %@ + %@ ilə əlaqə qur + new chat action + + + Connect to desktop + İş masasına qoşulun + No comment provided by engineer. + + + Connect to your friends faster. + Dostlarınızla daha tez əlaqə qurun. + No comment provided by engineer. + + + Connect to yourself? +This is your own SimpleX address! + Özünüzə qoşulun? Bu, sizin şəxsi SimpleX ünvanınızdır! + new chat sheet title + + + Connect to yourself? +This is your own one-time link! + Özünüzlə əlaqə qurun? +Bu, sizin şəxsi birdəfəlik keçidinizdir! + new chat sheet title + + + Connect via contact address + Əlaqə ünvanı vasitəsilə əlaqə saxlayın + new chat sheet title + + + Connect via link + Keçid vasitəsilə qoşulun + new chat sheet title + + + Connect via link or QR code + Keçid və ya QR kod vasitəsilə qoşulun + No comment provided by engineer. + + + Connect via one-time link + Birdəfəlik keçid vasitəsilə qoşulun + new chat sheet title + + + Connect with %@ + %@ ilə əlaqə yaradın + new chat action + + + Connected + Əlaqəli + No comment provided by engineer. + + + Connected desktop + Qoşulmuş iş masası + No comment provided by engineer. + + + Connected servers + Əlaqəli serverlər + No comment provided by engineer. + + + Connected to desktop + Masaüstü kompüterə qoşulub + No comment provided by engineer. + + + Connecting + Əlaqə qurulur + No comment provided by engineer. + + + Connecting to server… + Serverə qoşulur… + No comment provided by engineer. + + + Connecting to server… (error: %@) + Serverə qoşulur… (xəta: %@) + No comment provided by engineer. + + + Connecting to contact, please wait or check later! + Əlaqəyə qoşulur, zəhmət olmasa gözləyin və ya daha sonra yoxlayın! + No comment provided by engineer. + + + Connecting to desktop + İş masasına qoşulur + No comment provided by engineer. + + + Connection + Əlaqə + No comment provided by engineer. + + + Connection and servers status. + Əlaqə və serverlərin vəziyyəti. + No comment provided by engineer. + + + Connection blocked + Əlaqə bloklandı + No comment provided by engineer. + + + Connection blocked: %@ + Əlaqə bloklandı: %@ + conn error description + + + Connection error + Bağlantı xətası + alert title + + + Connection failed + Əlaqə uğursuz oldu + No comment provided by engineer. + + + Connection is blocked by server operator: +%@ + Server operator tərəfindən bağlantı bloklanıb: %@ + No comment provided by engineer. + + + Connection link removed + Əlaqə linki silindi + conn error description + + + Connection not ready. + Bağlantı hazır deyil. + No comment provided by engineer. + + + Connection notifications + Əlaqə bildirişləri + No comment provided by engineer. + + + Connection request sent! + Əlaqə sorğusu göndərildi! + No comment provided by engineer. + + + Connection requires encryption renegotiation. + Əlaqə şifrələmənin yenidən razılaşdırılmasını tələb edir. + No comment provided by engineer. + + + Connection security + Bağlantı təhlükəsizliyi + No comment provided by engineer. + + + Connection terminated + Əlaqə kəsildi + No comment provided by engineer. + + + Connection timeout + Əlaqə kəsildi + alert title + + + Connection with desktop stopped + Masaüstü ilə əlaqə kəsildi + No comment provided by engineer. + + + Connections + Əlaqələr + No comment provided by engineer. + + + Contact + Əlaqə + No comment provided by engineer. + + + Contact address + Əlaqə ünvanı + chat link info line + + + Contact allows + Əlaqə imkan verir + No comment provided by engineer. + + + Contact already exists + Kontakt artıq mövcuddur + No comment provided by engineer. + + + Contact deleted! + Kontakt silindi! + No comment provided by engineer. + + + Contact hidden: + Əlaqə məlumatı gizlədilib: + notification + + + Contact is connected + Əlaqə quruldu + notification + + + Contact is deleted. + Kontakt silindi. + No comment provided by engineer. + + + Contact name + Kontakt adı + No comment provided by engineer. + + + Contact preferences + Kontakt seçimləri + No comment provided by engineer. + + + Contact requests in groups + Qruplarda əlaqə sorğuları + No comment provided by engineer. + + + Contact will be deleted - this cannot be undone! + Kontakt silinəcək – bunu geri qaytarmaq mümkün olmayacaq! + No comment provided by engineer. + + + Contacts + Kontakt + No comment provided by engineer. + + + Contacts can mark messages for deletion; you will be able to view them. + Kontaktlar mesajları silinmək üçün işarələyə bilər; siz onları görə biləcəksiniz. + No comment provided by engineer. + + + Content violates conditions of use + Məzmun istifadə şərtlərini pozur + blocking reason + + + Continue + Davam edin + alert action + + + Contribute + Töhfə verin + No comment provided by engineer. + + + Conversation deleted! + Söhbət silindi! + No comment provided by engineer. + + + Copy + Kopyalayın + No comment provided by engineer. + + + Copy code + Kodu kopyalayın + No comment provided by engineer. + + + Copy error + Kopyalama xətası + No comment provided by engineer. + + + Core version: v%@ + Əsas versiya: v%@ + No comment provided by engineer. + + + Corner + Künc + No comment provided by engineer. + + + Correct name to %@? + Düzgün ad %@-dir? + alert message + + + Create 1-time link + Bir dəfəlik keçid yarat + No comment provided by engineer. + + + Create SimpleX address + SimpleX ünvanı yaradın + No comment provided by engineer. + + + Create a group using a random profile. + Təsadüfi profildən istifadə edərək qrup yaradın. + No comment provided by engineer. + + + Create a webpage to show your channel preview to visitors before they subscribe. Host it yourself or use any static hosting. + Ziyarətçilərə abunə olmazdan əvvəl kanalınızın önizləməsini göstərmək üçün bir veb-səhifə yaradın. Onu özünüz host edin və ya hər hansı statik hostinq xidmətindən istifadə edin. + No comment provided by engineer. + + + Create file + Fayl yarat + server test step + + + Create group + Qrup yarat + No comment provided by engineer. + + + Create group link + Qrup linki yarat + No comment provided by engineer. + + + Create link + Keçid yarat + No comment provided by engineer. + + + Create list + Siyahı yarat + No comment provided by engineer. + + + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 + [Masaüstü tətbiqində](https://simplex.chat/downloads/) yeni profil yaradın. 💻 + No comment provided by engineer. + + + Create profile + Profil yarat + No comment provided by engineer. + + + Create public channel + Açıq kanal yarat + No comment provided by engineer. + + + Create queue + Növbə yarat + server test step + + + Create web preview. + Veb-önbaxış yaradın. + No comment provided by engineer. + + + Create your address + Ünvanınızı yaradın + No comment provided by engineer. + + + Create your link + Linkinizi yaradın + No comment provided by engineer. + + + Create your profile + Profilinizi yaradın + No comment provided by engineer. + + + Create your public address + İctimai ünvanınızı yaradın + No comment provided by engineer. + + + Created + Yaradılıb + No comment provided by engineer. + + + Created at + Yaradılma tarixi + No comment provided by engineer. + + + Created at: %@ + Yaradılma tarixi: %@ + copied message info + + + Creating archive link + Arxiv keçidi yaradılır + No comment provided by engineer. + + + Creating channel + Kanal yaradılır + No comment provided by engineer. + + + Creating link… + Keçid yaradılır… + No comment provided by engineer. + + + Crowdfunding on Wefunder + Wefunder kraudfandinq + No comment provided by engineer. + + + Crowdfunding on Wefunder. + Wefunder də kraudfandinq. + No comment provided by engineer. + + + Current Passcode + Cari giriş kodu + No comment provided by engineer. + + + Current conditions text couldn't be loaded, you can review conditions via this link: + Hazırkı şərtlər mətni yüklənə bilmədi; şərtlərlə bu keçid vasitəsilə tanış ola bilərsiniz: + No comment provided by engineer. + + + Current passphrase… + Cari parolu ifadəsi… + No comment provided by engineer. + + + Current profile + Cari profil + No comment provided by engineer. + + + Currently maximum supported file size is %@. + Hazırda dəstəklənən maksimum fayl ölçüsü %@-dır. + No comment provided by engineer. + + + Custom time + Fərdi vaxt + No comment provided by engineer. + + + Customizable message shape. + Fərdiləşdirilə bilən mesaj forması. + No comment provided by engineer. + + + Customize theme + Üslubu fərdiləşdirin + No comment provided by engineer. + + + Dark + Qaranlıq + No comment provided by engineer. + + + Dark mode colors + Qaranlıq rejim rəngləri + No comment provided by engineer. + + + Database ID + Verilənlər bazası identifikatoru + No comment provided by engineer. + + + Database ID: %d + Verilənlər bazası ID: %d + copied message info + + + Database IDs and Transport isolation option. + Verilənlər bazası identifikatorları və nəqliyyat səviyyəsində təcrid seçimi. + No comment provided by engineer. + + + Database downgrade + Verilənlər bazasının aşağı səviyyəsi + No comment provided by engineer. + + + Database encrypted! + Verilənlər bazası şifrələndi! + No comment provided by engineer. + + + Database encryption passphrase will be updated and stored in the keychain. + + Verilənlər bazasının şifrələnməsi üçün istifadə olunan parol yenilənəcək və açar dəstində (keychain) saxlanılacaq. + + No comment provided by engineer. + + + Database encryption passphrase will be updated. + + Verilənlər bazasının şifrələnməsi üçün istifadə olunan parol ifadəsi yenilənəcək. + + No comment provided by engineer. + + + Database error + Verilənlər bazası xətası + No comment provided by engineer. + + + Database is encrypted using a random passphrase, you can change it. + Verilənlər bazası təsadüfi bir parol ifadəsi ilə şifrələnib; onu dəyişə bilərsiniz. + No comment provided by engineer. + + + Database is encrypted using a random passphrase. Please change it before exporting. + Verilənlər bazası təsadüfi bir parol ifadəsi ilə şifrələnib. Zəhmət olmasa, onu ixrac etməzdən əvvəl dəyişdirin. + No comment provided by engineer. + + + Database passphrase + Verilənlər bazasının parolu + No comment provided by engineer. + + + Database passphrase & export + Verilənlər bazasının parolu və ixracı + No comment provided by engineer. + + + Database passphrase is different from saved in the keychain. + Verilənlər bazasının parolu "Keychain" saxlanılandan fərqlidir. + No comment provided by engineer. + + + Database passphrase is required to open chat. + Çatı açmaq üçün verilənlər bazasının parolu tələb olunur. + No comment provided by engineer. + + + Database upgrade + Verilənlər bazasının təkmilləşdirilməsi + No comment provided by engineer. + + + Database will be encrypted and the passphrase stored in the keychain. + + Verilənlər bazası şifrələnəcək və parol açar dəstində (keychain) saxlanılacaq. + + No comment provided by engineer. + + + Database will be encrypted. + + Verilənlər bazası şifrələnəcək. + + No comment provided by engineer. + + + Database will be migrated when the app restarts + Tətbiq yenidən başladıldıqda verilənlər bazası miqrasiya olunacaq. + No comment provided by engineer. + + + Debug delivery + Çatdırılmanın sazlanması + No comment provided by engineer. + + + Decode link + Linki deşifrə et + relay test step + + + Decryption error + Şifrənin açılması xətası + message decrypt error item + + + Delete + Sil + alert action +swipe action + + + Delete %lld messages of members? + Üzvlərin %lld mesajını silmək? + No comment provided by engineer. + + + Delete %lld messages? + %lld mesaj silinsin? + No comment provided by engineer. + + + Delete address + Ünvanı sil + No comment provided by engineer. + + + Delete address? + Ünvan silinsin? + No comment provided by engineer. + + + Delete after + Sonradan sil + No comment provided by engineer. + + + Delete all files + Bütün faylları silin + No comment provided by engineer. + + + Delete and notify contact + Kontaktı sil və ona bildiriş göndər + No comment provided by engineer. + + + Delete channel + Kanalı sil + No comment provided by engineer. + + + Delete channel? + Kanalı sil? + No comment provided by engineer. + + + Delete chat + Çatı sil + No comment provided by engineer. + + + Delete chat messages from your device. + Cihazınızdan söhbət mesajlarını silin. + No comment provided by engineer. + + + Delete chat profile + Çat profilini sil + No comment provided by engineer. + + + Delete chat profile? + Çat profilini sil? + No comment provided by engineer. + + + Delete chat with member? + İştirakçı ilə söhbəti sil? + alert title + + + Delete chat? + Çatı sil? + No comment provided by engineer. + + + Delete connection + Əlaqəni sil + No comment provided by engineer. + + + Delete contact + Kontaktı sil + No comment provided by engineer. + + + Delete contact? + Kontaktı sil? + No comment provided by engineer. + + + Delete database + Verilənlər bazasını silin + No comment provided by engineer. + + + Delete database from this device + Verilənlər bazasını bu cihazdan silin + No comment provided by engineer. + + + Delete file + Faylı sil + server test step + + + Delete files and media? + Faylları və media fayllarını sil? + No comment provided by engineer. + + + Delete files for all chat profiles + Bütün söhbət profilləri üçün faylları silin + No comment provided by engineer. + + + Delete for everyone + Hamı üçün sil + chat feature + + + Delete for me + Mənim üçün sil + No comment provided by engineer. + + + Delete from history + Tarixçədən sil + No comment provided by engineer. + + + Delete group + Qrupu sil + No comment provided by engineer. + + + Delete group? + Qrupu sil? + No comment provided by engineer. + + + Delete invitation + Dəvəti sil + No comment provided by engineer. + + + Delete link + Linki sil + No comment provided by engineer. + + + Delete link? + Linki sil? + No comment provided by engineer. + + + Delete list? + Siyahını sil? + alert title + + + Delete member message? + Üzvün mesajını sil? + No comment provided by engineer. + + + Delete member messages + Üzv mesajlarını silin + No comment provided by engineer. + + + Delete member messages? + Üzv mesajları silinsin? + alert title + + + Delete message? + Mesaj silinsin? + No comment provided by engineer. + + + Delete messages + Mesajları sil + alert action +alert button + + + Delete messages after + Mesajları ... sonra sil + No comment provided by engineer. + + + Delete old database + Köhnə verilənlər bazasını silin + No comment provided by engineer. + + + Delete old database? + Köhnə verilənlər bazası silinsinmi? + No comment provided by engineer. + + + Delete or moderate up to 200 messages. + 200 dənə mesajı silin və ya moderasiya edin. + No comment provided by engineer. + + + Delete pending connection? + Gözləmədə olan əlaqə silinsin? + No comment provided by engineer. + + + Delete profile + Profili sil + No comment provided by engineer. + + + Delete queue + Növbəni sil + server test step + + + Delete relay + Ötürücü sil + No comment provided by engineer. + + + Delete report + Hesabatı sil + No comment provided by engineer. + + + Delete up to 20 messages at once. + Eyni anda 20 qədər mesajı silin. + No comment provided by engineer. + + + Delete user profile? + İstifadəçi profilini sil? + No comment provided by engineer. + + + Delete without notification + Bildiriş olmadan silin + No comment provided by engineer. + + + Deleted + Silinib + No comment provided by engineer. + + + Deleted at + Silinmə vaxtı: + No comment provided by engineer. + + + Deleted at: %@ + Silinmə vaxtı: %@ + copied message info + + + Deletion errors + Silinmə səhvləri + No comment provided by engineer. + + + Delivered even when Apple drops them. + Hətta Apple onlardan imtina etdiyi halda belə təqdim olunur. + No comment provided by engineer. + + + Delivery + Çatdırılma + No comment provided by engineer. + + + Delivery receipts are disabled! + Çatdırılma bildirişləri deaktiv edilib! + No comment provided by engineer. + + + Delivery receipts! + Çatdırılma bildirişləri! + No comment provided by engineer. + + + Deprecated options + Köhnəlmiş seçimlər + No comment provided by engineer. + + + Description + Təsvir + No comment provided by engineer. + + + Description too large + Təsvir çox böyükdür + alert title + + + Desktop address + Masaüstü ünvanı + No comment provided by engineer. + + + Desktop app version %@ is not compatible with this app. + Masaüstü tətbiqinin %@ versiyası bu tətbiqlə uyğun deyil. + No comment provided by engineer. + + + Desktop devices + Masaüstü cihazlar + No comment provided by engineer. + + + Destination server address of %1$@ is incompatible with forwarding server %2$@ settings. + %1$@ ünvanının təyinat serveri %2$@ yönləndirmə serverinin parametrləri ilə uyğun deyil. + No comment provided by engineer. + + + Destination server error: %@ + Təyinat serveri xətası: %@ + snd error text + + + Destination server version of %1$@ is incompatible with forwarding server %2$@. + %1$@ təyinat serverinin versiyası %2$@ yönləndirmə serveri ilə uyğun deyil. + No comment provided by engineer. + + + Detailed statistics + Ətraflı statistika + No comment provided by engineer. + + + Details + Təfərrüatlar + No comment provided by engineer. + + + Developer + Proqramçı + No comment provided by engineer. + + + Developer options + Tərtibatçı seçimləri + No comment provided by engineer. + + + Device + Cihaz + No comment provided by engineer. + + + Device authentication is disabled. Turning off SimpleX Lock. + Cihazın autentifikasiyası deaktiv edilib. SimpleX Lock söndürülür. + No comment provided by engineer. + + + Device authentication is not enabled. You can turn on SimpleX Lock via Settings, once you enable device authentication. + Cihazın autentifikasiyası aktivləşdirilməyib. Cihazın autentifikasiyasını aktivləşdirdikdən sonra "Ayarlar" vasitəsilə "SimpleX Lock" funksiyasını işə sala bilərsiniz. + No comment provided by engineer. + + + Different names, avatars and transport isolation. + Müxtəlif adlar, avatarlar və nəqliyyat təcridi. + No comment provided by engineer. + + + Direct messages + Birbaşa mesajlar + chat feature + + + Direct messages between members are prohibited in this chat. + Bu çatda üzvlər arasında birbaşa mesajlaşma qadağandır. + No comment provided by engineer. + + + Direct messages between members are prohibited. + Üzvlər arasında birbaşa mesajlaşma qadağandır. + No comment provided by engineer. + + + Direct messages between subscribers are prohibited. + Abunəçilər arasında birbaşa mesajlaşma qadağandır. + No comment provided by engineer. + + + Disable + Deaktiv et + alert button + + + Disable (keep overrides) + Deaktiv et (dəyişiklikləri saxla) + No comment provided by engineer. + + + Disable SimpleX Lock + SimpleX kilidini deaktiv edin + authentication reason + + + Disable automatic message deletion? + Mesajların avtomatik silinməsini deaktiv et? + alert title + + + Disable delete messages + Mesajların silinməsini deaktiv edin + alert button + + + Disable for all + Hamısı üçün deaktiv et + No comment provided by engineer. + + + Disabled + Deaktifdir + No comment provided by engineer. + + + Disappearing message + Yoxa çıxan mesaj + No comment provided by engineer. + + + Disappearing messages + Yoxa çıxan mesajlar + chat feature + + + Disappearing messages are prohibited in this chat. + Bu çatda yoxa çıxan mesajlara icazə verilmir. + No comment provided by engineer. + + + Disappearing messages are prohibited. + Yoxa çıxan mesajlar qadağandır. + No comment provided by engineer. + + + Disappears at + Yoxa çıxma vaxtı + No comment provided by engineer. + + + Disappears at: %@ + Yox olur: %@ + copied message info + + + Disconnect + Əlaqəni kəs + server test step + + + Disconnect desktop? + İş masası ayrılsın? + No comment provided by engineer. + + + Discover and join groups + Qrupları kəşf edin və onlara qoşulun + No comment provided by engineer. + + + Discover via local network + Yerli şəbəkə vasitəsilə aşkar edin + No comment provided by engineer. + + + Do NOT send messages directly, even if your or destination server does not support private routing. + Hətta sizin və ya təyinat serveriniz şəxsi marşrutlaşdırmanı dəstəkləməsə belə, mesajları birbaşa GÖNDƏRMƏYİN. + No comment provided by engineer. + + + Do NOT use SimpleX for emergency calls. + No comment provided by engineer. + + + Do NOT use private routing. + Şəxsi marşrutlaşdırmadan istifadə etməyin. + No comment provided by engineer. + + + Do it later + Daha sonra edin + No comment provided by engineer. + + + Do not require signing messages. + Mesajları imzalamağı tələb etməyin. + No comment provided by engineer. + + + Do not send history to new members. + Tarixçəni yeni üzvlərə göndərməyin. + No comment provided by engineer. + + + Do not send history to new subscribers. + Yeni abunəçilərə tarixçə göndərməyin. + No comment provided by engineer. + + + Do not use credentials with proxy. + Etimadnamələri proxy ilə istifadə etməyin. + No comment provided by engineer. + + + Documents: + Sənədlər: + No comment provided by engineer. + + + Don't create address + Ünvan yaratmayın + No comment provided by engineer. + + + Don't enable + Aktivləşdirməyin + No comment provided by engineer. + + + Don't miss important messages. + Vacib mesajları qaçırmayın. + No comment provided by engineer. + + + Don't save + Yadda saxlamayın + alert action + + + Don't show again + Bir daha göstərməyin + alert action + + + Done + Hazırdır + No comment provided by engineer. + + + Downgrade and open chat + Siyahını aşağı salın və söhbəti açın + No comment provided by engineer. + + + Download + Yükləyin + alert button +chat item action + + + Download errors + Yükləmə xətaları + No comment provided by engineer. + + + Download failed + Yükləmə uğursuz oldu + No comment provided by engineer. + + + Download file + Faylı yükləyin + server test step + + + Download files + Faylları yükləyin + alert action + + + Downloaded + Yüklənib + No comment provided by engineer. + + + Downloaded files + Yüklənmiş fayllar + No comment provided by engineer. + + + Downloading archive + Arxiv yüklənir + No comment provided by engineer. + + + Downloading link details + Yükləmə linki detalları + No comment provided by engineer. + + + Duplicate display name! + Dublikat ekran adı! + No comment provided by engineer. + + + Duration + Müddət + No comment provided by engineer. + + + E2E encrypted notifications. + E2E şifrələnmiş bildirişlər. + No comment provided by engineer. + + + Easier to invite your friends 👋 + Dostlarınızı dəvət etmək daha asandır 👋 + No comment provided by engineer. + + + Easier to read. + Oxumaq daha asandır. + No comment provided by engineer. + + + Edit + Redaktə edin + chat item action + + + Edit channel profile + Kanal profilini redaktə edin + No comment provided by engineer. + + + Edit description + Təsviri redaktə edin + No comment provided by engineer. + + + Edit group profile + Qrup profilini redaktə edin + No comment provided by engineer. + + + Empty message! + Boş mesaj! + No comment provided by engineer. + + + Enable + Aktivləşdirin + alert button + + + Enable (keep overrides) + Aktivləşdirin (əvəzləmələri saxlayın) + No comment provided by engineer. + + + Enable Flux in Network & servers settings for better metadata privacy. + Daha yaxşı metaməlumat məxfiliyi üçün Şəbəkə və server parametrlərində Flux aktivləşdirin. + No comment provided by engineer. + + + Enable SimpleX Lock + SimpleX Lock-u aktivləşdirin + authentication reason + + + Enable TCP keep-alive + TCP aktiv qalmasını aktivləşdirin + No comment provided by engineer. + + + Enable at least one chat relay in Network & Servers. + Şəbəkə və Serverlər bölməsində ən azı bir söhbət ötürməsini aktivləşdirin. + channel creation warning + + + Enable automatic message deletion? + Avtomatik mesaj silinməsi aktivləşdirilsin? + alert title + + + Enable camera access + Kamera girişini aktivləşdirin + No comment provided by engineer. + + + Enable chats with admins? + Adminlərlə söhbətlər aktivləşdirilsin? + alert title + + + Enable disappearing messages by default. + Varsayılan olaraq, mesajların yoxa çıxmasını aktivləşdirin. + No comment provided by engineer. + + + Enable for all + Hamı üçün aktivləşdirin + No comment provided by engineer. + + + Enable in direct chats (BETA)! + Birbaşa söhbətlərdə aktivləşdirin (BETA)! + No comment provided by engineer. + + + Enable instant notifications? + Ani bildirişlər aktivləşdirilsin? + No comment provided by engineer. + + + Enable link previews? + Link önizləmələri aktivləşdirilsin? + alert title + + + Enable lock + Kilidi aktivləşdirin + No comment provided by engineer. + + + Enable periodic notifications? + Dövri bildirişlər aktivləşdirilsin? + No comment provided by engineer. + + + Enable self-destruct + Özünü məhv etməyi aktivləşdirin + No comment provided by engineer. + + + Enable self-destruct passcode + Özünüməhvetmə parolunu aktivləşdirin + set passcode view + + + Enabled + Aktivləşdirilib + No comment provided by engineer. + + + Enabled for + Aktivləşdirilib + No comment provided by engineer. + + + Encrypt + Şifrələyin + No comment provided by engineer. + + + Encrypt database? + Verilənlər bazasını şifrələyin? + No comment provided by engineer. + + + Encrypt local files + Yerli faylları şifrələyin + No comment provided by engineer. + + + Encrypt stored files & media + Saxlanılan faylları və medianı şifrələyin + No comment provided by engineer. + + + Encrypted database + Şifrələnmiş verilənlər bazası + No comment provided by engineer. + + + Encrypted message or another event + Şifrələnmiş mesaj və ya başqa bir hadisə + notification + + + Encrypted message: app is stopped + Şifrələnmiş mesaj: tətbiq dayandırılıb + notification + + + Encrypted message: database error + Şifrələnmiş mesaj: verilənlər bazası xətası + notification + + + Encrypted message: database migration error + Şifrələnmiş mesaj: verilənlər bazasının köçürülməsi xətası + notification + + + Encrypted message: keychain error + Şifrələnmiş mesaj: açar zəncir xətası + notification + + + Encrypted message: no passphrase + Şifrələnmiş mesaj: parol yoxdur + notification + + + Encrypted message: unexpected error + Şifrələnmiş mesaj: gözlənilməz xəta + notification + + + Encryption re-negotiation error + Şifrələmənin yenidən danışıqları xətası + message decrypt error item + + + Encryption re-negotiation failed. + Şifrələmənin yenidən müzakirəsi uğursuz oldu. + No comment provided by engineer. + + + Encryption renegotiation in progress. + Şifrələmənin yenidən müzakirəsi davam edir. + No comment provided by engineer. + + + Enter Passcode + Şifrəni daxil edin + No comment provided by engineer. + + + Enter channel name… + Kanal adını daxil edin… + No comment provided by engineer. + + + Enter correct passphrase. + Düzgün parol daxil edin. + No comment provided by engineer. + + + Enter description (optional) + Təsvir daxil edin (isteğe bağlı) + placeholder + + + Enter group name… + Qrup adını daxil edin… + No comment provided by engineer. + + + Enter passphrase + Şifrəni daxil edin + No comment provided by engineer. + + + Enter passphrase… + Şifrəni daxil edin… + No comment provided by engineer. + + + Enter password above to show! + Göstərmək üçün yuxarıdakı parolu daxil edin! + No comment provided by engineer. + + + Enter profile name... + Profil adını daxil edin... + No comment provided by engineer. + + + Enter relay name… + Ötürücü adını daxil edin… + No comment provided by engineer. + + + Enter server manually + Serveri əl ilə daxil edin + No comment provided by engineer. + + + Enter this device name… + Bu cihazın adını daxil edin… + No comment provided by engineer. + + + Enter webpage URL + Veb səhifə URL daxil edin + No comment provided by engineer. + + + Enter welcome message… + Xoş gəldin mesajını daxil edin… + placeholder + + + Enter welcome message… (optional) + Xoş gəldin mesajını daxil edin... (isteğe bağlı) + placeholder + + + Enter your name… + Adınızı daxil edin… + No comment provided by engineer. + + + Error + Xəta + No comment provided by engineer. + + + Error aborting address change + Ünvan dəyişikliyini ləğv edərkən xəta + No comment provided by engineer. + + + Error accepting conditions + Şərtləri qəbul edərkən xəta baş verdi + alert title + + + Error accepting contact request + Əlaqə sorğusunu qəbul edərkən xəta baş verdi + No comment provided by engineer. + + + Error accepting member + Üzv qəbul edərkən xəta baş verdi + alert title + + + Error adding member(s) + Üzv(lər) əlavə etmə xətası + No comment provided by engineer. + + + Error adding relay + Ötürücü əlavə etmə xətası + alert title + + + Error adding relays + Ötürücü əlavə edərkən xəta baş verdi + alert title + + + Error adding server + Server əlavə etmə xətası + alert title + + + Error adding short link + Qısa link əlavə etmə xətası + No comment provided by engineer. + + + Error changing address + Ünvanı dəyişdirərkən xəta baş verdi + No comment provided by engineer. + + + Error changing chat profile + Çat profilini dəyişdirərkən xəta baş verdi + alert title + + + Error changing connection profile + Bağlantı profilini dəyişdirərkən xəta baş verdi + No comment provided by engineer. + + + Error changing role + Rolu dəyişdirərkən xəta baş verdi + No comment provided by engineer. + + + Error changing setting + Ayarı dəyişdirərkən xəta baş verdi + alert title + + + Error changing to incognito! + İnkoqnito rejiminə keçməkdə xəta baş verdi! + No comment provided by engineer. + + + Error checking token status + Token statusunu yoxlamaqda xəta + No comment provided by engineer. + + + Error connecting to forwarding server %@. Please try later. + %@ yönləndirmə serverinə qoşulma xətası. Zəhmət olmasa, daha sonra cəhd edin. + alert message + + + Error connecting to the server used to receive messages from this connection: %@ + Bu bağlantıdan mesaj almaq üçün istifadə edilən serverə qoşulma xətası: %@ + subscription status explanation + + + Error creating address + Ünvan yaratmaqda xəta + No comment provided by engineer. + + + Error creating channel + Kanal yaratmaqda xəta + alert title + + + Error creating group + Qrup yaratmaqda xəta + No comment provided by engineer. + + + Error creating group link + Qrup linki yaradılarkən xəta baş verdi + No comment provided by engineer. + + + Error creating list + Siyahı yaratmaqda xəta + alert title + + + Error creating member contact + Üzv əlaqəsi yaratmaqda xəta + No comment provided by engineer. + + + Error creating message + Mesaj yaratmaqda xəta + No comment provided by engineer. + + + Error creating profile! + Profil yaratmaqda xəta! + No comment provided by engineer. + + + Error creating report + Hesabat yaratmaqda xəta + No comment provided by engineer. + + + Error decrypting file + Faylın şifrəsini açmaqda xəta + No comment provided by engineer. + + + Error deleting chat + Söhbəti silməkdə xəta baş verdi + alert title + + + Error deleting chat database + Çat verilənlər bazasını silməkdə xəta baş verdi + alert title + + + Error deleting chat! + Söhbəti silməkdə xəta baş verdi! + alert title + + + Error deleting connection + Bağlantı silinmə xətası + No comment provided by engineer. + + + Error deleting database + Verilənlər bazasını silməkdə xəta + alert title + + + Error deleting message + Mesajı silməkdə xəta baş verdi + alert title + + + Error deleting old database + Köhnə verilənlər bazasını silməkdə xəta + alert title + + + Error deleting token + Tokeni silməkdə xəta baş verdi + No comment provided by engineer. + + + Error deleting user profile + İstifadəçi profilini silməkdə xəta baş verdi + No comment provided by engineer. + + + Error downloading the archive + Arxivi yükləməkdə xəta + No comment provided by engineer. + + + Error enabling delivery receipts! + Çatdırılma qəbzlərini aktivləşdirmədə xəta baş verdi! + No comment provided by engineer. + + + Error enabling notifications + Bildirişləri aktivləşdirmə xətası + No comment provided by engineer. + + + Error encrypting database + No comment provided by engineer. + + + Error exporting chat database + alert title + + + Error exporting theme: %@ + No comment provided by engineer. + + + Error importing chat database + alert title + + + Error joining group + No comment provided by engineer. + + + Error loading servers + alert title + + + Error migrating settings + No comment provided by engineer. + + + Error opening chat + No comment provided by engineer. + + + Error receiving file + alert title + + + Error reconnecting server + No comment provided by engineer. + + + Error reconnecting servers + No comment provided by engineer. + + + Error registering for notifications + alert title + + + Error rejecting contact request + alert title + + + Error removing member + alert title + + + Error reordering lists + alert title + + + Error resetting statistics + No comment provided by engineer. + + + Error saving ICE servers + No comment provided by engineer. + + + Error saving channel profile + No comment provided by engineer. + + + Error saving chat list + alert title + + + Error saving group profile + No comment provided by engineer. + + + Error saving name + alert title + + + Error saving passcode + No comment provided by engineer. + + + Error saving passphrase to keychain + No comment provided by engineer. + + + Error saving servers + alert title + + + Error saving settings + when migrating + + + Error saving user password + No comment provided by engineer. + + + Error scanning code: %@ + No comment provided by engineer. + + + Error sending email + No comment provided by engineer. + + + Error sending member contact invitation + No comment provided by engineer. + + + Error sending message + No comment provided by engineer. + + + Error setting auto-accept + No comment provided by engineer. + + + Error setting delivery receipts! + No comment provided by engineer. + + + Error sharing address + alert title + + + Error sharing channel + alert title + + + Error starting chat + No comment provided by engineer. + + + Error stopping chat + No comment provided by engineer. + + + Error switching profile + alert title + + + Error switching profile! + alertTitle + + + Error synchronizing connection + No comment provided by engineer. + + + Error testing server connection + No comment provided by engineer. + + + Error updating group link + No comment provided by engineer. + + + Error updating message + No comment provided by engineer. + + + Error updating server + alert title + + + Error updating settings + No comment provided by engineer. + + + Error updating user privacy + No comment provided by engineer. + + + Error uploading the archive + No comment provided by engineer. + + + Error verifying passphrase: + No comment provided by engineer. + + + Error: + No comment provided by engineer. + + + Error: %@ + alert message +conn error description +file error text +snd error text + + + Error: %@. + relay test error +server test error + + + Error: URL is invalid + No comment provided by engineer. + + + Error: no database file + No comment provided by engineer. + + + Errors + No comment provided by engineer. + + + Errors in servers configuration. + servers error + + + Even when disabled in the conversation. + No comment provided by engineer. + + + Exit without saving + No comment provided by engineer. + + + Expand + chat item action + + + Expired + token status text + + + Export database + No comment provided by engineer. + + + Export error: + No comment provided by engineer. + + + Export theme + No comment provided by engineer. + + + Exported database archive. + No comment provided by engineer. + + + Exported file doesn't exist + No comment provided by engineer. + + + Exporting database archive… + No comment provided by engineer. + + + Failed to remove passphrase + No comment provided by engineer. + + + Fast and no wait until the sender is online! + No comment provided by engineer. + + + Faster deletion of groups. + No comment provided by engineer. + + + Faster joining and more reliable messages. + No comment provided by engineer. + + + Faster sending messages. + No comment provided by engineer. + + + Favorite + swipe action + + + Favorites + No comment provided by engineer. + + + File error + file error alert title + + + File errors: +%@ + alert message + + + File is blocked by server operator: +%@. + file error text + + + File not found - most likely file was deleted or cancelled. + file error text + + + File server error: %@ + file error text + + + File servers + No comment provided by engineer. + + + File servers: %@ + copied message info + + + File status + No comment provided by engineer. + + + File status: %@ + copied message info + + + File will be deleted from servers. + No comment provided by engineer. + + + File will be received when your contact completes uploading it. + No comment provided by engineer. + + + File will be received when your contact is online, please wait or check later! + No comment provided by engineer. + + + File: %@ + No comment provided by engineer. + + + Files + No comment provided by engineer. + + + Files & media + No comment provided by engineer. + + + Files and media + chat feature + + + Files and media are prohibited in this chat. + No comment provided by engineer. + + + Files and media are prohibited. + No comment provided by engineer. + + + Files and media not allowed + No comment provided by engineer. + + + Files and media prohibited! + No comment provided by engineer. + + + Filter + No comment provided by engineer. + + + Filter unread and favorite chats. + No comment provided by engineer. + + + Finalize migration + No comment provided by engineer. + + + Finalize migration on another device. + No comment provided by engineer. + + + Finally, we have them! 🚀 + No comment provided by engineer. + + + Find chats faster + No comment provided by engineer. + + + Fingerprint in destination server address does not match certificate: %@. + No comment provided by engineer. + + + Fingerprint in forwarding server address does not match certificate: %@. + No comment provided by engineer. + + + Fingerprint in server address does not match certificate. + relay test error +server test error + + + Fingerprint in server address does not match certificate: %@. + No comment provided by engineer. + + + Fix + No comment provided by engineer. + + + Fix connection + No comment provided by engineer. + + + Fix connection? + No comment provided by engineer. + + + Fix encryption after restoring backups. + No comment provided by engineer. + + + Fix not supported by contact + No comment provided by engineer. + + + Fix not supported by group member + No comment provided by engineer. + + + For all moderators + No comment provided by engineer. + + + For anyone to reach you + No comment provided by engineer. + + + For chat profile %@: + servers error +servers warning + + + For console + No comment provided by engineer. + + + For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server. + No comment provided by engineer. + + + For me + No comment provided by engineer. + + + For private routing + No comment provided by engineer. + + + For social media + No comment provided by engineer. + + + Forward + chat item action + + + Forward %d message(s)? + alert title + + + Forward and save messages + No comment provided by engineer. + + + Forward messages + alert action + + + Forward messages without files? + alert message + + + Forward up to 20 messages at once. + No comment provided by engineer. + + + Forwarded + No comment provided by engineer. + + + Forwarded from + No comment provided by engineer. + + + Forwarding %lld messages + No comment provided by engineer. + + + Forwarding server %1$@ failed to connect to destination server %2$@. Please try later. + alert message + + + Forwarding server address is incompatible with network settings: %@. + No comment provided by engineer. + + + Forwarding server version is incompatible with network settings: %@. + No comment provided by engineer. + + + Forwarding server: %1$@ +Destination server error: %2$@ + snd error text + + + Forwarding server: %1$@ +Error: %2$@ + snd error text + + + Found desktop + No comment provided by engineer. + + + French interface + No comment provided by engineer. + + + Full link + No comment provided by engineer. + + + Full name (optional) + No comment provided by engineer. + + + Fully decentralized – visible only to members. + No comment provided by engineer. + + + Fully re-implemented - work in background! + No comment provided by engineer. + + + Further reduced battery usage + No comment provided by engineer. + + + GIFs and stickers + No comment provided by engineer. + + + Get SimpleX name (BETA) + No comment provided by engineer. + + + Get link + relay test step + + + Get notified when mentioned. + No comment provided by engineer. + + + Get started + No comment provided by engineer. + + + Good afternoon! + message preview + + + Good morning! + message preview + + + Group + No comment provided by engineer. + + + Group already exists + No comment provided by engineer. + + + Group already exists! + new chat sheet title + + + Group display name + No comment provided by engineer. + + + Group full name (optional) + No comment provided by engineer. + + + Group image + No comment provided by engineer. + + + Group invitation + No comment provided by engineer. + + + Group invitation expired + No comment provided by engineer. + + + Group invitation is no longer valid, it was removed by sender. + No comment provided by engineer. + + + Group invitations + No comment provided by engineer. + + + Group link + chat link info line + + + Group links + No comment provided by engineer. + + + Group message: + notification + + + Group moderation + No comment provided by engineer. + + + Group preferences + No comment provided by engineer. + + + Group profile + No comment provided by engineer. + + + Group profile is stored on members' devices, not on the servers. + No comment provided by engineer. + + + Group profile was changed. If you save it, the updated profile will be sent to group members. + alert message + + + Group webpage + No comment provided by engineer. + + + Group welcome message + No comment provided by engineer. + + + Group will be deleted for all members - this cannot be undone! + No comment provided by engineer. + + + Group will be deleted for you - this cannot be undone! + No comment provided by engineer. + + + Groups + No comment provided by engineer. + + + Help + No comment provided by engineer. + + + Help & support + No comment provided by engineer. + + + Help admins moderating their groups. + No comment provided by engineer. + + + Hidden + No comment provided by engineer. + + + Hidden chat profiles + No comment provided by engineer. + + + Hidden profile password + No comment provided by engineer. + + + Hide + chat item action + + + Hide app screen in the recent apps. + No comment provided by engineer. + + + Hide profile + No comment provided by engineer. + + + Hide: + No comment provided by engineer. + + + History + No comment provided by engineer. + + + History is not sent to new members. + No comment provided by engineer. + + + History is not sent to new subscribers. + No comment provided by engineer. + + + How SimpleX works + No comment provided by engineer. + + + How it affects privacy + No comment provided by engineer. + + + How it helps privacy + No comment provided by engineer. + + + How it works + alert button + + + How to + No comment provided by engineer. + + + How to register a test name + No comment provided by engineer. + + + How to use it + No comment provided by engineer. + + + How to use your servers + No comment provided by engineer. + + + Hungarian interface + No comment provided by engineer. + + + ICE servers (one per line) + No comment provided by engineer. + + + IP address + No comment provided by engineer. + + + If you can't meet in person, show QR code in a video call, or share the link. + No comment provided by engineer. + + + If you enter this passcode when opening the app, all app data will be irreversibly removed! + No comment provided by engineer. + + + If you enter your self-destruct passcode while opening the app: + No comment provided by engineer. + + + If you joined or created channels, they will stop working permanently. + down migration warning + + + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). + No comment provided by engineer. + + + Ignore + No comment provided by engineer. + + + Image will be received when your contact completes uploading it. + No comment provided by engineer. + + + Image will be received when your contact is online, please wait or check later! + No comment provided by engineer. + + + Images + No comment provided by engineer. + + + Immediately + No comment provided by engineer. + + + Import + No comment provided by engineer. + + + Import chat database? + No comment provided by engineer. + + + Import database + No comment provided by engineer. + + + Import failed + No comment provided by engineer. + + + Import theme + No comment provided by engineer. + + + Importing archive + No comment provided by engineer. + + + Improved delivery, reduced traffic usage. +More improvements are coming soon! + No comment provided by engineer. + + + Improved message delivery + No comment provided by engineer. + + + Improved privacy and security + No comment provided by engineer. + + + Improved server configuration + No comment provided by engineer. + + + In order to continue, chat should be stopped. + No comment provided by engineer. + + + In reply to + No comment provided by engineer. + + + In-call sounds + No comment provided by engineer. + + + Inappropriate content + report reason + + + Inappropriate profile + report reason + + + Incognito + No comment provided by engineer. + + + Incognito groups + No comment provided by engineer. + + + Incognito mode + No comment provided by engineer. + + + Incognito mode protects your privacy by using a new random profile for each contact. + No comment provided by engineer. + + + Incoming audio call + notification + + + Incoming call + notification + + + Incoming video call + notification + + + Incompatible database version + No comment provided by engineer. + + + Incompatible version + No comment provided by engineer. + + + Incorrect passcode + PIN entry + + + Incorrect security code! + No comment provided by engineer. + + + Info + chat item action + + + Initial role + No comment provided by engineer. + + + Install SimpleX Chat for terminal + No comment provided by engineer. + + + Instant + No comment provided by engineer. + + + Instant push notifications will be hidden! + + No comment provided by engineer. + + + Interface + No comment provided by engineer. + + + Interface colors + No comment provided by engineer. + + + Invalid + token status text + + + Invalid (bad token) + token status text + + + Invalid (expired) + token status text + + + Invalid (unregistered) + token status text + + + Invalid (wrong topic) + token status text + + + Invalid QR code + No comment provided by engineer. + + + Invalid connection link + conn error description + + + Invalid display name! + No comment provided by engineer. + + + Invalid link + alert title + + + Invalid migration confirmation + No comment provided by engineer. + + + Invalid name! + alert title + + + Invalid relay address! + alert title + + + Invalid relay name! + alert title + + + Invalid response + No comment provided by engineer. + + + Invalid server address! + alert title + + + Invalid status + item status text + + + Invitation expired! + No comment provided by engineer. + + + Invite friends + No comment provided by engineer. + + + Invite member + No comment provided by engineer. + + + Invite members + No comment provided by engineer. + + + Invite someone privately + No comment provided by engineer. + + + Invite to chat + No comment provided by engineer. + + + Invite to group + No comment provided by engineer. + + + Irreversible message deletion + No comment provided by engineer. + + + Irreversible message deletion is prohibited in this chat. + No comment provided by engineer. + + + Irreversible message deletion is prohibited. + No comment provided by engineer. + + + It allows having many anonymous connections without any shared data between them in a single chat profile. + No comment provided by engineer. + + + It can happen when you or your connection used the old database backup. + No comment provided by engineer. + + + It can happen when: +1. The messages expired in the sending client after 2 days or on the server after 30 days. +2. Message decryption failed, because you or your contact used old database backup. +3. The connection was compromised. + No comment provided by engineer. + + + It protects your IP address and connections. + No comment provided by engineer. + + + It seems like you are already connected via this link. If it is not the case, there was an error (%@). + No comment provided by engineer. + + + It will be shown to subscribers and used to allow loading the preview. + No comment provided by engineer. + + + Italian interface + No comment provided by engineer. + + + Japanese interface + No comment provided by engineer. + + + Join + swipe action + + + Join as %@ + No comment provided by engineer. + + + Join channel + No comment provided by engineer. + + + Join channel %@ + new chat action + + + Join group + new chat sheet title + + + Join group conversations + No comment provided by engineer. + + + Join incognito + No comment provided by engineer. + + + Join your group? +This is your link for group %@! + new chat action + + + Joining group + No comment provided by engineer. + + + Keep + alert action + + + Keep conversation + No comment provided by engineer. + + + Keep the app open to use it from desktop + No comment provided by engineer. + + + Keep unused invitation? + alert title + + + Keep your chats clean + No comment provided by engineer. + + + Keep your connections + No comment provided by engineer. + + + KeyChain error + No comment provided by engineer. + + + Keychain error + No comment provided by engineer. + + + LIVE + No comment provided by engineer. + + + Large file! + No comment provided by engineer. + + + Learn more + badge alert button + + + Leave + swipe action + + + Leave channel + No comment provided by engineer. + + + Leave channel? + No comment provided by engineer. + + + Leave chat + No comment provided by engineer. + + + Leave chat? + No comment provided by engineer. + + + Leave group + No comment provided by engineer. + + + Leave group? + No comment provided by engineer. + + + Less traffic on mobile networks. + No comment provided by engineer. + + + Let people connect to you via name registered with your SimpleX address. + No comment provided by engineer. + + + Let people join via name registered with this channel link. + No comment provided by engineer. + + + Let someone connect to you + No comment provided by engineer. + + + Let's talk in SimpleX Chat + email subject + + + Light + No comment provided by engineer. + + + Limitations + No comment provided by engineer. + + + Link mobile and desktop apps! 🔗 + No comment provided by engineer. + + + Link signature verified. + owner verification + + + Linked desktop options + No comment provided by engineer. + + + Linked desktops + No comment provided by engineer. + + + Links + No comment provided by engineer. + + + List + swipe action + + + List name and emoji should be different for all lists. + No comment provided by engineer. + + + List name... + No comment provided by engineer. + + + Live message! + No comment provided by engineer. + + + Live messages + No comment provided by engineer. + + + Loading profile… + in progress text + + + Local name + No comment provided by engineer. + + + Local profile data only + No comment provided by engineer. + + + Lock after + No comment provided by engineer. + + + Lock mode + No comment provided by engineer. + + + Make one message disappear + No comment provided by engineer. + + + Make profile private! + No comment provided by engineer. + + + Make sure WebRTC ICE server addresses are in correct format, line separated and are not duplicated. + No comment provided by engineer. + + + Manage your relays. + No comment provided by engineer. + + + Mark deleted for everyone + No comment provided by engineer. + + + Mark read + No comment provided by engineer. + + + Mark verified + No comment provided by engineer. + + + Markdown in messages + No comment provided by engineer. + + + Max 30 seconds, received instantly. + No comment provided by engineer. + + + Media & file servers + No comment provided by engineer. + + + Medium + blur media + + + Member + No comment provided by engineer. + + + Member %@ + past/unknown group member + + + Member admission + No comment provided by engineer. + + + Member inactive + item status text + + + Member is deleted - can't accept request + No comment provided by engineer. + + + Member messages will be deleted - this cannot be undone! + alert message + + + Member reports + chat feature + + + Member will be removed from chat - this cannot be undone! + alert message + + + Member will be removed from group - this cannot be undone! + alert message + + + Member will join the group, accept member? + alert message + + + Members can add message reactions. + No comment provided by engineer. + + + Members can chat with admins. + No comment provided by engineer. + + + Members can irreversibly delete sent messages. (24 hours) + No comment provided by engineer. + + + Members can report messsages to moderators. + No comment provided by engineer. + + + Members can send SimpleX links. + No comment provided by engineer. + + + Members can send direct messages. + No comment provided by engineer. + + + Members can send disappearing messages. + No comment provided by engineer. + + + Members can send files and media. + No comment provided by engineer. + + + Members can send voice messages. + No comment provided by engineer. + + + Mention members 👋 + No comment provided by engineer. + + + Menus + No comment provided by engineer. + + + Message delivery error + item status text + + + Message delivery receipts! + No comment provided by engineer. + + + Message delivery warning + item status text + + + Message draft + No comment provided by engineer. + + + Message error + No comment provided by engineer. + + + Message forwarded + item status text + + + Message instantly once you tap Connect. + No comment provided by engineer. + + + Message may be delivered later if member becomes active. + item status description + + + Message queue info + No comment provided by engineer. + + + Message reactions + chat feature + + + Message reactions are prohibited in this chat. + No comment provided by engineer. + + + Message reactions are prohibited. + No comment provided by engineer. + + + Message reception + No comment provided by engineer. + + + Message servers + No comment provided by engineer. + + + Message shape + No comment provided by engineer. + + + Message signing is not required. + No comment provided by engineer. + + + Message signing is required. + No comment provided by engineer. + + + Message source remains private. + No comment provided by engineer. + + + Message status + No comment provided by engineer. + + + Message status: %@ + copied message info + + + Message text + No comment provided by engineer. + + + Message too large + No comment provided by engineer. + + + Messages + No comment provided by engineer. + + + Messages & files + No comment provided by engineer. + + + Messages are protected by **end-to-end encryption**. + No comment provided by engineer. + + + Messages from %@ will be shown! + No comment provided by engineer. + + + Messages in this channel are **not end-to-end encrypted**. Chat relays can see these messages. + No comment provided by engineer. + + + Messages in this channel are not end-to-end encrypted. Chat relays can see these messages. + E2EE info chat item + + + Messages in this chat will never be deleted. + alert message + + + Messages received + No comment provided by engineer. + + + Messages sent + No comment provided by engineer. + + + Messages were deleted after you selected them. + alert message + + + Messages, files and calls are protected by **end-to-end encryption** with perfect forward secrecy, repudiation and break-in recovery. + No comment provided by engineer. + + + Messages, files and calls are protected by **quantum resistant e2e encryption** with perfect forward secrecy, repudiation and break-in recovery. + No comment provided by engineer. + + + Migrate + No comment provided by engineer. + + + Migrate device + No comment provided by engineer. + + + Migrate here + No comment provided by engineer. + + + Migrate to another device + No comment provided by engineer. + + + Migrate to another device via QR code. + No comment provided by engineer. + + + Migrating + No comment provided by engineer. + + + Migrating database archive… + No comment provided by engineer. + + + Migration complete + No comment provided by engineer. + + + Migration error: + No comment provided by engineer. + + + Migration failed. Tap **Skip** below to continue using the current database. Please report the issue to the app developers via chat or email [chat@simplex.chat](mailto:chat@simplex.chat). + No comment provided by engineer. + + + Migration is completed + No comment provided by engineer. + + + Migrations: + No comment provided by engineer. + + + Moderate + chat item action + + + Moderated at + No comment provided by engineer. + + + Moderated at: %@ + copied message info + + + More + swipe action + + + More improvements are coming soon! + No comment provided by engineer. + + + More privacy + No comment provided by engineer. + + + More reliable network connection. + No comment provided by engineer. + + + More reliable notifications + No comment provided by engineer. + + + Most likely this connection is deleted. + item status description + + + Multiple chat profiles + No comment provided by engineer. + + + Mute + notification label action + + + Mute all + notification label action + + + Muted when inactive! + No comment provided by engineer. + + + Name + swipe action + + + Name not found + No comment provided by engineer. + + + Network & servers + No comment provided by engineer. + + + Network commitments + No comment provided by engineer. + + + Network connection + No comment provided by engineer. + + + Network decentralization + No comment provided by engineer. + + + Network error + conn error description + + + Network issues - message expired after many attempts to send it. + snd error text + + + Network management + No comment provided by engineer. + + + Network operator + No comment provided by engineer. + + + Network routers cannot know +who talks to whom + No comment provided by engineer. + + + Network settings + No comment provided by engineer. + + + Network status + alert title + + + New + token status text + + + New 1-time link + No comment provided by engineer. + + + New Passcode + No comment provided by engineer. + + + New SOCKS credentials will be used every time you start the app. + No comment provided by engineer. + + + New SOCKS credentials will be used for each server. + No comment provided by engineer. + + + New chat + No comment provided by engineer. + + + New chat experience 🎉 + No comment provided by engineer. + + + New chat relay + No comment provided by engineer. + + + New contact request + notification + + + New contact: + notification + + + New desktop app! + No comment provided by engineer. + + + New display name + No comment provided by engineer. + + + New events + notification + + + New group role: Moderator + No comment provided by engineer. + + + New in %@ + No comment provided by engineer. + + + New media options + No comment provided by engineer. + + + New member role + No comment provided by engineer. + + + New member wants to join the group. + rcv group event chat item + + + New message + notification + + + New passphrase… + No comment provided by engineer. + + + New server + No comment provided by engineer. + + + No + No comment provided by engineer. + + + No account. No phone. No email. No ID. +The most secure encryption. + No comment provided by engineer. + + + No active relays + No comment provided by engineer. + + + No app password + Authentication unavailable + + + No available relays + No comment provided by engineer. + + + No chat relays + No comment provided by engineer. + + + No chat relays enabled. + servers warning + + + No chats + No comment provided by engineer. + + + No chats found + No comment provided by engineer. + + + No chats in list %@ + No comment provided by engineer. + + + No chats with members + No comment provided by engineer. + + + No contacts selected + No comment provided by engineer. + + + No contacts to add + No comment provided by engineer. + + + No delivery information + No comment provided by engineer. + + + No device token! + No comment provided by engineer. + + + No direct connection yet, message is forwarded by admin. + item status description + + + No filtered chats + No comment provided by engineer. + + + Group not found! + No comment provided by engineer. + + + No history + No comment provided by engineer. + + + No info, try to reload + No comment provided by engineer. + + + No media & file servers. + servers error + + + No message + No comment provided by engineer. + + + No message servers. + servers error + + + No network connection + No comment provided by engineer. + + + No permission to record speech + No comment provided by engineer. + + + No permission to record video + No comment provided by engineer. + + + No permission to record voice message + No comment provided by engineer. + + + No private routing session + alert title + + + No push server + No comment provided by engineer. + + + No received or sent files + No comment provided by engineer. + + + No relays + No comment provided by engineer. + + + No servers for private message routing. + servers error + + + No servers to receive files. + servers error + + + No servers to receive messages. + servers error + + + No servers to resolve names. + servers warning + + + No servers to send files. + servers error + + + No token! + alert title + + + No unread chats + No comment provided by engineer. + + + No valid link + No comment provided by engineer. + + + 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. + No comment provided by engineer. + + + Non-profit governance + No comment provided by engineer. + + + None of your servers are set to resolve SimpleX names. Configure servers, or use a connection link. + No comment provided by engineer. + + + Not a better lock on someone else's door. Not a nicer landlord that respects your privacy, but still keeps the record of all visitors. You are not a guest. You are home. No king can enter it - you are sovereign. + No comment provided by engineer. + + + Not all relays connected + alert title + + + Not compatible! + No comment provided by engineer. + + + Notes + No comment provided by engineer. + + + Nothing selected + No comment provided by engineer. + + + Nothing to forward! + alert title + + + Notifications + No comment provided by engineer. + + + Notifications are disabled! + No comment provided by engineer. + + + Notifications error + alert title + + + Notifications privacy + No comment provided by engineer. + + + Notifications status + alert title + + + Now admins can: +- delete members' messages. +- disable members ("observer" role) + No comment provided by engineer. + + + OK + alert button + + + Off + blur media + + + Ok + alert action +alert button +new chat action + + + Old database + No comment provided by engineer. + + + On your phone, not on servers. + No comment provided by engineer. + + + One-time invitation link + No comment provided by engineer. + + + One-time link + chat link info line + + + Onion hosts will be **required** for connection. +Requires compatible VPN. + No comment provided by engineer. + + + Onion hosts will be used when available. +Requires compatible VPN. + No comment provided by engineer. + + + Onion hosts will not be used. + No comment provided by engineer. + + + Only channel owners can change channel preferences. + No comment provided by engineer. + + + Only chat owners can change preferences. + No comment provided by engineer. + + + Only client devices store user profiles, contacts, groups, and messages. + No comment provided by engineer. + + + Only delete conversation + No comment provided by engineer. + + + Only group owners can change group preferences. + No comment provided by engineer. + + + Only group owners can enable files and media. + No comment provided by engineer. + + + Only group owners can enable voice messages. + No comment provided by engineer. + + + Only sender and moderators see it + No comment provided by engineer. + + + Only you and moderators see it + No comment provided by engineer. + + + Only you can add message reactions. + No comment provided by engineer. + + + Only you can irreversibly delete messages (your contact can mark them for deletion). (24 hours) + No comment provided by engineer. + + + Only you can make calls. + No comment provided by engineer. + + + Only you can send disappearing messages. + No comment provided by engineer. + + + Only you can send files and media. + No comment provided by engineer. + + + Only you can send voice messages. + No comment provided by engineer. + + + Only your contact can add message reactions. + No comment provided by engineer. + + + Only your contact can irreversibly delete messages (you can mark them for deletion). (24 hours) + No comment provided by engineer. + + + Only your contact can make calls. + No comment provided by engineer. + + + Only your contact can send disappearing messages. + No comment provided by engineer. + + + Only your contact can send files and media. + No comment provided by engineer. + + + Only your contact can send voice messages. + No comment provided by engineer. + + + Only your page above can show the preview. + No comment provided by engineer. + + + Open + alert action +alert button + + + Open Settings + No comment provided by engineer. + + + Open changes + No comment provided by engineer. + + + Open channel + new chat action + + + Open chat + new chat action + + + Open chat console + authentication reason + + + Open clean link + alert action + + + Open conditions + No comment provided by engineer. + + + Open external link? + alert title + + + Open full link + alert action + + + Open group + new chat action + + + Open link? + alert title + + + Open migration to another device + authentication reason + + + Open new channel + new chat action + + + Open new chat + new chat action + + + Open new group + new chat action + + + Open to accept + No comment provided by engineer. + + + Open to connect + No comment provided by engineer. + + + Open to join + No comment provided by engineer. + + + Open to use bot + No comment provided by engineer. + + + Opening app… + No comment provided by engineer. + + + Operator + No comment provided by engineer. + + + Operator server + alert title + + + Operators commit to: +- Be independent +- Minimize metadata usage +- Run verified open-source code + No comment provided by engineer. + + + Or import archive file + No comment provided by engineer. + + + Or paste archive link + No comment provided by engineer. + + + Or scan QR code + No comment provided by engineer. + + + Or securely share this file link + No comment provided by engineer. + + + Or show QR in person or via video call. + No comment provided by engineer. + + + Or show this code + No comment provided by engineer. + + + Or to share privately + No comment provided by engineer. + + + Or use this QR - print or show online. + No comment provided by engineer. + + + Organize chats into lists + No comment provided by engineer. + + + Other + No comment provided by engineer. + + + Other file errors: +%@ + alert message + + + Owner + No comment provided by engineer. + + + Owners & contributors + No comment provided by engineer. + + + Ownership: you can run your own relays. + No comment provided by engineer. + + + PING count + No comment provided by engineer. + + + PING interval + No comment provided by engineer. + + + Passcode + No comment provided by engineer. + + + Passcode changed! + No comment provided by engineer. + + + Passcode entry + No comment provided by engineer. + + + Passcode not changed! + No comment provided by engineer. + + + Passcode set! + No comment provided by engineer. + + + Password + No comment provided by engineer. + + + Password to show + No comment provided by engineer. + + + Paste desktop address + No comment provided by engineer. + + + Paste image + No comment provided by engineer. + + + Paste link / Scan + No comment provided by engineer. + + + Paste link to connect! + No comment provided by engineer. + + + Paste the link you received + No comment provided by engineer. + + + Pending + No comment provided by engineer. + + + Periodic + No comment provided by engineer. + + + Permanent decryption error + message decrypt error item + + + Picture-in-picture calls + No comment provided by engineer. + + + Play from the chat list. + No comment provided by engineer. + + + Please ask your contact to enable calls. + No comment provided by engineer. + + + Please ask your contact to enable sending voice messages. + No comment provided by engineer. + + + Please check that mobile and desktop are connected to the same local network, and that desktop firewall allows the connection. +Please share any other issues with the developers. + No comment provided by engineer. + + + Please check that you used the correct link or ask your contact to send you another one. + No comment provided by engineer. + + + Please check your network connection with %@ and try again. + alert message + + + Please check yours and your contact preferences. + No comment provided by engineer. + + + Please confirm that network settings are correct for this device. + No comment provided by engineer. + + + Please contact developers. +Error: %@ + No comment provided by engineer. + + + Please contact group admin. + No comment provided by engineer. + + + Please enter correct current passphrase. + No comment provided by engineer. + + + Please enter the previous password after restoring database backup. This action can not be undone. + No comment provided by engineer. + + + Please remember or store it securely - there is no way to recover a lost passcode! + No comment provided by engineer. + + + Please report it to the developers. + No comment provided by engineer. + + + Please restart the app and migrate the database to enable push notifications. + No comment provided by engineer. + + + Please store passphrase securely, you will NOT be able to access chat if you lose it. + No comment provided by engineer. + + + Please store passphrase securely, you will NOT be able to change it if you lose it. + No comment provided by engineer. + + + Please try to disable and re-enable notfications. + token info + + + Please wait for group moderators to review your request to join the group. + snd group event chat item + + + Please wait for token activation to complete. + token info + + + Please wait for token to be registered. + token info + + + Polish interface + No comment provided by engineer. + + + Port + No comment provided by engineer. + + + Preserve the last message draft, with attachments. + No comment provided by engineer. + + + Preset relay address + No comment provided by engineer. + + + Preset relay name + No comment provided by engineer. + + + Preset server address + No comment provided by engineer. + + + Preset servers + No comment provided by engineer. + + + Preview + No comment provided by engineer. + + + Previously connected servers + No comment provided by engineer. + + + Privacy for your customers. + No comment provided by engineer. + + + Privacy policy and conditions of use. + No comment provided by engineer. + + + Privacy: for owners and subscribers. + No comment provided by engineer. + + + Private and secure messaging. + No comment provided by engineer. + + + Private filenames + No comment provided by engineer. + + + Private media file names. + No comment provided by engineer. + + + Private message routing + No comment provided by engineer. + + + Private message routing 🚀 + No comment provided by engineer. + + + Private notes + name of notes to self + + + Private routing + No comment provided by engineer. + + + Private routing error + alert title + + + Private routing timeout + alert title + + + Profile and server connections + No comment provided by engineer. + + + Profile image + No comment provided by engineer. + + + Profile images + No comment provided by engineer. + + + Profile password + No comment provided by engineer. + + + Profile theme + No comment provided by engineer. + + + Profile update will be sent to your SimpleX contacts. + alert message +alert title + + + Prohibit audio/video calls. + No comment provided by engineer. + + + Prohibit chats with admins. + No comment provided by engineer. + + + Prohibit irreversible message deletion. + No comment provided by engineer. + + + Prohibit message reactions. + No comment provided by engineer. + + + Prohibit messages reactions. + No comment provided by engineer. + + + Prohibit reporting messages to moderators. + No comment provided by engineer. + + + Prohibit sending SimpleX links. + No comment provided by engineer. + + + Prohibit sending direct messages to members. + No comment provided by engineer. + + + Prohibit sending direct messages to subscribers. + No comment provided by engineer. + + + Prohibit sending disappearing messages. + No comment provided by engineer. + + + Prohibit sending files and media. + No comment provided by engineer. + + + Prohibit sending voice messages. + No comment provided by engineer. + + + Protect IP address + No comment provided by engineer. + + + Protect app screen + No comment provided by engineer. + + + Protect your IP address from the messaging relays chosen by your contacts. +Enable in *Network & servers* settings. + No comment provided by engineer. + + + Protect your chat profiles with a password! + No comment provided by engineer. + + + Protocol background timeout + No comment provided by engineer. + + + Protocol timeout + No comment provided by engineer. + + + Protocol timeout per KB + No comment provided by engineer. + + + Proxied + No comment provided by engineer. + + + Proxied servers + No comment provided by engineer. + + + Proxy requires password + No comment provided by engineer. + + + Public channels - speak freely 🚀 + No comment provided by engineer. + + + Public names for your channel or business. + No comment provided by engineer. + + + Push notifications + No comment provided by engineer. + + + Push server + No comment provided by engineer. + + + Quantum resistant encryption + No comment provided by engineer. + + + Rate the app + No comment provided by engineer. + + + Reachable chat toolbar + No comment provided by engineer. + + + React… + chat item menu + + + Read + swipe action + + + Read more + profile description teaser + + + Read more in User Guide. + No comment provided by engineer. + + + Read more in our GitHub repository. + No comment provided by engineer. + + + Receipts are disabled + No comment provided by engineer. + + + Receive errors + No comment provided by engineer. + + + Received at + No comment provided by engineer. + + + Received at: %@ + copied message info + + + Received message + message info title + + + Received messages + No comment provided by engineer. + + + Received reply + No comment provided by engineer. + + + Received total + No comment provided by engineer. + + + Receiving address will be changed to a different server. Address change will complete after sender comes online. + No comment provided by engineer. + + + Receiving file will be stopped. + No comment provided by engineer. + + + Receiving via + No comment provided by engineer. + + + Recent history and improved [directory bot](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion). + No comment provided by engineer. + + + Recipient(s) can't see who this message is from. + No comment provided by engineer. + + + Recipients see updates as you type them. + No comment provided by engineer. + + + Reconnect + No comment provided by engineer. + + + Reconnect all connected servers to force message delivery. It uses additional traffic. + No comment provided by engineer. + + + Reconnect all servers + No comment provided by engineer. + + + Reconnect all servers? + No comment provided by engineer. + + + Reconnect server to force message delivery. It uses additional traffic. + No comment provided by engineer. + + + Reconnect server? + No comment provided by engineer. + + + Reconnect servers? + No comment provided by engineer. + + + Record updated at + No comment provided by engineer. + + + Record updated at: %@ + copied message info + + + Reduced battery usage + No comment provided by engineer. + + + Register + No comment provided by engineer. + + + Register notification token? + token info + + + Registered + token status text + + + Reject + alert action +reject incoming call via notification +swipe action + + + Reject (sender NOT notified) + No comment provided by engineer. + + + Reject contact request + alert title + + + Reject member? + alert title + + + Relay + No comment provided by engineer. + + + Relay address + alert title + + + Relay connection failed + alert title + + + Relay link + No comment provided by engineer. + + + Relay results: + alert message + + + Relay server is only used if necessary. Another party can observe your IP address. + No comment provided by engineer. + + + Relay server protects your IP address, but it can observe the duration of the call. + No comment provided by engineer. + + + Relay test failed! + No comment provided by engineer. + + + Relay will be removed from channel - this cannot be undone! + alert message + + + Relays added: %@. + alert message + + + Reliability: many relays per channel. + No comment provided by engineer. + + + Remove + alert action + + + Remove and delete messages + alert action + + + Remove archive? + No comment provided by engineer. + + + Remove image + No comment provided by engineer. + + + Remove link tracking + No comment provided by engineer. + + + Remove member + No comment provided by engineer. + + + Remove member? + alert title + + + Remove name + No comment provided by engineer. + + + Remove passphrase from keychain? + No comment provided by engineer. + + + Remove relay + No comment provided by engineer. + + + Remove relay? + alert title + + + Remove subscriber? + alert title + + + Removes messages and blocks members. + No comment provided by engineer. + + + Renegotiate + No comment provided by engineer. + + + Renegotiate encryption + No comment provided by engineer. + + + Renegotiate encryption? + No comment provided by engineer. + + + Repeat download + No comment provided by engineer. + + + Repeat import + No comment provided by engineer. + + + Repeat upload + No comment provided by engineer. + + + Reply + chat item action + + + Report + chat item action + + + Report content: only group moderators will see it. + report reason + + + Report member profile: only group moderators will see it. + report reason + + + Report other: only group moderators will see it. + report reason + + + Report reason? + No comment provided by engineer. + + + Report sent to moderators + alert title + + + Report spam: only group moderators will see it. + report reason + + + Report violation: only group moderators will see it. + report reason + + + Report: %@ + report in notification + + + Reporting messages to moderators is prohibited. + No comment provided by engineer. + + + Reports + No comment provided by engineer. + + + Require signing messages. + No comment provided by engineer. + + + Required + No comment provided by engineer. + + + Reset + No comment provided by engineer. + + + Reset all hints + No comment provided by engineer. + + + Reset all statistics + No comment provided by engineer. + + + Reset all statistics? + No comment provided by engineer. + + + Reset colors + No comment provided by engineer. + + + Reset to app theme + No comment provided by engineer. + + + Reset to defaults + No comment provided by engineer. + + + Reset to user theme + No comment provided by engineer. + + + Resolver error: %@ + No comment provided by engineer. + + + Restart the app to create a new chat profile + No comment provided by engineer. + + + Restart the app to use imported chat database + No comment provided by engineer. + + + Restore + No comment provided by engineer. + + + Restore database backup + No comment provided by engineer. + + + Restore database backup? + No comment provided by engineer. + + + Restore database error + No comment provided by engineer. + + + Retry + alert action + + + Reveal + chat item action + + + Review conditions + No comment provided by engineer. + + + Review group members + No comment provided by engineer. + + + Review members + admission stage + + + Review members before admitting ("knocking"). + admission stage description + + + Revoke + No comment provided by engineer. + + + Revoke file + cancel file action + + + Revoke file? + No comment provided by engineer. + + + Role + No comment provided by engineer. + + + Role will be changed to "%@". All chat members will be notified. + No comment provided by engineer. + + + Role will be changed to "%@". All group members will be notified. + No comment provided by engineer. + + + Role will be changed to "%@". All subscribers will be notified. + No comment provided by engineer. + + + Role will be changed to "%@". The member will receive a new invitation. + No comment provided by engineer. + + + Run chat + No comment provided by engineer. + + + SMP server + No comment provided by engineer. + + + SOCKS proxy + No comment provided by engineer. + + + Safe web links + No comment provided by engineer. + + + Safely receive files + No comment provided by engineer. + + + Safer groups + No comment provided by engineer. + + + Save + alert action +alert button +chat item action + + + Save (and notify contacts) + alert button + + + Save (and notify members) + alert button + + + Save (and notify subscribers) + alert button + + + Save SimpleX name? + alert title + + + Save admission settings? + alert title + + + Save and notify contact + alert button + + + Save and notify group members + No comment provided by engineer. + + + Save and notify members + No comment provided by engineer. + + + Save and notify subscribers + No comment provided by engineer. + + + Save and reconnect + No comment provided by engineer. + + + Save and update group profile + No comment provided by engineer. + + + Save channel profile + No comment provided by engineer. + + + Save channel profile? + alert title + + + Save group profile + No comment provided by engineer. + + + Save group profile? + alert title + + + Save list + No comment provided by engineer. + + + Save passphrase and open chat + No comment provided by engineer. + + + Save passphrase in Keychain + No comment provided by engineer. + + + Save preferences? + alert title + + + Save profile password + No comment provided by engineer. + + + Save servers + No comment provided by engineer. + + + Save servers? + alert title + + + Save webpage settings? + alert title + + + Save welcome message? + No comment provided by engineer. + + + Save your profile? + alert title + + + Saved + No comment provided by engineer. + + + Saved WebRTC ICE servers will be removed + No comment provided by engineer. + + + Saved from + No comment provided by engineer. + + + Saved message + message info title + + + Saving %lld messages + No comment provided by engineer. + + + Scale + No comment provided by engineer. + + + Scan / Paste link + No comment provided by engineer. + + + Scan QR code + No comment provided by engineer. + + + Scan QR code from desktop + No comment provided by engineer. + + + Scan code + No comment provided by engineer. + + + Scan security code from your contact's app. + No comment provided by engineer. + + + Scan server QR code + No comment provided by engineer. + + + Search + No comment provided by engineer. + + + Search bar accepts invitation links. + No comment provided by engineer. + + + Search files + No comment provided by engineer. + + + Search images + No comment provided by engineer. + + + Search links + No comment provided by engineer. + + + Search or paste SimpleX link + No comment provided by engineer. + + + Search videos + No comment provided by engineer. + + + Search voice messages + No comment provided by engineer. + + + Secondary + No comment provided by engineer. + + + Secure queue + server test step + + + Secured + No comment provided by engineer. + + + Security assessment + No comment provided by engineer. + + + Security code + No comment provided by engineer. + + + Security: owners hold channel keys. + No comment provided by engineer. + + + Select + chat item action + + + Select chat profile + No comment provided by engineer. + + + Selected %lld + No comment provided by engineer. + + + Selected chat preferences prohibit this message. + No comment provided by engineer. + + + Self-destruct + No comment provided by engineer. + + + Self-destruct passcode + No comment provided by engineer. + + + Self-destruct passcode changed! + No comment provided by engineer. + + + Self-destruct passcode enabled! + No comment provided by engineer. + + + Send + No comment provided by engineer. + + + Send a live message - it will update for the recipient(s) as you type it + No comment provided by engineer. + + + Send contact request? + No comment provided by engineer. + + + Send delivery receipts to + No comment provided by engineer. + + + Send direct message to connect + No comment provided by engineer. + + + Send disappearing message + No comment provided by engineer. + + + Send errors + No comment provided by engineer. + + + Send link previews + No comment provided by engineer. + + + Send live message + No comment provided by engineer. + + + Send message to enable calls. + No comment provided by engineer. + + + Send messages directly when IP address is protected and your or destination server does not support private routing. + No comment provided by engineer. + + + Send messages directly when your or destination server does not support private routing. + No comment provided by engineer. + + + Send notifications + No comment provided by engineer. + + + Send private reports + No comment provided by engineer. + + + Send questions and ideas + No comment provided by engineer. + + + Send receipts + No comment provided by engineer. + + + Send request + No comment provided by engineer. + + + Send request without message + No comment provided by engineer. + + + Send the link via any messenger - it's secure. Ask to paste into SimpleX. + No comment provided by engineer. + + + Send them from gallery or custom keyboards. + No comment provided by engineer. + + + Send up to 100 last messages to new members. + No comment provided by engineer. + + + Send up to 100 last messages to new subscribers. + No comment provided by engineer. + + + Send your private feedback to groups. + No comment provided by engineer. + + + Sender cancelled file transfer. + alert message + + + Sending a link preview may reveal your IP address to the website. You can change this in Privacy settings later. + alert message + + + Sending delivery receipts will be enabled for all contacts in all visible chat profiles. + No comment provided by engineer. + + + Sending delivery receipts will be enabled for all contacts. + No comment provided by engineer. + + + Sending file will be stopped. + No comment provided by engineer. + + + Sending receipts is disabled for %lld contacts + No comment provided by engineer. + + + Sending receipts is disabled for %lld groups + No comment provided by engineer. + + + Sending receipts is enabled for %lld contacts + No comment provided by engineer. + + + Sending receipts is enabled for %lld groups + No comment provided by engineer. + + + Sending via + No comment provided by engineer. + + + Sent at + No comment provided by engineer. + + + Sent at: %@ + copied message info + + + Sent directly + No comment provided by engineer. + + + Sent message + message info title + + + Sent messages + No comment provided by engineer. + + + Sent messages will be deleted after set time. + No comment provided by engineer. + + + Sent reply + No comment provided by engineer. + + + Sent total + No comment provided by engineer. + + + Sent via proxy + No comment provided by engineer. + + + Server + No comment provided by engineer. + + + Server %@ does not support name resolution. Configure servers, or use a connection link. + No comment provided by engineer. + + + Server added to operator %@. + alert message + + + Server address + No comment provided by engineer. + + + Server address is incompatible with network settings. + srv error text. + + + Server address is incompatible with network settings: %@. + No comment provided by engineer. + + + Server operator changed. + alert title + + + Server operators + No comment provided by engineer. + + + Server protocol changed. + alert title + + + Server requires authorization to connect to relay, check password. + relay test error + + + Server requires authorization to create queues, check password. + server test error + + + Server requires authorization to upload, check password. + server test error + + + Server test failed! + No comment provided by engineer. + + + Server type + No comment provided by engineer. + + + Server version is incompatible with network settings. + srv error text + + + Server version is incompatible with your app: %@. + No comment provided by engineer. + + + Servers + No comment provided by engineer. + + + Servers info + No comment provided by engineer. + + + Servers statistics will be reset - this cannot be undone! + No comment provided by engineer. + + + Session code + No comment provided by engineer. + + + Set 1 day + No comment provided by engineer. + + + Set chat name… + No comment provided by engineer. + + + Set contact name… + No comment provided by engineer. + + + Set default theme + No comment provided by engineer. + + + Set group preferences + No comment provided by engineer. + + + Set it instead of system authentication. + No comment provided by engineer. + + + Set member admission + No comment provided by engineer. + + + Set message expiration in chats. + No comment provided by engineer. + + + Set passcode + No comment provided by engineer. + + + Set passphrase + No comment provided by engineer. + + + Set passphrase to export + No comment provided by engineer. + + + Set profile bio and welcome message. + No comment provided by engineer. + + + Set the message shown to new members! + No comment provided by engineer. + + + Set timeouts for proxy/VPN + No comment provided by engineer. + + + Settings + No comment provided by engineer. + + + Settings were changed. + alert message + + + Setup notifications + No comment provided by engineer. + + + Setup routers + No comment provided by engineer. + + + Shape profile images + No comment provided by engineer. + + + Share + alert action +chat item action + + + Share 1-time link + No comment provided by engineer. + + + Share 1-time link with a friend + No comment provided by engineer. + + + Share SimpleX address on social media. + No comment provided by engineer. + + + Share address + No comment provided by engineer. + + + Share address publicly + No comment provided by engineer. + + + Share address with SimpleX contacts? + alert title + + + Share channel + No comment provided by engineer. + + + Share from other apps. + No comment provided by engineer. + + + Share link + No comment provided by engineer. + + + Share old address + alert button + + + Share old link + alert button + + + Share profile + No comment provided by engineer. + + + Share relay address + No comment provided by engineer. + + + Share this 1-time invite link + No comment provided by engineer. + + + Share to SimpleX + No comment provided by engineer. + + + Share via chat + No comment provided by engineer. + + + Share with SimpleX contacts + No comment provided by engineer. + + + Share your address + No comment provided by engineer. + + + Short SimpleX address + No comment provided by engineer. + + + Short description + No comment provided by engineer. + + + Short link + No comment provided by engineer. + + + Show QR code + No comment provided by engineer. + + + Show calls in phone history + No comment provided by engineer. + + + Show developer options + No comment provided by engineer. + + + Show encryption + No comment provided by engineer. + + + Show last messages + No comment provided by engineer. + + + Show message status + No comment provided by engineer. + + + Show percentage + No comment provided by engineer. + + + Show preview + No comment provided by engineer. + + + Show → on messages sent via private routing. + No comment provided by engineer. + + + Show: + No comment provided by engineer. + + + Sign message + No comment provided by engineer. + + + Sign messages + chat feature + + + Signature missing + alert title +copied message info + + + Signed + copied message info + + + Signed & verified + copied message info + + + Signing proves you authored this message and can't be denied later. + No comment provided by engineer. + + + SimpleX + No comment provided by engineer. + + + SimpleX Address + No comment provided by engineer. + + + SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app. + No comment provided by engineer. + + + SimpleX Chat security was audited by Trail of Bits. + No comment provided by engineer. + + + SimpleX Lock + No comment provided by engineer. + + + SimpleX Lock mode + No comment provided by engineer. + + + SimpleX Lock not enabled! + No comment provided by engineer. + + + SimpleX Lock turned on + No comment provided by engineer. + + + SimpleX address + No comment provided by engineer. + + + SimpleX address and 1-time links are safe to share via any messenger. + No comment provided by engineer. + + + SimpleX address or 1-time link? + No comment provided by engineer. + + + SimpleX address settings + alert title + + + SimpleX channel link + simplex link type + + + SimpleX contact address + simplex link type + + + SimpleX encrypted message or connection event + notification + + + SimpleX group link + simplex link type + + + SimpleX links + chat feature + + + SimpleX links are prohibited. + No comment provided by engineer. + + + SimpleX links not allowed + No comment provided by engineer. + + + SimpleX name + No comment provided by engineer. + + + SimpleX name error + No comment provided by engineer. + + + SimpleX name not verified + alert title + + + SimpleX one-time invitation + simplex link type + + + SimpleX protocols reviewed by Trail of Bits. + No comment provided by engineer. + + + SimpleX public names (BETA) + No comment provided by engineer. + + + SimpleX relay address + simplex link type + + + Simplified incognito mode + No comment provided by engineer. + + + Size + No comment provided by engineer. + + + Skip + No comment provided by engineer. + + + Skipped messages + No comment provided by engineer. + + + Small groups (max 20) + No comment provided by engineer. + + + Soft + blur media + + + Some app settings were not migrated. + No comment provided by engineer. + + + Some file(s) were not exported: + No comment provided by engineer. + + + Some non-fatal errors occurred during import - you may see Chat console for more details. + No comment provided by engineer. + + + Some non-fatal errors occurred during import: + No comment provided by engineer. + + + Some servers failed the test: +%@ + alert message + + + Somebody + notification title + + + Spam + blocking reason +report reason + + + Square, circle, or anything in between. + No comment provided by engineer. + + + Star on GitHub + No comment provided by engineer. + + + Start chat + No comment provided by engineer. + + + Start chat? + No comment provided by engineer. + + + Start migration + No comment provided by engineer. + + + Starting from %@. + No comment provided by engineer. + + + Statistics + No comment provided by engineer. + + + Status + No comment provided by engineer. + + + Stop + No comment provided by engineer. + + + Stop SimpleX + authentication reason + + + Stop chat + No comment provided by engineer. + + + Stop chat to export, import or delete chat database. You will not be able to receive and send messages while the chat is stopped. + No comment provided by engineer. + + + Stop chat? + No comment provided by engineer. + + + Stop file + cancel file action + + + Stop receiving file? + No comment provided by engineer. + + + Stop sending file? + No comment provided by engineer. + + + Stop sharing + alert action + + + Stop sharing address? + alert title + + + Stopping chat + No comment provided by engineer. + + + Storage + No comment provided by engineer. + + + Strong + blur media + + + Submit + No comment provided by engineer. + + + Subscribed + No comment provided by engineer. + + + Subscriber + No comment provided by engineer. + + + Subscriber reports + chat feature + + + Subscriber will be removed from channel - this cannot be undone! + alert message + + + Subscribers + No comment provided by engineer. + + + Subscribers can add message reactions. + No comment provided by engineer. + + + Subscribers can chat with admins. + No comment provided by engineer. + + + Subscribers can irreversibly delete sent messages. (24 hours) + No comment provided by engineer. + + + Subscribers can report messsages to moderators. + No comment provided by engineer. + + + Subscribers can send SimpleX links. + No comment provided by engineer. + + + Subscribers can send direct messages. + No comment provided by engineer. + + + Subscribers can send disappearing messages. + No comment provided by engineer. + + + Subscribers can send files and media. + No comment provided by engineer. + + + Subscribers can send voice messages. + No comment provided by engineer. + + + Subscribers use relay link to connect to the channel. +Relay address was used to set up this relay for the channel. + No comment provided by engineer. + + + Subscription errors + No comment provided by engineer. + + + Subscriptions ignored + No comment provided by engineer. + + + Support the project + No comment provided by engineer. + + + Switch audio and video during the call. + No comment provided by engineer. + + + Switch chat profile for 1-time invitations. + No comment provided by engineer. + + + System + No comment provided by engineer. + + + System authentication + No comment provided by engineer. + + + TCP connection + No comment provided by engineer. + + + TCP connection bg timeout + No comment provided by engineer. + + + TCP connection timeout + No comment provided by engineer. + + + TCP port for messaging + No comment provided by engineer. + + + TCP_KEEPCNT + No comment provided by engineer. + + + TCP_KEEPIDLE + No comment provided by engineer. + + + TCP_KEEPINTVL + No comment provided by engineer. + + + Tail + No comment provided by engineer. + + + Take picture + No comment provided by engineer. + + + Talk to someone + No comment provided by engineer. + + + Tap Connect to chat + No comment provided by engineer. + + + Tap Connect to send request + No comment provided by engineer. + + + Tap Connect to use bot + No comment provided by engineer. + + + Tap Join channel + No comment provided by engineer. + + + Tap Join group + No comment provided by engineer. + + + Tap button + No comment provided by engineer. + + + Tap to Connect + No comment provided by engineer. + + + Tap to activate profile. + No comment provided by engineer. + + + Tap to join + No comment provided by engineer. + + + Tap to join incognito + No comment provided by engineer. + + + Tap to open + No comment provided by engineer. + + + Tap to paste link + No comment provided by engineer. + + + Tap to scan + No comment provided by engineer. + + + Temporary file error + file error alert title + + + Test failed at step %@. + relay test failure +server test failure + + + Test notifications + No comment provided by engineer. + + + Test relay + No comment provided by engineer. + + + Test server + No comment provided by engineer. + + + Test servers + No comment provided by engineer. + + + Tests failed! + alert title + + + Thank you for installing SimpleX Chat! + No comment provided by engineer. + + + Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! + No comment provided by engineer. + + + Thanks to the users – contribute via Weblate! + No comment provided by engineer. + + + The ID of the next message is incorrect (less or equal to the previous). +It can happen because of some bug or when the connection is compromised. + No comment provided by engineer. + + + The SimpleX name #%@ is registered without channel link. Add channel link to the name via the registration page. + alert message + + + The SimpleX name %@ is registered, but it has no valid link. + No comment provided by engineer. + + + The SimpleX name %@ is registered, but not added to profile. Please add it to your address or channel profile, if you are the owner. + No comment provided by engineer. + + + The SimpleX name @%@ is registered without SimpleX address. Add your SimpleX address to the name via the registration page. + alert message + + + The address will be short, and your profile will be shared via the address. + alert message + + + The app can notify you when you receive messages or contact requests - please open settings to enable. + No comment provided by engineer. + + + The app protects your privacy by using different operators in each conversation. + No comment provided by engineer. + + + The app removed this message after %lld attempts to receive it. + No comment provided by engineer. + + + The app will ask to confirm downloads from unknown file servers (except .onion). + No comment provided by engineer. + + + The attempt to change database passphrase was not completed. + No comment provided by engineer. + + + The badge is signed with a key that this version of the app does not recognize. Update the app to verify this badge. + badge alert + + + The channel required this message to be signed, but the signature is missing. + alert message + + + The code you scanned is not a SimpleX link QR code. + No comment provided by engineer. + + + The connection reached the limit of undelivered messages + conn error description + + + The connection reached the limit of undelivered messages, your contact may be offline. + No comment provided by engineer. + + + The connection you accepted will be cancelled! + No comment provided by engineer. + + + The contact you shared this link with will NOT be able to connect! + No comment provided by engineer. + + + The created archive is available via app Settings / Database / Old database archive. + No comment provided by engineer. + + + The encryption is working and the new encryption agreement is not required. It may result in connection errors! + No comment provided by engineer. + + + The first network where you own +your contacts and groups. + No comment provided by engineer. + + + The hash of the previous message is different. + No comment provided by engineer. + + + The link will be short, and group profile will be shared via the link. + alert message + + + The message will be deleted for all members. + No comment provided by engineer. + + + The message will be marked as moderated for all members. + No comment provided by engineer. + + + The messages will be deleted for all members. + No comment provided by engineer. + + + The messages will be marked as moderated for all members. + No comment provided by engineer. + + + The old database was not removed during the migration, it can be deleted. + No comment provided by engineer. + + + The oldest human freedom - to speak to another person without being watched - built on infrastructure that cannot betray it. + No comment provided by engineer. + + + The same conditions will apply to operator **%@**. + No comment provided by engineer. + + + The second preset operator in the app! + No comment provided by engineer. + + + The second tick we missed! ✅ + No comment provided by engineer. + + + The sender deleted the connection request. + No comment provided by engineer. + + + The sender will NOT be notified + alert message + + + The servers for new connections of your current chat profile **%@**. + No comment provided by engineer. + + + The servers for new files of your current chat profile **%@**. + No comment provided by engineer. + + + The text you pasted is not a SimpleX link. + No comment provided by engineer. + + + The uploaded database archive will be permanently removed from the servers. + No comment provided by engineer. + + + Themes + No comment provided by engineer. + + + 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. + No comment provided by engineer. + + + There is another way. A network with no phone numbers. No usernames. No accounts. No user identities of any kind. A network that connects people and carries encrypted messages without knowing who is connected. + No comment provided by engineer. + + + These conditions will also apply for: **%@**. + No comment provided by engineer. + + + These settings are for your current profile **%@**. + No comment provided by engineer. + + + They can be overridden in contact and group settings. + No comment provided by engineer. + + + This SimpleX name is not registered. Please check the name. + No comment provided by engineer. + + + This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain. + No comment provided by engineer. + + + This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes. + No comment provided by engineer. + + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + alert message + + + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. + No comment provided by engineer. + + + This badge could not be verified and may not be genuine. + badge alert + + + This chat is protected by end-to-end encryption. + E2EE info chat item + + + This chat is protected by quantum resistant end-to-end encryption. + E2EE info chat item + + + This device name + No comment provided by engineer. + + + This display name is invalid. Please choose another name. + No comment provided by engineer. + + + This group has over %lld members, delivery receipts are not sent. + No comment provided by engineer. + + + This group no longer exists. + No comment provided by engineer. + + + This group requires a newer version of the app. Please update the app to join. + alert message +alert subtitle + + + This is a chat relay address, it cannot be used to connect. + alert message + + + This is the last active relay. Removing it will prevent message delivery to subscribers. + alert message + + + This is your link for channel %@! + new chat action + + + This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. + No comment provided by engineer. + + + This link was used with another mobile device, please create a new link on the desktop. + No comment provided by engineer. + + + This message was deleted or not received yet. + No comment provided by engineer. + + + This setting applies to messages in your current chat profile **%@**. + No comment provided by engineer. + + + Time to disappear is set only for new contacts. + No comment provided by engineer. + + + Title + No comment provided by engineer. + + + To ask any questions and to receive updates: + No comment provided by engineer. + + + To connect, your contact can scan QR code or use the link in the app. + No comment provided by engineer. + + + To hide unwanted messages. + No comment provided by engineer. + + + To make SimpleX Network last. + No comment provided by engineer. + + + To make a new connection + No comment provided by engineer. + + + To protect against your link being replaced, you can compare contact security codes. + No comment provided by engineer. + + + To protect timezone, image/voice files use UTC. + No comment provided by engineer. + + + To protect your IP address, private routing uses your SMP servers to deliver messages. + No comment provided by engineer. + + + To protect your information, turn on SimpleX Lock. +You will be prompted to complete authentication before this feature is enabled. + No comment provided by engineer. + + + To protect your privacy, SimpleX uses separate IDs for each of your contacts. + No comment provided by engineer. + + + To receive + No comment provided by engineer. + + + To record speech please grant permission to use Microphone. + No comment provided by engineer. + + + To record video please grant permission to use Camera. + No comment provided by engineer. + + + To record voice message please grant permission to use Microphone. + No comment provided by engineer. + + + To resolve names + No comment provided by engineer. + + + To reveal your hidden profile, enter a full password into a search field in **Your chat profiles** page. + No comment provided by engineer. + + + To send + No comment provided by engineer. + + + To send commands you must be connected. + alert message + + + To support instant push notifications the chat database has to be migrated. + No comment provided by engineer. + + + To use another profile after connection attempt, delete the chat and use the link again. + alert message + + + To use the servers of **%@**, accept conditions of use. + No comment provided by engineer. + + + To verify end-to-end encryption with your contact compare (or scan) the code on your devices. + No comment provided by engineer. + + + To verify keys with this subscriber, compare (or scan) the code on your devices. + No comment provided by engineer. + + + Toggle incognito when connecting. + No comment provided by engineer. + + + Token status: %@. + token status + + + Toolbar opacity + No comment provided by engineer. + + + Top bar + No comment provided by engineer. + + + Total + No comment provided by engineer. + + + Transport isolation + No comment provided by engineer. + + + Transport sessions + No comment provided by engineer. + + + Trying to connect to the server used to receive messages from this connection. + subscription status explanation + + + Turkish interface + No comment provided by engineer. + + + Turn off + No comment provided by engineer. + + + Turn on + No comment provided by engineer. + + + Unable to record voice message + No comment provided by engineer. + + + Unblock + No comment provided by engineer. + + + Unblock for all + No comment provided by engineer. + + + Unblock member + No comment provided by engineer. + + + Unblock member for all? + No comment provided by engineer. + + + Unblock member? + No comment provided by engineer. + + + Unblock subscriber for all? + No comment provided by engineer. + + + Unconfirmed name + No comment provided by engineer. + + + Undelivered messages + No comment provided by engineer. + + + Unexpected migration state + No comment provided by engineer. + + + Unfav. + swipe action + + + Unhide + No comment provided by engineer. + + + Unhide chat profile + No comment provided by engineer. + + + Unhide profile + No comment provided by engineer. + + + Unit + No comment provided by engineer. + + + Unknown caller + callkit banner + + + Unknown database error: %@ + No comment provided by engineer. + + + Unknown error + No comment provided by engineer. + + + Unknown servers! + alert title + + + Unless you use iOS call interface, enable Do Not Disturb mode to avoid interruptions. + No comment provided by engineer. + + + Unlink + No comment provided by engineer. + + + Unlink desktop? + No comment provided by engineer. + + + Unlock + No comment provided by engineer. + + + Unlock app + authentication reason + + + Unmute + notification label action + + + Unread + swipe action + + + Unsupported connection link + conn error description + + + Unverified badge + badge alert title + + + Up to 100 last messages are sent to new members. + No comment provided by engineer. + + + Up to 100 last messages are sent to new subscribers. + No comment provided by engineer. + + + Update + No comment provided by engineer. + + + Update database passphrase + No comment provided by engineer. + + + Update network settings? + No comment provided by engineer. + + + Update settings? + No comment provided by engineer. + + + Updated conditions + No comment provided by engineer. + + + Updating settings will re-connect the client to all servers. + No comment provided by engineer. + + + Upgrade + alert button + + + Upgrade address + No comment provided by engineer. + + + Upgrade address? + alert message +alert title + + + Upgrade and open chat + No comment provided by engineer. + + + Upgrade group link? + alert message + + + Upgrade link + No comment provided by engineer. + + + Upgrade your address + No comment provided by engineer. + + + Upload errors + No comment provided by engineer. + + + Upload failed + No comment provided by engineer. + + + Upload file + server test step + + + Uploaded + No comment provided by engineer. + + + Uploaded files + No comment provided by engineer. + + + Uploading archive + No comment provided by engineer. + + + Use %@ + No comment provided by engineer. + + + Use .onion hosts + No comment provided by engineer. + + + Use SOCKS proxy + No comment provided by engineer. + + + Use SimpleX Chat servers? + No comment provided by engineer. + + + Use TCP port %@ when no port is specified. + No comment provided by engineer. + + + Use TCP port 443 for preset servers only. + No comment provided by engineer. + + + Use current profile + new chat action + + + Use for files + No comment provided by engineer. + + + Use for messages + No comment provided by engineer. + + + Use for new channels + No comment provided by engineer. + + + Use for new connections + No comment provided by engineer. + + + Use from desktop + No comment provided by engineer. + + + Use iOS call interface + No comment provided by engineer. + + + Use incognito profile + No comment provided by engineer. + + + Use new incognito profile + new chat action + + + Use only local notifications? + No comment provided by engineer. + + + Use private routing with unknown servers when IP address is not protected. + No comment provided by engineer. + + + Use private routing with unknown servers. + No comment provided by engineer. + + + Use relay + No comment provided by engineer. + + + Use server + No comment provided by engineer. + + + Use servers + No comment provided by engineer. + + + Use the app while in the call. + No comment provided by engineer. + + + Use the app with one hand. + No comment provided by engineer. + + + Use this address in your social media profile, website, or email signature. + No comment provided by engineer. + + + Use web port + No comment provided by engineer. + + + Used chat relays do not support webpages. + No comment provided by engineer. + + + User selection + No comment provided by engineer. + + + Username + No comment provided by engineer. + + + Using SimpleX Chat servers. + No comment provided by engineer. + + + Verify + relay test step + + + Verify SimpleX names + No comment provided by engineer. + + + Verify code with desktop + No comment provided by engineer. + + + Verify connection + No comment provided by engineer. + + + Verify connection security + No comment provided by engineer. + + + Verify connections + No comment provided by engineer. + + + Verify database passphrase + No comment provided by engineer. + + + Verify name + No comment provided by engineer. + + + Verify passphrase + No comment provided by engineer. + + + Verify security code + No comment provided by engineer. + + + Via browser + No comment provided by engineer. + + + Via secure quantum resistant protocol. + No comment provided by engineer. + + + Video call + No comment provided by engineer. + + + Video will be received when your contact completes uploading it. + No comment provided by engineer. + + + Video will be received when your contact is online, please wait or check later! + No comment provided by engineer. + + + Videos + No comment provided by engineer. + + + Videos and files up to 1gb + No comment provided by engineer. + + + View conditions + No comment provided by engineer. + + + View security code + No comment provided by engineer. + + + View updated conditions + No comment provided by engineer. + + + Visible history + chat feature + + + Voice messages + chat feature + + + Voice messages are prohibited in this chat. + No comment provided by engineer. + + + Voice messages are prohibited. + No comment provided by engineer. + + + Voice messages not allowed + No comment provided by engineer. + + + Voice messages prohibited! + No comment provided by engineer. + + + Voice message… + No comment provided by engineer. + + + Wait + alert action + + + Wait response + relay test step + + + Waiting for channel owner to add relays. + No comment provided by engineer. + + + Waiting for desktop... + No comment provided by engineer. + + + Waiting for file + No comment provided by engineer. + + + Waiting for image + No comment provided by engineer. + + + Waiting for video + No comment provided by engineer. + + + Wallpaper accent + No comment provided by engineer. + + + Wallpaper background + No comment provided by engineer. + + + Warning: starting chat on multiple devices is not supported and will cause message delivery failures + No comment provided by engineer. + + + Warning: you may lose some data! + No comment provided by engineer. + + + We made connecting simpler for new users. + No comment provided by engineer. + + + WebRTC ICE servers + No comment provided by engineer. + + + Webpage code + No comment provided by engineer. + + + Webpage settings were changed. If you save, the updated settings will be sent to subscribers. + alert message + + + Welcome %@! + No comment provided by engineer. + + + Welcome message + No comment provided by engineer. + + + Welcome message is too long + No comment provided by engineer. + + + Welcome your contacts 👋 + No comment provided by engineer. + + + What's new + No comment provided by engineer. + + + When available + No comment provided by engineer. + + + When connecting audio and video calls. + No comment provided by engineer. + + + When more than one operator is enabled, none of them has metadata to learn who communicates with whom. + No comment provided by engineer. + + + When you share an incognito profile with somebody, this profile will be used for the groups they invite you to. + No comment provided by engineer. + + + Why SimpleX is built. + No comment provided by engineer. + + + WiFi + No comment provided by engineer. + + + Will be enabled in direct chats! + No comment provided by engineer. + + + Wired ethernet + No comment provided by engineer. + + + With encrypted files and media. + No comment provided by engineer. + + + With optional welcome message. + No comment provided by engineer. + + + With reduced battery usage. + No comment provided by engineer. + + + Without Tor or VPN, your IP address will be visible to file servers. + No comment provided by engineer. + + + Without Tor or VPN, your IP address will be visible to these XFTP relays: %@. + alert message + + + Wrong database passphrase + No comment provided by engineer. + + + Wrong key or unknown connection - most likely this connection is deleted. + snd error text + + + Wrong key or unknown file chunk address - most likely file is deleted. + file error text + + + Wrong passphrase! + No comment provided by engineer. + + + XFTP server + No comment provided by engineer. + + + You **must not** use the same database on two devices. + No comment provided by engineer. + + + You accepted connection + No comment provided by engineer. + + + You allow + No comment provided by engineer. + + + You already have a chat profile with the same display name. Please choose another name. + No comment provided by engineer. + + + You are already connected to %@. + No comment provided by engineer. + + + You are already connected with %@. + No comment provided by engineer. + + + You are already connecting to %@. + new chat sheet message + + + You are already connecting via this one-time link! + new chat sheet message + + + You are already in group %@. + No comment provided by engineer. + + + You are already joining the group %@. + new chat sheet message + + + You are already joining the group via this link. + new chat sheet message + + + You are already joining the group! +Repeat join request? + new chat sheet title + + + You are connected to the server used to receive messages from this connection. + subscription status explanation + + + You are invited to group + No comment provided by engineer. + + + You are not connected to the server used to receive messages from this connection (no subscription). + subscription status explanation + + + You are not connected to these servers. Private routing is used to deliver messages to them. + No comment provided by engineer. + + + You can accept calls from lock screen, without device and app authentication. + No comment provided by engineer. + + + You can change it in Appearance settings. + No comment provided by engineer. + + + You can configure servers via settings. + No comment provided by engineer. + + + You can create it later + No comment provided by engineer. + + + You can enable later via Settings + No comment provided by engineer. + + + You can enable them later via app Your privacy settings. + No comment provided by engineer. + + + You can give another try. + No comment provided by engineer. + + + You can hide or mute a user profile - swipe it to the right. + No comment provided by engineer. + + + You can make it visible to your SimpleX contacts via Settings. + No comment provided by engineer. + + + You can now chat with %@ + notification body + + + You can now invest in SimpleX Chat + No comment provided by engineer. + + + You can now invest in SimpleX Chat! 🚀 + No comment provided by engineer. + + + You can send messages to %@ from Archived contacts. + No comment provided by engineer. + + + You can set connection name, to remember who the link was shared with. + No comment provided by engineer. + + + You can set lock screen notification preview via settings. + No comment provided by engineer. + + + You can share a link or a QR code - anybody will be able to join the channel. + No comment provided by engineer. + + + You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it. + No comment provided by engineer. + + + You can share this address with your contacts to let them connect with **%@**. + No comment provided by engineer. + + + You can start chat via app Settings / Database or by restarting the app + No comment provided by engineer. + + + You can still view conversation with %@ in the list of chats. + No comment provided by engineer. + + + You can support SimpleX starting from v7 of the app. + badge alert + + + You can turn on SimpleX Lock via Settings. + No comment provided by engineer. + + + You can use markdown to format messages: + No comment provided by engineer. + + + You can view invitation link again in connection details. + alert message + + + You can view your reports in Chat with admins. + alert message + + + You can't send messages! + alert title + + + You commit to: +- Only legal content in public groups +- Respect other users - no spam + No comment provided by engineer. + + + You connected to the channel via this relay link. + No comment provided by engineer. + + + You could not be verified; please try again. + No comment provided by engineer. + + + You have already requested connection! +Repeat connection request? + new chat sheet title + + + You have to enter passphrase every time the app starts - it is not stored on the device. + No comment provided by engineer. + + + You invited a contact + No comment provided by engineer. + + + You joined this group + No comment provided by engineer. + + + You joined this group. Connecting to inviting group member. + No comment provided by engineer. + + + You may migrate the exported database. + No comment provided by engineer. + + + You may save the exported archive. + No comment provided by engineer. + + + You must use the most recent version of your chat database on one device ONLY, otherwise you may stop receiving the messages from some contacts. + No comment provided by engineer. + + + You need to allow your contact to call to be able to call them. + No comment provided by engineer. + + + You need to allow your contact to send voice messages to be able to send them. + No comment provided by engineer. + + + You rejected group invitation + No comment provided by engineer. + + + You sent group invitation + No comment provided by engineer. + + + You should receive notifications. + token info + + + You were born without an account + No comment provided by engineer. + + + You will be able to send messages **only after your request is accepted**. + No comment provided by engineer. + + + You will be connected to group when the group host's device is online, please wait or check later! + No comment provided by engineer. + + + You will be connected when group link host's device is online, please wait or check later! + No comment provided by engineer. + + + You will be connected when your connection request is accepted, please wait or check later! + No comment provided by engineer. + + + You will be connected when your contact's device is online, please wait or check later! + No comment provided by engineer. + + + You will be required to authenticate when you start or resume the app after 30 seconds in background. + No comment provided by engineer. + + + You will still receive calls and notifications from muted profiles when they are active. + No comment provided by engineer. + + + You will stop receiving messages from this channel. Chat history will be preserved. + No comment provided by engineer. + + + You will stop receiving messages from this chat. Chat history will be preserved. + No comment provided by engineer. + + + You will stop receiving messages from this group. Chat history will be preserved. + No comment provided by engineer. + + + You won't lose your contacts if you later delete your address. + No comment provided by engineer. + + + You're trying to invite contact with whom you've shared an incognito profile to the group in which you're using your main profile + No comment provided by engineer. + + + You're using an incognito profile for this group - to prevent sharing your main profile inviting contacts is not allowed + No comment provided by engineer. + + + Your ICE servers + No comment provided by engineer. + + + Your SimpleX address + No comment provided by engineer. + + + Your SimpleX name + No comment provided by engineer. + + + Your business contact + No comment provided by engineer. + + + Your calls + No comment provided by engineer. + + + Your channel + No comment provided by engineer. + + + Your chat database is not encrypted - set passphrase to encrypt it. + No comment provided by engineer. + + + Your chat preferences + alert title + + + Your chat profiles + No comment provided by engineer. + + + Your chat was moved to %@ but an unexpected error occurred while redirecting you to the profile. + alert message + + + Your connection was moved to %@ but an error happened when switching profile. + No comment provided by engineer. + + + Your contact + No comment provided by engineer. + + + Your contact removed this link, or it was a one-time link that was already used. +To connect, ask your contact to create a new link. + No comment provided by engineer. + + + Your contact sent a file that is larger than currently supported maximum size (%@). + No comment provided by engineer. + + + Your contacts can allow full message deletion. + No comment provided by engineer. + + + Your contacts will remain connected. + No comment provided by engineer. + + + Your conversations belong to you, as it had always been before the Internet. The network is not a place you visit. It is a place you create and own. And nobody can take it from you, whether you make it private or public. + No comment provided by engineer. + + + Your credentials may be sent unencrypted. + No comment provided by engineer. + + + Your current chat database will be DELETED and REPLACED with the imported one. + No comment provided by engineer. + + + Your current profile + No comment provided by engineer. + + + Your group + No comment provided by engineer. + + + Your network + No comment provided by engineer. + + + Your new channel %1$@ is connected to %2$d of %3$d relays. +If you cancel, the channel will be deleted - you can create it again. + alert message + + + Your preferences + No comment provided by engineer. + + + Your privacy + No comment provided by engineer. + + + Your profile + No comment provided by engineer. + + + Your profile **%@** will be shared with channel relays and subscribers. +Relays can access channel messages. + No comment provided by engineer. + + + Your profile **%@** will be shared. + No comment provided by engineer. + + + Your profile is stored on your device and only shared with your contacts. + No comment provided by engineer. + + + Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile. + No comment provided by engineer. + + + Your profile was changed. If you save it, the updated profile will be sent to all your contacts. + alert message + + + Your public address + No comment provided by engineer. + + + Your random profile + No comment provided by engineer. + + + Your relay address + No comment provided by engineer. + + + Your relay name + No comment provided by engineer. + + + Your server address + No comment provided by engineer. + + + Your servers + No comment provided by engineer. + + + Your settings + No comment provided by engineer. + + + [Send us email](mailto:chat@simplex.chat) + No comment provided by engineer. + + + \_italic_ + No comment provided by engineer. + + + \`a + b` + No comment provided by engineer. + + + above, then choose: + No comment provided by engineer. + + + accepted + No comment provided by engineer. + + + accepted %@ + rcv group event chat item + + + accepted call + call status + + + accepted invitation + chat list item title + + + accepted you + rcv group event chat item + + + acknowledged roster + No comment provided by engineer. + + + active + No comment provided by engineer. + + + admin + member role + + + admins + feature role + + + agreeing encryption for %@… + chat item text + + + agreeing encryption… + chat item text + + + all + member criteria value + + + all members + feature role + + + always + pref value + + + and %lld other events + No comment provided by engineer. + + + archived report + No comment provided by engineer. + + + attempts + No comment provided by engineer. + + + audio call (not e2e encrypted) + No comment provided by engineer. + + + author + member role + + + bad message ID + integrity error chat item + + + bad message hash + integrity error chat item + + + blocked + marked deleted chat item preview text + + + blocked %@ + rcv group event chat item + + + blocked by admin + blocked chat item +marked deleted chat item preview text + + + bold + No comment provided by engineer. + + + call + No comment provided by engineer. + + + call error + call status + + + call in progress + call status + + + calling… + call status + + + can't broadcast + No comment provided by engineer. + + + can't send messages + No comment provided by engineer. + + + cancelled %@ + feature offered item + + + changed address for you + chat item text + + + changed role of %1$@ to %2$@ + rcv group event chat item + + + changed your role to %@ + rcv group event chat item + + + changing address for %@… + chat item text + + + changing address… + chat item text + + + channel + shown as sender role for channel messages + + + channel profile updated + snd group event chat item + + + colored + No comment provided by engineer. + + + complete + No comment provided by engineer. + + + connect to SimpleX Chat developers. + No comment provided by engineer. + + + connected + No comment provided by engineer. + + + connecting + No comment provided by engineer. + + + connecting (accepted) + No comment provided by engineer. + + + connecting (announced) + No comment provided by engineer. + + + connecting (introduced) + No comment provided by engineer. + + + connecting (introduction invitation) + No comment provided by engineer. + + + connecting call… + call status + + + connecting… + No comment provided by engineer. + + + connection established + chat list item title (it should not be shown + + + connection:%@ + connection information + + + contact %1$@ changed to %2$@ + profile update event chat item + + + contact deleted + No comment provided by engineer. + + + contact disabled + No comment provided by engineer. + + + contact has e2e encryption + No comment provided by engineer. + + + contact has no e2e encryption + No comment provided by engineer. + + + contact not ready + No comment provided by engineer. + + + contact should accept… + No comment provided by engineer. + + + contributor + member role + + + creator + No comment provided by engineer. + + + custom + dropdown time picker choice + + + database version is newer than the app, but no down migration for: %@ + No comment provided by engineer. + + + days + time unit + + + decryption errors + No comment provided by engineer. + + + default (%@) + delete after time +pref value + + + default (no) + No comment provided by engineer. + + + default (yes) + No comment provided by engineer. + + + deleted + deleted chat item + + + deleted channel + rcv group event chat item + + + deleted contact + rcv direct event chat item + + + deleted group + rcv group event chat item + + + different migration in the app/database: %@ / %@ + No comment provided by engineer. + + + direct + connection level description + + + disabled + No comment provided by engineer. + + + duplicate message + integrity error chat item + + + duplicates + No comment provided by engineer. + + + e2e encrypted + No comment provided by engineer. + + + enabled + enabled status + + + enabled for contact + enabled status + + + enabled for you + enabled status + + + encryption agreed + chat item text + + + encryption agreed for %@ + chat item text + + + encryption ok + chat item text + + + encryption ok for %@ + chat item text + + + encryption re-negotiation allowed + chat item text + + + encryption re-negotiation allowed for %@ + chat item text + + + encryption re-negotiation required + chat item text + + + encryption re-negotiation required for %@ + chat item text + + + ended + No comment provided by engineer. + + + ended call %@ + call status + + + error + No comment provided by engineer. + + + error: %@ + receive error chat item + + + expired + No comment provided by engineer. + + + failed + No comment provided by engineer. + + + forwarded + No comment provided by engineer. + + + group + shown on group welcome message + + + group deleted + No comment provided by engineer. + + + group is deleted + No comment provided by engineer. + + + group profile updated + snd group event chat item + + + hours + time unit + + + https:// + No comment provided by engineer. + + + iOS Keychain is used to securely store passphrase - it allows receiving push notifications. + No comment provided by engineer. + + + iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications. + No comment provided by engineer. + + + inactive + No comment provided by engineer. + + + incognito via contact address link + chat list item description + + + incognito via group link + chat list item description + + + incognito via one-time link + chat list item description + + + indirect (%d) + connection level description + + + invalid chat + invalid chat data + + + invalid chat data + No comment provided by engineer. + + + invalid data + invalid chat item + + + invitation to group %@ + group name + + + invite + No comment provided by engineer. + + + invited + No comment provided by engineer. + + + invited %@ + rcv group event chat item + + + invited to connect + chat list item title + + + invited via your group link + rcv group event chat item + + + italic + No comment provided by engineer. + + + left + rcv group event chat item + + + link + No comment provided by engineer. + + + marked deleted + marked deleted chat item preview text + + + member + member role + + + member %1$@ changed to %2$@ + profile update event chat item + + + connected + rcv group event chat item + + + member has old version + No comment provided by engineer. + + + message + No comment provided by engineer. + + + message received + notification + + + minutes + time unit + + + missed call + call status + + + moderated + moderated chat item + + + moderated by %@ + marked deleted chat item preview text + + + moderator + member role + + + months + time unit + + + never + delete after time + + + new + No comment provided by engineer. + + + new message + notification + + + no + pref value + + + no e2e encryption + No comment provided by engineer. + + + no subscription + No comment provided by engineer. + + + no text + copied message info in history + + + not synchronized + No comment provided by engineer. + + + observer + member role + + + off + enabled status +group pref value +member criteria value +time to disappear + + + offered %@ + feature offered item + + + offered %1$@: %2$@ + feature offered item + + + on + group pref value + + + other + No comment provided by engineer. + + + other errors + No comment provided by engineer. + + + owner + member role + + + owners + feature role + + + peer-to-peer + No comment provided by engineer. + + + pending + No comment provided by engineer. + + + pending approval + No comment provided by engineer. + + + pending review + No comment provided by engineer. + + + quantum resistant e2e encryption + chat item text + + + received answer… + No comment provided by engineer. + + + received confirmation… + No comment provided by engineer. + + + rejected + No comment provided by engineer. + + + rejected call + call status + + + relay + member role + + + removed + No comment provided by engineer. + + + removed %@ + rcv group event chat item + + + removed (%d attempts) + receive error chat item + + + removed by operator + No comment provided by engineer. + + + removed contact address + profile update event chat item + + + removed from group + No comment provided by engineer. + + + removed profile picture + profile update event chat item + + + removed you + rcv group event chat item + + + request is sent + No comment provided by engineer. + + + request to join rejected + No comment provided by engineer. + + + requested connection + rcv group event chat item + + + requested connection from group %@ + rcv direct event chat item + + + requested to connect + chat list item title + + + review + No comment provided by engineer. + + + reviewed by admins + No comment provided by engineer. + + + saved + No comment provided by engineer. + + + saved from %@ + No comment provided by engineer. + + + search + No comment provided by engineer. + + + sec + network option + + + seconds + time unit + + + secret + No comment provided by engineer. + + + security code changed + chat item text + + + server queue info: %1$@ + +last received msg: %2$@ + queue info + + + set new contact address + profile update event chat item + + + set new profile picture + profile update event chat item + + + standard end-to-end encryption + chat item text + + + starting… + No comment provided by engineer. + + + strike + No comment provided by engineer. + + + subscriber + member role + + + this contact + notification title + + + unblocked %@ + rcv group event chat item + + + unknown + connection info + + + unknown servers + No comment provided by engineer. + + + unknown status + No comment provided by engineer. + + + unprotected + No comment provided by engineer. + + + updated channel profile + rcv group event chat item + + + updated group profile + rcv group event chat item + + + updated profile + profile update event chat item + + + v%@ + No comment provided by engineer. + + + via %@ + relay hostname + + + via contact address link + chat list item description + + + via group link + chat list item description + + + via one-time link + chat list item description + + + via relay + No comment provided by engineer. + + + video + No comment provided by engineer. + + + video call (not e2e encrypted) + No comment provided by engineer. + + + waiting for answer… + No comment provided by engineer. + + + waiting for confirmation… + No comment provided by engineer. + + + wants to connect to you! + No comment provided by engineer. + + + weeks + time unit + + + when IP hidden + No comment provided by engineer. + + + yes + pref value + + + you + No comment provided by engineer. + + + you accepted this member + snd group event chat item + + + you are observer + No comment provided by engineer. + + + you are subscriber + No comment provided by engineer. + + + you blocked %@ + snd group event chat item + + + you changed address + chat item text + + + you changed address for %@ + chat item text + + + you changed role for yourself to %@ + snd group event chat item + + + you changed role of %1$@ to %2$@ + snd group event chat item + + + you left + snd group event chat item + + + you removed %@ + snd group event chat item + + + you shared one-time link + chat list item description + + + you shared one-time link incognito + chat list item description + + + you unblocked %@ + snd group event chat item + + + you: + No comment provided by engineer. + + + \~strike~ + No comment provided by engineer. + + + ⚠️ Signature verification failed: %@. + owner verification + + +
+ +
+ +
+ + + SimpleX + Bundle name + + + SimpleX needs camera access to scan QR codes to connect to other users and for video calls. + Privacy - Camera Usage Description + + + SimpleX uses Face ID for local authentication + Privacy - Face ID Usage Description + + + SimpleX uses local network access to allow using user chat profile via desktop app on the same network. + Privacy - Local Network Usage Description + + + SimpleX needs microphone access for audio and video calls, and to record voice messages. + Privacy - Microphone Usage Description + + + SimpleX needs access to Photo Library for saving captured and received media + Privacy - Photo Library Additions Usage Description + + +
+ +
+ +
+ + + SimpleXChat + Bundle name + + + Copyright © 2022 SimpleX Chat. All rights reserved. + Copyright (human-readable) + + +
+ +
+ +
+ + + SimpleX NSE + Bundle display name + + + SimpleX NSE + Bundle name + + + Copyright © 2022 SimpleX Chat. All rights reserved. + Copyright (human-readable) + + +
+ +
+ +
+ + + %d new events + notification body + + + From %d chat(s) + notification body + + + From: %@ + notification body + + + New events + notification + + + New messages + notification + + +
+ +
+ +
+ + + SimpleX SE + Bundle display name + + + SimpleX SE + Bundle name + + + Copyright © 2024 SimpleX Chat. All rights reserved. + Copyright (human-readable) + + +
+ +
+ +
+ + + %@ + No comment provided by engineer. + + + App is locked! + No comment provided by engineer. + + + Cancel + No comment provided by engineer. + + + Cannot access keychain to save database password + No comment provided by engineer. + + + Cannot forward message + No comment provided by engineer. + + + Comment + No comment provided by engineer. + + + Currently maximum supported file size is %@. + No comment provided by engineer. + + + Database downgrade required + No comment provided by engineer. + + + Database encrypted! + No comment provided by engineer. + + + Database error + No comment provided by engineer. + + + Database passphrase is different from saved in the keychain. + No comment provided by engineer. + + + Database passphrase is required to open chat. + No comment provided by engineer. + + + Database upgrade required + No comment provided by engineer. + + + Error preparing file + No comment provided by engineer. + + + Error preparing message + No comment provided by engineer. + + + Error: %@ + No comment provided by engineer. + + + File error + No comment provided by engineer. + + + Incompatible database version + No comment provided by engineer. + + + Invalid migration confirmation + No comment provided by engineer. + + + Keychain error + No comment provided by engineer. + + + Large file! + No comment provided by engineer. + + + No active profile + No comment provided by engineer. + + + Ok + No comment provided by engineer. + + + Open the app to downgrade the database. + No comment provided by engineer. + + + Open the app to upgrade the database. + No comment provided by engineer. + + + Passphrase + No comment provided by engineer. + + + Please create a profile in the SimpleX app + No comment provided by engineer. + + + Selected chat preferences prohibit this message. + No comment provided by engineer. + + + Sending a message takes longer than expected. + No comment provided by engineer. + + + Sending message… + No comment provided by engineer. + + + Share + No comment provided by engineer. + + + Slow network? + No comment provided by engineer. + + + Unknown database error: %@ + No comment provided by engineer. + + + Unsupported format + No comment provided by engineer. + + + Wait + No comment provided by engineer. + + + Wrong database passphrase + No comment provided by engineer. + + + You can allow sharing in Your privacy / SimpleX Lock settings. + No comment provided by engineer. + + +
+
diff --git a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff index d0e27058e8..9b35dc90e6 100644 --- a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff +++ b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff @@ -39,6 +39,10 @@ %1$@ supported SimpleX Chat. The badge expired on %2$@. badge alert + + %1$02d hrs %2$02d min %3$02d sec + countdown + %@ %@ @@ -152,6 +156,10 @@ %@: copied message info + + %d day + time interval + %d days %d дни @@ -2603,6 +2611,10 @@ This is your own one-time link! Линкът се създава… No comment provided by engineer. + + Crowdfunding investors can reserve names before the sale starts: [simplex.domains](https://simplex.domains/) + No comment provided by engineer. + Crowdfunding on Wefunder No comment provided by engineer. @@ -6311,18 +6323,10 @@ alert button Отвори миграцията към друго устройство authentication reason - - Open new channel - new chat action - Open new chat new chat action - - Open new group - new chat action - Open to accept No comment provided by engineer. @@ -8287,6 +8291,10 @@ copied message info SimpleX name not verified alert title + + SimpleX name sale starts in + No comment provided by engineer. + SimpleX one-time invitation Еднократна покана за SimpleX @@ -9325,6 +9333,10 @@ You will be prompted to complete authentication before this feature is enabled.< Update settings? No comment provided by engineer. + + Update the app to register a SimpleX domain + No comment provided by engineer. + Updated conditions No comment provided by engineer. @@ -9841,6 +9853,23 @@ alert title Вече имате чат профил със същото име. Моля, изберете друго име. No comment provided by engineer. + + You are a contributor + new chat alert + + + You are a member + Вие сте член + new chat alert + + + You are a moderator + new chat alert + + + You are a subscriber + new chat alert + You are already connected to %@. Вече сте вече свързани с %@. @@ -9882,6 +9911,21 @@ Repeat join request? Изпрати отново заявката за присъединяване? new chat sheet title + + You are an admin + Вие сте админ + new chat alert + + + You are an observer + Вие сте наблюдател + new chat alert + + + You are an owner + Вие сте собственик + new chat alert + You are connected to the server used to receive messages from this connection. subscription status explanation @@ -10815,6 +10859,10 @@ pref value препратено No comment provided by engineer. + + forwarded from + No comment provided by engineer. + group shown on group welcome message @@ -11194,9 +11242,9 @@ time to disappear запазено No comment provided by engineer. - - saved from %@ - запазено от %@ + + saved from + запазено от No comment provided by engineer. @@ -11286,6 +11334,10 @@ last received msg: %2$@ unprotected No comment provided by engineer. + + until you can register a SimpleX domain + No comment provided by engineer. + updated channel profile rcv group event chat item diff --git a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff index 26c97d549d..28fc9e9244 100644 --- a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff +++ b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff @@ -39,6 +39,10 @@ %1$@ supported SimpleX Chat. The badge expired on %2$@. badge alert + + %1$02d hrs %2$02d min %3$02d sec + countdown + %@ %@ @@ -152,6 +156,10 @@ %@: copied message info + + %d day + time interval + %d days %d dní @@ -2499,6 +2507,10 @@ Toto je váš vlastní jednorázový odkaz! Creating link… No comment provided by engineer. + + Crowdfunding investors can reserve names before the sale starts: [simplex.domains](https://simplex.domains/) + No comment provided by engineer. + Crowdfunding on Wefunder No comment provided by engineer. @@ -6120,18 +6132,10 @@ alert button Open migration to another device authentication reason - - Open new channel - new chat action - Open new chat new chat action - - Open new group - new chat action - Open to accept No comment provided by engineer. @@ -8056,6 +8060,10 @@ copied message info SimpleX name not verified alert title + + SimpleX name sale starts in + No comment provided by engineer. + SimpleX one-time invitation Jednorázová pozvánka SimpleX @@ -9074,6 +9082,10 @@ Před zapnutím této funkce budete vyzváni k dokončení ověření. Update settings? No comment provided by engineer. + + Update the app to register a SimpleX domain + No comment provided by engineer. + Updated conditions No comment provided by engineer. @@ -9567,6 +9579,23 @@ alert title Již máte profil chatu se stejným zobrazovacím názvem. Zvolte prosím jiné jméno. No comment provided by engineer. + + You are a contributor + new chat alert + + + You are a member + Jste člen + new chat alert + + + You are a moderator + new chat alert + + + You are a subscriber + new chat alert + You are already connected to %@. Již jste připojeni k %@. @@ -9601,6 +9630,21 @@ alert title Repeat join request? new chat sheet title + + You are an admin + Jste správce + new chat alert + + + You are an observer + Jste pozorovatel + new chat alert + + + You are an owner + Jste vlastník + new chat alert + You are connected to the server used to receive messages from this connection. subscription status explanation @@ -10519,6 +10563,10 @@ pref value forwarded No comment provided by engineer. + + forwarded from + No comment provided by engineer. + group shown on group welcome message @@ -10892,8 +10940,8 @@ time to disappear saved No comment provided by engineer. - - saved from %@ + + saved from No comment provided by engineer. @@ -10978,6 +11026,10 @@ last received msg: %2$@ unprotected No comment provided by engineer. + + until you can register a SimpleX domain + No comment provided by engineer. + updated channel profile rcv group event chat item diff --git a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff index 1265c782e0..e139c82f80 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff +++ b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff @@ -40,6 +40,10 @@ %1$@ hat SimpleX Chat unterstützt. Das Abzeichen ist am %2$@ abgelaufen. badge alert + + %1$02d hrs %2$02d min %3$02d sec + countdown + %@ %@ @@ -155,6 +159,10 @@ %@: copied message info + + %d day + time interval + %d days %d Tage @@ -2527,7 +2535,7 @@ Das ist Ihr eigener Einmal-Link! Contact requests in groups - KONTAKTANFRAGEN VON GRUPPEN + Kontaktanfragen in Gruppen No comment provided by engineer. @@ -2715,12 +2723,18 @@ Das ist Ihr eigener Einmal-Link! Link wird erstellt… No comment provided by engineer. + + Crowdfunding investors can reserve names before the sale starts: [simplex.domains](https://simplex.domains/) + No comment provided by engineer. + Crowdfunding on Wefunder + Crowdfunding auf Wefunder No comment provided by engineer. Crowdfunding on Wefunder. + Crowdfunding auf Wefunder. No comment provided by engineer. @@ -4780,6 +4794,7 @@ Fehler: %2$@ Group invitations + Gruppeneinladungen No comment provided by engineer. @@ -6744,21 +6759,11 @@ alert button Migration auf ein anderes Gerät öffnen authentication reason - - Open new channel - Neuen Kanal öffnen - new chat action - Open new chat Neuen Chat öffnen new chat action - - Open new group - Neue Gruppe öffnen - new chat action - Open to accept Zum Akzeptieren öffnen @@ -8945,6 +8950,10 @@ copied message info SimpleX-Name ist nicht verifiziert alert title + + SimpleX name sale starts in + No comment provided by engineer. + SimpleX one-time invitation SimpleX-Einmal-Einladung @@ -10098,6 +10107,10 @@ Sie werden aufgefordert, die Authentifizierung abzuschließen, bevor diese Funkt Einstellungen aktualisieren? No comment provided by engineer. + + Update the app to register a SimpleX domain + No comment provided by engineer. + Updated conditions Aktualisierte Nutzungsbedingungen @@ -10664,6 +10677,26 @@ alert title Sie haben schon ein Chat-Profil mit dem gleichen Anzeigenamen. Bitte wählen Sie einen anderen Namen aus. No comment provided by engineer. + + You are a contributor + Sie sind Mitwirkender + new chat alert + + + You are a member + Sie sind Mitglied + new chat alert + + + You are a moderator + Sie sind Moderator + new chat alert + + + You are a subscriber + Sie sind Abonnent + new chat alert + You are already connected to %@. Sie sind bereits mit %@ verbunden. @@ -10706,6 +10739,21 @@ Repeat join request? Verbindungsanfrage wiederholen? new chat sheet title + + You are an admin + Sie sind Admin + new chat alert + + + You are an observer + Sie sind Beobachter + new chat alert + + + You are an owner + Sie sind Eigentümer + new chat alert + You are connected to the server used to receive messages from this connection. Sie sind mit dem Server verbunden, der für den Empfang von Nachrichten dieser Verbindung genutzt wird. @@ -10778,10 +10826,12 @@ Verbindungsanfrage wiederholen? You can now invest in SimpleX Chat + Sie können nun in SimpleX-Chat investieren No comment provided by engineer. You can now invest in SimpleX Chat! 🚀 + Sie können nun in SimpleX-Chat investieren! 🚀 No comment provided by engineer. @@ -11708,6 +11758,10 @@ pref value weitergeleitet No comment provided by engineer. + + forwarded from + No comment provided by engineer. + group Gruppe @@ -12116,9 +12170,9 @@ time to disappear abgespeichert No comment provided by engineer. - - saved from %@ - abgespeichert von %@ + + saved from + abgespeichert von No comment provided by engineer. @@ -12215,6 +12269,10 @@ Zuletzt empfangene Nachricht: %2$@ Ungeschützt No comment provided by engineer. + + until you can register a SimpleX domain + No comment provided by engineer. + updated channel profile Kanalprofil aktualisiert @@ -12352,7 +12410,7 @@ Zuletzt empfangene Nachricht: %2$@ you removed %@ - entfernt %@ aus der Gruppe + Sie haben %@ aus der Gruppe entfernt snd group event chat item diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff index 38e0ecfe85..184c9fd6b4 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -40,6 +40,11 @@ %1$@ supported SimpleX Chat. The badge expired on %2$@. badge alert + + %1$02d hrs %2$02d min %3$02d sec + %1$02d hrs %2$02d min %3$02d sec + countdown + %@ %@ @@ -155,6 +160,11 @@ %@: copied message info + + %d day + %d day + time interval + %d days %d days @@ -2715,6 +2725,11 @@ This is your own one-time link! Creating link… No comment provided by engineer. + + Crowdfunding investors can reserve names before the sale starts: [simplex.domains](https://simplex.domains/) + Crowdfunding investors can reserve names before the sale starts: [simplex.domains](https://simplex.domains/) + No comment provided by engineer. + Crowdfunding on Wefunder Crowdfunding on Wefunder @@ -6747,21 +6762,11 @@ alert button Open migration to another device authentication reason - - Open new channel - Open new channel - new chat action - Open new chat Open new chat new chat action - - Open new group - Open new group - new chat action - Open to accept Open to accept @@ -8948,6 +8953,11 @@ copied message info SimpleX name not verified alert title + + SimpleX name sale starts in + SimpleX name sale starts in + No comment provided by engineer. + SimpleX one-time invitation SimpleX one-time invitation @@ -10101,6 +10111,11 @@ You will be prompted to complete authentication before this feature is enabled.< Update settings? No comment provided by engineer. + + Update the app to register a SimpleX domain + Update the app to register a SimpleX domain + No comment provided by engineer. + Updated conditions Updated conditions @@ -10667,6 +10682,26 @@ alert title You already have a chat profile with the same display name. Please choose another name. No comment provided by engineer. + + You are a contributor + You are a contributor + new chat alert + + + You are a member + You are a member + new chat alert + + + You are a moderator + You are a moderator + new chat alert + + + You are a subscriber + You are a subscriber + new chat alert + You are already connected to %@. You are already connected to %@. @@ -10709,6 +10744,21 @@ Repeat join request? Repeat join request? new chat sheet title + + You are an admin + You are an admin + new chat alert + + + You are an observer + You are an observer + new chat alert + + + You are an owner + You are an owner + new chat alert + You are connected to the server used to receive messages from this connection. You are connected to the server used to receive messages from this connection. @@ -11713,6 +11763,11 @@ pref value forwarded No comment provided by engineer. + + forwarded from + forwarded from + No comment provided by engineer. + group group @@ -12121,9 +12176,9 @@ time to disappear saved No comment provided by engineer. - - saved from %@ - saved from %@ + + saved from + saved from No comment provided by engineer. @@ -12220,6 +12275,11 @@ last received msg: %2$@ unprotected No comment provided by engineer. + + until you can register a SimpleX domain + until you can register a SimpleX domain + No comment provided by engineer. + updated channel profile updated channel profile diff --git a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff index 60f0dd3e2d..c898d8b893 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff +++ b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff @@ -40,6 +40,10 @@ %1$@ ha apoyado a SimpleX Chat. La insignia caducó el %2$@. badge alert + + %1$02d hrs %2$02d min %3$02d sec + countdown + %@ %@ @@ -155,6 +159,10 @@ %@: copied message info + + %d day + time interval + %d days %d día(s) @@ -2715,12 +2723,18 @@ This is your own one-time link! Creando enlace… No comment provided by engineer. + + Crowdfunding investors can reserve names before the sale starts: [simplex.domains](https://simplex.domains/) + No comment provided by engineer. + Crowdfunding on Wefunder + Crowdfunding en Wefunder No comment provided by engineer. Crowdfunding on Wefunder. + Crowdfunding en Wefunder. No comment provided by engineer. @@ -4780,6 +4794,7 @@ Error: %2$@ Group invitations + Invitaciones en grupo No comment provided by engineer. @@ -6744,21 +6759,11 @@ alert button Abrir menú migración a otro dispositivo authentication reason - - Open new channel - Abrir canal nuevo - new chat action - Open new chat Abrir chat nuevo new chat action - - Open new group - Abrir grupo nuevo - new chat action - Open to accept Abrir para aceptar @@ -8945,6 +8950,10 @@ copied message info Nombre SimpleX no verificado alert title + + SimpleX name sale starts in + No comment provided by engineer. + SimpleX one-time invitation Invitación SimpleX de un uso @@ -10098,6 +10107,10 @@ Se te pedirá que completes la autenticación antes de activar esta función.¿Actualizar configuración? No comment provided by engineer. + + Update the app to register a SimpleX domain + No comment provided by engineer. + Updated conditions Condiciones actualizadas @@ -10664,6 +10677,26 @@ alert title Ya tienes un perfil con este nombre mostrado. Por favor, elige otro nombre. No comment provided by engineer. + + You are a contributor + Eres colaborador + new chat alert + + + You are a member + Eres miembro + new chat alert + + + You are a moderator + Eres moderador + new chat alert + + + You are a subscriber + Eres suscriptor + new chat alert + You are already connected to %@. Ya estás conectado con %@. @@ -10706,6 +10739,21 @@ Repeat join request? ¿Repetir solicitud de admisión? new chat sheet title + + You are an admin + Eres administrador + new chat alert + + + You are an observer + Eres observador + new chat alert + + + You are an owner + Eres propietario + new chat alert + You are connected to the server used to receive messages from this connection. Estás conectado al servidor usado para recibir mensajes de esta conexión. @@ -10778,10 +10826,12 @@ Repeat join request? You can now invest in SimpleX Chat + Ahora puedes invertir en SimpleX Chat No comment provided by engineer. You can now invest in SimpleX Chat! 🚀 + Ahora puedes invertir en SimpleX Chat! 🚀 No comment provided by engineer. @@ -11708,6 +11758,10 @@ pref value reenviado No comment provided by engineer. + + forwarded from + No comment provided by engineer. + group grupo @@ -12116,9 +12170,9 @@ time to disappear guardado No comment provided by engineer. - - saved from %@ - Guardado desde %@ + + saved from + Guardado desde No comment provided by engineer. @@ -12215,6 +12269,10 @@ last received msg: %2$@ desprotegida No comment provided by engineer. + + until you can register a SimpleX domain + No comment provided by engineer. + updated channel profile perfil del canal actualizado diff --git a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff index 78caebe522..e01c47949d 100644 --- a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff @@ -39,6 +39,10 @@ %1$@ supported SimpleX Chat. The badge expired on %2$@. badge alert + + %1$02d hrs %2$02d min %3$02d sec + countdown + %@ % @ @@ -144,6 +148,10 @@ %@: copied message info + + %d day + time interval + %d days %d päivää @@ -2386,6 +2394,10 @@ This is your own one-time link! Creating link… No comment provided by engineer. + + Crowdfunding investors can reserve names before the sale starts: [simplex.domains](https://simplex.domains/) + No comment provided by engineer. + Crowdfunding on Wefunder No comment provided by engineer. @@ -6000,18 +6012,10 @@ alert button Open migration to another device authentication reason - - Open new channel - new chat action - Open new chat new chat action - - Open new group - new chat action - Open to accept No comment provided by engineer. @@ -7935,6 +7939,10 @@ copied message info SimpleX name not verified alert title + + SimpleX name sale starts in + No comment provided by engineer. + SimpleX one-time invitation SimpleX-kertakutsu @@ -8948,6 +8956,10 @@ Sinua kehotetaan suorittamaan todennus loppuun, ennen kuin tämä ominaisuus ote Update settings? No comment provided by engineer. + + Update the app to register a SimpleX domain + No comment provided by engineer. + Updated conditions No comment provided by engineer. @@ -9441,6 +9453,23 @@ alert title Sinulla on jo keskusteluprofiili samalla näyttönimellä. Valitse toinen nimi. No comment provided by engineer. + + You are a contributor + new chat alert + + + You are a member + Olet jäsen + new chat alert + + + You are a moderator + new chat alert + + + You are a subscriber + new chat alert + You are already connected to %@. Olet jo muodostanut yhteyden %@:n kanssa. @@ -9475,6 +9504,21 @@ alert title Repeat join request? new chat sheet title + + You are an admin + Olet ylläpitäjä + new chat alert + + + You are an observer + Olet tarkkailija + new chat alert + + + You are an owner + Olet omistaja + new chat alert + You are connected to the server used to receive messages from this connection. subscription status explanation @@ -10391,6 +10435,10 @@ pref value forwarded No comment provided by engineer. + + forwarded from + No comment provided by engineer. + group shown on group welcome message @@ -10764,8 +10812,8 @@ time to disappear saved No comment provided by engineer. - - saved from %@ + + saved from No comment provided by engineer. @@ -10850,6 +10898,10 @@ last received msg: %2$@ unprotected No comment provided by engineer. + + until you can register a SimpleX domain + No comment provided by engineer. + updated channel profile rcv group event chat item diff --git a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff index 78e8e30096..b4afa63843 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff +++ b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff @@ -40,6 +40,10 @@ %1$@ a soutenu SimpleX Chat. Le badge a expiré le %2$@. badge alert + + %1$02d hrs %2$02d min %3$02d sec + countdown + %@ %@ @@ -155,6 +159,10 @@ %@ : copied message info + + %d day + time interval + %d days %d jours @@ -327,7 +335,7 @@ channel relay bar %lld file(s) with total size of %@ - %lld fichier·s pour une taille totale de %@ + %lld fichier(s) pour une taille totale de %@ No comment provided by engineer. @@ -509,7 +517,7 @@ channel relay bar - connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! - delivery receipts (up to 20 members). - faster and more stable. - - connexion au [service d'annuaire](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA) ! + - connexion au [service d'annuaire](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BÊTA) ! - les accusés de réception (jusqu'à 20 membres). - plus rapide et plus stable. No comment provided by engineer. @@ -938,7 +946,7 @@ swipe action All chats and messages will be deleted - this cannot be undone! - Toutes les conversations et tous les messages seront supprimés - il est impossible de revenir en arrière ! + Toutes les conversations et tous les messages seront supprimés — cette action est irréversible ! No comment provided by engineer. @@ -973,12 +981,12 @@ swipe action All messages will be deleted - this cannot be undone! - Tous les messages seront supprimés - il n'est pas possible de revenir en arrière ! + Tous les messages seront supprimés — cette action est irréversible ! No comment provided by engineer. All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you. - Tous les messages seront supprimés - impossible de revenir en arrière ! Les messages seront supprimés UNIQUEMENT pour vous. + Tous les messages seront supprimés — cette action est irréversible ! Les messages seront supprimés UNIQUEMENT pour vous. No comment provided by engineer. @@ -1640,7 +1648,7 @@ au sein de votre réseau By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). - Par profil de messagerie (par défaut) ou [par connexion](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). + Par profil de messagerie (par défaut) ou [par connexion](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BÊTA). No comment provided by engineer. @@ -1864,12 +1872,12 @@ alert subtitle Channel will be deleted for all subscribers - this cannot be undone! - Le canal sera supprimé pour tous les abonné·es ; ceci ne peut pas être annulé ! + Le canal sera supprimé pour tous les abonnés — cette action est irréversible ! No comment provided by engineer. Channel will be deleted for you - this cannot be undone! - Le canal sera supprimé pour vous ; ceci ne peut pas être annulé ! + Le canal sera supprimé pour vous — cette action est irréversible ! No comment provided by engineer. @@ -1999,12 +2007,12 @@ alert subtitle Chat will be deleted for all members - this cannot be undone! - La conversation sera supprimée pour tous les membres - cela ne peut pas être annulé ! + La conversation sera supprimée pour tous les membres — cette action est irréversible ! No comment provided by engineer. Chat will be deleted for you - this cannot be undone! - La conversation sera supprimée pour vous - il n'est pas possible de revenir en arrière ! + La conversation sera supprimée pour vous — cette action est irréversible ! No comment provided by engineer. @@ -2532,7 +2540,7 @@ Il s'agit de votre propre lien unique ! Contact will be deleted - this cannot be undone! - Le contact sera supprimé - il n'est pas possible de revenir en arrière ! + Le contact sera supprimé — cette action est irréversible ! No comment provided by engineer. @@ -2715,12 +2723,18 @@ Il s'agit de votre propre lien unique ! Création d'un lien… No comment provided by engineer. + + Crowdfunding investors can reserve names before the sale starts: [simplex.domains](https://simplex.domains/) + No comment provided by engineer. + Crowdfunding on Wefunder + Financement participatif sur Wefunder No comment provided by engineer. Crowdfunding on Wefunder. + Financement participatif sur Wefunder. No comment provided by engineer. @@ -3235,7 +3249,7 @@ alert button Developer - Outils du développeur + Outils développeur No comment provided by engineer. @@ -3250,12 +3264,12 @@ alert button Device authentication is disabled. Turning off SimpleX Lock. - L'authentification de l'appareil est désactivée. Désactivation de SimpleX Lock. + L'authentification de l'appareil est désactivée. Désactivation du Verrouillage SimpleX. No comment provided by engineer. Device authentication is not enabled. You can turn on SimpleX Lock via Settings, once you enable device authentication. - L'authentification de l'appareil n'est pas activée. Vous pouvez activer SimpleX Lock via Paramètres, une fois que vous avez activé l'authentification de l'appareil. + L'authentification de l'appareil n'est pas activée. Vous pouvez activer le Verrouillage SimpleX dans les Paramètres une fois l'authentification de l'appareil activée. No comment provided by engineer. @@ -3295,7 +3309,7 @@ alert button Disable SimpleX Lock - Désactiver SimpleX Lock + Désactiver le Verrouillage SimpleX authentication reason @@ -3556,12 +3570,12 @@ chat item action Enable Flux in Network & servers settings for better metadata privacy. - Activez Flux dans les paramètres du réseau et des serveurs pour une meilleure confidentialité des métadonnées. + Activez Flux dans les paramètres Réseau et serveurs pour une meilleure confidentialité des métadonnées. No comment provided by engineer. Enable SimpleX Lock - Activer SimpleX Lock + Activer le Verrouillage SimpleX authentication reason @@ -3571,7 +3585,7 @@ chat item action Enable at least one chat relay in Network & Servers. - Activez au moins un relais de messagerie dans Réseaux et serveurs. + Activez au moins un relais de messagerie dans Réseau et serveurs. channel creation warning @@ -3601,7 +3615,7 @@ chat item action Enable in direct chats (BETA)! - Activer dans les conversations directes (BETA) ! + Activer dans les conversations directes (BÊTA) ! No comment provided by engineer. @@ -4705,7 +4719,7 @@ Erreur : %2$@ Get SimpleX name (BETA) - Obtenir un nom SimpleX (BETA) + Obtenir un nom SimpleX (BÊTA) No comment provided by engineer. @@ -4780,6 +4794,7 @@ Erreur : %2$@ Group invitations + Invitations de groupe No comment provided by engineer. @@ -4834,12 +4849,12 @@ Erreur : %2$@ Group will be deleted for all members - this cannot be undone! - Le groupe va être supprimé pour tout les membres - impossible de revenir en arrière ! + Le groupe va être supprimé pour tout les membres — cette action est irréversible ! No comment provided by engineer. Group will be deleted for you - this cannot be undone! - Le groupe va être supprimé pour vous - impossible de revenir en arrière ! + Le groupe va être supprimé pour vous — cette action est irréversible ! No comment provided by engineer. @@ -5691,7 +5706,7 @@ Voici votre lien pour le groupe %@ ! Member messages will be deleted - this cannot be undone! - Les messages des membres seront supprimés ; ceci ne peut pas être annulé ! + Les messages des membres seront supprimés — cette action est irréversible ! alert message @@ -5701,12 +5716,12 @@ Voici votre lien pour le groupe %@ ! Member will be removed from chat - this cannot be undone! - Le membre sera retiré de la conversation - cette action est irréversible ! + Le membre sera retiré de la conversation — cette action est irréversible ! alert message Member will be removed from group - this cannot be undone! - Ce membre sera retiré du groupe - impossible de revenir en arrière ! + Ce membre sera retiré du groupe — cette action est irréversible ! alert message @@ -6450,7 +6465,7 @@ Le chiffrement le plus sûr. Not a better lock on someone else's door. Not a nicer landlord that respects your privacy, but still keeps the record of all visitors. You are not a guest. You are home. No king can enter it - you are sovereign. - Ce n’est pas une meilleure serrure sur la porte de quelqu’un d’autre. Ce n’est pas un propriétaire plus aimable qui respecte votre vie privée, mais qui tient tout de même un registre de tous les visiteurs. Vous n’êtes pas un·e invité·e, vous êtes chez vous. Aucun roi ne peut y entrer : c’est vous le souverain. + Ce n’est pas une meilleure serrure sur la porte de quelqu'un d'autre. Ni un propriétaire plus attentionné qui respecte votre vie privée, mais conserve toujours le registre de tous les visiteurs. Vous n'êtes pas un invité. Vous êtes chez vous. Aucun roi ne peut y entrer — vous êtes souverain. No comment provided by engineer. @@ -6744,21 +6759,11 @@ alert button Ouvrir le transfert vers un autre appareil authentication reason - - Open new channel - Ouvrir un nouveau canal - new chat action - Open new chat Ouvrir une nouvelle conversation new chat action - - Open new group - Ouvrir le nouveau groupe - new chat action - Open to accept Ouvrir pour accepter @@ -7123,7 +7128,7 @@ Erreur : %@ Privacy for your customers. - Respect de la vie privée de vos clients. + Confidentialité pour vos clients. No comment provided by engineer. @@ -7583,7 +7588,7 @@ swipe action Relay will be removed from channel - this cannot be undone! - Le relais sera supprimé du canal ; cette action est irréversible ! + Le relais sera supprimé du canal — cette action est irréversible ! alert message @@ -7863,7 +7868,7 @@ swipe action Review members before admitting ("knocking"). - Contrôler les membres avant de les admettre (« toquer »). + Examiner les membres avant leur admission (« frapper à la porte »). admission stage description @@ -8540,7 +8545,7 @@ chat item action Servers statistics will be reset - this cannot be undone! - Les statistiques des serveurs seront réinitialisées - il n'est pas possible de revenir en arrière ! + Les statistiques des serveurs seront réinitialisées — cette action est irréversible ! No comment provided by engineer. @@ -8857,22 +8862,22 @@ copied message info SimpleX Lock - SimpleX Lock + Verrouillage SimpleX No comment provided by engineer. SimpleX Lock mode - Mode de SimpleX Lock + Mode de Verrouillage SimpleX No comment provided by engineer. SimpleX Lock not enabled! - SimpleX Lock n'est pas activé ! + Le Verrouillage SimpleX n'est pas activé ! No comment provided by engineer. SimpleX Lock turned on - SimpleX Lock activé + Verrouillage SimpleX activé No comment provided by engineer. @@ -8945,6 +8950,10 @@ copied message info Nom SimpleX non vérifié alert title + + SimpleX name sale starts in + No comment provided by engineer. + SimpleX one-time invitation Invitation unique SimpleX @@ -8957,7 +8966,7 @@ copied message info SimpleX public names (BETA) - Noms publics SimpleX (BETA) + Noms publics SimpleX (BÊTA) No comment provided by engineer. @@ -9160,7 +9169,7 @@ report reason Subscriber will be removed from channel - this cannot be undone! - L'abonné sera supprimé du canal ; cette action est irréversible ! + L'abonné sera supprimé du canal — cette action est irréversible ! alert message @@ -9652,22 +9661,22 @@ vos contacts et vos groupes. This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain. - Cette action ne peut être annulée - tous les fichiers et médias reçus et envoyés seront supprimés. Les photos à faible résolution seront conservées. + Cette action est irréversible — tous les fichiers et médias reçus et envoyés seront supprimés. Les photos à faible résolution seront conservées. No comment provided by engineer. This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes. - Cette action ne peut être annulée - les messages envoyés et reçus avant la date sélectionnée seront supprimés. Cela peut prendre plusieurs minutes. + Cette action est irréversible — les messages envoyés et reçus avant la date sélectionnée seront supprimés. Cela peut prendre plusieurs minutes. No comment provided by engineer. This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. - Cette action est irréversible : les messages envoyés et reçus dans cette conversation avant la date sélectionnée seront supprimés. + Cette action est irréversible — les messages envoyés et reçus dans cette conversation avant la date sélectionnée seront supprimés. alert message This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. - Cette action ne peut être annulée - votre profil, vos contacts, vos messages et vos fichiers seront irréversiblement perdus. + Cette action est irréversible — votre profil, vos contacts, vos messages et vos fichiers seront irréversiblement perdus. No comment provided by engineer. @@ -9799,8 +9808,8 @@ alert subtitle To protect your information, turn on SimpleX Lock. You will be prompted to complete authentication before this feature is enabled. - Pour protéger vos informations, activez la fonction SimpleX Lock. -Vous serez invité à confirmer l'authentification avant que cette fonction ne soit activée. + Pour protéger vos informations, activez le Verrouillage SimpleX. +Une authentification vous sera demandée avant que cette fonctionnalité ne soit activée. No comment provided by engineer. @@ -10098,6 +10107,10 @@ Vous serez invité à confirmer l'authentification avant que cette fonction ne s Mettre à jour les paramètres ? No comment provided by engineer. + + Update the app to register a SimpleX domain + No comment provided by engineer. + Updated conditions Conditions mises à jour @@ -10664,6 +10677,26 @@ alert title Vous avez déjà un profil de messagerie avec le même nom d’affichage. Veuillez choisir un autre nom. No comment provided by engineer. + + You are a contributor + Vous êtes contributeur + new chat alert + + + You are a member + Vous êtes membre + new chat alert + + + You are a moderator + Vous êtes modérateur + new chat alert + + + You are a subscriber + Vous êtes abonné·e + new chat alert + You are already connected to %@. Vous êtes déjà connecté·e à %@ via ce lien. @@ -10706,6 +10739,21 @@ Repeat join request? Répéter la demande d'adhésion ? new chat sheet title + + You are an admin + Vous êtes admin + new chat alert + + + You are an observer + Vous êtes observateur + new chat alert + + + You are an owner + Vous êtes propriétaire + new chat alert + You are connected to the server used to receive messages from this connection. Vous êtes connecté au serveur utilisé pour recevoir les messages de cette connexion. @@ -10753,7 +10801,7 @@ Répéter la demande d'adhésion ? You can enable them later via app Your privacy settings. - Vous pourrez les activer plus tard dans les paramètres « Votre vie privée ». + Vous pouvez les activer plus tard dans les paramètres de confidentialité de l'application. No comment provided by engineer. @@ -10778,10 +10826,12 @@ Répéter la demande d'adhésion ? You can now invest in SimpleX Chat + Vous pouvez désormais investir dans SimpleX Chat No comment provided by engineer. You can now invest in SimpleX Chat! 🚀 + Vous pouvez désormais investir dans SimpleX Chat ! 🚀 No comment provided by engineer. @@ -10831,7 +10881,7 @@ Répéter la demande d'adhésion ? You can turn on SimpleX Lock via Settings. - Vous pouvez activer SimpleX Lock dans les Paramètres. + Vous pouvez activer le Verrouillage SimpleX dans les Paramètres. No comment provided by engineer. @@ -11136,7 +11186,7 @@ Si vous annulez, le canal sera supprimé : vous pourrez le recréer. Your privacy - Votre vie privée + Votre confidentialité No comment provided by engineer. @@ -11708,6 +11758,10 @@ pref value transféré No comment provided by engineer. + + forwarded from + No comment provided by engineer. + group groupe @@ -12116,9 +12170,9 @@ time to disappear enregistré No comment provided by engineer. - - saved from %@ - enregistré à partir de %@ + + saved from + enregistré à partir de No comment provided by engineer. @@ -12215,6 +12269,10 @@ dernier message reçu : %2$@ non protégé No comment provided by engineer. + + until you can register a SimpleX domain + No comment provided by engineer. + updated channel profile profil du canal mis à jour @@ -12702,7 +12760,7 @@ dernier message reçu : %2$@ You can allow sharing in Your privacy / SimpleX Lock settings. - Vous pouvez autoriser le partage dans Votre vie privée / Réglages de verrouillage SimpleX. + Vous pouvez autoriser le partage dans les paramètres Votre confidentialité / Verrouillage SimpleX. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff index 4663996480..9093d33a0e 100644 --- a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff +++ b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff @@ -40,6 +40,10 @@ %1$@ támogatta a SimpleX Chatet. A kitűző lejárt ekkor: %2$@. badge alert + + %1$02d hrs %2$02d min %3$02d sec + countdown + %@ %@ @@ -155,6 +159,10 @@ %@: copied message info + + %d day + time interval + %d days %d nap @@ -1640,7 +1648,7 @@ a saját hálózatában By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). - A csevegési profillal (alapértelmezett), vagy a [kapcsolattal] (https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (béta). + A csevegési profillal (alapértelmezés), vagy a [kapcsolattal](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (béta). No comment provided by engineer. @@ -2715,12 +2723,18 @@ Ez a saját egyszer használható meghívója! Hivatkozás létrehozása… No comment provided by engineer. + + Crowdfunding investors can reserve names before the sale starts: [simplex.domains](https://simplex.domains/) + No comment provided by engineer. + Crowdfunding on Wefunder + Közösségi finanszírozás a Wefunder oldalon No comment provided by engineer. Crowdfunding on Wefunder. + Közösségi finanszírozás a Wefunder oldalon. No comment provided by engineer. @@ -3591,7 +3605,7 @@ chat item action Enable disappearing messages by default. - Eltűnő üzenetek engedélyezése alapértelmezetten. + Eltűnő üzenetek engedélyezése alapértelmezésként. No comment provided by engineer. @@ -4780,6 +4794,7 @@ Hiba: %2$@ Group invitations + Meghívások csoportokba No comment provided by engineer. @@ -5151,7 +5166,7 @@ További fejlesztések hamarosan! Info - Információ + Adatok chat item action @@ -5811,7 +5826,7 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Message queue info - Üzenet várólista-információi + Üzenet várólistaadatai No comment provided by engineer. @@ -6305,7 +6320,7 @@ A legbiztonságosabb titkosítás. No delivery information - Nincs kézbesítési információ + Nincsenek kézbesítési adatok No comment provided by engineer. @@ -6335,7 +6350,7 @@ A legbiztonságosabb titkosítás. No info, try to reload - Nincs információ, próbálja meg újratölteni + Nincsenek adatok, próbálja meg újratölteni No comment provided by engineer. @@ -6744,21 +6759,11 @@ alert button Átköltöztetés indítása egy másik eszközre authentication reason - - Open new channel - Új csatorna megnyitása - new chat action - Open new chat Új csevegés megnyitása new chat action - - Open new group - Új csoport megnyitása - new chat action - Open to accept Megnyitás az elfogadáshoz @@ -7376,12 +7381,12 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Read more in User Guide. - További információkat a használati útmutatóban talál. + További tudnivalókat a használati útmutatóban talál. No comment provided by engineer. Read more in our GitHub repository. - További információkat a GitHub-tárolónkban talál. + További tudnivalókat a GitHub-tárolónkban talál. No comment provided by engineer. @@ -7793,7 +7798,7 @@ swipe action Reset to defaults - Visszaállítás alapértelmezettre + Visszaállítás alapértelmezésre No comment provided by engineer. @@ -8535,7 +8540,7 @@ chat item action Servers info - Információk a kiszolgálókról + Kiszolgálóadatok No comment provided by engineer. @@ -8945,6 +8950,10 @@ copied message info Nincs ellenőrizve a SimpleX-név alert title + + SimpleX name sale starts in + No comment provided by engineer. + SimpleX one-time invitation Egyszer használható SimpleX meghívó @@ -10098,6 +10107,10 @@ A funkció bekapcsolása előtt a rendszer felszólítja a képernyőzár beáll Frissíti a beállításokat? No comment provided by engineer. + + Update the app to register a SimpleX domain + No comment provided by engineer. + Updated conditions Frissített feltételek @@ -10664,6 +10677,26 @@ alert title Már van egy csevegési profil ugyanezzel a megjelenítendő névvel. Válasszon egy másik nevet. No comment provided by engineer. + + You are a contributor + Ön közreműködő + new chat alert + + + You are a member + Ön tag + new chat alert + + + You are a moderator + Ön moderátor + new chat alert + + + You are a subscriber + Ön feliratkozó + new chat alert + You are already connected to %@. Ön már kapcsolódott a következőhöz: %@. @@ -10706,6 +10739,21 @@ Repeat join request? Megismétli a csatlakozási kérést? new chat sheet title + + You are an admin + Ön adminisztrátor + new chat alert + + + You are an observer + Ön megfigyelő + new chat alert + + + You are an owner + Ön tulajdonos + new chat alert + You are connected to the server used to receive messages from this connection. Ön kapcsolódott ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál. @@ -10778,10 +10826,12 @@ Megismétli a csatlakozási kérést? You can now invest in SimpleX Chat + Mostantól befektethet a SimpleX Chatbe No comment provided by engineer. You can now invest in SimpleX Chat! 🚀 + Mostantól befektethet a SimpleX Chatbe! 🚀 No comment provided by engineer. @@ -11554,18 +11604,18 @@ marked deleted chat item preview text default (%@) - alapértelmezett (%@) + alapértelmezés (%@) delete after time pref value default (no) - alapértelmezett (nem) + alapértelmezés (nem) No comment provided by engineer. default (yes) - alapértelmezett (igen) + alapértelmezés (igen) No comment provided by engineer. @@ -11708,6 +11758,10 @@ pref value továbbított No comment provided by engineer. + + forwarded from + No comment provided by engineer. + group csoport @@ -12116,9 +12170,9 @@ time to disappear mentett No comment provided by engineer. - - saved from %@ - mentve innen: %@ + + saved from + mentve innen: No comment provided by engineer. @@ -12150,7 +12204,7 @@ time to disappear server queue info: %1$@ last received msg: %2$@ - kiszolgáló várólista-információi: %1$@ + kiszolgáló várólistaadatai: %1$@ utoljára fogadott üzenet: %2$@ queue info @@ -12215,6 +12269,10 @@ utoljára fogadott üzenet: %2$@ nem védett No comment provided by engineer. + + until you can register a SimpleX domain + No comment provided by engineer. + updated channel profile frissítette a csatorna profilját diff --git a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff index 80f6c9a224..e6136681a0 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff +++ b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff @@ -40,6 +40,10 @@ %1$@ ha sostenuto SimpleX Chat. La targhetta è scaduta il %2$@. badge alert + + %1$02d hrs %2$02d min %3$02d sec + countdown + %@ %@ @@ -155,6 +159,10 @@ %@: copied message info + + %d day + time interval + %d days %d giorni @@ -2715,12 +2723,18 @@ Questo è il tuo link una tantum! Creazione link… No comment provided by engineer. + + Crowdfunding investors can reserve names before the sale starts: [simplex.domains](https://simplex.domains/) + No comment provided by engineer. + Crowdfunding on Wefunder + Raccolta fondi su Wefunder No comment provided by engineer. Crowdfunding on Wefunder. + Raccolta fondi su Wefunder. No comment provided by engineer. @@ -4780,6 +4794,7 @@ Errore: %2$@ Group invitations + Inviti in gruppi No comment provided by engineer. @@ -6744,21 +6759,11 @@ alert button Apri migrazione ad un altro dispositivo authentication reason - - Open new channel - Apri il nuovo canale - new chat action - Open new chat Apri la nuova chat new chat action - - Open new group - Apri il nuovo gruppo - new chat action - Open to accept Apri per accettare @@ -8945,6 +8950,10 @@ copied message info Nome SimpleX non verificato alert title + + SimpleX name sale starts in + No comment provided by engineer. + SimpleX one-time invitation Invito SimpleX una tantum @@ -10098,6 +10107,10 @@ Ti verrà chiesto di completare l'autenticazione prima di attivare questa funzio Aggiornare le impostazioni? No comment provided by engineer. + + Update the app to register a SimpleX domain + No comment provided by engineer. + Updated conditions Condizioni aggiornate @@ -10664,6 +10677,26 @@ alert title Hai già un profilo chat con lo stesso nome da mostrare. Scegli un altro nome. No comment provided by engineer. + + You are a contributor + Sei un collaboratore + new chat alert + + + You are a member + Sei un membro + new chat alert + + + You are a moderator + Sei un moderatore + new chat alert + + + You are a subscriber + Sei iscritto/a + new chat alert + You are already connected to %@. Sei già connesso/a a %@. @@ -10706,6 +10739,21 @@ Repeat join request? Ripetere la richiesta di ingresso? new chat sheet title + + You are an admin + Sei un amministratore + new chat alert + + + You are an observer + Sei un osservatore + new chat alert + + + You are an owner + Sei un proprietario + new chat alert + You are connected to the server used to receive messages from this connection. Sei connesso/a al server usato per ricevere messaggi da questa connessione. @@ -10778,10 +10826,12 @@ Ripetere la richiesta di ingresso? You can now invest in SimpleX Chat + Ora puoi investire in SimpleX Chat No comment provided by engineer. You can now invest in SimpleX Chat! 🚀 + Ora puoi investire in SimpleX Chat! 🚀 No comment provided by engineer. @@ -11708,6 +11758,10 @@ pref value inoltrato No comment provided by engineer. + + forwarded from + No comment provided by engineer. + group gruppo @@ -12116,9 +12170,9 @@ time to disappear salvato No comment provided by engineer. - - saved from %@ - salvato da %@ + + saved from + salvato da No comment provided by engineer. @@ -12215,6 +12269,10 @@ ultimo msg ricevuto: %2$@ non protetto No comment provided by engineer. + + until you can register a SimpleX domain + No comment provided by engineer. + updated channel profile profilo del canale aggiornato diff --git a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff index 345e6836a6..5d9be60493 100644 --- a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff +++ b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff @@ -39,6 +39,10 @@ %1$@ supported SimpleX Chat. The badge expired on %2$@. badge alert + + %1$02d hrs %2$02d min %3$02d sec + countdown + %@ %@ @@ -152,6 +156,10 @@ %@: copied message info + + %d day + time interval + %d days %d 日 @@ -2491,6 +2499,10 @@ This is your own one-time link! Creating link… No comment provided by engineer. + + Crowdfunding investors can reserve names before the sale starts: [simplex.domains](https://simplex.domains/) + No comment provided by engineer. + Crowdfunding on Wefunder No comment provided by engineer. @@ -6120,18 +6132,10 @@ alert button Open migration to another device authentication reason - - Open new channel - new chat action - Open new chat new chat action - - Open new group - new chat action - Open to accept No comment provided by engineer. @@ -8048,6 +8052,10 @@ copied message info SimpleX name not verified alert title + + SimpleX name sale starts in + No comment provided by engineer. + SimpleX one-time invitation SimpleX使い捨て招待リンク @@ -9061,6 +9069,10 @@ You will be prompted to complete authentication before this feature is enabled.< Update settings? No comment provided by engineer. + + Update the app to register a SimpleX domain + No comment provided by engineer. + Updated conditions No comment provided by engineer. @@ -9554,6 +9566,23 @@ alert title 同じ表示名前のチャットプロフィールが既にあります。別のを選んでください。 No comment provided by engineer. + + You are a contributor + new chat alert + + + You are a member + あなたはメンバーです + new chat alert + + + You are a moderator + new chat alert + + + You are a subscriber + new chat alert + You are already connected to %@. すでに %@ に接続されています。 @@ -9588,6 +9617,21 @@ alert title Repeat join request? new chat sheet title + + You are an admin + あなたは管理者です + new chat alert + + + You are an observer + あなたはオブザーバーです + new chat alert + + + You are an owner + あなたはオーナーです + new chat alert + You are connected to the server used to receive messages from this connection. subscription status explanation @@ -10506,6 +10550,10 @@ pref value forwarded No comment provided by engineer. + + forwarded from + No comment provided by engineer. + group shown on group welcome message @@ -10879,8 +10927,8 @@ time to disappear saved No comment provided by engineer. - - saved from %@ + + saved from No comment provided by engineer. @@ -10965,6 +11013,10 @@ last received msg: %2$@ unprotected No comment provided by engineer. + + until you can register a SimpleX domain + No comment provided by engineer. + updated channel profile rcv group event chat item diff --git a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff index 5ebd47c99a..d1b092f63b 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff +++ b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff @@ -39,6 +39,10 @@ %1$@ supported SimpleX Chat. The badge expired on %2$@. badge alert + + %1$02d hrs %2$02d min %3$02d sec + countdown + %@ %@ @@ -152,6 +156,10 @@ %@: copied message info + + %d day + time interval + %d days %d dagen @@ -2603,6 +2611,10 @@ Dit is uw eigen eenmalige link! Link maken… No comment provided by engineer. + + Crowdfunding investors can reserve names before the sale starts: [simplex.domains](https://simplex.domains/) + No comment provided by engineer. + Crowdfunding on Wefunder No comment provided by engineer. @@ -6508,18 +6520,10 @@ alert button Open de migratie naar een ander apparaat authentication reason - - Open new channel - new chat action - Open new chat new chat action - - Open new group - new chat action - Open to accept No comment provided by engineer. @@ -8613,6 +8617,10 @@ copied message info SimpleX name not verified alert title + + SimpleX name sale starts in + No comment provided by engineer. + SimpleX one-time invitation Eenmalige SimpleX uitnodiging @@ -9705,6 +9713,10 @@ U wordt gevraagd de authenticatie te voltooien voordat deze functie wordt ingesc Instellingen actualiseren? No comment provided by engineer. + + Update the app to register a SimpleX domain + No comment provided by engineer. + Updated conditions Bijgewerkte voorwaarden @@ -10248,6 +10260,24 @@ alert title Je hebt al een chatprofiel met dezelfde weergave naam. Kies een andere naam. No comment provided by engineer. + + You are a contributor + new chat alert + + + You are a member + Je bent lid + new chat alert + + + You are a moderator + Je bent moderator + new chat alert + + + You are a subscriber + new chat alert + You are already connected to %@. U bent al verbonden met %@. @@ -10290,6 +10320,21 @@ Repeat join request? Deelnameverzoek herhalen? new chat sheet title + + You are an admin + Je bent beheerder + new chat alert + + + You are an observer + Je bent waarnemer + new chat alert + + + You are an owner + Je bent eigenaar + new chat alert + You are connected to the server used to receive messages from this connection. subscription status explanation @@ -11254,6 +11299,10 @@ pref value doorgestuurd No comment provided by engineer. + + forwarded from + No comment provided by engineer. + group shown on group welcome message @@ -11651,9 +11700,9 @@ time to disappear opgeslagen No comment provided by engineer. - - saved from %@ - opgeslagen van %@ + + saved from + opgeslagen van No comment provided by engineer. @@ -11749,6 +11798,10 @@ laatst ontvangen bericht: %2$@ onbeschermd No comment provided by engineer. + + until you can register a SimpleX domain + No comment provided by engineer. + updated channel profile rcv group event chat item diff --git a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff index 68f836184f..a48e4219d4 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff +++ b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff @@ -39,6 +39,10 @@ %1$@ supported SimpleX Chat. The badge expired on %2$@. badge alert + + %1$02d hrs %2$02d min %3$02d sec + countdown + %@ %@ @@ -152,6 +156,10 @@ %@: copied message info + + %d day + time interval + %d days %d dni @@ -2622,6 +2630,10 @@ To jest twój jednorazowy link! Tworzenie linku… No comment provided by engineer. + + Crowdfunding investors can reserve names before the sale starts: [simplex.domains](https://simplex.domains/) + No comment provided by engineer. + Crowdfunding on Wefunder No comment provided by engineer. @@ -6565,20 +6577,11 @@ alert button Otwórz migrację na innym urządzeniu authentication reason - - Open new channel - new chat action - Open new chat Otwórz nowy czat new chat action - - Open new group - Otwórz nową grupę - new chat action - Open to accept Otwórz by zaakceptować @@ -8699,6 +8702,10 @@ copied message info SimpleX name not verified alert title + + SimpleX name sale starts in + No comment provided by engineer. + SimpleX one-time invitation Zaproszenie jednorazowe SimpleX @@ -9805,6 +9812,10 @@ Przed włączeniem tej funkcji zostanie wyświetlony monit uwierzytelniania.Zaktualizować ustawienia? No comment provided by engineer. + + Update the app to register a SimpleX domain + No comment provided by engineer. + Updated conditions Zaktualizowane warunki @@ -10357,6 +10368,24 @@ alert title Masz już profil czatu o tej samej nazwie wyświetlanej. Proszę wybrać inną nazwę. No comment provided by engineer. + + You are a contributor + new chat alert + + + You are a member + Jesteś członkiem + new chat alert + + + You are a moderator + Jesteś moderatorem + new chat alert + + + You are a subscriber + new chat alert + You are already connected to %@. Jesteś już połączony z %@. @@ -10399,6 +10428,21 @@ Repeat join request? Powtórzyć prośbę dołączenia? new chat sheet title + + You are an admin + Jesteś administratorem + new chat alert + + + You are an observer + Jesteś obserwatorem + new chat alert + + + You are an owner + Jesteś właścicielem + new chat alert + You are connected to the server used to receive messages from this connection. Jesteś połączony z serwerem służącym do odbierania wiadomości z tego połączenia. @@ -11374,6 +11418,10 @@ pref value przekazane dalej No comment provided by engineer. + + forwarded from + No comment provided by engineer. + group grupa @@ -11776,9 +11824,9 @@ time to disappear zapisane No comment provided by engineer. - - saved from %@ - zapisane od %@ + + saved from + zapisane od No comment provided by engineer. @@ -11874,6 +11922,10 @@ ostatnia otrzymana wiadomość: %2$@ niezabezpieczony No comment provided by engineer. + + until you can register a SimpleX domain + No comment provided by engineer. + updated channel profile rcv group event chat item diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff index 4d08db0a99..67d70d9708 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -40,6 +40,10 @@ %1$@ поддерживал(а) SimpleX Chat. Срок действия значка истёк %2$@. badge alert + + %1$02d hrs %2$02d min %3$02d sec + countdown + %@ %@ @@ -155,6 +159,10 @@ %@: copied message info + + %d day + time interval + %d days %d дней @@ -2715,6 +2723,10 @@ This is your own one-time link! Создаётся ссылка… No comment provided by engineer. + + Crowdfunding investors can reserve names before the sale starts: [simplex.domains](https://simplex.domains/) + No comment provided by engineer. + Crowdfunding on Wefunder No comment provided by engineer. @@ -6743,21 +6755,11 @@ alert button Открытие миграции на другое устройство authentication reason - - Open new channel - Открыть новый канал - new chat action - Open new chat Открыть новый чат new chat action - - Open new group - Открыть новую группу - new chat action - Open to accept Откройте чтобы принять @@ -8944,6 +8946,10 @@ copied message info SimpleX имя не проверено alert title + + SimpleX name sale starts in + No comment provided by engineer. + SimpleX one-time invitation SimpleX одноразовая ссылка @@ -10097,6 +10103,10 @@ You will be prompted to complete authentication before this feature is enabled.< Обновить настройки? No comment provided by engineer. + + Update the app to register a SimpleX domain + No comment provided by engineer. + Updated conditions Обновлённые условия @@ -10663,6 +10673,26 @@ alert title У Вас уже есть профиль с таким именем. Пожалуйста, выберите другое имя. No comment provided by engineer. + + You are a contributor + Вы соавтор + new chat alert + + + You are a member + Вы член группы + new chat alert + + + You are a moderator + Вы модератор + new chat alert + + + You are a subscriber + Вы подписчик + new chat alert + You are already connected to %@. Вы уже соединены с контактом %@. @@ -10705,6 +10735,21 @@ Repeat join request? Повторить запрос на вступление? new chat sheet title + + You are an admin + Вы админ + new chat alert + + + You are an observer + Вы читатель + new chat alert + + + You are an owner + Вы владелец + new chat alert + You are connected to the server used to receive messages from this connection. Вы подключены к серверу, используемому для приёма сообщений от этого соединения. @@ -11707,6 +11752,10 @@ pref value переслано No comment provided by engineer. + + forwarded from + No comment provided by engineer. + group группа @@ -12115,9 +12164,9 @@ time to disappear сохранено No comment provided by engineer. - - saved from %@ - сохранено из %@ + + saved from + сохранено из No comment provided by engineer. @@ -12214,6 +12263,10 @@ last received msg: %2$@ незащищённый No comment provided by engineer. + + until you can register a SimpleX domain + No comment provided by engineer. + updated channel profile обновил профиль канала diff --git a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff index 5859a228a3..b77da016c7 100644 --- a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff +++ b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff @@ -36,6 +36,10 @@ %1$@ supported SimpleX Chat. The badge expired on %2$@. badge alert + + %1$02d hrs %2$02d min %3$02d sec + countdown + %@ %@ @@ -139,6 +143,10 @@ %@: copied message info + + %d day + time interval + %d days %d วัน @@ -2375,6 +2383,10 @@ This is your own one-time link! Creating link… No comment provided by engineer. + + Crowdfunding investors can reserve names before the sale starts: [simplex.domains](https://simplex.domains/) + No comment provided by engineer. + Crowdfunding on Wefunder No comment provided by engineer. @@ -5979,18 +5991,10 @@ alert button Open migration to another device authentication reason - - Open new channel - new chat action - Open new chat new chat action - - Open new group - new chat action - Open to accept No comment provided by engineer. @@ -7907,6 +7911,10 @@ copied message info SimpleX name not verified alert title + + SimpleX name sale starts in + No comment provided by engineer. + SimpleX one-time invitation คำเชิญ SimpleX แบบครั้งเดียว @@ -8918,6 +8926,10 @@ You will be prompted to complete authentication before this feature is enabled.< Update settings? No comment provided by engineer. + + Update the app to register a SimpleX domain + No comment provided by engineer. + Updated conditions No comment provided by engineer. @@ -9409,6 +9421,23 @@ alert title คุณมีโปรไฟล์แชทที่ใช้ชื่อแสดงเดียวกันอยู่แล้ว กรุณาเลือกชื่ออื่น No comment provided by engineer. + + You are a contributor + new chat alert + + + You are a member + คุณเป็นสมาชิก + new chat alert + + + You are a moderator + new chat alert + + + You are a subscriber + new chat alert + You are already connected to %@. คุณได้เชื่อมต่อกับ %@ แล้ว @@ -9443,6 +9472,21 @@ alert title Repeat join request? new chat sheet title + + You are an admin + คุณเป็นผู้ดูแลระบบ + new chat alert + + + You are an observer + คุณเป็นผู้สังเกตการณ์ + new chat alert + + + You are an owner + คุณเป็นเจ้าของ + new chat alert + You are connected to the server used to receive messages from this connection. subscription status explanation @@ -10356,6 +10400,10 @@ pref value forwarded No comment provided by engineer. + + forwarded from + No comment provided by engineer. + group shown on group welcome message @@ -10729,8 +10777,8 @@ time to disappear saved No comment provided by engineer. - - saved from %@ + + saved from No comment provided by engineer. @@ -10815,6 +10863,10 @@ last received msg: %2$@ unprotected No comment provided by engineer. + + until you can register a SimpleX domain + No comment provided by engineer. + updated channel profile rcv group event chat item diff --git a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff index f6c20f8cdd..7b34d788cc 100644 --- a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff +++ b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff @@ -40,6 +40,10 @@ %1$@, SimpleX Chat'i destekledi. Rozetin süresi %2$@ tarihinde doldu. badge alert + + %1$02d hrs %2$02d min %3$02d sec + countdown + %@ %@ @@ -155,6 +159,10 @@ %@: copied message info + + %d day + time interval + %d days %d gün @@ -2633,6 +2641,10 @@ Bu senin kendi tek kullanımlık bağlantın! Link oluşturuluyor… No comment provided by engineer. + + Crowdfunding investors can reserve names before the sale starts: [simplex.domains](https://simplex.domains/) + No comment provided by engineer. + Crowdfunding on Wefunder No comment provided by engineer. @@ -6561,20 +6573,11 @@ alert button Başka bir cihaza açık geçiş authentication reason - - Open new channel - new chat action - Open new chat Yeni sohbet aç new chat action - - Open new group - Yeni grup aç - new chat action - Open to accept Kabul etmek için aç @@ -8689,6 +8692,10 @@ copied message info SimpleX name not verified alert title + + SimpleX name sale starts in + No comment provided by engineer. + SimpleX one-time invitation SimpleX tek kullanımlık davet @@ -9791,6 +9798,10 @@ Bu özellik etkinleştirilmeden önce kimlik doğrulamayı tamamlamanız istenec Ayarları güncelleyelim mi? No comment provided by engineer. + + Update the app to register a SimpleX domain + No comment provided by engineer. + Updated conditions Güncellenmiş koşullar @@ -10342,6 +10353,24 @@ alert title Aynı görünen ada sahip bir konuşma profilin zaten var. Lütfen başka bir ad seç. No comment provided by engineer. + + You are a contributor + new chat alert + + + You are a member + Üyesiniz + new chat alert + + + You are a moderator + Moderatörsünüz + new chat alert + + + You are a subscriber + new chat alert + You are already connected to %@. Zaten %@'a bağlısınız. @@ -10384,6 +10413,21 @@ Repeat join request? Katılma isteği tekrarlansın mı? new chat sheet title + + You are an admin + Yöneticisiniz + new chat alert + + + You are an observer + Gözlemcisiniz + new chat alert + + + You are an owner + Sahipsiniz + new chat alert + You are connected to the server used to receive messages from this connection. subscription status explanation @@ -11354,6 +11398,10 @@ pref value iletildi No comment provided by engineer. + + forwarded from + No comment provided by engineer. + group grup @@ -11755,9 +11803,9 @@ time to disappear kaydedildi No comment provided by engineer. - - saved from %@ - %@ tarafından kaydedildi + + saved from + kaydedildi: No comment provided by engineer. @@ -11853,6 +11901,10 @@ son alınan msj: %2$@ korumasız No comment provided by engineer. + + until you can register a SimpleX domain + No comment provided by engineer. + updated channel profile rcv group event chat item diff --git a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff index 1d6c687a94..e2df9d37af 100644 --- a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff @@ -40,6 +40,10 @@ %1$@ підтримував SimpleX Chat. Термін дії значка вичерпався %2$@. badge alert + + %1$02d hrs %2$02d min %3$02d sec + countdown + %@ %@ @@ -155,6 +159,10 @@ %@: copied message info + + %d day + time interval + %d days %d днів @@ -2654,6 +2662,10 @@ This is your own one-time link! Створення посилання… No comment provided by engineer. + + Crowdfunding investors can reserve names before the sale starts: [simplex.domains](https://simplex.domains/) + No comment provided by engineer. + Crowdfunding on Wefunder No comment provided by engineer. @@ -6573,20 +6585,11 @@ alert button Відкрита міграція на інший пристрій authentication reason - - Open new channel - new chat action - Open new chat Відкрити новий чат new chat action - - Open new group - Відкрити нову групу - new chat action - Open to accept Відкрити для прийняття @@ -8699,6 +8702,10 @@ copied message info SimpleX name not verified alert title + + SimpleX name sale starts in + No comment provided by engineer. + SimpleX one-time invitation Одноразове запрошення SimpleX @@ -9799,6 +9806,10 @@ You will be prompted to complete authentication before this feature is enabled.< Оновити налаштування? No comment provided by engineer. + + Update the app to register a SimpleX domain + No comment provided by engineer. + Updated conditions Оновлені умови @@ -10350,6 +10361,24 @@ alert title Ви вже маєте профіль у чаті з таким самим іменем. Будь ласка, виберіть інше ім'я. No comment provided by engineer. + + You are a contributor + new chat alert + + + You are a member + Ви учасник + new chat alert + + + You are a moderator + Ви модератор + new chat alert + + + You are a subscriber + new chat alert + You are already connected to %@. Ви вже підключені до %@. @@ -10392,6 +10421,21 @@ Repeat join request? Повторити запит на приєднання? new chat sheet title + + You are an admin + Ви адмін + new chat alert + + + You are an observer + Ви спостерігач + new chat alert + + + You are an owner + Ви власник + new chat alert + You are connected to the server used to receive messages from this connection. subscription status explanation @@ -11362,6 +11406,10 @@ pref value переслано No comment provided by engineer. + + forwarded from + No comment provided by engineer. + group група @@ -11762,9 +11810,9 @@ time to disappear збережено No comment provided by engineer. - - saved from %@ - збережено з %@ + + saved from + збережено з No comment provided by engineer. @@ -11860,6 +11908,10 @@ last received msg: %2$@ незахищені No comment provided by engineer. + + until you can register a SimpleX domain + No comment provided by engineer. + updated channel profile rcv group event chat item diff --git a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff index c977009785..1f7e0c5fa2 100644 --- a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff +++ b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff @@ -40,6 +40,10 @@ %1$@ 曾是 SimpleX Chat 支持者。徽章已于 %2$@ 过期。 badge alert + + %1$02d hrs %2$02d min %3$02d sec + countdown + %@ %@ @@ -155,6 +159,10 @@ %@: copied message info + + %d day + time interval + %d days %d 天 @@ -2710,6 +2718,10 @@ This is your own one-time link! 创建链接中… No comment provided by engineer. + + Crowdfunding investors can reserve names before the sale starts: [simplex.domains](https://simplex.domains/) + No comment provided by engineer. + Crowdfunding on Wefunder No comment provided by engineer. @@ -6725,21 +6737,11 @@ alert button 打开迁移到另一台设备 authentication reason - - Open new channel - 打开新频道 - new chat action - Open new chat 打开新聊天 new chat action - - Open new group - 打开新群 - new chat action - Open to accept 打开以接受 @@ -8913,6 +8915,10 @@ copied message info SimpleX 名称未验证 alert title + + SimpleX name sale starts in + No comment provided by engineer. + SimpleX one-time invitation SimpleX 一次性邀请 @@ -10056,6 +10062,10 @@ You will be prompted to complete authentication before this feature is enabled.< 更新设置? No comment provided by engineer. + + Update the app to register a SimpleX domain + No comment provided by engineer. + Updated conditions 条款已更新 @@ -10620,6 +10630,25 @@ alert title 您已经有一个显示名相同的聊天资料。请选择另一个名字。 No comment provided by engineer. + + You are a contributor + new chat alert + + + You are a member + 你是成员 + new chat alert + + + You are a moderator + 你是协管 + new chat alert + + + You are a subscriber + 你是订阅者 + new chat alert + You are already connected to %@. 您已经连接到 %@。 @@ -10662,6 +10691,21 @@ Repeat join request? 重复加入请求? new chat sheet title + + You are an admin + 你是管理员 + new chat alert + + + You are an observer + 你是观察者 + new chat alert + + + You are an owner + 你是群主 + new chat alert + You are connected to the server used to receive messages from this connection. 你已连接到用于接收该连接消息的服务器。 @@ -11662,6 +11706,10 @@ pref value 已转发 No comment provided by engineer. + + forwarded from + No comment provided by engineer. + group 群 @@ -12070,9 +12118,9 @@ time to disappear 已保存 No comment provided by engineer. - - saved from %@ - 保存自 %@ + + saved from + 保存自 No comment provided by engineer. @@ -12168,6 +12216,10 @@ last received msg: %2$@ 未受保护 No comment provided by engineer. + + until you can register a SimpleX domain + No comment provided by engineer. + updated channel profile 频道更新了频道资料 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 SE/fr.lproj/Localizable.strings b/apps/ios/SimpleX SE/fr.lproj/Localizable.strings index f20a8cfb99..338248b950 100644 --- a/apps/ios/SimpleX SE/fr.lproj/Localizable.strings +++ b/apps/ios/SimpleX SE/fr.lproj/Localizable.strings @@ -107,5 +107,5 @@ "Wrong database passphrase" = "Mauvaise phrase secrète pour la base de données"; /* No comment provided by engineer. */ -"You can allow sharing in Your privacy / SimpleX Lock settings." = "Vous pouvez autoriser le partage dans Votre vie privée / Réglages de verrouillage SimpleX."; +"You can allow sharing in Your privacy / SimpleX Lock settings." = "Vous pouvez autoriser le partage dans les paramètres Votre confidentialité / Verrouillage SimpleX."; diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 890a85fac1..9d16c5c1f4 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -179,12 +179,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.3-9pyEF8uuax6HMofQg4cpqD-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.3-9pyEF8uuax6HMofQg4cpqD-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.3-9pyEF8uuax6HMofQg4cpqD.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.3-9pyEF8uuax6HMofQg4cpqD.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.6-ErPlbJcf1H358Yh01XLorw-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.6-ErPlbJcf1H358Yh01XLorw-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.6-ErPlbJcf1H358Yh01XLorw.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.6-ErPlbJcf1H358Yh01XLorw.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 +200,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 +536,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 = ""; }; @@ -559,12 +570,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.3-9pyEF8uuax6HMofQg4cpqD-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.3-9pyEF8uuax6HMofQg4cpqD-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.3-9pyEF8uuax6HMofQg4cpqD.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.3-9pyEF8uuax6HMofQg4cpqD.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.6-ErPlbJcf1H358Yh01XLorw-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.6-ErPlbJcf1H358Yh01XLorw-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.6-ErPlbJcf1H358Yh01XLorw.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.6-ErPlbJcf1H358Yh01XLorw.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 +592,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 +756,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.3-9pyEF8uuax6HMofQg4cpqD-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.3-9pyEF8uuax6HMofQg4cpqD.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.6-ErPlbJcf1H358Yh01XLorw-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.6-ErPlbJcf1H358Yh01XLorw.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -772,6 +793,7 @@ 5C2E260D27A30E2400F70299 /* Views */ = { isa = PBXGroup; children = ( + 64C03BF8302F423300072BDE /* Badges */, B76E6C2F2C5C41C300EC11AA /* Contacts */, 5CB0BA8C282711BC00B3292C /* Onboarding */, 3C714775281C080100CB4D4B /* Call */, @@ -822,8 +844,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.3-9pyEF8uuax6HMofQg4cpqD-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.3-9pyEF8uuax6HMofQg4cpqD.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.6-ErPlbJcf1H358Yh01XLorw-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.6-ErPlbJcf1H358Yh01XLorw.a */, ); path = Libraries; sourceTree = ""; @@ -892,6 +914,7 @@ 5CA059BD279559F40002BEB4 = { isa = PBXGroup; children = ( + 642E412B3033084000F04EE1 /* BadgeTestProducts.storekit */, E5C0BBFD2F82BBC000EA7527 /* Debug.xcconfig */, E5C0BBFE2F82BBC900EA7527 /* Release.xcconfig */, 5C55A92D283D0FDE00C4E99E /* sounds */, @@ -1186,6 +1209,23 @@ path = Group; sourceTree = ""; }; + 64C03BF8302F423300072BDE /* Badges */ = { + isa = PBXGroup; + children = ( + 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 = ( @@ -1602,6 +1642,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 +1691,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 */, @@ -1689,6 +1738,7 @@ 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 +2131,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 350; + CURRENT_PROJECT_VERSION = 356; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2131,7 +2181,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 350; + CURRENT_PROJECT_VERSION = 356; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2173,7 +2223,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 350; + CURRENT_PROJECT_VERSION = 356; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2193,7 +2243,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 350; + CURRENT_PROJECT_VERSION = 356; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2218,7 +2268,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 350; + CURRENT_PROJECT_VERSION = 356; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2255,7 +2305,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 350; + CURRENT_PROJECT_VERSION = 356; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2292,7 +2342,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 350; + CURRENT_PROJECT_VERSION = 356; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2343,7 +2393,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 350; + CURRENT_PROJECT_VERSION = 356; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2397,7 +2447,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 350; + CURRENT_PROJECT_VERSION = 356; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2431,7 +2481,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 350; + CURRENT_PROJECT_VERSION = 356; 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 6219b68e55..5c00638da2 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -856,6 +856,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) @@ -867,6 +868,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) @@ -1121,6 +1200,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 e3212ee87a..c6e3bd5d74 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -302,13 +302,65 @@ public enum BadgeStatus: String, Codable { public struct BadgeInfo: Codable, Hashable { public var badgeType: BadgeType - public var badgeExpiry: Date? + 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 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 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 +4730,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 @@ -4685,6 +4748,8 @@ public struct CIFile: Decodable, Hashable { public var fileSource: CryptoFile? 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? @@ -4718,6 +4783,10 @@ public struct CIFile: Decodable, Hashable { } } + public var expired: Bool { + if let fileExpires { fileExpires < Date.now } else { false } + } + public var cancelAction: CancelAction? { get { switch self.fileStatus { @@ -4754,7 +4823,7 @@ public struct CIFile: Decodable, Hashable { case .sndCancelled: true case .sndError: true case .sndWarning: true - case .rcvInvitation: false + case .rcvInvitation: expired case .rcvAccepted: true case .rcvTransfer: true case .rcvAborted: true 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/bg.lproj/Localizable.strings b/apps/ios/bg.lproj/Localizable.strings index 05f5185e4d..4eecbe2fdc 100644 --- a/apps/ios/bg.lproj/Localizable.strings +++ b/apps/ios/bg.lproj/Localizable.strings @@ -3516,10 +3516,10 @@ chat item action */ "Saved" = "Запазено"; /* No comment provided by engineer. */ -"Saved from" = "Запазено от"; +"saved from" = "запазено от"; /* No comment provided by engineer. */ -"saved from" = "запазено от"; +"Saved from" = "Запазено от"; /* message info title */ "Saved message" = "Запазено съобщение"; @@ -4393,6 +4393,9 @@ server test failure */ /* No comment provided by engineer. */ "You already have a chat profile with the same display name. Please choose another name." = "Вече имате чат профил със същото име. Моля, изберете друго име."; +/* new chat alert */ +"You are a member" = "Вие сте член"; + /* No comment provided by engineer. */ "You are already connected to %@." = "Вече сте вече свързани с %@."; @@ -4414,24 +4417,21 @@ server test failure */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "Вече се присъединихте към групата!\nИзпрати отново заявката за присъединяване?"; -/* No comment provided by engineer. */ -"You are invited to group" = "Поканени сте в групата"; - -/* No comment provided by engineer. */ -"you are observer" = "вие сте наблюдател"; +/* new chat alert */ +"You are an admin" = "Вие сте админ"; /* new chat alert */ "You are an observer" = "Вие сте наблюдател"; -/* new chat alert */ -"You are a member" = "Вие сте член"; - -/* new chat alert */ -"You are an admin" = "Вие сте админ"; - /* new chat alert */ "You are an owner" = "Вие сте собственик"; +/* No comment provided by engineer. */ +"You are invited to group" = "Поканени сте в групата"; + +/* No comment provided by engineer. */ +"you are observer" = "вие сте наблюдател"; + /* snd group event chat item */ "you blocked %@" = "вие блокирахте %@"; diff --git a/apps/ios/cs.lproj/Localizable.strings b/apps/ios/cs.lproj/Localizable.strings index cb596480dc..d9d723be36 100644 --- a/apps/ios/cs.lproj/Localizable.strings +++ b/apps/ios/cs.lproj/Localizable.strings @@ -3505,27 +3505,27 @@ server test failure */ /* No comment provided by engineer. */ "You already have a chat profile with the same display name. Please choose another name." = "Již máte profil chatu se stejným zobrazovacím názvem. Zvolte prosím jiné jméno."; +/* new chat alert */ +"You are a member" = "Jste člen"; + /* No comment provided by engineer. */ "You are already connected to %@." = "Již jste připojeni k %@."; +/* new chat alert */ +"You are an admin" = "Jste správce"; + +/* new chat alert */ +"You are an observer" = "Jste pozorovatel"; + +/* new chat alert */ +"You are an owner" = "Jste vlastník"; + /* No comment provided by engineer. */ "You are invited to group" = "Jste pozváni do skupiny"; /* No comment provided by engineer. */ "you are observer" = "jste pozorovatel"; -/* new chat alert */ -"You are an observer" = "Jste pozorovatel"; - -/* new chat alert */ -"You are a member" = "Jste člen"; - -/* new chat alert */ -"You are an admin" = "Jste správce"; - -/* new chat alert */ -"You are an owner" = "Jste vlastník"; - /* No comment provided by engineer. */ "You can accept calls from lock screen, without device and app authentication." = "Můžete přijímat hovory z obrazovky zámku, bez ověření zařízení a aplikace."; diff --git a/apps/ios/de.lproj/Localizable.strings b/apps/ios/de.lproj/Localizable.strings index 924cbc2735..89dbd33ecc 100644 --- a/apps/ios/de.lproj/Localizable.strings +++ b/apps/ios/de.lproj/Localizable.strings @@ -1692,7 +1692,7 @@ server test step */ "Contact preferences" = "Kontakt-Präferenzen"; /* No comment provided by engineer. */ -"Contact requests in groups" = "KONTAKTANFRAGEN VON GRUPPEN"; +"Contact requests in groups" = "Kontaktanfragen in Gruppen"; /* No comment provided by engineer. */ "contact should accept…" = "Kontakt sollte annehmen…"; @@ -1814,6 +1814,12 @@ server test step */ /* No comment provided by engineer. */ "creator" = "Ersteller"; +/* No comment provided by engineer. */ +"Crowdfunding on Wefunder" = "Crowdfunding auf Wefunder"; + +/* No comment provided by engineer. */ +"Crowdfunding on Wefunder." = "Crowdfunding auf Wefunder."; + /* No comment provided by engineer. */ "Current conditions text couldn't be loaded, you can review conditions via this link:" = "Der Text der aktuellen Nutzungsbedingungen konnte nicht geladen werden. Sie können die Nutzungsbedingungen unter diesem Link einsehen:"; @@ -3153,6 +3159,9 @@ servers warning */ /* No comment provided by engineer. */ "Group invitation is no longer valid, it was removed by sender." = "Die Gruppeneinladung ist nicht mehr gültig, da sie vom Absender entfernt wurde."; +/* No comment provided by engineer. */ +"Group invitations" = "Gruppeneinladungen"; + /* No comment provided by engineer. */ "group is deleted" = "Gruppe wurde gelöscht"; @@ -4464,15 +4473,9 @@ alert button */ /* authentication reason */ "Open migration to another device" = "Migration auf ein anderes Gerät öffnen"; -/* new chat action */ -"Open new channel" = "Neuen Kanal öffnen"; - /* new chat action */ "Open new chat" = "Neuen Chat öffnen"; -/* new chat action */ -"Open new group" = "Neue Gruppe öffnen"; - /* No comment provided by engineer. */ "Open Settings" = "Geräte-Einstellungen öffnen"; @@ -5343,10 +5346,10 @@ chat item action */ "Saved" = "Abgespeichert"; /* No comment provided by engineer. */ -"Saved from" = "Abgespeichert von"; +"saved from" = "abgespeichert von"; /* No comment provided by engineer. */ -"saved from" = "abgespeichert von"; +"Saved from" = "Abgespeichert von"; /* message info title */ "Saved message" = "Gespeicherte Nachricht"; @@ -7013,6 +7016,18 @@ alert title */ /* No comment provided by engineer. */ "You already have a chat profile with the same display name. Please choose another name." = "Sie haben schon ein Chat-Profil mit dem gleichen Anzeigenamen. Bitte wählen Sie einen anderen Namen aus."; +/* new chat alert */ +"You are a contributor" = "Sie sind Mitwirkender"; + +/* new chat alert */ +"You are a member" = "Sie sind Mitglied"; + +/* new chat alert */ +"You are a moderator" = "Sie sind Moderator"; + +/* new chat alert */ +"You are a subscriber" = "Sie sind Abonnent"; + /* No comment provided by engineer. */ "You are already connected to %@." = "Sie sind bereits mit %@ verbunden."; @@ -7037,6 +7052,15 @@ alert title */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "Sie sind bereits Mitglied dieser Gruppe!\nVerbindungsanfrage wiederholen?"; +/* new chat alert */ +"You are an admin" = "Sie sind Admin"; + +/* new chat alert */ +"You are an observer" = "Sie sind Beobachter"; + +/* new chat alert */ +"You are an owner" = "Sie sind Eigentümer"; + /* subscription status explanation */ "You are connected to the server used to receive messages from this connection." = "Sie sind mit dem Server verbunden, der für den Empfang von Nachrichten dieser Verbindung genutzt wird."; @@ -7052,27 +7076,6 @@ alert title */ /* No comment provided by engineer. */ "you are observer" = "Sie sind Beobachter"; -/* new chat alert */ -"You are an observer" = "Sie sind Beobachter"; - -/* new chat alert */ -"You are a member" = "Sie sind Mitglied"; - -/* new chat alert */ -"You are a moderator" = "Sie sind Moderator"; - -/* new chat alert */ -"You are an admin" = "Sie sind Admin"; - -/* new chat alert */ -"You are an owner" = "Sie sind Eigentümer"; - -/* new chat alert */ -"You are a subscriber" = "Sie sind Abonnent"; - -/* new chat alert */ -"You are a contributor" = "Sie sind Mitwirkender"; - /* No comment provided by engineer. */ "you are subscriber" = "Sie sind Abonnent"; @@ -7109,6 +7112,12 @@ alert title */ /* notification body */ "You can now chat with %@" = "Sie können nun Nachrichten an %@ versenden"; +/* No comment provided by engineer. */ +"You can now invest in SimpleX Chat" = "Sie können nun in SimpleX-Chat investieren"; + +/* No comment provided by engineer. */ +"You can now invest in SimpleX Chat! 🚀" = "Sie können nun in SimpleX-Chat investieren! 🚀"; + /* No comment provided by engineer. */ "You can send messages to %@ from Archived contacts." = "Sie können aus den archivierten Kontakten heraus Nachrichten an %@ versenden."; @@ -7209,7 +7218,7 @@ alert title */ "You rejected group invitation" = "Sie haben die Gruppeneinladung abgelehnt"; /* snd group event chat item */ -"you removed %@" = "entfernt %@ aus der Gruppe"; +"you removed %@" = "Sie haben %@ aus der Gruppe entfernt"; /* No comment provided by engineer. */ "You sent group invitation" = "Sie haben eine Gruppeneinladung gesendet"; diff --git a/apps/ios/es.lproj/Localizable.strings b/apps/ios/es.lproj/Localizable.strings index 02f6415cca..da5ba6c929 100644 --- a/apps/ios/es.lproj/Localizable.strings +++ b/apps/ios/es.lproj/Localizable.strings @@ -1814,6 +1814,12 @@ server test step */ /* No comment provided by engineer. */ "creator" = "creador"; +/* No comment provided by engineer. */ +"Crowdfunding on Wefunder" = "Crowdfunding en Wefunder"; + +/* No comment provided by engineer. */ +"Crowdfunding on Wefunder." = "Crowdfunding en Wefunder."; + /* No comment provided by engineer. */ "Current conditions text couldn't be loaded, you can review conditions via this link:" = "El texto con las condiciones actuales no se ha podido cargar, puedes revisar las condiciones en el siguiente enlace:"; @@ -3153,6 +3159,9 @@ servers warning */ /* No comment provided by engineer. */ "Group invitation is no longer valid, it was removed by sender." = "La invitación al grupo ya no es válida, ha sido eliminada por el remitente."; +/* No comment provided by engineer. */ +"Group invitations" = "Invitaciones en grupo"; + /* No comment provided by engineer. */ "group is deleted" = "el grupo ha sido eliminado"; @@ -4464,15 +4473,9 @@ alert button */ /* authentication reason */ "Open migration to another device" = "Abrir menú migración a otro dispositivo"; -/* new chat action */ -"Open new channel" = "Abrir canal nuevo"; - /* new chat action */ "Open new chat" = "Abrir chat nuevo"; -/* new chat action */ -"Open new group" = "Abrir grupo nuevo"; - /* No comment provided by engineer. */ "Open Settings" = "Abrir Configuración"; @@ -5343,10 +5346,10 @@ chat item action */ "Saved" = "Guardado"; /* No comment provided by engineer. */ -"Saved from" = "Guardado desde"; +"saved from" = "Guardado desde"; /* No comment provided by engineer. */ -"saved from" = "Guardado desde"; +"Saved from" = "Guardado desde"; /* message info title */ "Saved message" = "Mensaje guardado"; @@ -7013,6 +7016,18 @@ alert title */ /* No comment provided by engineer. */ "You already have a chat profile with the same display name. Please choose another name." = "Ya tienes un perfil con este nombre mostrado. Por favor, elige otro nombre."; +/* new chat alert */ +"You are a contributor" = "Eres colaborador"; + +/* new chat alert */ +"You are a member" = "Eres miembro"; + +/* new chat alert */ +"You are a moderator" = "Eres moderador"; + +/* new chat alert */ +"You are a subscriber" = "Eres suscriptor"; + /* No comment provided by engineer. */ "You are already connected to %@." = "Ya estás conectado con %@."; @@ -7037,6 +7052,15 @@ alert title */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "¡En proceso de unirte al grupo!\n¿Repetir solicitud de admisión?"; +/* new chat alert */ +"You are an admin" = "Eres administrador"; + +/* new chat alert */ +"You are an observer" = "Eres observador"; + +/* new chat alert */ +"You are an owner" = "Eres propietario"; + /* subscription status explanation */ "You are connected to the server used to receive messages from this connection." = "Estás conectado al servidor usado para recibir mensajes de esta conexión."; @@ -7052,27 +7076,6 @@ alert title */ /* No comment provided by engineer. */ "you are observer" = "Tu rol es observador"; -/* new chat alert */ -"You are an observer" = "Eres observador"; - -/* new chat alert */ -"You are a member" = "Eres miembro"; - -/* new chat alert */ -"You are a moderator" = "Eres moderador"; - -/* new chat alert */ -"You are an admin" = "Eres administrador"; - -/* new chat alert */ -"You are an owner" = "Eres propietario"; - -/* new chat alert */ -"You are a subscriber" = "Eres suscriptor"; - -/* new chat alert */ -"You are a contributor" = "Eres colaborador"; - /* No comment provided by engineer. */ "you are subscriber" = "eres suscriptor"; @@ -7109,6 +7112,12 @@ alert title */ /* notification body */ "You can now chat with %@" = "Ya puedes chatear con %@"; +/* No comment provided by engineer. */ +"You can now invest in SimpleX Chat" = "Ahora puedes invertir en SimpleX Chat"; + +/* No comment provided by engineer. */ +"You can now invest in SimpleX Chat! 🚀" = "Ahora puedes invertir en SimpleX Chat! 🚀"; + /* No comment provided by engineer. */ "You can send messages to %@ from Archived contacts." = "Puedes enviar mensajes a %@ desde Contactos archivados."; diff --git a/apps/ios/fi.lproj/Localizable.strings b/apps/ios/fi.lproj/Localizable.strings index 8abb4e69f8..0ec2565331 100644 --- a/apps/ios/fi.lproj/Localizable.strings +++ b/apps/ios/fi.lproj/Localizable.strings @@ -3141,27 +3141,27 @@ server test failure */ /* No comment provided by engineer. */ "You already have a chat profile with the same display name. Please choose another name." = "Sinulla on jo keskusteluprofiili samalla näyttönimellä. Valitse toinen nimi."; +/* new chat alert */ +"You are a member" = "Olet jäsen"; + /* No comment provided by engineer. */ "You are already connected to %@." = "Olet jo muodostanut yhteyden %@:n kanssa."; +/* new chat alert */ +"You are an admin" = "Olet ylläpitäjä"; + +/* new chat alert */ +"You are an observer" = "Olet tarkkailija"; + +/* new chat alert */ +"You are an owner" = "Olet omistaja"; + /* No comment provided by engineer. */ "You are invited to group" = "Sinut on kutsuttu ryhmään"; /* No comment provided by engineer. */ "you are observer" = "olet tarkkailija"; -/* new chat alert */ -"You are an observer" = "Olet tarkkailija"; - -/* new chat alert */ -"You are a member" = "Olet jäsen"; - -/* new chat alert */ -"You are an admin" = "Olet ylläpitäjä"; - -/* new chat alert */ -"You are an owner" = "Olet omistaja"; - /* No comment provided by engineer. */ "You can accept calls from lock screen, without device and app authentication." = "Voit vastaanottaa puheluita lukitusnäytöltä ilman laitteen ja sovelluksen todennusta."; diff --git a/apps/ios/fr.lproj/Localizable.strings b/apps/ios/fr.lproj/Localizable.strings index 6816de9f5f..3037b29aa9 100644 --- a/apps/ios/fr.lproj/Localizable.strings +++ b/apps/ios/fr.lproj/Localizable.strings @@ -5,7 +5,7 @@ "_italic_" = "\\_italique_"; /* No comment provided by engineer. */ -"- connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)!\n- delivery receipts (up to 20 members).\n- faster and more stable." = "- connexion au [service d'annuaire](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA) !\n- les accusés de réception (jusqu'à 20 membres).\n- plus rapide et plus stable."; +"- connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)!\n- delivery receipts (up to 20 members).\n- faster and more stable." = "- connexion au [service d'annuaire](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BÊTA) !\n- les accusés de réception (jusqu'à 20 membres).\n- plus rapide et plus stable."; /* No comment provided by engineer. */ "- more stable message delivery.\n- a bit better groups.\n- and more!" = "- une diffusion plus stable des messages.\n- des groupes un peu plus performants.\n- et bien d'autres choses encore !"; @@ -265,7 +265,7 @@ channel relay bar */ "%lld contact(s) selected" = "%lld contact·s sélectionné·s"; /* No comment provided by engineer. */ -"%lld file(s) with total size of %@" = "%lld fichier·s pour une taille totale de %@"; +"%lld file(s) with total size of %@" = "%lld fichier(s) pour une taille totale de %@"; /* No comment provided by engineer. */ "%lld group events" = "%lld événements de groupe"; @@ -602,7 +602,7 @@ swipe action */ "All app data is deleted." = "Toutes les données de l'application sont supprimées."; /* No comment provided by engineer. */ -"All chats and messages will be deleted - this cannot be undone!" = "Toutes les conversations et tous les messages seront supprimés - il est impossible de revenir en arrière !"; +"All chats and messages will be deleted - this cannot be undone!" = "Toutes les conversations et tous les messages seront supprimés — cette action est irréversible !"; /* alert message */ "All chats will be removed from the list %@, and the list deleted." = "Toutes les conversations seront supprimées de la liste %@ et la liste sera supprimée."; @@ -626,10 +626,10 @@ swipe action */ "All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages." = "Tous les messages et fichiers sont envoyés **chiffrés de bout en bout**, avec une sécurité post-quantique dans les messages directs."; /* No comment provided by engineer. */ -"All messages will be deleted - this cannot be undone!" = "Tous les messages seront supprimés - il n'est pas possible de revenir en arrière !"; +"All messages will be deleted - this cannot be undone!" = "Tous les messages seront supprimés — cette action est irréversible !"; /* No comment provided by engineer. */ -"All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you." = "Tous les messages seront supprimés - impossible de revenir en arrière ! Les messages seront supprimés UNIQUEMENT pour vous."; +"All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you." = "Tous les messages seront supprimés — cette action est irréversible ! Les messages seront supprimés UNIQUEMENT pour vous."; /* No comment provided by engineer. */ "All new messages from %@ will be hidden!" = "Tous les nouveaux messages de %@ seront cachés !"; @@ -1062,7 +1062,7 @@ marked deleted chat item preview text */ "Businesses" = "Entreprises"; /* No comment provided by engineer. */ -"By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Par profil de messagerie (par défaut) ou [par connexion](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."; +"By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Par profil de messagerie (par défaut) ou [par connexion](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BÊTA)."; /* No comment provided by engineer. */ "call" = "appeler"; @@ -1240,10 +1240,10 @@ alert subtitle */ "Channel webpage" = "Page Web du canal"; /* No comment provided by engineer. */ -"Channel will be deleted for all subscribers - this cannot be undone!" = "Le canal sera supprimé pour tous les abonné·es ; ceci ne peut pas être annulé !"; +"Channel will be deleted for all subscribers - this cannot be undone!" = "Le canal sera supprimé pour tous les abonnés — cette action est irréversible !"; /* No comment provided by engineer. */ -"Channel will be deleted for you - this cannot be undone!" = "Le canal sera supprimé pour vous ; ceci ne peut pas être annulé !"; +"Channel will be deleted for you - this cannot be undone!" = "Le canal sera supprimé pour vous — cette action est irréversible !"; /* alert message */ "Channel will start working with %d of %d relays. Continue?" = "Le canal commencera à fonctionner avec %1$d relais sur %2$d. Continuer ?"; @@ -1321,10 +1321,10 @@ alert subtitle */ "Chat theme" = "Thème de la conversation"; /* No comment provided by engineer. */ -"Chat will be deleted for all members - this cannot be undone!" = "La conversation sera supprimée pour tous les membres - cela ne peut pas être annulé !"; +"Chat will be deleted for all members - this cannot be undone!" = "La conversation sera supprimée pour tous les membres — cette action est irréversible !"; /* No comment provided by engineer. */ -"Chat will be deleted for you - this cannot be undone!" = "La conversation sera supprimée pour vous - il n'est pas possible de revenir en arrière !"; +"Chat will be deleted for you - this cannot be undone!" = "La conversation sera supprimée pour vous — cette action est irréversible !"; /* chat feature chat toolbar */ @@ -1698,7 +1698,7 @@ server test step */ "contact should accept…" = "Le contact devrait accepter…"; /* No comment provided by engineer. */ -"Contact will be deleted - this cannot be undone!" = "Le contact sera supprimé - il n'est pas possible de revenir en arrière !"; +"Contact will be deleted - this cannot be undone!" = "Le contact sera supprimé — cette action est irréversible !"; /* No comment provided by engineer. */ "Contacts" = "Contacts"; @@ -1814,6 +1814,12 @@ server test step */ /* No comment provided by engineer. */ "creator" = "créateur"; +/* No comment provided by engineer. */ +"Crowdfunding on Wefunder" = "Financement participatif sur Wefunder"; + +/* No comment provided by engineer. */ +"Crowdfunding on Wefunder." = "Financement participatif sur Wefunder."; + /* No comment provided by engineer. */ "Current conditions text couldn't be loaded, you can review conditions via this link:" = "Le texte sur les conditions actuelles n'a pas pu être chargé. Vous pouvez consulter les conditions en cliquant sur ce lien :"; @@ -2151,7 +2157,7 @@ alert button */ "Details" = "Détails"; /* No comment provided by engineer. */ -"Developer" = "Outils du développeur"; +"Developer" = "Outils développeur"; /* No comment provided by engineer. */ "Developer options" = "Options pour les développeurs"; @@ -2160,10 +2166,10 @@ alert button */ "Device" = "Appareil"; /* No comment provided by engineer. */ -"Device authentication is disabled. Turning off SimpleX Lock." = "L'authentification de l'appareil est désactivée. Désactivation de SimpleX Lock."; +"Device authentication is disabled. Turning off SimpleX Lock." = "L'authentification de l'appareil est désactivée. Désactivation du Verrouillage SimpleX."; /* No comment provided by engineer. */ -"Device authentication is not enabled. You can turn on SimpleX Lock via Settings, once you enable device authentication." = "L'authentification de l'appareil n'est pas activée. Vous pouvez activer SimpleX Lock via Paramètres, une fois que vous avez activé l'authentification de l'appareil."; +"Device authentication is not enabled. You can turn on SimpleX Lock via Settings, once you enable device authentication." = "L'authentification de l'appareil n'est pas activée. Vous pouvez activer le Verrouillage SimpleX dans les Paramètres une fois l'authentification de l'appareil activée."; /* No comment provided by engineer. */ "different migration in the app/database: %@ / %@" = "migration différente dans l'app/la base de données : %@ / %@"; @@ -2202,7 +2208,7 @@ alert button */ "Disable for all" = "Désactiver pour tous"; /* authentication reason */ -"Disable SimpleX Lock" = "Désactiver SimpleX Lock"; +"Disable SimpleX Lock" = "Désactiver le Verrouillage SimpleX"; /* No comment provided by engineer. */ "disabled" = "désactivé"; @@ -2362,7 +2368,7 @@ chat item action */ "Enable (keep overrides)" = "Activer (conserver les remplacements)"; /* channel creation warning */ -"Enable at least one chat relay in Network & Servers." = "Activez au moins un relais de messagerie dans Réseaux et serveurs."; +"Enable at least one chat relay in Network & Servers." = "Activez au moins un relais de messagerie dans Réseau et serveurs."; /* alert title */ "Enable automatic message deletion?" = "Activer la suppression automatique des messages ?"; @@ -2377,13 +2383,13 @@ chat item action */ "Enable disappearing messages by default." = "Activer les messages éphémères par défaut."; /* No comment provided by engineer. */ -"Enable Flux in Network & servers settings for better metadata privacy." = "Activez Flux dans les paramètres du réseau et des serveurs pour une meilleure confidentialité des métadonnées."; +"Enable Flux in Network & servers settings for better metadata privacy." = "Activez Flux dans les paramètres Réseau et serveurs pour une meilleure confidentialité des métadonnées."; /* No comment provided by engineer. */ "Enable for all" = "Activer pour tous"; /* No comment provided by engineer. */ -"Enable in direct chats (BETA)!" = "Activer dans les conversations directes (BETA) !"; +"Enable in direct chats (BETA)!" = "Activer dans les conversations directes (BÊTA) !"; /* No comment provided by engineer. */ "Enable instant notifications?" = "Activer les notifications instantanées ?"; @@ -2404,7 +2410,7 @@ chat item action */ "Enable self-destruct passcode" = "Activer le code d'autodestruction"; /* authentication reason */ -"Enable SimpleX Lock" = "Activer SimpleX Lock"; +"Enable SimpleX Lock" = "Activer le Verrouillage SimpleX"; /* No comment provided by engineer. */ "Enable TCP keep-alive" = "Activer le TCP keep-alive"; @@ -3106,7 +3112,7 @@ servers warning */ "Get notified when mentioned." = "Soyez averti·e quand vous êtes mentionné·e."; /* No comment provided by engineer. */ -"Get SimpleX name (BETA)" = "Obtenir un nom SimpleX (BETA)"; +"Get SimpleX name (BETA)" = "Obtenir un nom SimpleX (BÊTA)"; /* No comment provided by engineer. */ "Get started" = "Commençons"; @@ -3153,6 +3159,9 @@ servers warning */ /* No comment provided by engineer. */ "Group invitation is no longer valid, it was removed by sender." = "L'invitation du groupe n'est plus valide, elle a été supprimé par l'expéditeur."; +/* No comment provided by engineer. */ +"Group invitations" = "Invitations de groupe"; + /* No comment provided by engineer. */ "group is deleted" = "le groupe est supprimé"; @@ -3190,10 +3199,10 @@ servers warning */ "Group welcome message" = "Message d'accueil du groupe"; /* No comment provided by engineer. */ -"Group will be deleted for all members - this cannot be undone!" = "Le groupe va être supprimé pour tout les membres - impossible de revenir en arrière !"; +"Group will be deleted for all members - this cannot be undone!" = "Le groupe va être supprimé pour tout les membres — cette action est irréversible !"; /* No comment provided by engineer. */ -"Group will be deleted for you - this cannot be undone!" = "Le groupe va être supprimé pour vous - impossible de revenir en arrière !"; +"Group will be deleted for you - this cannot be undone!" = "Le groupe va être supprimé pour vous — cette action est irréversible !"; /* No comment provided by engineer. */ "Groups" = "Groupes"; @@ -3775,16 +3784,16 @@ servers warning */ "Member is deleted - can't accept request" = "Le membre est supprimé ; impossible d'accepter la demande"; /* alert message */ -"Member messages will be deleted - this cannot be undone!" = "Les messages des membres seront supprimés ; ceci ne peut pas être annulé !"; +"Member messages will be deleted - this cannot be undone!" = "Les messages des membres seront supprimés — cette action est irréversible !"; /* chat feature */ "Member reports" = "Signalements des membres"; /* alert message */ -"Member will be removed from chat - this cannot be undone!" = "Le membre sera retiré de la conversation - cette action est irréversible !"; +"Member will be removed from chat - this cannot be undone!" = "Le membre sera retiré de la conversation — cette action est irréversible !"; /* alert message */ -"Member will be removed from group - this cannot be undone!" = "Ce membre sera retiré du groupe - impossible de revenir en arrière !"; +"Member will be removed from group - this cannot be undone!" = "Ce membre sera retiré du groupe — cette action est irréversible !"; /* alert message */ "Member will join the group, accept member?" = "Le membre rejoindra le groupe ; accepter le membre ?"; @@ -4273,7 +4282,7 @@ servers warning */ "None of your servers are set to resolve SimpleX names. Configure servers, or use a connection link." = "Aucun serveur de résolution de noms SimpleX n'est configuré. Configurez des serveurs ou utilisez un lien de connexion."; /* No comment provided by engineer. */ -"Not a better lock on someone else's door. Not a nicer landlord that respects your privacy, but still keeps the record of all visitors. You are not a guest. You are home. No king can enter it - you are sovereign." = "Ce n’est pas une meilleure serrure sur la porte de quelqu’un d’autre. Ce n’est pas un propriétaire plus aimable qui respecte votre vie privée, mais qui tient tout de même un registre de tous les visiteurs. Vous n’êtes pas un·e invité·e, vous êtes chez vous. Aucun roi ne peut y entrer : c’est vous le souverain."; +"Not a better lock on someone else's door. Not a nicer landlord that respects your privacy, but still keeps the record of all visitors. You are not a guest. You are home. No king can enter it - you are sovereign." = "Ce n’est pas une meilleure serrure sur la porte de quelqu'un d'autre. Ni un propriétaire plus attentionné qui respecte votre vie privée, mais conserve toujours le registre de tous les visiteurs. Vous n'êtes pas un invité. Vous êtes chez vous. Aucun roi ne peut y entrer — vous êtes souverain."; /* alert title */ "Not all relays connected" = "Les relais ne sont pas tous connectés"; @@ -4464,15 +4473,9 @@ alert button */ /* authentication reason */ "Open migration to another device" = "Ouvrir le transfert vers un autre appareil"; -/* new chat action */ -"Open new channel" = "Ouvrir un nouveau canal"; - /* new chat action */ "Open new chat" = "Ouvrir une nouvelle conversation"; -/* new chat action */ -"Open new group" = "Ouvrir le nouveau groupe"; - /* No comment provided by engineer. */ "Open Settings" = "Ouvrir les Paramètres"; @@ -4711,7 +4714,7 @@ alert button */ "Previously connected servers" = "Serveurs précédemment connectés"; /* No comment provided by engineer. */ -"Privacy for your customers." = "Respect de la vie privée de vos clients."; +"Privacy for your customers." = "Confidentialité pour vos clients."; /* No comment provided by engineer. */ "Privacy policy and conditions of use." = "Politique de confidentialité et conditions d'utilisation."; @@ -5005,7 +5008,7 @@ swipe action */ "Relay test failed!" = "Échec du test de relais !"; /* alert message */ -"Relay will be removed from channel - this cannot be undone!" = "Le relais sera supprimé du canal ; cette action est irréversible !"; +"Relay will be removed from channel - this cannot be undone!" = "Le relais sera supprimé du canal — cette action est irréversible !"; /* alert message */ "Relays added: %@." = "Relais ajoutés : %@."; @@ -5215,7 +5218,7 @@ swipe action */ "Review members" = "Contrôler les membres"; /* admission stage description */ -"Review members before admitting (\"knocking\")." = "Contrôler les membres avant de les admettre (« toquer »)."; +"Review members before admitting (\"knocking\")." = "Examiner les membres avant leur admission (« frapper à la porte »)."; /* No comment provided by engineer. */ "reviewed by admins" = "révisé par les admins"; @@ -5343,10 +5346,10 @@ chat item action */ "Saved" = "Enregistré"; /* No comment provided by engineer. */ -"Saved from" = "Enregistré depuis"; +"saved from" = "enregistré à partir de"; /* No comment provided by engineer. */ -"saved from" = "enregistré à partir de"; +"Saved from" = "Enregistré depuis"; /* message info title */ "Saved message" = "Message enregistré"; @@ -5643,7 +5646,7 @@ chat item action */ "Servers info" = "Infos serveurs"; /* No comment provided by engineer. */ -"Servers statistics will be reset - this cannot be undone!" = "Les statistiques des serveurs seront réinitialisées - il n'est pas possible de revenir en arrière !"; +"Servers statistics will be reset - this cannot be undone!" = "Les statistiques des serveurs seront réinitialisées — cette action est irréversible !"; /* No comment provided by engineer. */ "Session code" = "Code de session"; @@ -5873,16 +5876,16 @@ copied message info */ "SimpleX links not allowed" = "Les liens SimpleX ne sont pas autorisés"; /* No comment provided by engineer. */ -"SimpleX Lock" = "SimpleX Lock"; +"SimpleX Lock" = "Verrouillage SimpleX"; /* No comment provided by engineer. */ -"SimpleX Lock mode" = "Mode de SimpleX Lock"; +"SimpleX Lock mode" = "Mode de Verrouillage SimpleX"; /* No comment provided by engineer. */ -"SimpleX Lock not enabled!" = "SimpleX Lock n'est pas activé !"; +"SimpleX Lock not enabled!" = "Le Verrouillage SimpleX n'est pas activé !"; /* No comment provided by engineer. */ -"SimpleX Lock turned on" = "SimpleX Lock activé"; +"SimpleX Lock turned on" = "Verrouillage SimpleX activé"; /* No comment provided by engineer. */ "SimpleX name" = "Nom SimpleX"; @@ -5900,7 +5903,7 @@ copied message info */ "SimpleX protocols reviewed by Trail of Bits." = "Protocoles SimpleX audité par Trail of Bits."; /* No comment provided by engineer. */ -"SimpleX public names (BETA)" = "Noms publics SimpleX (BETA)"; +"SimpleX public names (BETA)" = "Noms publics SimpleX (BÊTA)"; /* simplex link type */ "SimpleX relay address" = "Adresse relais SimpleX"; @@ -6039,7 +6042,7 @@ report reason */ "Subscriber reports" = "Signalements d'abonnés"; /* alert message */ -"Subscriber will be removed from channel - this cannot be undone!" = "L'abonné sera supprimé du canal ; cette action est irréversible !"; +"Subscriber will be removed from channel - this cannot be undone!" = "L'abonné sera supprimé du canal — cette action est irréversible !"; /* No comment provided by engineer. */ "Subscribers" = "Abonnés"; @@ -6328,16 +6331,16 @@ server test failure */ "They can be overridden in contact and group settings." = "Ils peuvent être modifiés dans les paramètres des contacts et des groupes."; /* No comment provided by engineer. */ -"This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain." = "Cette action ne peut être annulée - tous les fichiers et médias reçus et envoyés seront supprimés. Les photos à faible résolution seront conservées."; +"This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain." = "Cette action est irréversible — tous les fichiers et médias reçus et envoyés seront supprimés. Les photos à faible résolution seront conservées."; /* No comment provided by engineer. */ -"This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes." = "Cette action ne peut être annulée - les messages envoyés et reçus avant la date sélectionnée seront supprimés. Cela peut prendre plusieurs minutes."; +"This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes." = "Cette action est irréversible — les messages envoyés et reçus avant la date sélectionnée seront supprimés. Cela peut prendre plusieurs minutes."; /* alert message */ -"This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted." = "Cette action est irréversible : les messages envoyés et reçus dans cette conversation avant la date sélectionnée seront supprimés."; +"This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted." = "Cette action est irréversible — les messages envoyés et reçus dans cette conversation avant la date sélectionnée seront supprimés."; /* No comment provided by engineer. */ -"This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost." = "Cette action ne peut être annulée - votre profil, vos contacts, vos messages et vos fichiers seront irréversiblement perdus."; +"This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost." = "Cette action est irréversible — votre profil, vos contacts, vos messages et vos fichiers seront irréversiblement perdus."; /* badge alert */ "This badge could not be verified and may not be genuine." = "Ce badge n'a pas pu être vérifié et pourrait ne pas être authentique."; @@ -6419,7 +6422,7 @@ alert subtitle */ "To protect timezone, image/voice files use UTC." = "Pour préserver le fuseau horaire, les fichiers image/voix utilisent le système UTC."; /* No comment provided by engineer. */ -"To protect your information, turn on SimpleX Lock.\nYou will be prompted to complete authentication before this feature is enabled." = "Pour protéger vos informations, activez la fonction SimpleX Lock.\nVous serez invité à confirmer l'authentification avant que cette fonction ne soit activée."; +"To protect your information, turn on SimpleX Lock.\nYou will be prompted to complete authentication before this feature is enabled." = "Pour protéger vos informations, activez le Verrouillage SimpleX.\nUne authentification vous sera demandée avant que cette fonctionnalité ne soit activée."; /* No comment provided by engineer. */ "To protect your IP address, private routing uses your SMP servers to deliver messages." = "Pour protéger votre adresse IP, le routage privé utilise vos serveurs SMP pour délivrer les messages."; @@ -7013,6 +7016,18 @@ alert title */ /* No comment provided by engineer. */ "You already have a chat profile with the same display name. Please choose another name." = "Vous avez déjà un profil de messagerie avec le même nom d’affichage. Veuillez choisir un autre nom."; +/* new chat alert */ +"You are a contributor" = "Vous êtes contributeur"; + +/* new chat alert */ +"You are a member" = "Vous êtes membre"; + +/* new chat alert */ +"You are a moderator" = "Vous êtes modérateur"; + +/* new chat alert */ +"You are a subscriber" = "Vous êtes abonné·e"; + /* No comment provided by engineer. */ "You are already connected to %@." = "Vous êtes déjà connecté·e à %@ via ce lien."; @@ -7037,6 +7052,15 @@ alert title */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "Vous êtes déjà membre de ce groupe !\nRépéter la demande d'adhésion ?"; +/* new chat alert */ +"You are an admin" = "Vous êtes admin"; + +/* new chat alert */ +"You are an observer" = "Vous êtes observateur"; + +/* new chat alert */ +"You are an owner" = "Vous êtes propriétaire"; + /* subscription status explanation */ "You are connected to the server used to receive messages from this connection." = "Vous êtes connecté au serveur utilisé pour recevoir les messages de cette connexion."; @@ -7052,27 +7076,6 @@ alert title */ /* No comment provided by engineer. */ "you are observer" = "vous êtes observateur"; -/* new chat alert */ -"You are an observer" = "Vous êtes observateur"; - -/* new chat alert */ -"You are a member" = "Vous êtes membre"; - -/* new chat alert */ -"You are a moderator" = "Vous êtes modérateur"; - -/* new chat alert */ -"You are an admin" = "Vous êtes admin"; - -/* new chat alert */ -"You are an owner" = "Vous êtes propriétaire"; - -/* new chat alert */ -"You are a subscriber" = "Vous êtes abonné·e"; - -/* new chat alert */ -"You are a contributor" = "Vous êtes contributeur"; - /* No comment provided by engineer. */ "you are subscriber" = "vous êtes abonné·e"; @@ -7095,7 +7098,7 @@ alert title */ "You can enable later via Settings" = "Vous pouvez l'activer ultérieurement via Paramètres"; /* No comment provided by engineer. */ -"You can enable them later via app Your privacy settings." = "Vous pourrez les activer plus tard dans les paramètres « Votre vie privée »."; +"You can enable them later via app Your privacy settings." = "Vous pouvez les activer plus tard dans les paramètres de confidentialité de l'application."; /* No comment provided by engineer. */ "You can give another try." = "Vous pouvez faire un nouvel essai."; @@ -7109,6 +7112,12 @@ alert title */ /* notification body */ "You can now chat with %@" = "Vous pouvez maintenant envoyer des messages à %@"; +/* No comment provided by engineer. */ +"You can now invest in SimpleX Chat" = "Vous pouvez désormais investir dans SimpleX Chat"; + +/* No comment provided by engineer. */ +"You can now invest in SimpleX Chat! 🚀" = "Vous pouvez désormais investir dans SimpleX Chat ! 🚀"; + /* No comment provided by engineer. */ "You can send messages to %@ from Archived contacts." = "Vous pouvez envoyer des messages à %@ à partir des contacts archivés."; @@ -7137,7 +7146,7 @@ alert title */ "You can support SimpleX starting from v7 of the app." = "Vous pouvez soutenir SimpleX à partir de la version 7 de l'application."; /* No comment provided by engineer. */ -"You can turn on SimpleX Lock via Settings." = "Vous pouvez activer SimpleX Lock dans les Paramètres."; +"You can turn on SimpleX Lock via Settings." = "Vous pouvez activer le Verrouillage SimpleX dans les Paramètres."; /* No comment provided by engineer. */ "You can use markdown to format messages:" = "Vous pouvez utiliser le format markdown pour mettre en forme les messages :"; @@ -7338,7 +7347,7 @@ alert title */ "Your preferences" = "Vos préférences"; /* No comment provided by engineer. */ -"Your privacy" = "Votre vie privée"; +"Your privacy" = "Votre confidentialité"; /* No comment provided by engineer. */ "Your profile" = "Votre profil"; diff --git a/apps/ios/hu.lproj/Localizable.strings b/apps/ios/hu.lproj/Localizable.strings index 004111f914..6806e210f9 100644 --- a/apps/ios/hu.lproj/Localizable.strings +++ b/apps/ios/hu.lproj/Localizable.strings @@ -1062,7 +1062,7 @@ marked deleted chat item preview text */ "Businesses" = "Üzleti"; /* No comment provided by engineer. */ -"By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "A csevegési profillal (alapértelmezett), vagy a [kapcsolattal] (https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (béta)."; +"By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "A csevegési profillal (alapértelmezés), vagy a [kapcsolattal](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (béta)."; /* No comment provided by engineer. */ "call" = "hívás"; @@ -1814,6 +1814,12 @@ server test step */ /* No comment provided by engineer. */ "creator" = "készítő"; +/* No comment provided by engineer. */ +"Crowdfunding on Wefunder" = "Közösségi finanszírozás a Wefunder oldalon"; + +/* No comment provided by engineer. */ +"Crowdfunding on Wefunder." = "Közösségi finanszírozás a Wefunder oldalon."; + /* No comment provided by engineer. */ "Current conditions text couldn't be loaded, you can review conditions via this link:" = "A jelenlegi feltételek szövegét nem sikerült betölteni, a feltételeket a következő hivatkozáson keresztül vizsgálhatja felül:"; @@ -1921,13 +1927,13 @@ server test step */ /* delete after time pref value */ -"default (%@)" = "alapértelmezett (%@)"; +"default (%@)" = "alapértelmezés (%@)"; /* No comment provided by engineer. */ -"default (no)" = "alapértelmezett (nem)"; +"default (no)" = "alapértelmezés (nem)"; /* No comment provided by engineer. */ -"default (yes)" = "alapértelmezett (igen)"; +"default (yes)" = "alapértelmezés (igen)"; /* alert action swipe action */ @@ -2374,7 +2380,7 @@ chat item action */ "Enable chats with admins?" = "Engedélyezi a csevegést az adminisztrátorokkal?"; /* No comment provided by engineer. */ -"Enable disappearing messages by default." = "Eltűnő üzenetek engedélyezése alapértelmezetten."; +"Enable disappearing messages by default." = "Eltűnő üzenetek engedélyezése alapértelmezésként."; /* No comment provided by engineer. */ "Enable Flux in Network & servers settings for better metadata privacy." = "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."; @@ -3153,6 +3159,9 @@ servers warning */ /* No comment provided by engineer. */ "Group invitation is no longer valid, it was removed by sender." = "A csoportmeghívó már nem érvényes, a küldője eltávolította."; +/* No comment provided by engineer. */ +"Group invitations" = "Meghívások csoportokba"; + /* No comment provided by engineer. */ "group is deleted" = "csoport törölve"; @@ -3397,7 +3406,7 @@ servers warning */ "indirect (%d)" = "közvetett (%d)"; /* chat item action */ -"Info" = "Információ"; +"Info" = "Adatok"; /* No comment provided by engineer. */ "Initial role" = "Kezdeti szerepkör"; @@ -3850,7 +3859,7 @@ servers warning */ "Message may be delivered later if member becomes active." = "Az üzenet később is kézbesíthető, ha a tag aktívvá válik."; /* No comment provided by engineer. */ -"Message queue info" = "Üzenet várólista-információi"; +"Message queue info" = "Üzenet várólistaadatai"; /* chat feature */ "Message reactions" = "Üzenetreakciók"; @@ -4177,7 +4186,7 @@ servers warning */ "No contacts to add" = "Nincs hozzáadandó partner"; /* No comment provided by engineer. */ -"No delivery information" = "Nincs kézbesítési információ"; +"No delivery information" = "Nincsenek kézbesítési adatok"; /* No comment provided by engineer. */ "No device token!" = "Nincs készüléktoken!"; @@ -4198,7 +4207,7 @@ servers warning */ "No history" = "Nincsenek előzmények"; /* No comment provided by engineer. */ -"No info, try to reload" = "Nincs információ, próbálja meg újratölteni"; +"No info, try to reload" = "Nincsenek adatok, próbálja meg újratölteni"; /* servers error */ "No media & file servers." = "Nincsenek fájl- és médiakiszolgálók."; @@ -4464,15 +4473,9 @@ alert button */ /* authentication reason */ "Open migration to another device" = "Átköltöztetés indítása egy másik eszközre"; -/* new chat action */ -"Open new channel" = "Új csatorna megnyitása"; - /* new chat action */ "Open new chat" = "Új csevegés megnyitása"; -/* new chat action */ -"Open new group" = "Új csoport megnyitása"; - /* No comment provided by engineer. */ "Open Settings" = "Beállítások megnyitása"; @@ -4865,10 +4868,10 @@ alert title */ "Read more" = "Tudjon meg többet"; /* No comment provided by engineer. */ -"Read more in our GitHub repository." = "További információkat a GitHub-tárolónkban talál."; +"Read more in our GitHub repository." = "További tudnivalókat a GitHub-tárolónkban talál."; /* No comment provided by engineer. */ -"Read more in User Guide." = "További információkat a használati útmutatóban talál."; +"Read more in User Guide." = "További tudnivalókat a használati útmutatóban talál."; /* No comment provided by engineer. */ "Receipts are disabled" = "A kézbesítési jelentések le vannak tiltva"; @@ -5170,7 +5173,7 @@ swipe action */ "Reset to app theme" = "Alkalmazás témájának visszaállítása"; /* No comment provided by engineer. */ -"Reset to defaults" = "Visszaállítás alapértelmezettre"; +"Reset to defaults" = "Visszaállítás alapértelmezésre"; /* No comment provided by engineer. */ "Reset to user theme" = "Felhasználó által létrehozott téma visszaállítása"; @@ -5343,10 +5346,10 @@ chat item action */ "Saved" = "Mentett"; /* No comment provided by engineer. */ -"Saved from" = "Mentve innen"; +"saved from" = "mentve innen:"; /* No comment provided by engineer. */ -"saved from" = "mentve innen:"; +"Saved from" = "Mentve innen"; /* message info title */ "Saved message" = "Mentett üzenet"; @@ -5613,7 +5616,7 @@ chat item action */ "Server protocol changed." = "A kiszolgálóprotokoll módosult."; /* queue info */ -"server queue info: %@\n\nlast received msg: %@" = "kiszolgáló várólista-információi: %1$@\n\nutoljára fogadott üzenet: %2$@"; +"server queue info: %@\n\nlast received msg: %@" = "kiszolgáló várólistaadatai: %1$@\n\nutoljára fogadott üzenet: %2$@"; /* relay test error */ "Server requires authorization to connect to relay, check password." = "A kiszolgáló hitelesítést igényel az átjátszóhoz való kapcsolódáshoz, ellenőrizze a jelszavát."; @@ -5640,7 +5643,7 @@ chat item action */ "Servers" = "Kiszolgálók"; /* No comment provided by engineer. */ -"Servers info" = "Információk a kiszolgálókról"; +"Servers info" = "Kiszolgálóadatok"; /* No comment provided by engineer. */ "Servers statistics will be reset - this cannot be undone!" = "A kiszolgálók statisztikái visszaállnak – ez a művelet nem vonható vissza!"; @@ -7013,6 +7016,18 @@ alert title */ /* No comment provided by engineer. */ "You already have a chat profile with the same display name. Please choose another name." = "Már van egy csevegési profil ugyanezzel a megjelenítendő névvel. Válasszon egy másik nevet."; +/* new chat alert */ +"You are a contributor" = "Ön közreműködő"; + +/* new chat alert */ +"You are a member" = "Ön tag"; + +/* new chat alert */ +"You are a moderator" = "Ön moderátor"; + +/* new chat alert */ +"You are a subscriber" = "Ön feliratkozó"; + /* No comment provided by engineer. */ "You are already connected to %@." = "Ön már kapcsolódott a következőhöz: %@."; @@ -7037,6 +7052,15 @@ alert title */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "A csatlakozás már folyamatban van a csoporthoz!\nMegismétli a csatlakozási kérést?"; +/* new chat alert */ +"You are an admin" = "Ön adminisztrátor"; + +/* new chat alert */ +"You are an observer" = "Ön megfigyelő"; + +/* new chat alert */ +"You are an owner" = "Ön tulajdonos"; + /* subscription status explanation */ "You are connected to the server used to receive messages from this connection." = "Ön kapcsolódott ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál."; @@ -7052,27 +7076,6 @@ alert title */ /* No comment provided by engineer. */ "you are observer" = "Ön megfigyelő"; -/* new chat alert */ -"You are an observer" = "Ön megfigyelő"; - -/* new chat alert */ -"You are a member" = "Ön tag"; - -/* new chat alert */ -"You are a moderator" = "Ön moderátor"; - -/* new chat alert */ -"You are an admin" = "Ön adminisztrátor"; - -/* new chat alert */ -"You are an owner" = "Ön tulajdonos"; - -/* new chat alert */ -"You are a subscriber" = "Ön feliratkozó"; - -/* new chat alert */ -"You are a contributor" = "Ön közreműködő"; - /* No comment provided by engineer. */ "you are subscriber" = "Ön feliratkozó"; @@ -7109,6 +7112,12 @@ alert title */ /* notification body */ "You can now chat with %@" = "Mostantól küldhet üzeneteket %@ számára"; +/* No comment provided by engineer. */ +"You can now invest in SimpleX Chat" = "Mostantól befektethet a SimpleX Chatbe"; + +/* No comment provided by engineer. */ +"You can now invest in SimpleX Chat! 🚀" = "Mostantól befektethet a SimpleX Chatbe! 🚀"; + /* No comment provided by engineer. */ "You can send messages to %@ from Archived contacts." = "Az „Archivált partnerekből” továbbra is küldhet üzeneteket neki: %@."; diff --git a/apps/ios/it.lproj/Localizable.strings b/apps/ios/it.lproj/Localizable.strings index dd364b27ce..b9aafc7d4b 100644 --- a/apps/ios/it.lproj/Localizable.strings +++ b/apps/ios/it.lproj/Localizable.strings @@ -1814,6 +1814,12 @@ server test step */ /* No comment provided by engineer. */ "creator" = "creatore"; +/* No comment provided by engineer. */ +"Crowdfunding on Wefunder" = "Raccolta fondi su Wefunder"; + +/* No comment provided by engineer. */ +"Crowdfunding on Wefunder." = "Raccolta fondi su Wefunder."; + /* No comment provided by engineer. */ "Current conditions text couldn't be loaded, you can review conditions via this link:" = "Il testo delle condizioni attuali testo non è stato caricato, puoi consultare le condizioni tramite questo link:"; @@ -3153,6 +3159,9 @@ servers warning */ /* No comment provided by engineer. */ "Group invitation is no longer valid, it was removed by sender." = "L'invito al gruppo non è più valido, è stato rimosso dal mittente."; +/* No comment provided by engineer. */ +"Group invitations" = "Inviti in gruppi"; + /* No comment provided by engineer. */ "group is deleted" = "il gruppo è eliminato"; @@ -4464,15 +4473,9 @@ alert button */ /* authentication reason */ "Open migration to another device" = "Apri migrazione ad un altro dispositivo"; -/* new chat action */ -"Open new channel" = "Apri il nuovo canale"; - /* new chat action */ "Open new chat" = "Apri la nuova chat"; -/* new chat action */ -"Open new group" = "Apri il nuovo gruppo"; - /* No comment provided by engineer. */ "Open Settings" = "Apri le impostazioni"; @@ -5343,10 +5346,10 @@ chat item action */ "Saved" = "Salvato"; /* No comment provided by engineer. */ -"Saved from" = "Salvato da"; +"saved from" = "salvato da"; /* No comment provided by engineer. */ -"saved from" = "salvato da"; +"Saved from" = "Salvato da"; /* message info title */ "Saved message" = "Messaggio salvato"; @@ -7013,6 +7016,18 @@ alert title */ /* No comment provided by engineer. */ "You already have a chat profile with the same display name. Please choose another name." = "Hai già un profilo chat con lo stesso nome da mostrare. Scegli un altro nome."; +/* new chat alert */ +"You are a contributor" = "Sei un collaboratore"; + +/* new chat alert */ +"You are a member" = "Sei un membro"; + +/* new chat alert */ +"You are a moderator" = "Sei un moderatore"; + +/* new chat alert */ +"You are a subscriber" = "Sei iscritto/a"; + /* No comment provided by engineer. */ "You are already connected to %@." = "Sei già connesso/a a %@."; @@ -7037,6 +7052,15 @@ alert title */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "Stai già entrando nel gruppo!\nRipetere la richiesta di ingresso?"; +/* new chat alert */ +"You are an admin" = "Sei un amministratore"; + +/* new chat alert */ +"You are an observer" = "Sei un osservatore"; + +/* new chat alert */ +"You are an owner" = "Sei un proprietario"; + /* subscription status explanation */ "You are connected to the server used to receive messages from this connection." = "Sei connesso/a al server usato per ricevere messaggi da questa connessione."; @@ -7052,27 +7076,6 @@ alert title */ /* No comment provided by engineer. */ "you are observer" = "sei un osservatore"; -/* new chat alert */ -"You are an observer" = "Sei un osservatore"; - -/* new chat alert */ -"You are a member" = "Sei un membro"; - -/* new chat alert */ -"You are a moderator" = "Sei un moderatore"; - -/* new chat alert */ -"You are an admin" = "Sei un amministratore"; - -/* new chat alert */ -"You are an owner" = "Sei un proprietario"; - -/* new chat alert */ -"You are a subscriber" = "Sei iscritto/a"; - -/* new chat alert */ -"You are a contributor" = "Sei un collaboratore"; - /* No comment provided by engineer. */ "you are subscriber" = "sei iscritto/a"; @@ -7109,6 +7112,12 @@ alert title */ /* notification body */ "You can now chat with %@" = "Ora puoi inviare messaggi a %@"; +/* No comment provided by engineer. */ +"You can now invest in SimpleX Chat" = "Ora puoi investire in SimpleX Chat"; + +/* No comment provided by engineer. */ +"You can now invest in SimpleX Chat! 🚀" = "Ora puoi investire in SimpleX Chat! 🚀"; + /* No comment provided by engineer. */ "You can send messages to %@ from Archived contacts." = "Puoi inviare messaggi a %@ dai contatti archiviati."; diff --git a/apps/ios/ja.lproj/Localizable.strings b/apps/ios/ja.lproj/Localizable.strings index 3eb5fa5d8b..9d5d160b0a 100644 --- a/apps/ios/ja.lproj/Localizable.strings +++ b/apps/ios/ja.lproj/Localizable.strings @@ -3475,27 +3475,27 @@ server test failure */ /* No comment provided by engineer. */ "You already have a chat profile with the same display name. Please choose another name." = "同じ表示名前のチャットプロフィールが既にあります。別のを選んでください。"; +/* new chat alert */ +"You are a member" = "あなたはメンバーです"; + /* No comment provided by engineer. */ "You are already connected to %@." = "すでに %@ に接続されています。"; +/* new chat alert */ +"You are an admin" = "あなたは管理者です"; + +/* new chat alert */ +"You are an observer" = "あなたはオブザーバーです"; + +/* new chat alert */ +"You are an owner" = "あなたはオーナーです"; + /* No comment provided by engineer. */ "You are invited to group" = "グループ招待が届きました"; /* No comment provided by engineer. */ "you are observer" = "あなたはオブザーバーです"; -/* new chat alert */ -"You are an observer" = "あなたはオブザーバーです"; - -/* new chat alert */ -"You are a member" = "あなたはメンバーです"; - -/* new chat alert */ -"You are an admin" = "あなたは管理者です"; - -/* new chat alert */ -"You are an owner" = "あなたはオーナーです"; - /* No comment provided by engineer. */ "You can accept calls from lock screen, without device and app authentication." = "デバイスやアプリの認証を行わずに、ロック画面から通話を受けることができます。"; diff --git a/apps/ios/nl.lproj/Localizable.strings b/apps/ios/nl.lproj/Localizable.strings index 7164633090..4161bac952 100644 --- a/apps/ios/nl.lproj/Localizable.strings +++ b/apps/ios/nl.lproj/Localizable.strings @@ -4408,10 +4408,10 @@ chat item action */ "Saved" = "Opgeslagen"; /* No comment provided by engineer. */ -"Saved from" = "Opgeslagen van"; +"saved from" = "opgeslagen van"; /* No comment provided by engineer. */ -"saved from" = "opgeslagen van"; +"Saved from" = "Opgeslagen van"; /* message info title */ "Saved message" = "Opgeslagen bericht"; @@ -5706,6 +5706,12 @@ server test failure */ /* No comment provided by engineer. */ "You already have a chat profile with the same display name. Please choose another name." = "Je hebt al een chatprofiel met dezelfde weergave naam. Kies een andere naam."; +/* new chat alert */ +"You are a member" = "Je bent lid"; + +/* new chat alert */ +"You are a moderator" = "Je bent moderator"; + /* No comment provided by engineer. */ "You are already connected to %@." = "U bent al verbonden met %@."; @@ -5730,6 +5736,15 @@ server test failure */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "Je sluit je al aan bij de groep!\nDeelnameverzoek herhalen?"; +/* new chat alert */ +"You are an admin" = "Je bent beheerder"; + +/* new chat alert */ +"You are an observer" = "Je bent waarnemer"; + +/* new chat alert */ +"You are an owner" = "Je bent eigenaar"; + /* No comment provided by engineer. */ "You are invited to group" = "Je bent uitgenodigd voor de groep"; @@ -5739,21 +5754,6 @@ server test failure */ /* No comment provided by engineer. */ "you are observer" = "je bent waarnemer"; -/* new chat alert */ -"You are an observer" = "Je bent waarnemer"; - -/* new chat alert */ -"You are a member" = "Je bent lid"; - -/* new chat alert */ -"You are a moderator" = "Je bent moderator"; - -/* new chat alert */ -"You are an admin" = "Je bent beheerder"; - -/* new chat alert */ -"You are an owner" = "Je bent eigenaar"; - /* snd group event chat item */ "you blocked %@" = "je hebt %@ geblokkeerd"; diff --git a/apps/ios/pl.lproj/Localizable.strings b/apps/ios/pl.lproj/Localizable.strings index eb5074dc00..3288d2545b 100644 --- a/apps/ios/pl.lproj/Localizable.strings +++ b/apps/ios/pl.lproj/Localizable.strings @@ -3900,9 +3900,6 @@ alert button */ /* new chat action */ "Open new chat" = "Otwórz nowy czat"; -/* new chat action */ -"Open new group" = "Otwórz nową grupę"; - /* No comment provided by engineer. */ "Open Settings" = "Otwórz Ustawienia"; @@ -4643,10 +4640,10 @@ chat item action */ "Saved" = "Zapisane"; /* No comment provided by engineer. */ -"Saved from" = "Zapisane od"; +"saved from" = "zapisane od"; /* No comment provided by engineer. */ -"saved from" = "zapisane od"; +"Saved from" = "Zapisane od"; /* message info title */ "Saved message" = "Zachowano wiadomość"; @@ -6056,6 +6053,12 @@ alert title */ /* No comment provided by engineer. */ "You already have a chat profile with the same display name. Please choose another name." = "Masz już profil czatu o tej samej nazwie wyświetlanej. Proszę wybrać inną nazwę."; +/* new chat alert */ +"You are a member" = "Jesteś członkiem"; + +/* new chat alert */ +"You are a moderator" = "Jesteś moderatorem"; + /* No comment provided by engineer. */ "You are already connected to %@." = "Jesteś już połączony z %@."; @@ -6080,6 +6083,15 @@ alert title */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "Już dołączasz do grupy!\nPowtórzyć prośbę dołączenia?"; +/* new chat alert */ +"You are an admin" = "Jesteś administratorem"; + +/* new chat alert */ +"You are an observer" = "Jesteś obserwatorem"; + +/* new chat alert */ +"You are an owner" = "Jesteś właścicielem"; + /* subscription status explanation */ "You are connected to the server used to receive messages from this connection." = "Jesteś połączony z serwerem służącym do odbierania wiadomości z tego połączenia."; @@ -6095,21 +6107,6 @@ alert title */ /* No comment provided by engineer. */ "you are observer" = "jesteś obserwatorem"; -/* new chat alert */ -"You are an observer" = "Jesteś obserwatorem"; - -/* new chat alert */ -"You are a member" = "Jesteś członkiem"; - -/* new chat alert */ -"You are a moderator" = "Jesteś moderatorem"; - -/* new chat alert */ -"You are an admin" = "Jesteś administratorem"; - -/* new chat alert */ -"You are an owner" = "Jesteś właścicielem"; - /* snd group event chat item */ "you blocked %@" = "zablokowałeś %@"; diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index 92bb8f4590..db62641eb8 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -4464,15 +4464,9 @@ alert button */ /* authentication reason */ "Open migration to another device" = "Открытие миграции на другое устройство"; -/* new chat action */ -"Open new channel" = "Открыть новый канал"; - /* new chat action */ "Open new chat" = "Открыть новый чат"; -/* new chat action */ -"Open new group" = "Открыть новую группу"; - /* No comment provided by engineer. */ "Open Settings" = "Открыть Настройки"; @@ -5343,10 +5337,10 @@ chat item action */ "Saved" = "Сохранено"; /* No comment provided by engineer. */ -"Saved from" = "Сохранено из"; +"saved from" = "сохранено из"; /* No comment provided by engineer. */ -"saved from" = "сохранено из"; +"Saved from" = "Сохранено из"; /* message info title */ "Saved message" = "Сохранённое сообщение"; @@ -7013,6 +7007,18 @@ alert title */ /* No comment provided by engineer. */ "You already have a chat profile with the same display name. Please choose another name." = "У Вас уже есть профиль с таким именем. Пожалуйста, выберите другое имя."; +/* new chat alert */ +"You are a contributor" = "Вы соавтор"; + +/* new chat alert */ +"You are a member" = "Вы член группы"; + +/* new chat alert */ +"You are a moderator" = "Вы модератор"; + +/* new chat alert */ +"You are a subscriber" = "Вы подписчик"; + /* No comment provided by engineer. */ "You are already connected to %@." = "Вы уже соединены с контактом %@."; @@ -7037,6 +7043,15 @@ alert title */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "Вы уже вступаете в группу!\nПовторить запрос на вступление?"; +/* new chat alert */ +"You are an admin" = "Вы админ"; + +/* new chat alert */ +"You are an observer" = "Вы читатель"; + +/* new chat alert */ +"You are an owner" = "Вы владелец"; + /* subscription status explanation */ "You are connected to the server used to receive messages from this connection." = "Вы подключены к серверу, используемому для приёма сообщений от этого соединения."; @@ -7055,27 +7070,6 @@ alert title */ /* No comment provided by engineer. */ "you are subscriber" = "Вы подписчик"; -/* new chat alert */ -"You are an observer" = "Вы читатель"; - -/* new chat alert */ -"You are a member" = "Вы член группы"; - -/* new chat alert */ -"You are a moderator" = "Вы модератор"; - -/* new chat alert */ -"You are an admin" = "Вы админ"; - -/* new chat alert */ -"You are an owner" = "Вы владелец"; - -/* new chat alert */ -"You are a subscriber" = "Вы подписчик"; - -/* new chat alert */ -"You are a contributor" = "Вы соавтор"; - /* snd group event chat item */ "you blocked %@" = "Вы заблокировали %@"; 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/th.lproj/Localizable.strings b/apps/ios/th.lproj/Localizable.strings index d92174d281..087baff8d8 100644 --- a/apps/ios/th.lproj/Localizable.strings +++ b/apps/ios/th.lproj/Localizable.strings @@ -3045,27 +3045,27 @@ server test failure */ /* No comment provided by engineer. */ "You already have a chat profile with the same display name. Please choose another name." = "คุณมีโปรไฟล์แชทที่ใช้ชื่อแสดงเดียวกันอยู่แล้ว กรุณาเลือกชื่ออื่น"; +/* new chat alert */ +"You are a member" = "คุณเป็นสมาชิก"; + /* No comment provided by engineer. */ "You are already connected to %@." = "คุณได้เชื่อมต่อกับ %@ แล้ว"; +/* new chat alert */ +"You are an admin" = "คุณเป็นผู้ดูแลระบบ"; + +/* new chat alert */ +"You are an observer" = "คุณเป็นผู้สังเกตการณ์"; + +/* new chat alert */ +"You are an owner" = "คุณเป็นเจ้าของ"; + /* No comment provided by engineer. */ "You are invited to group" = "คุณได้รับเชิญให้เข้าร่วมกลุ่ม"; /* No comment provided by engineer. */ "you are observer" = "คุณเป็นผู้สังเกตการณ์"; -/* new chat alert */ -"You are an observer" = "คุณเป็นผู้สังเกตการณ์"; - -/* new chat alert */ -"You are a member" = "คุณเป็นสมาชิก"; - -/* new chat alert */ -"You are an admin" = "คุณเป็นผู้ดูแลระบบ"; - -/* new chat alert */ -"You are an owner" = "คุณเป็นเจ้าของ"; - /* No comment provided by engineer. */ "You can accept calls from lock screen, without device and app authentication." = "คุณสามารถรับสายจากหน้าจอล็อกโดยไม่ต้องมีการตรวจสอบสิทธิ์อุปกรณ์และแอป"; diff --git a/apps/ios/tr.lproj/Localizable.strings b/apps/ios/tr.lproj/Localizable.strings index c5251f482e..9444453779 100644 --- a/apps/ios/tr.lproj/Localizable.strings +++ b/apps/ios/tr.lproj/Localizable.strings @@ -3886,9 +3886,6 @@ alert button */ /* new chat action */ "Open new chat" = "Yeni sohbet aç"; -/* new chat action */ -"Open new group" = "Yeni grup aç"; - /* No comment provided by engineer. */ "Open Settings" = "Ayarları aç"; @@ -4626,10 +4623,10 @@ chat item action */ "Saved" = "Kaydedildi"; /* No comment provided by engineer. */ -"Saved from" = "Tarafından kaydedildi"; +"saved from" = "kaydedildi:"; /* No comment provided by engineer. */ -"saved from" = "kaydedildi:"; +"Saved from" = "Tarafından kaydedildi"; /* message info title */ "Saved message" = "Kaydedilmiş mesaj"; @@ -6009,6 +6006,12 @@ alert title */ /* No comment provided by engineer. */ "You already have a chat profile with the same display name. Please choose another name." = "Aynı görünen ada sahip bir konuşma profilin zaten var. Lütfen başka bir ad seç."; +/* new chat alert */ +"You are a member" = "Üyesiniz"; + +/* new chat alert */ +"You are a moderator" = "Moderatörsünüz"; + /* No comment provided by engineer. */ "You are already connected to %@." = "Zaten %@'a bağlısınız."; @@ -6033,6 +6036,15 @@ alert title */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "Gruba zaten katılıyorsunuz!\nKatılma isteği tekrarlansın mı?"; +/* new chat alert */ +"You are an admin" = "Yöneticisiniz"; + +/* new chat alert */ +"You are an observer" = "Gözlemcisiniz"; + +/* new chat alert */ +"You are an owner" = "Sahipsiniz"; + /* No comment provided by engineer. */ "You are invited to group" = "Gruba davet edildiniz"; @@ -6042,21 +6054,6 @@ alert title */ /* No comment provided by engineer. */ "you are observer" = "gözlemcisiniz"; -/* new chat alert */ -"You are an observer" = "Gözlemcisiniz"; - -/* new chat alert */ -"You are a member" = "Üyesiniz"; - -/* new chat alert */ -"You are a moderator" = "Moderatörsünüz"; - -/* new chat alert */ -"You are an admin" = "Yöneticisiniz"; - -/* new chat alert */ -"You are an owner" = "Sahipsiniz"; - /* snd group event chat item */ "you blocked %@" = "engelledin %@"; diff --git a/apps/ios/uk.lproj/Localizable.strings b/apps/ios/uk.lproj/Localizable.strings index 513b7c34b6..9046e21f9b 100644 --- a/apps/ios/uk.lproj/Localizable.strings +++ b/apps/ios/uk.lproj/Localizable.strings @@ -3919,9 +3919,6 @@ alert button */ /* new chat action */ "Open new chat" = "Відкрити новий чат"; -/* new chat action */ -"Open new group" = "Відкрити нову групу"; - /* No comment provided by engineer. */ "Open Settings" = "Відкрийте Налаштування"; @@ -4647,10 +4644,10 @@ chat item action */ "Saved" = "Збережено"; /* No comment provided by engineer. */ -"Saved from" = "Збережено з"; +"saved from" = "збережено з"; /* No comment provided by engineer. */ -"saved from" = "збережено з"; +"Saved from" = "Збережено з"; /* message info title */ "Saved message" = "Збережене повідомлення"; @@ -6024,6 +6021,12 @@ alert title */ /* No comment provided by engineer. */ "You already have a chat profile with the same display name. Please choose another name." = "Ви вже маєте профіль у чаті з таким самим іменем. Будь ласка, виберіть інше ім'я."; +/* new chat alert */ +"You are a member" = "Ви учасник"; + +/* new chat alert */ +"You are a moderator" = "Ви модератор"; + /* No comment provided by engineer. */ "You are already connected to %@." = "Ви вже підключені до %@."; @@ -6048,6 +6051,15 @@ alert title */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "Ви вже приєдналися до групи!\nПовторити запит на приєднання?"; +/* new chat alert */ +"You are an admin" = "Ви адмін"; + +/* new chat alert */ +"You are an observer" = "Ви спостерігач"; + +/* new chat alert */ +"You are an owner" = "Ви власник"; + /* No comment provided by engineer. */ "You are invited to group" = "Запрошуємо вас до групи"; @@ -6057,21 +6069,6 @@ alert title */ /* No comment provided by engineer. */ "you are observer" = "ви спостерігач"; -/* new chat alert */ -"You are an observer" = "Ви спостерігач"; - -/* new chat alert */ -"You are a member" = "Ви учасник"; - -/* new chat alert */ -"You are a moderator" = "Ви модератор"; - -/* new chat alert */ -"You are an admin" = "Ви адмін"; - -/* new chat alert */ -"You are an owner" = "Ви власник"; - /* snd group event chat item */ "you blocked %@" = "ви заблокували %@"; diff --git a/apps/ios/zh-Hans.lproj/Localizable.strings b/apps/ios/zh-Hans.lproj/Localizable.strings index 7340147bf7..4ef03cb433 100644 --- a/apps/ios/zh-Hans.lproj/Localizable.strings +++ b/apps/ios/zh-Hans.lproj/Localizable.strings @@ -4404,15 +4404,9 @@ alert button */ /* authentication reason */ "Open migration to another device" = "打开迁移到另一台设备"; -/* new chat action */ -"Open new channel" = "打开新频道"; - /* new chat action */ "Open new chat" = "打开新聊天"; -/* new chat action */ -"Open new group" = "打开新群"; - /* No comment provided by engineer. */ "Open Settings" = "打开设置"; @@ -5268,10 +5262,10 @@ chat item action */ "Saved" = "已保存"; /* No comment provided by engineer. */ -"Saved from" = "保存自"; +"saved from" = "保存自"; /* No comment provided by engineer. */ -"saved from" = "保存自"; +"Saved from" = "保存自"; /* message info title */ "Saved message" = "已保存的消息"; @@ -6874,6 +6868,15 @@ alert title */ /* No comment provided by engineer. */ "You already have a chat profile with the same display name. Please choose another name." = "您已经有一个显示名相同的聊天资料。请选择另一个名字。"; +/* new chat alert */ +"You are a member" = "你是成员"; + +/* new chat alert */ +"You are a moderator" = "你是协管"; + +/* new chat alert */ +"You are a subscriber" = "你是订阅者"; + /* No comment provided by engineer. */ "You are already connected to %@." = "您已经连接到 %@。"; @@ -6898,6 +6901,15 @@ alert title */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "您已经加入了这个群组!\n重复加入请求?"; +/* new chat alert */ +"You are an admin" = "你是管理员"; + +/* new chat alert */ +"You are an observer" = "你是观察者"; + +/* new chat alert */ +"You are an owner" = "你是群主"; + /* subscription status explanation */ "You are connected to the server used to receive messages from this connection." = "你已连接到用于接收该连接消息的服务器。"; @@ -6916,24 +6928,6 @@ alert title */ /* No comment provided by engineer. */ "you are subscriber" = "你是订阅者"; -/* new chat alert */ -"You are an observer" = "你是观察者"; - -/* new chat alert */ -"You are a member" = "你是成员"; - -/* new chat alert */ -"You are a moderator" = "你是协管"; - -/* new chat alert */ -"You are an admin" = "你是管理员"; - -/* new chat alert */ -"You are an owner" = "你是群主"; - -/* new chat alert */ -"You are a subscriber" = "你是订阅者"; - /* snd group event chat item */ "you blocked %@" = "你阻止了%@"; 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/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index ec4235d344..413667c968 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -211,7 +211,7 @@ afterEvaluate { val fontLtGtRegex = Regex("[^>]*>.*<font[^>]*>.*</font>.*") val unbracketedColorRegex = Regex("color=#[abcdefABCDEF0-9]{3,6}") val correctHtmlRegex = Regex("[^>]*>.*.*.*|[^>]*>.*.*.*|[^>]*>.*.*.*|[^>]*>.*]*>.*.*") - val possibleFormat = listOf("s", "d", "1\$s", "2\$s", "3\$s", "4\$s", "1\$d", "2\$d", "3\$d", "4\$d", "2s", "f") + val possibleFormat = listOf("s", "d", "1\$s", "2\$s", "3\$s", "4\$s", "1\$d", "2\$d", "3\$d", "4\$d", "1\$02d", "2\$02d", "3\$02d", "2s", "f") fun String.id(): String = replace("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/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index aaa754bd61..e314d05e15 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 @@ -116,6 +116,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 * */ @@ -2196,7 +2224,7 @@ enum class BadgeStatus { @Serializable data class BadgeInfo( val badgeType: BadgeType, - val badgeExpiry: Instant? = null, + val badgeExpiry: Instant, val badgeExtra: String = "" ) @@ -2206,6 +2234,49 @@ 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 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 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( @@ -4259,6 +4330,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, @@ -4266,8 +4344,12 @@ data class CIFile( val fileSize: Long, val fileSource: CryptoFile? = null, val fileStatus: CIFileStatus, - val fileProtocol: FileProtocol + val fileProtocol: FileProtocol, + val fileExpires: Instant? = null, + val fileProhibited: FileProhibited? = null ) { + val expired: Boolean = fileExpires != null && fileExpires < Clock.System.now() + val loaded: Boolean = when (fileStatus) { is CIFileStatus.SndStored -> true is CIFileStatus.SndTransfer -> true @@ -4317,7 +4399,7 @@ data class CIFile( is CIFileStatus.SndCancelled -> true is CIFileStatus.SndError -> true is CIFileStatus.SndWarning -> true - is CIFileStatus.RcvInvitation -> false + is CIFileStatus.RcvInvitation -> expired is CIFileStatus.RcvAccepted -> true is CIFileStatus.RcvTransfer -> true is CIFileStatus.RcvAborted -> true 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 83d4470eb3..ccb15d18d6 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,7 @@ 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 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 +274,7 @@ class AppPreferences { hintPref(laNoticeShown, false), hintPref(oneHandUICardShown, false), hintPref(addressCreationCardShown, false), + hintPref(supporterBannerShown, false), hintPref(liveMessageAlertShown, false), hintPref(signMessageAlertShown, false), hintPref(showHiddenProfilesNotice, true), @@ -464,6 +466,7 @@ 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_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 +562,83 @@ 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 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 +760,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 +824,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 +845,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 +908,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 @@ -1161,9 +1246,9 @@ object ChatController { return null } - suspend fun apiGetChatItemInfo(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, itemId: Long): ChatItemInfo? { + suspend fun apiGetChatItemInfo(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, itemId: Long): Pair? { val r = sendCmd(rh, CC.ApiGetChatItemInfo(type, id, scope, itemId)) - if (r is API.Result && r.res is CR.ApiChatItemInfo) return r.res.chatItemInfo + if (r is API.Result && r.res is CR.ApiChatItemInfo) return r.res.chatItem.chatItem to r.res.chatItemInfo apiErrorAlert("apiGetChatItemInfo", generalGetString(MR.strings.error_loading_details), r) return null } @@ -3483,6 +3568,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}") } @@ -3780,6 +3881,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 { @@ -3959,6 +4066,10 @@ 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 ApiAckBadgeAlert(val userId: Long, val badgePurchaseId: Long, val alertKind: BadgeAlertKind, val snooze: Boolean, val episode: String): CC() // misc class ShowVersion(): CC() class ResetAgentServersStats(): CC() @@ -4184,6 +4295,9 @@ 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 ApiAckBadgeAlert -> "/_badge ack $userId $badgePurchaseId ${badgeAlertKindParam(alertKind)} ${onOff(snooze)} $episode" is ShowVersion -> "/version" is ResetAgentServersStats -> "/reset servers stats" is GetAgentSubsTotal -> "/get subs total $userId" @@ -4365,6 +4479,9 @@ sealed class CC { is ApiUploadStandaloneFile -> "apiUploadStandaloneFile" is ApiDownloadStandaloneFile -> "apiDownloadStandaloneFile" is ApiStandaloneFileInfo -> "apiStandaloneFileInfo" + is ApiRedeemBadgeCode -> "apiRedeemBadgeCode" + is ApiGetBadgeState -> "apiGetBadgeState" + is ApiAckBadgeAlert -> "apiAckBadgeAlert" is ShowVersion -> "showVersion" is ResetAgentServersStats -> "resetAgentServersStats" is GetAgentSubsTotal -> "getAgentSubsTotal" @@ -4386,6 +4503,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 } @@ -4421,6 +4540,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?, @@ -6740,6 +6868,12 @@ 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("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() @@ -6926,6 +7060,10 @@ sealed class CR { is ArchiveExported -> "archiveExported" is ArchiveImported -> "archiveImported" is AppSettingsR -> "appSettings" + is BadgeRedeemed -> "badgeRedeemed" + is BadgeStateR -> "badgeState" + is BadgeChanged -> "badgeChanged" + is BadgeAlertR -> "badgeAlert" is Response -> "* $type" is Invalid -> "* invalid json" } @@ -7129,6 +7267,10 @@ 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 BadgeChanged -> withUser(user, json.encodeToString(badgeState)) + is BadgeAlertR -> withUser(user, json.encodeToString(badgeAlert)) is Response -> json is Invalid -> str } @@ -7180,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?, @@ -7585,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" @@ -7669,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() @@ -8103,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() @@ -8111,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..7c16bee763 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowItWorksView.kt @@ -0,0 +1,34 @@ +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.res.MR + +// TODO [badges]: replace lorem ipsum with the real copy once the badge protocol and privacy properties are documented. +@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) + } +} 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..c17559d5b0 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourBadgeView.kt @@ -0,0 +1,75 @@ +package chat.simplex.common.views.badges + +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.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.platform.ColumnWithScrollBar +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() + } +} + +@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/ChatItemInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt index 64c6160665..554d79ebae 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt @@ -273,6 +273,11 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools if (deleteAt != null) { InfoRow(stringResource(MR.strings.info_row_disappears_at), localTimestamp(deleteAt)) } + val file = ci.file + if (file?.fileExpires != null) { + val expiresRes = if (file.expired) MR.strings.info_row_file_expired else MR.strings.info_row_file_expires + InfoRow(stringResource(expiresRes), localTimestamp(file.fileExpires)) + } if (ci.meta.msgVerified?.verified == true) { val signedRes = if (sent) MR.strings.info_row_signed else MR.strings.info_row_signed_verified InfoRow(stringResource(signedRes), "", icon = painterResource(MR.images.ic_verified)) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt index 6562d40cec..fd912a8c9e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt @@ -33,13 +33,21 @@ suspend fun apiLoadMessages( visibleItemIndexesNonReversed: () -> IntRange = { 0 .. 0 } ) = coroutineScope { val (chat, navInfo) = chatModel.controller.apiGetChat(rhId, chatType, apiId, chatsCtx.groupScopeInfo?.toChatScope(), contentTag ?: chatsCtx.contentTag, pagination, search) ?: return@coroutineScope - // For .initial allow the chatItems to be empty as well as chatModel.chatId to not match this chat because these values become set after .initial finishes - /** When [openAroundItemId] is provided, chatId can be different too */ - if (((chatModel.chatId.value != chat.id || chat.chatItems.isEmpty()) && pagination !is ChatPagination.Initial && pagination !is ChatPagination.Last && openAroundItemId == null) + // For .initial allow the chatItems to be empty, as well as for .last that is used for searching + val allowEmptyItems = pagination is ChatPagination.Initial || pagination is ChatPagination.Last || openAroundItemId != null + if (chatWasSwitched(chat, pagination, openAroundItemId) + || (!allowEmptyItems && chat.chatItems.isEmpty()) || !isActive) return@coroutineScope processLoadedChat(chatsCtx, chat, navInfo, pagination, openAroundItemId, visibleItemIndexesNonReversed) } +/** .initial pagination and opening around item set [ChatModel.chatId] themselves after the items are loaded. + * In other cases the chat could be switched while the items were loading, and the items of the previously opened chat + * must not be added to the items of the currently opened one */ +private fun chatWasSwitched(chat: Chat, pagination: ChatPagination, openAroundItemId: Long?): Boolean = + pagination !is ChatPagination.Initial && openAroundItemId == null && + (chatModel.chatId.value != chat.id || chatModel.remoteHostId() != chat.remoteHostId) + suspend fun processLoadedChat( chatsCtx: ChatModel.ChatsContext, chat: Chat, @@ -94,6 +102,7 @@ suspend fun processLoadedChat( val insertAt = (indexInCurrentItems - (wasSize - newItems.size) + trimmedIds.size).coerceAtLeast(0) newItems.addAll(insertAt, chat.chatItems) withContext(Dispatchers.Main) { + if (chatWasSwitched(chat, pagination, openAroundItemId)) return@withContext chatsCtx.chatItems.replaceAll(newItems) splits.value = newSplits chatState.moveUnreadAfterItem(oldUnreadSplitIndex, newUnreadSplitIndex, oldItems) @@ -113,6 +122,7 @@ suspend fun processLoadedChat( val indexToAddIsLast = indexToAdd == newItems.size newItems.addAll(indexToAdd, chat.chatItems) withContext(Dispatchers.Main) { + if (chatWasSwitched(chat, pagination, openAroundItemId)) return@withContext chatsCtx.chatItems.replaceAll(newItems) splits.value = newSplits chatState.moveUnreadAfterItem(splits.value.firstOrNull() ?: newItems.last().id, newItems) @@ -135,6 +145,7 @@ suspend fun processLoadedChat( newSplits.add(splitIndex, chat.chatItems.last().id) withContext(Dispatchers.Main) { + if (chatWasSwitched(chat, pagination, openAroundItemId)) return@withContext chatsCtx.chatItems.replaceAll(newItems) splits.value = newSplits unreadAfterItemId.value = chat.chatItems.last().id @@ -158,6 +169,7 @@ suspend fun processLoadedChat( removeDuplicates(newItems, chat) newItems.addAll(chat.chatItems) withContext(Dispatchers.Main) { + if (chatWasSwitched(chat, pagination, openAroundItemId)) return@withContext chatsCtx.chatItems.replaceAll(newItems) chatState.splits.value = newSplits unreadAfterNewestLoaded.value = 0 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 9bbfda558f..cb36d86230 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 @@ -699,7 +699,7 @@ fun ChatView( } }, showItemDetails = { cInfo, cItem -> - suspend fun loadChatItemInfo(): ChatItemInfo? = coroutineScope { + suspend fun loadChatItemInfo(): Pair? = coroutineScope { val ciInfo = chatModel.controller.apiGetChatItemInfo(chatRh, cInfo.chatType, cInfo.apiId, cInfo.groupChatScope(), cItem.id) if (ciInfo != null) { if (chatInfo is ChatInfo.Group) { @@ -717,11 +717,12 @@ fun ChatView( } ModalManager.end.showModalCloseable(endButtons = { ShareButton { - clipboard.shareText(itemInfoShareText(chatModel, cItem, initialCiInfo, chatModel.controller.appPrefs.developerTools.get())) + clipboard.shareText(itemInfoShareText(chatModel, initialCiInfo.first, initialCiInfo.second, chatModel.controller.appPrefs.developerTools.get())) } }) { close -> var ciInfo by remember(cItem.id) { mutableStateOf(initialCiInfo) } - ChatItemInfoView(chatRh, cItem, ciInfo, devTools = chatModel.controller.appPrefs.developerTools.get(), chatInfo) + val (item, info) = ciInfo + ChatItemInfoView(chatRh, item, info, devTools = chatModel.controller.appPrefs.developerTools.get(), chatInfo) LaunchedEffect(cItem.id) { withContext(Dispatchers.Default) { for (msg in controller.messagesChannel) { 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 28afc0132f..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 -> @@ -101,7 +98,7 @@ fun CIFileView( FileProtocol.LOCAL -> {} } file.fileStatus is CIFileStatus.RcvError -> - showFileErrorAlert(file.fileStatus.rcvFileError) + showFileErrorAlert(file.fileStatus.rcvFileError, file) file.fileStatus is CIFileStatus.RcvWarning -> showFileErrorAlert(file.fileStatus.rcvFileError, temporary = true) file.fileStatus is CIFileStatus.SndError -> @@ -157,10 +154,12 @@ 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)) - fileIcon(innerIcon = painterResource(MR.images.ic_arrow_downward), color = MaterialTheme.colors.primary, topPadding = 10.sp.toDp()) - else + if (!fileSizeValid(file)) fileIcon(innerIcon = painterResource(MR.images.ic_priority_high), color = WarningOrange) + else if (file.expired) + fileIcon(innerIcon = painterResource(MR.images.ic_close)) + else + fileIcon(innerIcon = painterResource(MR.images.ic_arrow_downward), color = MaterialTheme.colors.primary, topPadding = 10.sp.toDp()) is CIFileStatus.RcvAccepted -> fileIcon(innerIcon = painterResource(MR.images.ic_more_horiz)) is CIFileStatus.RcvTransfer -> if (file.fileProtocol == FileProtocol.XFTP && file.fileStatus.rcvProgress < file.fileStatus.rcvTotal) { @@ -237,11 +236,31 @@ 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 showFileErrorAlert(err: FileError, temporary: Boolean = false) { +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 + if (file != null && fileExpires != null && file.expired && (err is FileError.Auth || err is FileError.NoFile)) { + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.file_expired), + String.format(generalGetString(MR.strings.file_error_expired), localTimestamp(fileExpires)) + ) + return + } val title: String = generalGetString(if (temporary) MR.strings.temporary_file_error else MR.strings.file_error) val btn = err.moreInfoButton if (btn != null) { 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 67fc0a038c..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) } @@ -83,7 +82,11 @@ fun CIImageView( is CIFileStatus.SndCancelled -> fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file) 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 -> fileIcon(painterResource(MR.images.ic_arrow_downward), MR.strings.icon_descr_asked_to_receive) + is CIFileStatus.RcvInvitation -> + 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) is CIFileStatus.RcvAccepted -> fileIcon(painterResource(MR.images.ic_more_horiz), MR.strings.icon_descr_waiting_for_image) is CIFileStatus.RcvTransfer -> progressIndicator() is CIFileStatus.RcvComplete -> {} @@ -216,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) { @@ -239,7 +239,7 @@ fun CIImageView( FileProtocol.LOCAL -> {} } file.fileStatus is CIFileStatus.RcvError -> - showFileErrorAlert(file.fileStatus.rcvFileError) + showFileErrorAlert(file.fileStatus.rcvFileError, file) file.fileStatus is CIFileStatus.RcvWarning -> showFileErrorAlert(file.fileStatus.rcvFileError, temporary = true) file.fileStatus is CIFileStatus.SndError -> 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 8ca0add460..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,7 +128,7 @@ 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) } } } } @@ -525,7 +524,11 @@ private fun fileStatusIcon(file: CIFile?, smallView: Boolean) { showFileErrorAlert(file.fileStatus.sndFileError, temporary = true) } ) - is CIFileStatus.RcvInvitation -> fileIcon(painterResource(MR.images.ic_arrow_downward), MR.strings.icon_descr_video_asked_to_receive) + is CIFileStatus.RcvInvitation -> + 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) is CIFileStatus.RcvAccepted -> fileIcon(painterResource(MR.images.ic_more_horiz), MR.strings.icon_descr_waiting_for_video) is CIFileStatus.RcvTransfer -> if (file.fileProtocol == FileProtocol.XFTP && file.fileStatus.rcvProgress < file.fileStatus.rcvTotal) { @@ -541,7 +544,7 @@ private fun fileStatusIcon(file: CIFile?, smallView: Boolean) { painterResource(MR.images.ic_close), MR.strings.icon_descr_file, onClick = { - showFileErrorAlert(file.fileStatus.rcvFileError) + showFileErrorAlert(file.fileStatus.rcvFileError, file) } ) is CIFileStatus.RcvWarning -> @@ -561,14 +564,11 @@ private fun fileStatusIcon(file: CIFile?, smallView: Boolean) { 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/CIVoiceView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt index 136300e4ed..c3e1c92732 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt @@ -412,7 +412,7 @@ private fun VoiceMsgIndicator( } ) file?.fileStatus is CIFileStatus.RcvInvitation -> - PlayPauseButton(audioPlaying, sent, 0f, strokeWidth, strokeColor, true, error, sizeMultiplier, { receiveFile(file.fileId) }, {}, longClick = longClick) + PlayPauseButton(audioPlaying, sent, 0f, strokeWidth, strokeColor, true, error, sizeMultiplier, { receiveFile(file.fileId) }, {}, longClick = longClick, icon = if (file.expired) MR.images.ic_close else MR.images.ic_play_arrow_filled) file?.fileStatus is CIFileStatus.RcvTransfer || file?.fileStatus is CIFileStatus.RcvAccepted -> VoiceMsgLoadingProgressIndicator(sizeMultiplier) file?.fileStatus is CIFileStatus.RcvAborted -> @@ -424,7 +424,7 @@ private fun VoiceMsgIndicator( sizeMultiplier, longClick, onClick = { - showFileErrorAlert(file.fileStatus.rcvFileError) + showFileErrorAlert(file.fileStatus.rcvFileError, file) } ) file != null && file.fileStatus is CIFileStatus.RcvWarning -> 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/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 68fa25d553..a013cc97a2 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,7 @@ 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 activeFilter = remember { chatModel.activeChatTagFilter } LaunchedEffect(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) { @@ -1000,6 +1038,29 @@ 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 + ) + } + } + } 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/helpers/ChatInfoImage.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt index d2ee1db09c..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( @@ -231,10 +239,9 @@ fun showBadgeInfoAlert(name: String, badge: LocalBadge, uriHandler: UriHandler) ) else -> { // Supporter, Legend and unknown types use the supporter wording - val expiry = badge.badge.badgeExpiry val supports = - if (badge.status == BadgeStatus.Expired && expiry != null) - String.format(generalGetString(MR.strings.badge_supported_simplex), name, localDate(expiry)) + if (badge.status == BadgeStatus.Expired) + String.format(generalGetString(MR.strings.badge_supported_simplex), name, localDate(badge.badge.badgeExpiry)) else String.format(generalGetString(MR.strings.badge_supports_simplex), name) AlertManager.shared.showAlertMsg( @@ -245,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/newchat/OnboardingCards.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/OnboardingCards.kt index 26007c74af..bd33bde96a 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 @@ -42,13 +42,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) { 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..ca9ee078d6 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) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetSimplexNameView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetSimplexNameView.kt index 7ab1ffa33d..f2c7b02b42 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetSimplexNameView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetSimplexNameView.kt @@ -14,7 +14,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.* +import androidx.compose.ui.unit.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.* @@ -24,10 +25,14 @@ import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* +import kotlinx.datetime.* // Each dot-separated label is ASCII letters/digits with single internal hyphens (mirrors simplexmq SimplexName.hs nameLabelP). private val simplexNameLabelRegex = Regex("[A-Za-z0-9]+(-[A-Za-z0-9]+)*") +private val simplexNameSaleStart = LocalDateTime(2026, 12, 12, 18, 0).toInstant(TimeZone.UTC) +private const val SIMPLEX_DOMAINS_URL = "https://simplex.domains/" + // Set the user's own (prefix "@") or a channel's (prefix "#") SimpleX name. // The field is prefilled with the full prefixed name; `save` receives the encoded name (or null to // clear) and returns true on success (it shows its own error alert otherwise). @@ -128,6 +133,13 @@ fun SetSimplexDomainView( } } + fun saleCountdown(msRemaining: Long): String { + val total = (msRemaining / 1000).coerceAtLeast(0) + val days = total / 86400 + val dayStr = String.format(generalGetString(if (days == 1L) MR.strings.ttl_day else MR.strings.ttl_days), days) + return dayStr + " " + String.format(generalGetString(MR.strings.countdown_hrs_min_sec), total / 3600 % 24, total / 60 % 60, total % 60) + } + ModalView(close = { onClose(close) }, cardScreen = true) { ColumnWithScrollBar { AppBarTitle(title) @@ -163,7 +175,7 @@ fun SetSimplexDomainView( SettingsActionItem( painterResource(MR.images.ic_open_in_new), stringResource(MR.strings.register_test_name), - { openBrowserAlert("https://github.com/simplex-chat/simplex-chat/blob/master/docs/guide/register-simplex-name.md", uriHandler) }, + { openBrowserAlert("https://simplex.domains/#testing", uriHandler) }, textColor = MaterialTheme.colors.primary, iconColor = MaterialTheme.colors.primary ) @@ -179,6 +191,35 @@ fun SetSimplexDomainView( } } } + SectionDividerSpaced() + val msToSaleStart = remember { mutableStateOf(simplexNameSaleStart.toEpochMilliseconds() - System.currentTimeMillis()) } + LaunchedEffect(Unit) { + while (msToSaleStart.value > 0) { + delay(1000) + msToSaleStart.value = simplexNameSaleStart.toEpochMilliseconds() - System.currentTimeMillis() + } + } + SectionView(stringResource(MR.strings.simplex_name_sales)) { + SectionItemView { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(saleCountdown(msToSaleStart.value)) + Text( + stringResource(if (msToSaleStart.value > 0) MR.strings.until_register_simplex_domain else MR.strings.update_app_register_simplex_domain), + color = MaterialTheme.colors.secondary, + fontSize = 12.sp + ) + } + } + } + SectionTextFooter(buildAnnotatedString { + append(generalGetString(MR.strings.simplex_name_sales_footer)) + append(" ") + withLink(LinkAnnotation.Url(SIMPLEX_DOMAINS_URL) { uriHandler.openUriCatching(SIMPLEX_DOMAINS_URL) }) { + withStyle(SpanStyle(color = MaterialTheme.colors.primary)) { + append("simplex.domains") + } + } + }) SectionBottomSpacer() } } 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..cbd2be9ee3 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) { 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 0df6dafacb..5aa9270e4b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml @@ -1754,12 +1754,10 @@ غير محمي السماح بالرجوع إلى إصدار سابق لا تستخدم التوجيه الخاص. - وضع توجيه الرسائل لا عندما يكون IP مخفيًا لا ترسل رسائل مباشرةً، حتى لو كان خادمك أو خادم الوجهة لا يدعم التوجيه الخاص. أرسل الرسائل مباشرة عندما لا يدعم الخادم الوجهة الخاص بك أو الخادم الوجهة التوجيه الخاص. - احتياطي توجيه الرسالة أظهِر حالة الرسالة احمِ عنوان IP بدون تور أو VPN، سيكون عنوان IP الخاص بك مرئيًا لخوادم الملفات. @@ -1775,7 +1773,6 @@ خوادم غير معروفة! بدون تور أو VPN، سيكون عنوان IP الخاص بك مرئيًا لمُرحلات XFTP هذه: \n%1$s. - أظهِر قائمة الدردشة في نافذة جديدة ألوان الدردشة سمة الدردشة تلقيت رد @@ -1962,9 +1959,7 @@ متوسط ناعم مكالمة - اتصل مراسلة - افتح بحث الإعدادات فيديو @@ -2107,9 +2102,6 @@ عنوان SimpleX أو رابط لمرة واحدة؟ مُشغلي الشبكة يمكنك تضبيط الخوادم عبر الإعدادات. - حدد مشغلي الشبكة الذين تريد استخدامهم. - يمكنك تضبيط المُشغلين في إعدادات الشبكة والخوادم. - حدّث تابع الشروط المتفق عليها راجع الشروط @@ -2121,7 +2113,6 @@ مُشغل الشبكة المُشغل %s خوادم - الموقع الإلكتروني سيتم قبول الشروط في: %s. قُبل الشروط في: %s. استخدم %s @@ -2151,14 +2142,12 @@ فعّل flux في إعدادات الشبكة والخوادم لتحسين خصوصية البيانات الوصفية. اللامركزية الشبكية المُشغل المُعد مسبقًا الثاني في التطبيق! - لتحسين خصوصية البيانات الوصفية. تحسين التنقل في الدردشة اعرض الشروط المُحدثة اقبل الشروط أُضيفت خوادم الرسائل عنوان أو رابط لمرة واحدة؟ مع جهة اتصال واحدة فقط - المشاركة شخصيًا أو عبر أي مُراسل.]]> - سيتم قبول الشروط للمُشغلين المفعّلين بعد 30 يومًا. مُشغلي الخادم لا يمكن تحميل نص الشروط الحالية، يمكنك مراجعة الشروط عبر هذا الرابط: خطأ في قبول الشروط @@ -2171,12 +2160,10 @@ لا يوجد خوادم لاستلام الرسائل. - فتح الدردشة عند أول رسالة غير مقروءة.\n- الانتقال إلى الرسائل المقتبسة. يمكنك تعيين اسم الاتصال، لتتذكر الأشخاص الذين شاركتَ الرابط معهم. - راجع لاحقًا تغيّر بروتوكول الخادم. شارك العنوان علناً شارك عنوان SimpleX على وسائل التواصل الاجتماعي. عنوان SimpleX والروابط لمرة واحدة آمنة للمشاركة عبر أي برنامج مُراسلة. - انقر فوق أنشئ عنوان SimpleX في القائمة لإنشائه لاحقًا. حُذفت هذه الرسالة أو لم تُستلم بعد. استخدم للرسائل يحمي التطبيق خصوصيتك من خلال استخدام مُشغلين مختلفين في كل محادثة. @@ -2207,7 +2194,6 @@ الإشعارات والبطارية فقط مالكي الدردشة يمكنهم تغيير التفضيلات. الخصوصية لعملائك. - الجوّالات عن بُعد ادعُ للدردشة مغادرة المجموعة؟ سيتم إزالة العضو من الدردشة - لا يمكن التراجع عن هذا! @@ -2287,7 +2273,6 @@ بلّغ عن إزعاج (spam): سيراه مُشرفو المجموعة فقط. أرشفة البلاغ؟ سبب الإبلاغ؟ - سيتم أرشفة البلاغ لك. إزعاج (spam) نعم اسأل @@ -2312,7 +2297,6 @@ استخدم منفذ الويب منفذ TCP للمُراسلة اكتم الكل - ذّكورات غير مقروءة يمكنك ذكر ما يصل إلى %1$s من الأعضاء في الرسالة الواحدة! السماح بالإبلاغ عن الرسائل إلى المُشرفين. امنع الإبلاغ عن الرسائل للمُشرفين. @@ -2438,7 +2422,6 @@ إرسال طلب جهة اتصال؟ أرسل طلب أرسل طلب دون رسالة - أُرسل إلى جهة اتصالك بعد الاتصال. ترقية رابط المجموعة؟ رقِّ ترقية العنوان؟ @@ -2491,10 +2474,9 @@ شارك الرابط القديم سيكون الرابط قصيراً، وسيتم مشاركة الملف التعريفي للمجموعة عبر الرابط. رقِّ رابط المجموعة - طلبات التواصل من المجموعات + طلبات التواصل في المجموعات حُذف العضو - لا يمكن قبول الطلب طُلب اتصال من المجموعة %1$s - هذا الإعداد لملف تعريفك الحالي اسمح بالملفات والوسائط فقط إذا سمح جهة اتصالك بذلك. اسمح لجهات اتصالك بإرسال الملفات والوسائط. روبوت @@ -2526,7 +2508,6 @@ فشل الاتصال فشل ملفات - تصفية صور روابط ابحث عن ملفات @@ -2562,7 +2543,6 @@ قناة اسم القناة بالكامل رابط القناة - أعضاء القناة اسم القناة يُخزّن ملف تعريف القناة على أجهزة المشتركين وعلى مُرحلات الدردشة. حُدِّث ملف تعريف القناة @@ -2590,7 +2570,6 @@ أدخل اسم المُرحل… خطأ في إضافة المُرحل خطأ في إنشاء القناة - خطأ في فتح القناة خطأ: %s خطأ في حفظ ملف تعريف القناة فشل @@ -2610,7 +2589,6 @@ لا مُرحلات دردشة مفعّلة. ليس كل المُرحلات متصلة افتح قناة - افتح قناة جديدة المالك المالكين والمساهمين عنوان المُرحل مسبق الضبط @@ -2779,7 +2757,6 @@ عنوانك العام لقد وُلدت دون حساب تعريفي. قناتك الجديدة %1$s متصلة بـ %2$d من أصل %3$d مُرحلات. إذا ألغيت، ستُحذف القناة - يمكنك إنشاؤها مرة أخرى. - أضف أضف مُرحل أضف مُرحلات ألغِ واحذف القناة @@ -2814,14 +2791,11 @@ الحالة %1$d مالك %1$d مالكون - عن خيارات متقدّمة إعدادات متقدّمة اسمح لأي شخص بالتضمين يمكن لأي صفحة ويب عرض المعاينة. بيانات الدردشة - يتطلب الاتصال عبر اسم القناة إصدارًا أحدث من التطبيق. - يتطلب الاتصال عبر اسم جهة الاتصال إصدارًا أحدث من التطبيق. تواصل مساهم انسخ الرمز @@ -2833,7 +2807,6 @@ سيتم عرضه للمشتركين واستخدامه للسماح بتحميل المعاينة. مزيد من الخصوصية لا يمكن عرض المعاينة إلا على صفحتك المذكورة أعلاه. - يُرجى ترقية التطبيق. %1$d مالكين ومساهمين %1$s دعم تطبيق SimpleX Chat. انتهت صلاحية الشارة في %2$s. قائمة المعترف بهم @@ -2849,8 +2822,6 @@ سيتم تغيير الدور إلى "%s". وسيتم إشعار جميع المشاركين في القناة بذلك. تعذّر التحقق من صحة هذه الشارة، وقد لا تكون أصلية. تتطلب هذه المجموعة إصدارًا أحدث من التطبيق. يُرجى تحديث التطبيق للانضمام إليها. - اسم قناة غير مدعوم - اسم جهة اتصال غير مدعوم شارة غير متحقق منها لا تدعم خوادم ترحيل الدردشة المستعملة صفحات الويب. رمز صفحة الويب @@ -2865,7 +2836,6 @@ خطأ المحلّل: %1$s الخادم %1$s لا يدعم تحليل الأسماء. اضبط الخوادم أو استخدم رابط الاتصال. عيِّن اسم SimpleX - اسم SimpleX خطأ في اسم SimpleX لم يتحقق مِن اسم SimpleX اسم SimpleX %1$s مسجَّل، لكنه لا يحتوي على رابط صالح. @@ -2913,4 +2883,6 @@ الأسماء العامة لقناتك أو لشركتك. خوادم الملفات خوادم الملفات: %s + دعوات المجموعات + مُحوّلة مِن diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/az/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/az/strings.xml new file mode 100644 index 0000000000..55344e5192 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/az/strings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file 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 45f63d4cf9..f5fc7f8278 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -178,6 +178,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 +204,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 +369,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 @@ -403,6 +398,11 @@ Get SimpleX name (BETA) Channel SimpleX name How to register a test name + SimpleX name sale starts in + until you can register a SimpleX domain + Update the app to register a SimpleX domain + %1$02d hrs %2$02d min %3$02d sec + Crowdfunding investors can reserve names before the sale starts: Remove name Save Edit @@ -419,7 +419,6 @@ Files Links All messages - Filter Archive Archive report Archive reports @@ -523,7 +522,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 @@ -682,6 +680,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. @@ -692,6 +699,8 @@ Loading the file Please, wait while the file is being loaded from the linked mobile File error + File expired + File was available until %1$s. Temporary file error Open with %s @@ -710,8 +719,6 @@ Delete chat messages from your device. - connect - open message call search @@ -864,7 +871,6 @@ Unmute Favorite Unfavorite - Unread mentions Create list @@ -971,7 +977,6 @@ Verify name Verify SimpleX names SimpleX name not verified - SimpleX name Your SimpleX name Set SimpleX name Error saving name @@ -1138,7 +1143,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 @@ -1146,7 +1150,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 @@ -1213,7 +1216,6 @@ Stop sharing Auto-accept Group invitations - Sent to your contact after connection. Welcome message Enter welcome message… (optional) Save settings? @@ -1416,13 +1418,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 @@ -1563,7 +1560,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 @@ -1610,7 +1606,6 @@ Files Send delivery receipts to Contact requests in groups - About Contact Support the project Chat data @@ -1644,7 +1639,6 @@ Your chat database Run chat - Remote mobiles Chat is running Chat is stopped Chat database @@ -1778,7 +1772,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 @@ -2021,7 +2014,6 @@ Invite Link Chat with admins - Channel members Chat relays @@ -2039,6 +2031,8 @@ Deleted at Moderated at Disappears at + File available until + File was available until Database ID: %d Record updated at: %s Message status: %s @@ -2190,7 +2184,6 @@ Operator %s servers Network operator - Website Conditions accepted on: %s. Conditions will be accepted on: %s. Use servers @@ -2696,7 +2689,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 @@ -3157,7 +3149,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 @@ -3175,12 +3166,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? @@ -3211,4 +3200,74 @@ 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 + SimpleX doesn\'t sell ads or data. It\'s funded by its users and by investors who share the mission. You can support the project and show a badge on your profile. + Why SimpleX is built. + Choose your level + Redeem badge code + Continue + How private badges work + How private badges work + A badge is not an account. It is a signed credential stored on your device. It does not identify you, and no one keeps a record of who holds which badge. + Your contacts see the badge and its expiry date, and nothing else. The badge carries no identifier, so it cannot be used to find out who you are or to match you across chats. + Payment and badge are kept apart. Paying is one step; the badge is issued in another, under a key that exists only for that badge. Whoever handles the payment cannot see where the badge ends up. + My nickname + Support SimpleX + Get badge + files up to 5GB + 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 from your receipt. + 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 + Support ended + 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 support ended on %1$s. + Remind me later + Dismiss + 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..75de2d72f1 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml @@ -1780,7 +1780,6 @@ Препращане на съобщенията без файловете? Неизвестни сървъри Файлове - Показване на списъка на чатовете в нов прозорец Системна Тъмна Черна @@ -1833,7 +1832,6 @@ Винаги Не Никога - Режим за рутиране на съобщения Използвай поверително рутиране с неизвестни сървъри, когато IP адресът не е защитен. Светла Да @@ -2053,11 +2051,9 @@ Избрано %d Нищо не е избрано Съобщение - отвори съобщение обаждане Контактът ще бъде изтрит - това не може да бъде отменено! - свързване Изтрий без известие Разговорът е изтрит! Все още ще можете да видите разговора с %1$s в списъка с чатовете. @@ -2125,8 +2121,6 @@ Например, ако контактът ви получава съобщения чрез SimpleX Chat сървър, приложението ви ще ги достави чрез Flux сървър. Сървърни оператори Мрежови оператори - Преглед по-късно - Можете да конфигурирате операторите в Мрежа и сървъри. Условията ще бъдат автоматично приети за активираните оператори на: %s. Вашите сървъри Условията ще бъдат приети на: %s. @@ -2140,16 +2134,13 @@ Добавени сървъри за съобщения Използвай за файлове - Отворете чата при първото непрочетено съобщение.\n- Преминете към цитирани съобщения. - Резервно рутиране на съобщения Активирай логовете Добавете членовете на вашия екип към разговорите. задраскан Продължи Добави приятели %s.]]> - Уеб страница Условия, приети на: %s. - Дистанционни мобилни устройства Предварително зададени сървъри %s.]]> Отвори промените @@ -2160,7 +2151,6 @@ Персонализирана форма на съобщенията. По-добри дати на съобщението. Активиране на Flux в настройките Мрежа и сървъри за по -добра поверителност на метаданните. - за по-добра поверителност на метаданните. Подобрена навигация в чата Грешка при запазване на базата данни Вашият профил ще бъде изпратен до членовете @@ -2179,9 +2169,7 @@ Проверявай за съобщения на всеки 10 минути Приложението винаги работи във фонов режим Известия и батерия - Условията ще бъдат приети за активираните оператори след 30 дни. Можете да конфигурирате сървърите през настройките. - Актуализация %s.]]> Използвай за съобщения Личните съобщения между членовете са забранени. @@ -2195,7 +2183,6 @@ Грешка при зареждането на чат списъка Няма чатове в списъка %s. Няма непрочетени чатове - Докосни Създаване на SimpleX адрес в менюто, за да го създадете по-късно. Няма чатове Няма намерени чатове Любими @@ -2267,7 +2254,6 @@ Връзката изисква предоговаряне на криптирането. Сподели еднократен линк за връзка с приятел Настройки на адреса - Изберете мрежови оператори за използване. Как помага за поверителността Ще спрете да получавате съобщения от този чат. Историята на чата ще бъде запазена. Добави членове на екипа @@ -2455,7 +2441,6 @@ Изпрати заявка Изпрати заявка без съобщение Изпрати личната си обратна връзка до групи. - Изпратено до вашия контакт след осъществяване на връзка. Задаване на име на чат… Задаване на достъп за членове Задаване на срок на валидност на съобщенията в чатовете. @@ -2474,14 +2459,12 @@ Докосни Свързване за изпращане на заявка Докосни Присъединяване към групата TCP порт за съобщения - Докладът ще бъде архивиран за вас. Подателят НЯМА да бъде уведомен. Това действие не може да бъде отменено - съобщенията, изпратени и получени в този чат по-рано от избраното, ще бъдат изтрити. Тази връзка изисква по-нова версия на приложението. Моля, актуализирайте приложението или помолете контакта да ви изпрати съвместим линк. Времето за изчезване е зададено само за нови контакти. За да използвате друг профил след опит за връзка, изтрийте чата и използвайте линка отново. Отблокиране за всички членове? - Непрочетени споменавания Неподдържан линк за връзка Актуализирани условия Актуализирайте адреса си @@ -2508,7 +2491,6 @@ Заявки за контакт от групи Членът е изтрит - не може да се приеме заявката заявка за връзка от група %1$s - Тази настройка е за текущия профил Разреши файлове и медия само ако вашият контакт ги разрешава. Позволи на вашите контактите да изпращат файлове и медия. Бот diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml index 3d49c533ff..d5b8fb5fa0 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml @@ -493,7 +493,6 @@ Confirmeu les credencials Xats Parleu amb els desenvolupadors - connecta Confirmeu l\'eliminació del contacte? Connectat Confirma @@ -740,7 +739,6 @@ 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 @@ -1006,7 +1004,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 +1087,6 @@ Micròfon Trucada perduda Esborrany - Mòbils remots Elimina Expulsa t\'ha expulsat @@ -1226,9 +1222,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 @@ -1307,7 +1300,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 @@ -1746,7 +1738,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 @@ -1905,8 +1896,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. @@ -1936,7 +1925,6 @@ 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 @@ -1978,7 +1966,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 +2122,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 @@ -2262,7 +2248,6 @@ Contingut inadequat Perfil inadequat Suprimeix l\'informe - L\'informe s\'arxivarà. Informar Spam El contingut infringeix les condicions d\'ús @@ -2300,7 +2285,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 +2411,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 +2465,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 16241e07e2..f8a6dd83dc 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml @@ -1781,8 +1781,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 @@ -1937,7 +1935,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 +1946,6 @@ Chyba přepínání profilu Vyberte profil chatu zpráva - otevřít Kontakt smazán! neaktivní Detaily @@ -2058,7 +2054,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 +2139,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,7 +2185,6 @@ %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 @@ -2206,7 +2199,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 +2214,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 +2262,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 +2288,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 +2304,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 +2480,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 +2494,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 +2514,6 @@ Smazat zprávy člena? Smazat zprávy Soubory - Filtr Obrázky Odkazy Zprávy člena budou smazány - nelze zrušit! @@ -2582,7 +2563,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 +2617,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 +2636,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 +2687,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 +2753,8 @@ %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 + Pokročilé nastavení 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..8c7d92086c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml @@ -438,7 +438,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 @@ -547,7 +546,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 +677,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 @@ -874,7 +870,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 +889,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 +919,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 26ddf2107d..f8fa2dbd66 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml @@ -704,7 +704,7 @@ wurde über Ihren Gruppen-Link eingeladen Sie haben die Rolle von %s auf %s geändert Sie haben Ihre eigene Rolle auf %s geändert - entfernt %1$s aus der Gruppe. + Sie haben %1$s aus der Gruppe entfernt hat die Gruppe verlassen Gruppenprofil aktualisiert @@ -1848,11 +1848,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 +1891,6 @@ Chat-Farben Chat-Design Passend - Chat-Liste in einem neuen Fenster anzeigen Guten Nachmittag! Guten Morgen! Farben für die dunkle Variante @@ -2058,9 +2055,7 @@ Stark Mittel Aus - Verbinden Nachricht - Öffnen Chat-Inhalte gelöscht! Nur die Chat-Inhalte löschen Suchen @@ -2209,14 +2204,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 +2215,6 @@ %s Server Netzwerk-Betreiber Verwende Server - Webseite Verwende %s %s.]]> %s.]]> @@ -2267,12 +2256,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,7 +2267,6 @@ %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.]]> @@ -2372,7 +2358,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 +2394,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 +2518,6 @@ Kontakt-Anfrage senden? Anfrage senden Anfrage ohne Nachricht senden - Wird nach der Verbindung an Ihren Kontakt gesendet. Gruppen-Link aktualisieren? Aktualisieren Adresse aktualisieren? @@ -2587,10 +2570,9 @@ Alten Link teilen Der Link wird gekürzt sein, und das Gruppen-Profil wird über den Link geteilt. Gruppen-Link aktualisieren - Kontaktanfragen von Gruppen + 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 +2602,6 @@ Mitglied entfernen und Nachrichten löschen Alle Nachrichten Dateien - Filter Bilder Links Dateien suchen @@ -2650,7 +2631,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 +2660,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 +2676,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 +2854,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 +2890,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 +2900,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 +2911,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 +2919,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 +2933,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. @@ -3010,4 +2980,11 @@ Öffentliche SimpleX-Namen (BETA) Datei-Server Datei-Server: %s + Gruppeneinladungen + weitergeleitet aus + %1$02d h %2$02d min %3$02d s + Crowdfunding-Investoren können vor Verkaufsbeginn Namen reservieren: + Der Verkauf von SimpleX-Namen startet in + bis Sie eine SimpleX-Domain registrieren können + Aktualisieren Sie die App, um eine SimpleX-Domain registrieren zu können 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..66cd9cf03b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml @@ -543,7 +543,6 @@ Ολοκληρωμένο Οι όροι έγιναν αποδεκτοί στις: %s. Όροι χρήσης - Οι όροι θα γίνουν αποδεκτοί για τους ενεργούς χειριστές μετά από 30 ημέρες. Οι όροι θα γίνουν αποδεκτοί στις: %s. Οι όροι θα γίνουν αυτόματα αποδεκτοί για τους ενεργούς χειριστές στις: %s. Διαμορφωμένοι SMP διακομιστές @@ -560,7 +559,6 @@ Επιβεβαίωσε ότι θυμάσαι τη φράση πρόσβασης της βάσης δεδομένων για να τη μεταφέρεις. Επιβεβαίωση μεταφόρτωσης Επιβεβαίωση των διαπιστευτηρίων σου - σύνδεση Σύνδεση Σύνδεση Σύνδεση @@ -925,7 +923,6 @@ Αντιστροφή κάμερας Μέγεθος γραμματοσειράς Για όλους τους διαχειριστές - για καλύτερη ιδιωτικότητα μεταδεδομένων Για το προφίλ συνομιλίας %s: Για κονσόλα Για όλους @@ -1216,8 +1213,6 @@ Απαγορεύονται οι αντιδράσεις στα μηνύματα. Απαγορεύονται οι αντιδράσεις στα μηνύματα σε αυτήν τη συνομιλία. Λήψη μηνυμάτων - Εναλλακτική δρομολόγηση μηνυμάτων - Λειτουργία δρομολόγησης μηνυμάτων Μηνύματα Μηνύματα και αρχεία Διακομιστές μηνυμάτων @@ -1289,7 +1284,6 @@ Πάτα Σύνδεση για να συνομιλήσεις Πάτα Σύνδεση για αποστολή αιτήματος Πάτα Σύνδεση για να χρησιμοποιήσεις το μποτ - Πατήστε Δημιουργία διεύθυνσης SimpleX στο μενού, για να τη δημιουργήσετε αργότερα. Πάτα Συμμετοχή στην ομάδα Πάτα για να ενεργοποιήσεις το προφίλ. Πάτα για Σύνδεση @@ -1341,7 +1335,6 @@ Η φράση πρόσβασης αποθηκεύεται στις ρυθμίσεις ως απλό κείμενο. Η φράση πρόσβασης θα αποθηκευτεί στις ρυθμίσεις ως απλό κείμενο μετά την αλλαγή της ή την επανεκκίνηση της εφαρμογής. Το προφίλ κοινοποιείται μόνο στις επαφές σου. - Η αναφορά θα αρχειοθετηθεί για εσένα. Ο ρόλος θα αλλάξει σε %s. Όλοι οι συμμετέχοντες στη συνομιλία θα ειδοποιηθούν. Ο ρόλος θα αλλάξει σε %s. Όλα τα μέλη της ομάδας θα ενημερωθούν. Ο ρόλος θα αλλάξει σε %s. Το μέλος θα λάβει νέα πρόσκληση. @@ -1375,7 +1368,6 @@ Αυτό το μήνυμα διαγράφηκε ή δεν έχει ληφθεί ακόμα. Αυτός ο κωδικός QR δεν είναι σύνδεσμος! Αυτή η ρύθμιση ισχύει για τα μηνύματα στο τρέχον προφίλ συνομιλίας σου. - Αυτή η ρύθμιση αφορά το τρέχον προφίλ σου. Αυτό το κείμενο δεν είναι σύνδεσμος! Αυτό το κείμενο είναι διαθέσιμο στις ρυθμίσεις Εξαντλήθηκε ο χρόνος αναμονής κατά τη σύνδεση με τον υπολογιστή @@ -1438,11 +1430,9 @@ Απενεργοποίηση σίγασης Απροστάτευτο αδιάβαστο - Μη αναγνωσμένες αναφορές Μη υποστηριζόμενος σύνδεσμος σύνδεσης Αναβάθμιση Αναβάθμιση - Αναβάθμιση Διαθέσιμη αναβάθμιση: %s Ενημέρωση φράσης πρόσβασης της βάσης δεδομένων Ενημερωμένοι όροι @@ -1737,7 +1727,6 @@ Μόνο η επαφή σου μπορεί να στείλει μηνύματα που εξαφανίζονται. Μόνο η επαφή σου μπορεί να στείλει αρχεία και πολυμέσα. Μόνο η επαφή σου μπορεί να στείλει φωνητικά μηνύματα. - άνοιγμα Άνοιξε Άνοιγμα Άνοιξε τις ρυθμίσεις της εφαρμογής @@ -1955,7 +1944,6 @@ Ο διακομιστής αναμετάδοσης χρησιμοποιείται μόνο αν είναι απαραίτητο. Οι άλλοι μπορούν να δουν τη διεύθυνση IP σου. Ο διακομιστής αναμετάδοσης προστατεύει τη διεύθυνση IP σου, αλλά μπορεί να παρακολουθεί τη διάρκεια της κλήσης. Υπενθύμιση αργότερα - Απομακρυσμένα κινητά τηλέφωνα Κατάργηση Κατάργηση Κατάργηση και διαγραφή μηνυμάτων @@ -2025,7 +2013,6 @@ Προϋποθέσεις ελέγχου ελέγχθηκε από τους διαχειριστές Έλεγχος μελών ομάδας - Έλεγχος αργότερα Έλεγχος μελών Έλεγχος μελών πριν την αποδοχή τους (knocking). Ανάκληση @@ -2088,7 +2075,6 @@ Επέλεξε επαφές Οι επιλεγμένες προτιμήσεις συνομιλίας απαγορεύουν αυτό το μήνυμα. Επιλέχθηκαν %d - Επέλεξε τους χειριστές δικτύου που θέλεις να χρησιμοποιήσεις. Αυτοκαταστροφή Κωδικός αυτοκαταστροφής Κωδικός αυτοκαταστροφής @@ -2140,7 +2126,6 @@ Τα αποσταλμένα μηνύματα θα διαγραφούν μετά από καθορισμένο χρονικό διάστημα. Απεσταλμένη απάντηση Σύνολο απεσταλμένων - Αποστέλλεται στην επαφή σου μετά τη σύνδεση. Αποστολή μέσω διακομιστή μεσολάβησης Διακομιστής Ο διακομιστής προστέθηκε στο χειριστή %s. @@ -2204,7 +2189,6 @@ Σύντομη διεύθυνση SimpleX Εμφάνιση Εμφάνιση: - Εμφάνιση λίστας μηνυμάτων σε νέο παράθυρο Εμφάνιση κονσόλας τερματικού σε νέο παράθυρο Εμφάνιση επαφής και μηνύματος Εμφάνιση επιλογών για προγραμματιστές @@ -2377,7 +2361,6 @@ Προειδοποίηση: η έναρξη συνομιλίας σε πολλαπλές συσκευές δεν υποστηρίζεται και θα προκαλέσει σφάλματα στην παράδοση των μηνυμάτων. Προειδοποίηση: ενδέχεται να χάσεις ορισμένα δεδομένα! Διακομιστές WebRTC ICE - Ιστοσελίδα Δεν αποθηκεύουμε καμία από τις επαφές ή τα μηνύματά σου (αφού παραδοθούν) στους διακομιστές. εβδομάδες Καλωσόρισες! @@ -2437,7 +2420,6 @@ είσαι παρατηρητής μπλόκαρες %s Μπορείς να το αλλάξεις στις ρυθμίσεις Εμφάνισης. - Μπορείς να διαμορφώσεις τους χειριστές στις ρυθμίσεις Δικτύου & διακομιστών. Μπορείς να διαμορφώσεις τους διακομιστές μέσω των ρυθμίσεων. Μπορείς να αντιγράψεις και να μειώσεις το μέγεθος του μηνύματος για να το στείλεις. Μπορείς να το δημιουργήσεις αργότερα @@ -2513,7 +2495,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 4e63f2c76a..5ba89e4df5 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml @@ -1761,8 +1761,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 +1814,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 @@ -1984,13 +1981,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 +2118,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 +2149,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 +2156,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 +2172,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 +2186,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.]]> @@ -2281,7 +2267,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 +2314,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 +2450,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 +2493,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 +2523,6 @@ Eliminar miembro y sus mensajes Todos los mensajes Archivos - Filtro Imágenes Enlaces Buscar archivos @@ -2592,7 +2573,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 +2588,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 +2608,6 @@ Ningún servidor de chat activado. Hay servidores no conectados Abrir canal - Abrir canal nuevo Propietario Propietarios y colaboradores Direcciones predefinidas @@ -2804,7 +2782,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 +2820,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 +2834,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 +2846,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 +2863,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. @@ -2938,4 +2908,11 @@ Gestiona tus servidores. Nombres públicos para tu canal o negocio. Nombres públicos SimpleX (BETA) + reenviado desde + Invitación de grupo + La venta de nombres SimpleX comienza en + hasta que puedas registrar un dominio SimpleX + Actualiza la app para poder registrar un dominio SimpleX + %1$02d hrs %2$02d min %3$02d seg + Los inversores de crowdfunding pueden reservar nombres antes del comienzo de la venta: 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..0051298873 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml @@ -1576,10 +1576,8 @@ هرگز استفاده از مسیریابی خصوصی با سرورهای ناشناخته. استفاده از مسیریابی خصوصی با سرورهای ناشناخته وقتی نشانی IP محافظت نشده است. - حالت مسیریابی پیام بله پیام‌ها مستقیما فرستاده نشود، حتی اگر سرور مقصد شما از مسیریابی خصوصی پشتیبانی نکند. - گزینه پس‌رفت مسیریابی پیام نمایش وضعیت پیام برای محافظت از نشانی IP شما، مسیریابی خصوصی از سرورهای SMP شما به منظور تحویل پیام‌ها استفاده می‌کند. مسیریابی پیام خصوصی @@ -1797,7 +1795,6 @@ اطلاعات صف سرور: %1$s \n \nآخرین پیام دریافتی: %2$s - نمایش فهرست چت در پنجره جدید حالت تاریک سیاه حالت رنگ @@ -1972,7 +1969,6 @@ برای مجوز میکروفون، روی آیکون اطلاعات کنار نوار آدرس کلیک کنید تکمیل شد تایید حذف مخاطب؟ - اتصال اتصال متصل اتصال سریع تر! 🚀 @@ -2049,7 +2045,6 @@ رد شده رد کردن عضو؟ بعداً یادآوری کن - موبایل‌های از راه دور حذف کردن آرشیو؟ از گروه حذف شد اعضا حذف شوند؟ @@ -2075,7 +2070,6 @@ بررسی شرایط بررسی شده توسط مدیران بررسی اعضای گروه - بعداً بررسی شود بررسی اعضا بررسی اعضا قبل از پذیرش (کوبیدن). آیا تنظیمات پذیرش ذخیره شود؟ @@ -2089,7 +2083,6 @@ انتخاب پروفایل چت تنظیمات برگزیده چت، این پیام را ممنوع می‌کند. %d انتخاب شده - اپراتورهای شبکه را برای استفاده انتخاب کنید. تنظیم پذیرش اعضا تنظیم انقضای پیام در چت‌ها. تنظیمات @@ -2131,7 +2124,6 @@ انتها برای چت، روی \"اتصال\" ضربه بزنید. برای ارسال درخواست، روی \"اتصال\" ضربه بزنید. - برای ایجاد آدرس SimpleX در آینده، در منو روی \"ایجاد آدرس SimpleX\" ضربه بزنید. روی \"پیوستن به گروه\" ضربه بزنید. اتصال TCP وقفه زمانی اتصال TCP در پس‌زمینه @@ -2181,7 +2173,6 @@ بخش‌ها بارگذاری شدند شرایط در %s پذیرفته شد. شرایط استفاده - شرایط برای اپراتورهای فعال پس از ۳۰ روز پذیرفته خواهد شد. شرایط در: %s پذیرفته خواهد شد. شرایط به‌طور خودکار برای اپراتورهای فعال در: %s پذیرفته خواهد شد. سرورهای SMP پیکربندی‌شده @@ -2307,7 +2298,6 @@ خاموش فقط مالکین چت می‌توانند تنظیمات را تغییر دهند. فقط گفتگو حذف شود - باز کردن باز کردن تغییرات - باز کردن چت برای اولین پیام خوانده نشده.\n- پرش به پیام‌های نقل‌قول شده. شرایط باز کردن @@ -2354,7 +2344,6 @@ اتصال اصلاح شود؟ اندازه فونت برای همه مدیران - برای بهبود حریم خصوصی فراداده. به عنوان مثال، اگر مخاطب شما پیام‌ها را از طریق یک سرور چت SimpleX دریافت کند، برنامه شما آن‌ها را از طریق یک سرور Flux تحویل خواهد داد. برای من برای مسیریابی خصوصی @@ -2407,7 +2396,6 @@ مستقیم ارسال شد پیام‌های ارسال شده تعداد کل ارسال شده - پس از اتصال به مخاطب شما ارسال شد. توسط پروکسی ارسال شد سرور سرور به اپراتور %s اضافه شد. @@ -2418,7 +2406,6 @@ اطلاعات سرورها آمار سرورها بازنشانی خواهد شد - این عمل غیرقابل بازگشت است! نام چت را تنظیم کنید… - گزارش برای شما بایگانی خواهد شد. نقش به %s تغییر خواهد کرد. همه در چت مطلع خواهند شد. دومین اپراتور پیش‌فرض در برنامه! فرستنده مطلع نخواهد شد. @@ -2437,8 +2424,6 @@ شفافیت جلسات Transport رفع انسداد اعضا برای همه؟ - منشن‌های خوانده نشده - به‌روزرسانی به‌روزرسانی موجود است: %s شرایط به‌روزرسانی شده دانلود به‌روزرسانی لغو شد @@ -2461,7 +2446,6 @@ ویدئو مشاهده شرایط مشاهده شرایط به‌روزرسانی شده - وب‌سایت پیام خوش‌آمدگویی زمانی که بیش از یک اپراتور فعال است، هیچ‌کدام از آن‌ها فراداده‌ای برای فهمیدن اینکه چه کسی با چه کسی ارتباط برقرار می‌کند، ندارند. سرور XFTP @@ -2469,7 +2453,6 @@ شما این عضو را پذیرفتید شما به این سرورها متصل نیستید. از مسیریابی خصوصی برای ارسال پیام‌ها به آن‌ها استفاده می‌شود. شما می‌توانید آن را در تنظیمات ظاهر تغییر دهید. - شما می‌توانید اپراتورها را در تنظیمات شبکه و سرورها پیکربندی کنید. شما می‌توانید سرورها را از طریق تنظیمات پیکربندی کنید. شما می‌توانید پیام را کپی کرده و اندازه آن را کاهش دهید تا ارسال شود. شما می‌توانید در هر پیام تا %1$s عضو را منشن کنید! @@ -2510,7 +2493,6 @@ ۴ زبان جدید رابط کاربری کاتالان، اندونزیایی، رومانیایی و ویتنامی - با تشکر از کاربران ما! عضو حذف شده است - نمی‌توان درخواست را قبول کرد - این تنظیمات برای پروفایل فعلی شماست درخواست‌های تماس از گروه‌ها درخواست اتصال از گروه %1$s برای استفاده از ربات باز کنید 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 1997a54dca..572b10c988 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml @@ -94,15 +94,15 @@ nouveau message Nouvelle demande de contact Connecté - SimpleX Lock + Verrouillage SimpleX Activer - SimpleX Lock activé + Verrouillage SimpleX activé Il vous sera demandé de vous authentifier lorsque vous démarrez ou reprenez l\'application après 30 secondes en arrière-plan. Déverrouiller - Activer SimpleX Lock - Désactiver SimpleX Lock + Activer le Verrouillage SimpleX + Désactiver le Verrouillage SimpleX Authentification indisponible - L\'authentification de l\'appareil est désactivée. Désactivation de SimpleX Lock. + L\'authentification de l\'appareil est désactivée. Désactivation du Verrouillage SimpleX. Ouvrir la console de messagerie Erreur de distribution du message Il est fort probable que ce contact ait supprimé la connexion avec vous. @@ -120,9 +120,8 @@ Conversations Texte du message Caché - Pour protéger vos informations, activez la fonction SimpleX Lock. -\nVous serez invité à confirmer l\'authentification avant que cette fonction ne soit activée. - L\'authentification de l\'appareil n\'est pas activée. Vous pouvez activer SimpleX Lock via Paramètres, une fois que vous avez activé l\'authentification de l\'appareil. + Pour protéger vos informations, activez le Verrouillage SimpleX.\nUne authentification vous sera demandée avant que cette fonctionnalité ne soit activée. + L\'authentification de l\'appareil n\'est pas activée. Vous pouvez activer le Verrouillage SimpleX dans les Paramètres une fois l\'authentification de l\'appareil activée. La base de données ne fonctionne pas correctement. Appuyez ici pour en savoir plus. Appels SimpleX Chat Messages SimpleX Chat @@ -177,7 +176,7 @@ Fichier introuvable Erreur lors de la sauvegarde du fichier Supprimer le contact \? - Le contact et tous les messages seront supprimés - impossible de revenir en arrière ! + Le contact et tous les messages seront supprimés — cette action est irréversible ! Supprimer le contact Connecté Envoyer un message @@ -212,7 +211,7 @@ Accepter en incognito Rejeter Effacer la conversation \? - Tous les messages seront supprimés : impossible de revenir en arrière ! Les messages seront supprimés UNIQUEMENT pour vous. + Tous les messages seront supprimés — cette action est irréversible ! Les messages seront supprimés UNIQUEMENT pour vous. Effacer Supprimer Supprimer @@ -279,7 +278,7 @@ Connectez-vous en utilisant votre identifiant Confirmez vos identifiants Arrêter la messagerie - Le message sera supprimé - impossible de revenir en arrière ! + Le message sera supprimé — cette action est irréversible ! Le message sera marqué comme supprimé. Le·s destinataire·s pourrai·ent révéler ce message. modifié L\'image sera reçue quand votre contact sera en ligne, merci d\'attendre ou de revenir plus tard ! @@ -320,7 +319,7 @@ Accéder aux serveurs via un proxy SOCKS sur le port %d \? Le proxy doit être démarré avant d\'activer cette option. Utiliser les hôtes .onions Vos paramètres - SimpleX Lock + Verrouillage SimpleX Console de la messagerie Serveurs SMP Tester les serveurs @@ -448,7 +447,7 @@ 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\'app fonctionne toujours en arrière-plan - les notifications s\'affichent instantanément.]]> + 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) ID du message incorrect Paramètres @@ -459,10 +458,10 @@ invité par votre lien de groupe vous avez changé d\'adresse Arrêtez la messagerie pour exporter, importer ou supprimer sa base de données. Vous ne pourrez pas recevoir ni envoyer de messages tant que la messagerie est arrêtée. - Cette action ne peut être annulée - les messages envoyés et reçus avant la date sélectionnée seront supprimés. Cela peut prendre plusieurs minutes. + Cette action est irréversible — les messages envoyés et reçus avant la date sélectionnée seront supprimés. Cela peut prendre plusieurs minutes. La base de données est chiffrée à l\'aide d\'une phrase secrète aléatoire, que vous pouvez modifier. Restaurer la sauvegarde de la base de données - La phrase secrète n\'a pas été trouvée dans le Keystore, veuillez la saisir manuellement. Cela a pu se produire si vous avez restauré les données de l\'app à l\'aide d\'un outil de sauvegarde. Si ce n\'est pas le cas, veuillez contacter les développeurs. + Phrase secrète introuvable dans le Keystore ; veuillez la saisir manuellement. Cela peut être arrivé si vous avez restauré les données de l\'application à l\'aide d\'un outil de sauvegarde. Si ce n\'est pas le cas, veuillez contacter les développeurs. Veuillez entrer le mot de passe précédent après avoir restauré la sauvegarde de la base de données. Cette action ne peut pas être annulée. Erreur de restauration de la base de données appel vidéo (chiffrement de bout en bout) @@ -481,7 +480,7 @@ Vidéo OFF Appel en cours Appel terminé - Votre vie privée + Votre confidentialité Appareil Conversations Outils du développeur @@ -527,7 +526,7 @@ Supprimer la base de données Erreur lors du démarrage de la messagerie Importer - Cette action ne peut être annulée : votre profil, vos contacts, vos messages et vos fichiers seront irréversiblement perdus. + Cette action est irréversible — votre profil, vos contacts, vos messages et vos fichiers seront irréversiblement perdus. Base de données de la messagerie supprimée Redémarrez l\'application pour créer un nouveau profil de messagerie. Vous devez utiliser la version la plus récente de votre base de données de messagerie sur un seul appareil UNIQUEMENT, sinon vous risquez de ne plus recevoir les messages de certains contacts. @@ -562,7 +561,7 @@ Erreur lors de l\'arrêt de la messagerie Erreur lors de l\'exportation de la base de données de la messagerie Importer la base de données de la messagerie ? - Votre base de données actuelle sera SUPPRIMÉE et REMPLACÉE par celle importée.\nCette action est irréversible : votre profil, vos contacts, vos messages et vos fichiers seront définitivement perdus. + Votre base de données actuelle sera SUPPRIMÉE et REMPLACÉE par celle importée.\nCette action est irréversible — votre profil, vos contacts, vos messages et vos fichiers seront définitivement perdus. Entrez la phrase secrète… Appel audio entrant %1$s veut se connecter à vous via @@ -611,7 +610,7 @@ Base de données de la messagerie importée Supprimer le profil de messagerie ? Supprimer les fichiers et médias \? - Cette action ne peut être annulée - tous les fichiers et médias reçus et envoyés seront supprimés. Les photos à faible résolution seront conservées. + Cette action est irréversible — tous les fichiers et médias reçus et envoyés seront supprimés. Les photos à faible résolution seront conservées. Aucun fichier reçu ou envoyé 1 mois %s seconde(s) @@ -658,7 +657,7 @@ a modifié votre rôle pour %s vous a retiré a supprimé le groupe - mise à jour du profil de groupe + a mis à jour le profil du groupe vous avez modifié le rôle de %s pour %s vous avez modifié votre rôle pour %s vous avez quitté @@ -711,11 +710,11 @@ Ignorer l’invitation de membres Sélectionnez des contacts Aucun contact sélectionné - %1$s MEMBRES + %1$s membres vous : %1$s Supprimer le groupe - Le groupe va être supprimé pour tout les membres - impossible de revenir en arrière ! - Le groupe va être supprimé pour vous - impossible de revenir en arrière ! + Le groupe va être supprimé pour tout les membres — cette action est irréversible ! + Le groupe va être supprimé pour vous — cette action est irréversible ! Quitter le groupe Supprimer le lien \? Supprimer le lien @@ -727,7 +726,7 @@ ID de base de données Retirer le membre Envoyer un message direct - Ce membre sera retiré du groupe - impossible de revenir en arrière ! + Ce membre sera retiré du groupe — cette action est irréversible ! Rôle Changer le rôle Changer @@ -884,7 +883,7 @@ Version de l\'app : v%s Version du cœur : v%s Nombre de PING - Toutes les discussions et tous les messages seront supprimés - il est impossible de revenir en arrière ! + Toutes les discussions et tous les messages seront supprimés — cette action est irréversible ! Effacer tous les fichiers Supprimer le profil de messagerie pour pour chaque profil de messagerie que vous avez dans l\'application.]]> @@ -909,7 +908,7 @@ Vous avez déjà un profil de messagerie avec ce même nom affiché. Veuillez choisir un autre nom. Nom d\'affichage en double ! Interface en français - Par profil de messagerie (par défaut) ou par connexion (BETA). + Par profil de messagerie (par défaut) ou par connexion (BÊTA). Interface en italien Brouillon de message D\'autres améliorations sont à venir ! @@ -1031,8 +1030,8 @@ Paramètres de proxy SOCKS Utiliser un proxy SOCKS Utiliser les hôtes .onion sur Non si le proxy SOCKS ne les prend pas en charge.]]> - Mode de SimpleX Lock - SimpleX Lock n\'est pas activé ! + Mode de Verrouillage SimpleX + Le Verrouillage SimpleX n\'est pas activé ! Authentification du système Authentification Échec de l’authentification @@ -1089,7 +1088,7 @@ Vous n\'avez pas pu être vérifié·e ; veuillez réessayer. %1$d messages n\'ont pas pu être déchiffrés. %1$d messages ignorés. - Vous pouvez activer SimpleX Lock dans les Paramètres. + Vous pouvez activer le Verrouillage SimpleX dans les Paramètres. Merci aux utilisateurs - contribuez via Weblate ! Vidéos et fichiers jusqu\'à 1Go Code d\'accès à l\'app @@ -1228,9 +1227,9 @@ Message envoyé aucun texte L\'importation a entraîné des erreurs non fatales : - Les notifications ne fonctionnent pas tant que vous ne relancez pas l\'application + Les notifications cesseront de fonctionner jusqu’à ce que vous relanciez l’application Arrêt \? - Mise à l\'arrêt + Arrêter l\'application Redémarrer App Abandonner @@ -1282,7 +1281,7 @@ L\'envoi d\'accusés de réception sera activé pour tous les contacts dans tous les profils de messagerie visibles. Ils peuvent être modifiés dans les paramètres des contacts et des groupes. Ces paramètres s\'appliquent à votre profil actuel - Vous pouvez les activer ultérieurement via les paramètres de Confidentialité et Sécurité de l\'application. + Vous pouvez les activer plus tard dans les paramètres de confidentialité de l\'application. Activer les accusés de réception \? Désactiver les accusés de réception \? L\'envoi d\'accusés de réception est activé pour les contacts de %d @@ -1371,14 +1370,12 @@ Arabe, bulgare, finnois, hébreu, thaï et ukrainien - grâce aux utilisateurs et à Weblate. Créer un nouveau profil sur l\'application de bureau. 💻 Basculer en mode incognito lors de la connexion. - - connexion au service d\'annuaire (BETA) ! -\n- accusés de réception (jusqu\'à 20 membres). -\n- plus rapide et plus stable. + - connexion au service d\'annuaire (BÊTA) ! \n- accusés de réception (jusqu\'à 20 membres). \n- plus rapide et plus stable. Ouvrir Erreur lors de la création du contact du membre Envoyer un message direct pour vous connecter envoyer pour se connecter - s\'est connecté.e de manière directe + a demandé une connexion Étendre Répéter la demande de connexion ? contact supprimé @@ -1463,7 +1460,7 @@ Chargement du fichier Connexion au PC Ordinateurs - Lier un portable + Lier un téléphone Utiliser depuis l’ordinateur Mobile connecté Code de session @@ -1477,7 +1474,7 @@ Connexion au PC Se déconnecter auteur - Connecté au portable + Connecté au téléphone Adresse du PC non valide Coller l\'adresse du PC Vérifier le code avec le PC @@ -1507,7 +1504,7 @@ 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 Paramètres. + 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 @@ -1561,7 +1558,7 @@ inconnu statut inconnu Connexion interrompue - Ancien membre %1$s + Membre %1$s La fonctions prend trop de temps à s\'exécuter : %1$d secondes : %2$s Fonction lente Afficher les appels d\'API lents @@ -1578,7 +1575,7 @@ Historique récent et bot d\'annuaire amélioré. La barre de recherche accepte les liens d\'invitation. Consommation réduite de la batterie. - Tous les messages seront supprimés - il n\'est pas possible de revenir en arrière ! + Tous les messages seront supprimés — cette action est irréversible ! Interface utilisateur en hongrois et en turc Notes privées Créé à @@ -1604,8 +1601,8 @@ Erreur lors du blocage du membre pour tous %d messages bloqués par l\'administrateur bloqué par l\'administrateur - %s bloqué - %s débloqué + a bloqué %s + a débloqué %s vous avez bloqué %s vous avez débloqué %s Message trop volumineux @@ -1642,7 +1639,7 @@ Supprimer la base de données de cet appareil Téléchargement de l\'archive Téléchargement des détails du lien - Activé dans les conversations directes (BETA) ! + Activé dans les conversations directes (BÊTA) ! Entrer la phrase secrète Erreur lors de la suppression de la base de données Erreur lors de l\'exportation de la base de données des chats @@ -1767,13 +1764,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 @@ -1806,7 +1801,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 @@ -1889,7 +1883,7 @@ Reconnecter le serveur pour forcer la livraison des messages. Utilise du trafic supplémentaire. Erreur de réinitialisation des statistiques Réinitialiser - Les statistiques des serveurs seront réinitialisées - il n\'est pas possible de revenir en arrière ! + Les statistiques des serveurs seront réinitialisées — cette action est irréversible ! Téléversé Statistiques détaillées Messages envoyés @@ -1982,11 +1976,9 @@ Off Léger Paramètres - se connecter message - ouvrir Confirmer la suppression du contact ? - Le contact sera supprimé - il n\'est pas possible de revenir en arrière ! + Le contact sera supprimé — cette action est irréversible ! Supprimer sans notification Garder la conversation Ne supprimer que la conversation @@ -2090,7 +2082,7 @@ Utilisez des identifiants de proxy différents pour chaque profil. Utiliser des identifiants aléatoires Nom d\'utilisateur - Les messages seront supprimés - il n\'est pas possible de revenir en arrière ! + Les messages seront supprimés — cette action est irréversible ! Base de données de la messagerie Mode système Serveur @@ -2172,9 +2164,8 @@ Ajout de serveurs de messages %s.]]> 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 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 ! 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 ? @@ -2191,7 +2182,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) @@ -2217,9 +2207,8 @@ Les serveurs pour les nouveaux fichiers de votre profil de messagerie actuel Serveur de l\'opérateur Le protocole du serveur a été modifié. - Activer Flux + 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. @@ -2248,7 +2237,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 ! @@ -2263,7 +2251,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é @@ -2275,17 +2262,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 @@ -2299,7 +2283,7 @@ Nom de la liste... Quitter la conversation ? Vous ne recevrez plus de messages de cette conversation. L’historique sera conservé. - Le membre sera retiré de la conversation ; cette action est irréversible ! + Le membre sera retiré de la conversation — cette action est irréversible ! Votre profil de messagerie sera envoyé aux autres membres Vos serveurs Utiliser %s @@ -2313,9 +2297,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. @@ -2387,7 +2369,7 @@ échoué échoué Fichiers - Les membres seront retirés du groupe - impossible de revenir en arrière! + Les membres seront retirés du groupe — cette action est irréversible ! Le membre va rejoindre le groupe, accepter le membre? Les messages de ces membres seront affichés! modérateurs @@ -2428,7 +2410,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 @@ -2444,11 +2425,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 @@ -2459,7 +2438,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 @@ -2467,7 +2445,7 @@ Nom du canal SimpleX Canal temporairement indisponible Page web du canal - Le canal sera supprimé pour tous les abonnés ; cette action est irréversible ! + Le canal sera supprimé pour tous les abonnés — cette action est irréversible ! Lien Parce que nous avons détruit le pouvoir de vous identifier. Pour que votre pouvoir ne puisse jamais vous être enlevé. Bot @@ -2495,7 +2473,7 @@ Ce groupe nécessite une version plus récente de l\'application. Veuillez la mettre à jour pour le rejoindre. Erreur lors de la suppression du message Enregistrer le nom SimpleX ? - Obtenir le nom SimpleX (BETA) + Obtenir le nom SimpleX (BÊTA) Comment enregistrer un nom de test Supprimer le nom Rechercher des images @@ -2507,7 +2485,6 @@ Vidéos Messages vocaux Liens - Filtre Depuis l\'historique Parler à quelqu\'un Permettez à quelqu\'un de se connecter à vous @@ -2546,7 +2523,7 @@ (de la part du propriétaire) Vous pouvez consulter vos signalements dans la section Discuter avec les admins. Les messages de cette conversation ne seront jamais supprimés. - Cette action est irréversible : les messages envoyés et reçus dans cette conversation avant la date sélectionnée seront supprimés. + Cette action est irréversible — les messages envoyés et reçus dans cette conversation avant la date sélectionnée seront supprimés. Définir le nom de la conversation… Mettre tout en sourdine Les opérateurs s’engagent à :\n- Être indépendants\n- Réduire au minimum l’utilisation des métadonnées\n- Exécuter du code open source vérifié @@ -2559,16 +2536,11 @@ Discuter avec les admins Relais de messagerie Discuter avec un membre - Les membres seront retirés de la conversation ; cette action est irréversible ! + Les membres seront retirés de la conversation — cette action est irréversible ! L\'empreinte numérique dans l\'adresse du serveur ne correspond pas au certificat : %1$s. 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. @@ -2608,11 +2580,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 @@ -2658,9 +2628,282 @@ Saisissez le nom du profil… Migrer Vous êtes né·e sans compte. - Personne n\'a tracé vos conversations. Personne n\'a dressé la carte de vos déplacements. La confidentialité n\'a jamais été une fonctionnalité : c\'était un mode de vie. + Personne n\'a tracé vos conversations. Personne n\'a dressé la carte de vos déplacements. La confidentialité n\'a jamais été une fonctionnalité — c\'était un mode de vie. Puis nous sommes passés en ligne, et chaque plateforme a réclamé une partie de vous : votre nom, votre numéro, vos amis. Nous avons accepté que le prix à payer pour parler aux autres soit de permettre à quelqu\'un de savoir avec qui nous parlons. Chaque génération, humaine et technologique, a fonctionné ainsi : téléphone, email, messageries, réseaux sociaux. Il semblait que ce fût la seule voie possible. Il existe une autre voie. Un réseau sans numéros de téléphone. Sans noms d\'utilisateur. Sans comptes. Sans aucune identité utilisateur. Un réseau qui connecte les personnes et achemine des messages chiffrés sans savoir qui est connecté. - Pas une meilleure serrure sur la porte de quelqu\'un d\'autre. Ni un propriétaire plus sympa qui respecte votre vie privée, mais conserve tout de même la trace de tous les visiteurs. Vous n\'êtes pas un invité. Vous êtes chez vous. Aucun roi ne peut y entrer : vous êtes souverain. + Pas une meilleure serrure sur la porte de quelqu\'un d\'autre. Pas un propriétaire plus attentionné qui respecte votre vie privée, mais conserve toujours le registre de tous les visiteurs. Vous n\'êtes pas un invité. Vous êtes chez vous. Aucun roi ne peut y entrer — vous êtes souverain. Vos conversations vous appartiennent, comme c\'était toujours le cas avant Internet. Le réseau n\'est pas un lieu que vous visitez. C\'est un lieu que vous créez et possédez. Et personne ne peut vous l\'enlever, que vous le rendiez privé ou public. + Invitations de groupe + La plus ancienne liberté humaine : parler à une autre personne sans être surveillé — bâtie sur une infrastructure qui ne peut la trahir. + Soyez libre dans votre réseau. + Vous vous engagez à :\n- Ne publier que du contenu légal dans les groupes publics\n- Respecter les autres utilisateurs — pas de spam + Accepter + Les routeurs du réseau ne peuvent savoir\nqui parle à qui + Configurer les routeurs + Configurer les notifications + Engagements réseau + Ouvrir le lien externe ? + abandonné (%1$d tentatives) + erreur : %s + Erreur de message + 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 + Contact + Soutenir le projet + Aide et support + Plus de confidentialité + Paramètres avancés + La phrase secrète dans le Keystore ne peut pas être lue. Cela peut être arrivé après une mise à jour du système incompatible avec l\'application. Si ce n\'est pas le cas, veuillez contacter les développeurs. + La phrase secrète dans le Keystore ne peut pas être lue ; veuillez la saisir manuellement. Cela peut être arrivé après une mise à jour du système incompatible avec l\'application. Si ce n\'est pas le cas, veuillez contacter les développeurs. + Barre inférieure + Barre supérieure + Quitter le canal ? + demande de connexion du groupe %1$s + a accepté %1$s + a supprimé le canal + a mis à jour le profil du canal + Un nouveau membre souhaite rejoindre le groupe. + vous avez accepté ce membre + %d événements de canal + abonné + en cours d\'examen + examen + Supprimer le canal + Annuler et supprimer le canal + Supprimer le canal ? + Le canal sera supprimé pour vous — cette action est irréversible ! + Quitter le canal + Modifier le profil du canal + Site web du groupe + Options avancées + https:// + Autoriser tout le monde à intégrer + Saisir l\'URL du site web + Elle sera affichée aux abonnés et utilisée pour autoriser le chargement de l\'aperçu. + Code de la page web + Ajoutez ce code à votre page web. Il affichera l\'aperçu de votre canal / groupe. + Copier le code + Créez une page web pour montrer l\'aperçu de votre canal aux visiteurs avant qu\'ils ne s\'abonnent. Hébergez-la vous-même ou utilisez n\'importe quel hébergement statique. + N\'importe quelle page web peut afficher l\'aperçu. + Seule la page ci-dessus peut afficher l\'aperçu. + Vous pouvez partager un lien ou un QR code — n\'importe qui pourra rejoindre le canal. + Seuls les propriétaires du canal peuvent modifier ses préférences. + Serveurs de fichiers + Serveurs de fichiers : %s + Supprimer l\'abonné ? + Supprimer les messages du membre ? + Supprimer les messages du membre + L\'abonné sera supprimé du canal — cette action est irréversible ! + Les messages du membre seront supprimés — cette action est irréversible ! + Débloquer ces membres pour tous ? + Le rôle sera modifié en %s. Tous les membres du canal en seront informés. + Nom complet du canal : + Description trop longue + Enregistrer le profil du canal + Erreur lors de l\'enregistrement du profil du canal + Conditions mises à jour + Pour résoudre les noms + Délai d\'expiration de la connexion TCP en arrière-plan + Délai d\'expiration du protocole en arrière-plan + Définir l\'admission des membres + Le délai avant disparition est défini uniquement pour les nouveaux contacts. + Interdire l\'envoi de fichiers et de médias. + Vous et votre contact pouvez envoyer des fichiers et des médias. + Vous seul pouvez envoyer des fichiers et des médias. + Seul votre contact peut envoyer des fichiers et des médias. + Les fichiers et les médias sont interdits dans cette conversation. + Le signalement des messages est interdit dans ce groupe. + Discuter avec les admins + Autoriser les membres à discuter avec les admins. + Interdire les conversations avec les admins. + Les membres peuvent discuter avec les admins. + transféré de + Les conversations avec les admins sont interdites. + Les conversations avec les admins dans les canaux publics ne sont pas chiffrées de bout en bout — utilisez-les uniquement avec des relais de messagerie de confiance. + Activer les conversations avec les admins ? + Activer + Signalements des abonnés + Autoriser l\'envoi de messages directs aux abonnés. + Interdire l\'envoi de messages directs aux abonnés. + Envoyer jusqu\'à 100 derniers messages aux nouveaux abonnés. + Ne pas envoyer l\'historique aux nouveaux abonnés. + Les abonnés peuvent envoyer des messages éphémères. + Les abonnés peuvent envoyer des messages directs. + Les messages directs entre abonnés sont interdits. + Les abonnés peuvent supprimer définitivement les messages envoyés. (24 heures) + Les abonnés peuvent ajouter des réactions aux messages. + Les abonnés peuvent envoyer des messages vocaux. + Les abonnés peuvent envoyer des fichiers et des médias. + Les abonnés peuvent envoyer des liens SimpleX. + Jusqu\'à 100 derniers messages sont envoyés aux nouveaux abonnés. + L\'historique n\'est pas envoyé aux nouveaux abonnés. + Autoriser les abonnés à discuter avec les admins. + Les abonnés peuvent discuter avec les admins. + Admission des membres + Examiner les membres avant leur admission (demande d\'entrée). + désactivé + Conversations avec les membres + Pas de conversations avec les membres + Les conversations avec les membres sont désactivées + Supprimer la conversation + Supprimer la conversation avec le membre ? + Discuter avec les admins + Accepter + Accepter en tant que membre + Accepter en tant qu\'observateur + Mentionner des membres 👋 + Envoyer des signalements privés + Organiser les conversations en listes + Noms de fichiers multimédias privés. + Définir l’expiration des messages dans les conversations. + Connectez-vous plus rapidement ! 🚀 + Discutez instantanément en appuyant sur Se connecter. + Discutez avec les membres avant qu’ils ne rejoignent. + Discuter avec les admins + Moins de trafic sur les réseaux mobiles. + Accueillez vos contacts 👋 + Définissez votre bio du profil et votre message d’accueil. + Gardez vos conversations propres + Activer par défaut les messages éphémères. + Catalan, indonésien, roumain et vietnamien — grâce à nos utilisateurs ! + Canaux publics — exprimez-vous librement 🚀 + Fiabilité : plusieurs relais par canal. + Souveraineté : vous pouvez gérer vos propres relais. + Sécurité : les propriétaires détiennent les clés du canal. + Confidentialité : pour les propriétaires et les abonnés. + Invitez plus facilement vos amis 👋 + Nous avons simplifié la connexion pour les nouveaux utilisateurs. + Liens web sécurisés + - activez l’envoi des aperçus de liens.\n- utilisez le proxy SOCKS s’il est activé.\n- empêchez le phishing par hyperliens.\n- supprimez le suivi des liens. + Gouvernance à but non lucratif + Pour que le réseau SimpleX perdure. + Noms publics SimpleX (BÊTA) + Noms publics pour votre canal ou votre entreprise. + De meilleurs canaux 📢 + Créer un aperçu web. + Gérez vos relais. + Ajoutez des contributeurs. + Plus facile à lire. + L\'envoi d\'un aperçu de lien peut révéler votre adresse IP au site web. Vous pourrez modifier cela dans les paramètres de confidentialité plus tard. + Vous pouvez mentionner jusqu\'à %1$s membres par message ! + Abonnés + %1$d abonné + %1$d abonnés + %1$d propriétaire + %1$d propriétaires + %1$d propriétaires et contributeurs + Relais de messagerie + Nouveau relais de messagerie + Votre nom de relais + Votre adresse de relais + Utiliser le relais + Utiliser pour les nouveaux canaux + Tester le relais pour récupérer son nom.]]> + Échec du test du relais ! + Obtenir le lien + Décoder le lien + En attente de la réponse + Vérifier + Nom de relais invalide ! + Adresse de relais invalide ! + Erreur lors de l\'ajout du relais + Relais de messagerie + Les relais de messagerie transmettent les messages dans les canaux que vous créez. + Relais de messagerie + Aucun relais de messagerie + Les relais de messagerie transmettent les messages aux abonnés du canal. + connexion en cours + en échec + supprimé par l\'opérateur + supprimé + invité + accepté + liste reconnue + inactif + rejeté + Statut + rejeté par l\'opérateur du relais + Tous les relais ont été supprimés + Tous les relais sont en échec + Aucun relais actif + %1$d relais supprimés + %1$d relais en échec + %1$d relais non actifs + %1$d/%2$d relais actifs, %3$d en échec + %1$d/%2$d relais actifs, %3$d supprimés + %1$d/%2$d relais actifs, %3$d erreurs + %1$d/%2$d relais actifs + %1$d/%2$d relais connectés, %3$d erreurs + %1$d/%2$d relais connectés, %3$d en échec + %1$d/%2$d relais connectés, %3$d supprimés + %1$d/%2$d relais connectés + Aucun relais + Ajoutez des relais pour rétablir la distribution des messages. + En attente que le propriétaire du canal ajoute des relais. + Propriétaire + Abonné + via %1$s + Les abonnés utilisent le lien du relais pour se connecter au canal.\nL’adresse du relais a été utilisée pour configurer ce relais pour le canal. + Vous vous êtes connecté au canal via ce lien de relais. + Supprimer l\'abonné + Supprimer le relais + Supprimer le relais ? + Le relais sera supprimé du canal — cette action est irréversible ! + Il s’agit du dernier relais actif. Sa suppression empêchera la distribution des messages aux abonnés. + Bloquer l\'abonné pour tout le monde ? + Créer un canal public + Créer un canal public + Création du canal + Erreur lors de la création du canal. + Résultats du relais : + La connexion a atteint la limite de messages non distribués + Erreur réseau + Erreur + Annuler la création du canal ? + Votre nouveau canal %1$s est connecté à %2$d relais sur %3$d.\nSi vous annulez, le canal sera supprimé — vous pourrez le recréer. + Activez au moins un relais de messagerie pour créer un canal. + Votre profil %1$s sera partagé avec les relais du canal et les abonnés.\nLes relais peuvent accéder aux messages du canal. + Configurer les relais + Ajouter un relais + Ajouter des relais + Aucun relais disponible + Erreur lors de l\'ajout de relais + Relais ajoutés : %1$s. + Sélectionner des relais + Aucun relais sélectionné + %d relais sélectionné(s) + Tous les relais ne sont pas connectés + Attendre + 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 + %1$s !]]> + 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. + Activer + Désactiver + Réduire dans la zone de notification ? + Si vous choisissez Fermer, les messages ne seront pas reçus.\nVous pouvez modifier ce choix ultérieurement dans les paramètres d’apparence. + Fermer l\'application + Réduire dans la zone de notification + Afficher SimpleX + Quitter SimpleX + SimpleX — %d non lus + Fermer dans la zone de notification + Fonctionne en arrière-plan pour recevoir les messages + %s soutient SimpleX Chat. + %1$s a soutenu SimpleX Chat. Le badge a expiré le %2$s. + Vous pouvez soutenir SimpleX à partir de la version 7 de l’application. + %s a investi dans le financement participatif de SimpleX Chat. + Badge non vérifié + Ce badge n’a pas pu être vérifié et peut ne pas être authentique. + Le badge ne peut pas être vérifié + Le badge est signé avec une clé que cette version de l’application ne reconnaît pas. Mettez à jour l’application pour vérifier ce badge. + La vente du nom SimpleX commence dans + jusqu’à ce que vous puissiez enregistrer un domaine SimpleX + Mettez à jour l’application pour enregistrer un domaine SimpleX + %1$02d h %2$02d min %3$02d s + Les investisseurs du financement participatif peuvent réserver des noms avant le début de la vente : 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..7be983c71a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml @@ -137,7 +137,6 @@ Odstraniti %d poruke? Odstraniti Podešavanje - Ažuriraj Odstraniti sve datoteke Odstraniti profil razgovora? Poruke @@ -196,7 +195,6 @@ Dodaj prijatelja Dodaj člana tima Arhiva - poveži Kamera Više Otključaj @@ -502,7 +500,6 @@ Vaši kontakti Ne Započeti razgovor? - otvoriti Nastaviti Otvoriti Pozadina @@ -865,7 +862,6 @@ autor Izvesti temu Žive poruke - Website dani Prosleđivanje %1$s poruka Kada je dostupno @@ -880,7 +876,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 @@ -1110,7 +1105,6 @@ Unos pin koda Prosleđena poruka Ništa za prosleđivanje - Pregledati kasnije kontakt ima e2e šifrovanje Zaključati nakon Skriven kontakt: 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 2fca87fdcd..82518712da 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml @@ -49,7 +49,7 @@ Alkalmazásadatok biztonsági mentése Az adatbázis előkészítése sikertelen Az összes partnerével továbbra is kapcsolatban marad. A profilfrissítés el lesz küldve a partnerei számára. - A csevegési profillal (alapértelmezett), vagy a kapcsolattal (béta). + A csevegési profillal (alapértelmezés), vagy a kapcsolattal (béta). Egy új, véletlenszerű profil lesz megosztva. A hangüzenetek küldése csak abban az esetben van engedélyezve, ha a partnere is engedélyezi. Alkalmazás összeállítási száma: %s @@ -445,7 +445,7 @@ Látható a helyi hálózaton Nem engedélyezem Az eltűnő üzenetek küldése le van tiltva ebben a csevegésben. - alapértelmezett (%s) + alapértelmezés (%s) duplikált üzenet Leválasztja a számítógépet? A számítógépes alkalmazás verziója (%s) nem kompatibilis ezzel az alkalmazással. @@ -690,7 +690,7 @@ új üzenet Régi adatbázis-archívum Speciális beállítások - Nincs kézbesítési információ + 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. @@ -841,7 +841,7 @@ Válaszul erre Név és üzenet Az értesítések csak az alkalmazás bezárásáig érkeznek! - Információ + Adatok Üzenetek és fájlok tag Privát kapcsolat létrehozása @@ -1284,7 +1284,7 @@ Ön módosította a címet %s számára fájlok fogadása egyelőre még nem támogatott Csoportprofil mentése - Visszaállítás alapértelmezettre + Visszaállítás alapértelmezésre A partnere eltávolította ezt a hivatkozást, vagy egy egyszer használható meghívó volt, amit már felhasználtak.\nA kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy új hivatkozást. videóhívás (végpontok között NEM titkosított) Használat új kapcsolatokhoz @@ -1296,7 +1296,7 @@ A jelmondat nem található a Keystore-ban, ezért kézzel szükséges megadni. Ez akkor történhetett meg, ha visszaállította az alkalmazás adatait egy biztonsági mentési eszközzel. Ha nem így történt, akkor lépjen kapcsolatba a fejlesztőkkel. A partnereivel továbbra is kapcsolatban marad. A kiszolgálónak hitelesítésre van szüksége a feltöltéshez, ellenőrizze a jelszavát. - Az adatbázis nem működik megfelelően. Koppintson ide a további információkért + Az adatbázis nem működik megfelelően. Koppintson ide a további tudnivalókért A fájl küldése le fog állni. Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál. Nem sikerült ellenőrizni; próbálja meg újra. @@ -1478,13 +1478,13 @@ A titkosítás működik, és új titkosítási egyezményre nincs szükség. Ez kapcsolati hibákat eredményezhet! Ez a művelet nem vonható vissza – profiljai, partnerei, üzenetei és fájljai véglegesen törölve lesznek. Bejegyzés frissítve - használati útmutatóban talál.]]> + használati útmutatóban talál.]]> A jelmondat a beállításokban egyszerű szövegként van tárolva. Konzol megjelenítése új ablakban 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.]]> + 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. @@ -1595,7 +1595,7 @@ Hívás vége Videóhívás Hiba történt a böngésző megnyitásakor - A hívásokhoz egy alapértelmezett webböngésző szükséges. Állítson be egy alapértelmezett webböngészőt az eszközön, és osszon meg további információkat a SimpleX Chat fejlesztőivel. + A hívásokhoz egy alapértelmezett webböngésző szükséges. Állítson be egy alapértelmezett webböngészőt az eszközön, és osszon meg további tudnivalókat a SimpleX Chat fejlesztőivel. Hálózati beállítások megerősítése Hiba történt a csevegési adatbázis exportálásakor Alkalmaz @@ -1749,11 +1749,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 +1776,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 @@ -1805,10 +1802,10 @@ Csökkentett akkumulátor-használattal. Hiba történt a WebView előkészítésekor. Frissítse rendszerét az új verzióra. Lépjen kapcsolatba a fejlesztőkkel.\nHiba: %s Felhasználó által létrehozott téma visszaállítása - Üzenet várólista-információi + Üzenet várólistaadatai nincs Kézbesítési hibák felderítése - kiszolgáló várólista-információi: %1$s\n\nutoljára fogadott üzenet: %2$s + kiszolgáló várólistaadatai: %1$s\n\nutoljára fogadott üzenet: %2$s Érvénytelen kulcs vagy ismeretlen fájltöredékcím – valószínűleg a fájl törlődött. Ideiglenes fájlhiba Üzenet állapota @@ -1836,7 +1833,7 @@ letiltva inaktív Nagyítás - Információk a kiszolgálókról + Kiszolgálóadatok Kapcsolódás Hibák Függőben @@ -1901,7 +1898,7 @@ Hiba történt a kiszolgálókhoz való újrakapcsolódáskor Fájlok Betűméret - Nincs információ, próbálja meg újratölteni + Nincsenek adatok, próbálja meg újratölteni Korábban kapcsolódott kiszolgálók Privát útválasztási hiba Fogadott üzenetek @@ -1911,7 +1908,7 @@ Munkamenetek átvitele Összes kapcsolat Statisztikák - Információk megjelenítése a következőhöz + Kiszolgálóadatok megjelenítése a következőhöz A kiszolgáló verziója nem kompatibilis az alkalmazással: %1$s. Ön nem kapcsolódik ezekhez a kiszolgálókhoz. A privát útválasztás az üzenetek kézbesítésére szolgál. Aktív kapcsolatok száma @@ -1952,7 +1949,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 +1965,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 +2093,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 +2109,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 +2119,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,13 +2154,11 @@ 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. @@ -2253,7 +2239,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? @@ -2295,14 +2280,13 @@ Üzenetek törlésének letiltása Az ebben a csevegésben lévő üzenetek soha nem lesznek törölve. 1 év - alapértelmezett (%s) + alapértelmezés (%s) Csevegési üzenetek törlése az eszközről. Módosítja az automatikus üzenettörlést? Ez a művelet nem vonható vissza – a kiválasztott üzenettől korábban küldött és fogadott üzenetek törölve lesznek a csevegésből. 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 +2415,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. @@ -2470,7 +2453,7 @@ Az üzeneteltűnési idő csak az új partnerekre vonatkozik. Inkognitóprofil használata Saját cím létrehozása - Eltűnő üzenetek engedélyezése alapértelmezetten. + Eltűnő üzenetek engedélyezése alapértelmezésként. Tartsa tisztán a csevegéseit Életrajz és üdvözlőüzenet beállítása a profilokhoz. Saját cím megosztása @@ -2483,10 +2466,9 @@ A hivatkozás rövid lesz és a csoportprofil meg lesz osztva a hivatkozáson keresztül. Régi cím megosztása Régi (hosszú) hivatkozás megosztása - Partneri kapcsolatkérések a csoportokból + 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 +2498,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 +2516,6 @@ Csatorna Csatorna Csatornahivatkozás - Csatornatagok Csatorna neve Kapcsolódás kapcsolódott @@ -2550,7 +2530,6 @@ új meghíva Csatorna megnyitása - Új csatorna megnyitása Tulajdonos Tulajdonosok és közreműködők Csatorna elhagyása @@ -2633,7 +2612,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 +2751,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 +2782,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 +2833,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 @@ -2906,4 +2876,11 @@ Nyilvános SimpleX-nevek (béta) Fájlkiszolgálók Fájlkiszolgálók: %s + Meghívások csoportokba + továbbítva innen + %1$02d óra %2$02d perc %3$02d mp + A közösségi finanszírozásban részt vevő befektetők már az értékesítés megkezdése előtt lefoglalhatnak SimpleX-neveket: + SimpleX-nevek értékesítésének kezdete: + egy SimpleX-domainnév regisztrációjáig + Frissítse az alkalmazást egy SimpleX-domainnév regisztrálásához 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..ea18b5e428 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml @@ -545,7 +545,6 @@ Terlalu banyak video! Pesan suara pesan - buka Sistem setel alamat kontak baru Tema gelap @@ -969,8 +968,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. @@ -1330,7 +1327,6 @@ Buat grup Nama lengkap grup: Simpan profil grup - Peramban Server Anda Gagal simpan profil grup %s server @@ -1389,7 +1385,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 +1411,6 @@ %1$s!]]> Batal pindah Potongan diunggah - hubungkan Percakapan dihapus! Pesan sementara Hapus catatan pribadi? @@ -1487,7 +1481,6 @@ 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? @@ -1589,14 +1582,11 @@ 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.]]> @@ -1696,7 +1686,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. @@ -1907,7 +1896,6 @@ 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 @@ -2197,14 +2185,12 @@ 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. @@ -2277,7 +2263,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 +2290,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 +2458,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 +2479,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 +2517,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 +2552,6 @@ Gagal menyiarkan Saluran Tautan saluran - Anggota saluran Nama saluran Preferensi saluran Profil saluran disimpan di perangkat pelanggan dan di relai obrolan. @@ -2602,8 +2581,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 +2625,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 +2632,6 @@ gagal gagal Berkas - Filter Agar siapa pun dapat menghubungi Anda Dari riwayat (dari pemilik) @@ -2705,9 +2680,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 eba8b408df..2f19777c2b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml @@ -1772,12 +1772,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 +1817,6 @@ Adatta Risposta inviata Imposta tema predefinito - Mostra la lista di chat in una nuova finestra Sistema Tinta dello sfondo Buon pomeriggio! @@ -1989,7 +1986,6 @@ Forte chiama messaggio - apri cerca Impostazioni Elimina senza avvisare @@ -2016,7 +2012,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 +2129,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 +2142,6 @@ Operatore di rete Server di %s Usa %s - Sito web %s.]]> Condizioni accettate il: %s. Operatore @@ -2189,18 +2179,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,7 +2199,6 @@ 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. @@ -2303,7 +2289,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 +2324,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 +2449,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? @@ -2518,10 +2501,9 @@ Condividi il link vecchio Il link sarà breve e il profilo del gruppo verrà condiviso attraverso il link. Aggiorna il link del gruppo - Richieste di contatto dai gruppi + 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 +2542,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 +2561,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 +2590,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 +2603,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 +2785,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 +2821,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 +2831,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 +2842,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 +2850,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 +2864,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. @@ -2941,4 +2911,11 @@ Aggiungi collaboratori. Server di file Server di file: %s + Inviti in gruppi + inoltrato da + %1$02d ore %2$02d min %3$02d sec + Gli investitori della raccolta fondi possono prenotare i nomi prima che inizi la vendita: + La vendita di nomi SimpleX inizia in + fino a quando potrai registrare un dominio SimpleX + Aggiorna l\'app per registrare un dominio SimpleX 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..a2113482d9 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml @@ -1738,7 +1738,6 @@ אוזניות ערכת נושא לפרופיל צבעי הצא\'ט - הצג רשימת שיחות בחלון חדש מצב הקובץ סטטוס הודעה מצב הקובץ:%s @@ -2000,7 +1999,6 @@ אל תחמיץ הודעות חשובות. הורד גרסאות חדשות מ GitHub. פרופיל לא הולם - לפרטיות מטא דאטא טובה יותר. אפשר Flux בהגדרות רשת ושרתים בשביל לשפר את הפרטיות של המטא דאטא מעביר %1$s הודעות שגיאה בקריאת משפט-סיסמה של מסד נתונים @@ -2123,7 +2121,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..c28136d201 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml @@ -1821,7 +1821,6 @@ \n*ネットワークとサーバー*設定から有効にして下さい。 いいえ はい - メッセージルーティングモード フォントサイズ ベータ アップデートを確認 @@ -1847,7 +1846,6 @@ 全てのプロフィール 通話 連絡先の削除を確認しますか? - 接続 連絡先が削除されます - この操作は取り消せません! プロフィールの切り替えエラー メディアのぼかし @@ -1965,7 +1963,6 @@ 情報がありません、リロードしてください SMPサーバ メッセージ - ウェブサイト ビデオ 設定画面からサーバを構成できます。 改定履歴を開く @@ -1995,7 +1992,6 @@ バックグラウンドサービスを使用しません 利用条件の承諾 %s の利用条件に承諾しています。]]> - 後で作成する場合はメニューから「SimpleXのアドレスを作成」を選択してください。 運営者について 1年 1 件のレポート @@ -2053,7 +2049,6 @@ リンク すべて 音声メッセージ - フィルター メンバーとして承認する オブザーバーとして承認する スパム @@ -2097,11 +2092,6 @@ Tor または VPN を使用しない場合、あなたのIPアドレスはこれらのXFTPリレーに表示されます:\n%1$s。 サポートされていない接続リンク このリンクには新しいバージョンのアプリが必要です。アプリをアップグレードするか、互換性のあるリンクを送るよう連絡先に依頼してください。 - サポートされていないチャンネル名 - サポートされていない連絡先名 - チャンネル名による接続には新しいバージョンのアプリが必要です。 - 連絡先名による接続には新しいバージョンのアプリが必要です。 - アプリをアップグレードしてください。 チャンネルは一時的に利用できません チャンネルにアクティブなリレーがありません。後でもう一度参加をお試しください。 アプリの更新が必要です @@ -2126,7 +2116,6 @@ 報告をアーカイブしますか? %d件の報告をアーカイブしますか? すべての報告をアーカイブしますか? - 報告はあなたのためにアーカイブされます。 すべての報告があなたのためにアーカイブされます。 自分のみ すべてのモデレーター向け @@ -2265,7 +2254,6 @@ この操作は元に戻せません - このチャットで選択した時点より前に送受信したメッセージは削除されます。 メッセージ削除を無効にする デバイスからチャットメッセージを削除します。 - 開く 検索 会話を保持 会話のみ削除 @@ -2281,7 +2269,6 @@ 送信者には通知されません。 メンバーが削除されています - リクエストを承諾できません すべてミュート - 未読のメンション リストを変更 リスト名と絵文字はすべてのリストで異なる必要があります。 順序を変更 @@ -2321,7 +2308,6 @@ IPが隠されている場合 あなたまたは宛先のサーバがプライベートルーティングをサポートしていない場合、メッセージを直接送信します。 IPアドレスが保護されており、かつあなたまたは宛先のサーバがプライベートルーティングをサポートしていない場合、メッセージを直接送信します。 - メッセージルーティングのフォールバック IPアドレスを保護するため、プライベートルーティングはあなたのSMPサーバを使用してメッセージを配信します。 メッセージング用のTCPポート Webポートを使用 @@ -2342,7 +2328,6 @@ 新しいリリースの通知を受け取るには、安定版またはベータ版の定期チェックをオンにしてください。 非推奨のオプション 設定の保存エラー - 接続後に連絡先に送信されます。 ウェルカムメッセージ またはプライベートに共有する場合 SimpleXアドレスまたはワンタイムリンク? @@ -2389,12 +2374,7 @@ アプリは会話ごとに異なる運営者を使用することで、あなたのプライバシーを保護します。 複数の運営者が有効な場合、どの運営者も誰が誰と通信しているかを知るためのメタデータを持ちません。 例えば、あなたの連絡先がSimpleX Chatのサーバ経由でメッセージを受信する場合、あなたのアプリはFluxのサーバ経由でそれらを配信します。 - 使用するネットワーク運営者を選択してください。 プライバシーにどう役立つか - 有効な運営者の条件は30日後に承諾されます。 - 運営者は「ネットワークとサーバ」設定で構成できます。 - 後で確認 - 更新 あなたのネットワーク ネットワークのルーターは\n誰が誰と話しているかを知ることができません ルーターを設定 @@ -2411,7 +2391,6 @@ アプリは不明なファイルサーバからのダウンロードの確認を求めます(.onion の場合、またはSOCKSプロキシが有効な場合を除く)。 Tor または VPN を使用しない場合、あなたのIPアドレスはファイルサーバに表示されます。 リンクのトラッキングを削除 - この設定は現在のプロフィールに適用されます チャット一覧からリンクを開く はい いいえ @@ -2421,7 +2400,6 @@ 完全なリンクを開く クリーンなリンクを開く グループからの連絡先リクエスト - リモートのモバイル端末 デフォルト(%s) エクスポートしたアーカイブを保存できます。 エクスポートしたデータベースを移行できます。 @@ -2433,7 +2411,6 @@ 手の届くチャットツールバー 下部のバー 上部のバー - 新しいウィンドウでチャット一覧を表示 チャンネルに参加または作成した場合、それらは恒久的に機能しなくなります。 チャンネルから退出しますか? チャットから退出しますか? @@ -2475,7 +2452,6 @@ 設定を変更できるのはチャットのオーナーだけです。 リンク 管理者とのチャット - チャンネルのメンバー チャットリレー 購読者を削除しますか? メンバーを削除しますか? @@ -2616,7 +2592,6 @@ ネットワークの分散化 アプリに2番目のプリセット運営者が登場! メタデータのプライバシー向上のため、「ネットワークとサーバ」設定でFluxを有効にしてください。 - メタデータのプライバシー向上のため。 チャットナビゲーションの改善 - 最初の未読メッセージでチャットを開きます。\n- 引用されたメッセージにジャンプします。 ビジネスチャット @@ -2801,7 +2776,6 @@ あなたのプロフィール %1$s はチャンネルのリレーと購読者に共有されます。\nリレーはチャンネルのメッセージにアクセスできます。 リレーを構成 失敗 - 追加 リレーを追加 リレーを追加 利用可能なリレーがありません @@ -2817,10 +2791,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..a4fd13d137 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml @@ -1080,7 +1080,6 @@ 업로드 확인 모바일에 연결 연결된 모바일 - 연결 그룹 멤버에게 메시지를 보낼 수 없음 전송 알림을 활성화하시겠습니까? 암호화 OK @@ -1379,7 +1378,6 @@ 성공적으로 설치됨 업데이트 설치 인터페이스 색상 - 메시지 라우팅 대체 그룹 멤버가 메시지 반응을 추가할 수 있습니다. 메시지 모양 메시지 대기열 정보 @@ -1395,7 +1393,6 @@ 향상된 서버 구성 메시지 메시지 서버 - 메시지 라우팅 모드 안녕하세요!\nSimpleX Chat 초대장이 도착했습니다: %s 내부 오류 잘못된 이름입니다! @@ -1471,7 +1468,6 @@ 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..c429a8341a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ku/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ku/strings.xml @@ -108,7 +108,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 @@ -345,7 +344,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ê @@ -603,7 +601,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ê! 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..e09aef2162 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/lv/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/lv/strings.xml @@ -539,7 +539,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 @@ -1022,7 +1021,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 @@ -1134,7 +1132,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 +1164,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 @@ -1273,7 +1268,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 @@ -1390,7 +1384,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 +1391,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 @@ -1461,7 +1453,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? @@ -1598,13 +1589,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 @@ -1675,7 +1661,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 +1784,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 +1978,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 +2300,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 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..c2cc76fa50 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml @@ -1772,7 +1772,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 +1781,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 +1833,6 @@ Systeem Wallpaper achtergrond Stel het standaard thema in - Toon chat-lijst in nieuw venster geen Foutopsporing bezorging Informatie over berichtenwachtrij @@ -1999,13 +1996,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 +2124,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 +2137,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 +2153,6 @@ %s servers Gebruik %s Gebruik servers - Website %s.]]> %s.]]> %s te gebruiken, moet u de gebruiksvoorwaarden accepteren.]]> @@ -2180,7 +2168,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,7 +2195,6 @@ 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. @@ -2299,7 +2285,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 +2319,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..6b6c3ab3eb 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml @@ -1771,7 +1771,6 @@ Nigdy Niezabezpieczony NIE używaj prywatnego trasowania. - Tryb trasowania wiadomości Tak Nie Gdy IP ukryty @@ -1781,7 +1780,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 +1792,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ź @@ -1981,7 +1978,6 @@ Zwiększ rozmiar czcionki. wiadomość Wiadomość - otwórz szukaj Zaznaczono %d wideo @@ -2002,7 +1998,6 @@ Odtwórz z listy czatów. Zarchiwizowane kontakty zadzwoń - połącz Stan połączenia i serwerów. Utwórz Nieprawidłowy link @@ -2183,7 +2178,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 +2282,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 +2409,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 +2433,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 +2442,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 +2472,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 +2479,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 +2486,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 +2495,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 +2515,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 3b28d2fd20..5b54d0c452 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 @@ -119,7 +119,7 @@ chamada aceita o contato tem criptografia de ponta a ponta o contato não tem criptografia de ponta a ponta - Preferências de contato + Preferências do contato Permitir apagar permanentemente as mensagens enviadas em até 24h. sempre Adicione servidores escaneando o QR Code. @@ -161,7 +161,7 @@ conectando apagado Conectar - Apagar + Excluir Conectar conectando chamada… Excluir perfil de chat? @@ -461,7 +461,7 @@ Você convidou seu contato QR Code inválido Mais - Você será conectado ao grupo quando o dispositivo que hospeda o grupo estiver online. Por favor, aguarde ou verifique mais tarde. + 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! Quando disponível Compilação do aplicativo: %s @@ -502,7 +502,7 @@ Reinicie o aplicativo para usar o banco de dados importado. Reinicie o aplicativo para criar um novo perfil de chat. Essa ação não pode ser desfeita - todos os arquivos e mídias recebidos e enviados serão excluídos. Imagens de baixa resolução permanecerão. - Você deve usar a versão mais recente do banco de dados do chat em APENAS um dispositivo, caso contrário poderá parar de receber as mensagens de alguns contatos. + Você deve usar a versão mais recente do banco de dados do chat em APENAS um dispositivo. Caso contrário, poderá parar de receber mensagens de alguns contatos. Sem arquivos enviados ou recebidos Mensagens Esta configuração se aplica às mensagens do seu perfil atual @@ -630,7 +630,7 @@ 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! + 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. @@ -813,7 +813,7 @@ SimpleX Somente os proprietários do grupo podem ativar mensagens de voz. você compartilhou um link de uso único - Você será conectado quando sua solicitação de conexão for aceita, aguarde ou verifique mais tarde! + Você será conectado quando sua solicitação de conexão for aceita. Aguarde ou verifique mais tarde. Configurações Defina a mensagem mostrada aos novos membros! Configurações @@ -1446,7 +1446,7 @@ Uso de bateria do aplicativo / Irrestrito nas configurações do aplicativo.]]> Este grupo tem mais de %1$d membros; as confirmações de entrega não são enviadas. Carregando o arquivo - contato deletado + contato apagado Erro Este dispositivo Descobrir via rede local @@ -1648,7 +1648,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 @@ -1713,7 +1712,7 @@ IU Persa Aviso de entrega de mensagem Erro: %1$s - Chave incorreta ou conexão desconhecida - provavelmente esta conexão foi excluída. + Chave incorreta ou conexão desconhecida - provavelmente essa conexão foi excluída. Esta conversa é protegida por criptografia de ponta a ponta. Repetir envio Erro de conexão ao servidor de encaminhamento %1$s. Por favor, tente mais tarde. @@ -1731,7 +1730,6 @@ 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 +1844,6 @@ Servidores desconhecidos Desprotegido Nunca - Modo de roteamento de mensagens Conceder permissões Alto falante Headphones @@ -1912,7 +1909,6 @@ 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 @@ -2019,7 +2015,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 @@ -2170,7 +2165,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 +2188,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 +2277,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 +2300,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 +2308,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 +2320,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 +2336,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 +2379,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 +2465,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,15 +2498,13 @@ 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 Endereço do contato contato desativado o contato não está pronto - Solicitações de contato de grupos + Solicitações de contato em grupos o contato deve aceitar… colaborador Copiar código @@ -2582,7 +2561,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 +2576,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 +2673,6 @@ Abrir link limpo Abrir link externo? Abrir link completo - Abrir novo canal Abrir nova conversa Abrir novo grupo Abrir para aceitar @@ -2708,7 +2684,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 +2755,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 +2779,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 +2816,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 +2854,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 @@ -2921,4 +2891,5 @@ Selo não verificado Não foi possível verificar este selo, que pode não ser legítimo. O selo é assinado com uma chave que esta versão do aplicativo não reconhece. Atualize o aplicativo para verificar este selo. + Convites em grupo 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..3af5fbee19 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml @@ -250,7 +250,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 @@ -677,7 +676,6 @@ 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ă @@ -914,7 +912,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 @@ -1035,7 +1032,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 +1213,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 @@ -1587,7 +1582,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 @@ -1751,7 +1745,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 +1796,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 @@ -1965,8 +1956,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. @@ -2016,7 +2005,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. @@ -2155,8 +2143,6 @@ 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 @@ -2164,7 +2150,6 @@ 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! @@ -2286,7 +2271,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ă. @@ -2432,11 +2416,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 +2498,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..9a4e550dbe 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml @@ -1328,7 +1328,6 @@ Нет истории Отправка отчётов о доставке включена для %d контактов Отправка отчётов о доставке будет включена для всех контактов во всех видимых профилях чата. - Установка для Вашего активного профиля Установки для Вашего активного профиля Отправка отчётов о доставке выключена для %d контактов Шифрование работает, и новое соглашение не требуется. Это может привести к ошибкам соединения! @@ -1853,7 +1852,6 @@ информация сервера об очереди: %1$s \n \nпоследнее полученное сообщение: %2$s - Показать список чатов в новом окне Приложение будет запрашивать подтверждение загрузки с неизвестных серверов (за исключением .onion адресов или когда SOCKS-прокси включен). Незащищённый Без Tor или VPN, Ваш IP-адрес будет доступен серверам файлов. @@ -1895,8 +1893,6 @@ Неизвестные серверы Нет Показать статус сообщения - Прямая доставка сообщений - Режим доставки сообщений Конфиденциальная доставка сообщений Цвета чата Тема чата @@ -1934,7 +1930,6 @@ Не удаётся позвонить контакту Не удаётся написать члену группы Проверять обновления - соединиться Адрес сервера назначения %1$s несовместим с настройками пересылающего сервера %2$s. Ошибка подключения к пересылающему серверу %1$s. Попробуйте позже. Пересылающий сервер %1$s не смог подключиться к серверу назначения %2$s. Попробуйте позже. @@ -2045,7 +2040,6 @@ Член группы неактивен Прямого соединения пока нет, сообщение переслано или будет переслано админом. Ничего не выбрано - открыть поиск Выбрано %d Настройки @@ -2197,7 +2191,6 @@ Нет серверов для приёма файлов. Нет серверов для отправки файлов. Недоставленные сообщения - Нажмите Создать адрес SimpleX в меню, чтобы создать его позже. Адрес или одноразовая ссылка? Безопасность соединения Операторы серверов @@ -2207,7 +2200,6 @@ %s.]]> Условия будут автоматически приняты для включенных операторов: %s. Условия приняты: %s - Вебсайт %s.]]> %s.]]> %s, примите условия использования.]]> @@ -2223,7 +2215,6 @@ Децентрализация сети Второй оператор серверов в приложении! Включите Flux в настройках Сеть и серверы для лучшей конфиденциальности метаданных. - для лучшей конфиденциальности метаданных. Улучшенная навигация в разговоре Посмотреть измененные условия Устройства Xiaomi: пожалуйста, включите опцию Autostart в системных настройках для работы нотификаций.]]> @@ -2253,12 +2244,7 @@ Нотификации и батарейка Как это улучшает конфиденциальность Операторы серверов - Выберите операторов сети. - Вы можете настроить операторов в настройках Сети и серверов. Продолжить - Посмотреть позже - Обновить - Связанные мобильные устройства Покинуть разговор? Вы прекратите получать сообщения в этом разговоре. История будет сохранена. Добавить друзей @@ -2313,7 +2299,6 @@ Приложение улучшает конфиденциальность, используя разных операторов в каждом разговоре. Когда больше чем один оператор включен, ни один из них не видит метаданные, чтобы определить, кто соединен с кем. Ошибка сохранения серверов - Условия будут приняты для включенных операторов через 30 дней. Ошибка приёма условий Соединение достигло предела недоставленных сообщений. Возможно, Ваш контакт не в сети. Чтобы защитить Вашу ссылку от замены, Вы можете сравнить код безопасности. @@ -2368,7 +2353,6 @@ Конфиденциальные названия медиафайлов. Спам Сообщения о нарушениях - Непрочитанные упоминания Да Упоминайте членов группы 👋 Улучшенная конфиденциальность и безопасность @@ -2380,7 +2364,6 @@ Установите срок хранения сообщений в чатах. Вы можете упомянуть до %1$s пользователей в одном сообщении! Причина сообщения? - Эта жалоба будет архивирована для вас. Разрешить отправлять сообщения о нарушениях модераторам. Содержание нарушает условия использования Ошибка чтения пароля базы данных @@ -2549,7 +2532,6 @@ Отправить запрос Отправить запрос без сообщения Отправляйте Ваши конфиденциальные предложения группе. - Отправляется Вашему контакту после соединения. Обновить ссылку группы? Обновить Обновить адрес? @@ -2617,7 +2599,6 @@ Удалить вместе с сообщениями Все сообщения Файлы - Фильтр Изображения Ссылки Поиск файлов @@ -2636,7 +2617,6 @@ Канал Полное имя канала: Ссылка канала - Участники канала Имя канала профиль канала обновлён Канал будет удалён для всех подписчиков - это нельзя отменить! @@ -2656,7 +2636,6 @@ Введите имя релея… Ошибка добавления релея Ошибка при создании канала - Ошибка при открытии канала ошибка: %s Ошибка при сохранении профиля канала Неверный адрес релея! @@ -2715,7 +2694,6 @@ неактивен Подпись ссылки проверена. Открыть канал - Открыть новый канал Владельцы Владелец релей @@ -2878,7 +2856,6 @@ Нижнее меню Картинка ссылки будет загружена через SOCKS-прокси. DNS-запрос может быть локальным через Ваш резолвер. Верхнее меню - Добавить Добавить релей Добавить релеи Отменить и удалить канал @@ -2887,7 +2864,6 @@ %1$d владельцев %1$d владельцев и участников канала %1$s поддерживал SimpleX Chat. Срок действия бейджа истек %2$s. - О приложении Продвинутые настройки Приложение уже запущено Необходимо обновление приложения @@ -2909,8 +2885,6 @@ Веб-страница канала Выбрано %d релеев Данные чата - Для подключения по имени канала требуется более новая версия приложения. - Для подключения по имени контакта требуется более новая версия приложения. Контакт соавтор подписчик @@ -2949,9 +2923,6 @@ Если Вы выберете Закрыть, сообщения не будут доставляться.\nВы можете изменить это позже в настройках Интерфейса. Адрес будет показан подписчикам и разрешит загрузку предпросмотра. Вступить в канал %s - Неподдерживаемое имя канала - Неподдерживаемое имя контакта - Пожалуйста, обновите приложение. Ошибка SimpleX имени Ни один из Ваших серверов не настроен для разрешения SimpleX имён. Настройте серверы или используйте ссылку для соединения. Сервер %1$s не поддерживает разрешение имён. Настройте серверы или используйте ссылку для соединения. @@ -2980,7 +2951,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 dfd1f3d1f5..dd8155f512 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/sk/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/sk/strings.xml @@ -72,7 +72,6 @@ Prerušiť Prerušiť zmenu adresy Prerušiť zmenu adresy? - O aplikácii O operátoroch O SimpleX O adrese SimpleX @@ -82,7 +81,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 @@ -103,7 +101,7 @@ Adresa alebo jednorazový odkaz? Nastavenie adries Pridať server - Pridať servery skenovaním QR kódu. + Pridať servery skenovaním QR kódov. Pridať členov tímu Pridajte tento kód na vašu webovú stránku. Zobrazí ukážku vášho kanálu / skupiny. Pridať do iného zariadenia @@ -126,7 +124,7 @@ všetko Všetko Všetky dáta aplikácie sú vymazané. - Všetky chaty budú zo zoznamu %s odobrané, a zoznam bude vymazaný + Všetky chaty budú zo zoznamu %s odstránené, a zoznam bude vymazaný Všetky farebné režimy Všetky dáta sa pri zadaní vymažú. Všetky správy @@ -143,7 +141,7 @@ Povoliť posielať priame správy členom. Povoliť posielanie priamych správ odberateľom. Povoliť odberateľom chatovať so správcami. - Povoliť nezvratné mazanie odoslaných správ. (24 hodín) + Povoliť nezvratné vymazanie odoslaných správ. (24 hodín) Povoliť nahlasovanie správ moderátorom. Povoliť posielanie miznúcich správ. Povoliť posielanie súborov a médií. @@ -153,7 +151,7 @@ Povoliť kontaktom pridávať reakcie na správy. Všetky profily Všetky relé zlyhali - Všetky relé odobrané + Všetky relé odstránené Všetky servery Všetky Vaše kontakty, konverzácie a súbory budú bezpečne šifrované a oddelene nahraté na zvolené XFTP relé. Všetky Vaše kontakty zostanú pripojené. @@ -190,7 +188,7 @@ \nDostupné vo verzii v5.1 Späť Pozadie - Služba na pozadí je vždy spustená - notifikácia sa zobrazí hneď ako bude správa k dispozícii. + Služba na pozadí je vždy spustená - notifikácie budú zobrazené hneď ako budú správy k dispozícii. Pridať kontakt: vytvoriť novú pozvánku alebo sa pripojiť cez odkaz, ktorý ste obdržali.]]> Nesprávna adresa počítača Najlepšie pre batériu. Budete dostávať notifikácie len keď bude aplikácia bežať (ŽIADNA služba na pozadí).]]> @@ -214,8 +212,8 @@ Bot Vysielanie Otestujte relé pre načítanie jeho mena.]]> - Využíva viac batérie Aplikácia bude vždy bežať na pozadí - notifikácie sa zobrazia okamžite.]]> - Xiaomi zariadenie: prosím povoľte Spustenie na pozadí v systémových nastaveniach aby Vám fungovali notifikácie.]]> + Využíva viac batérie! Aplikácia bude vždy bežať na pozadí – notifikácie sa zobrazia okamžite.]]> + Xiaomi zariadenie: prosím povoľte Spustenie na pozadí v systémových nastaveniach aby vám fungovali notifikácie.]]> Zrušiť Zrušiť a odstrániť kanál Zrušiť vytvorenie kanálu? @@ -270,7 +268,7 @@ Prijať ako člena Prijať ako pozorovateľa Prijať podmienky - Prijať žiadosť o pripojenie? + Prijať žiadosť o spojenie? Prijať žiadosť o kontakt Prijať žiadosť o kontakt prijaté @@ -289,11 +287,11 @@ Všetky správy budú vymazané – toto je nezvratné! Správy sa vymažú IBA u vás. Povoliť miznúce správy, ale iba ak ich povolil váš kontakt. Povoliť súbory a médiá, ale iba ak ich povolil váš kontakt. - Povoliť nezvratné mazanie správ, ale iba ak ho povolil váš kontakt. (24 hodín) + Povoliť nezvratné vymazanie správ, ale iba ak ho povolil váš kontakt. (24 hodín) Povoliť reakcie na správy, ale iba ak ich povolil váš kontakt. Povoliť hlasové správy, ale iba ak ich povolil váš kontakt. Povoliť vašim kontaktom vám volať. - Povoliť vašim kontaktom nezvratne mazať poslané správy. (24 hodín) + Povoliť vašim kontaktom nezvratne vymazať poslané správy. (24 hodín) Povoliť vašim kontaktom posielať miznúce správy. Povoliť vašim kontaktom odosielať súbory a média. Povoliť vašim kontaktom posielať hlasové správy. @@ -320,7 +318,7 @@ prijal %1$s Chyby potvrdenia Pridajte adresu na váš profil, aby ju vaše SimpleX kontakty mohli zdieľať s ostatnými ľudmi. Aktualizácia profilu bude odoslaná vašim kontaktom. - Každá stránka môže zobraziť náhľad. + Každá webová stránka môže zobraziť náhľad. Zálohovať dáta aplikácie Migrácia dát aplikácie %s archivoval hlásenie @@ -367,7 +365,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 @@ -402,7 +399,7 @@ Pripojiť Hlasové a video hovory hlasový hovor - hlasový hovor (nešifrovaný e2e) + hlasový hovor (není e2e šifrovaný) Hlasové a video hovory Povoľte v nasledujúcom dialógu okamžité prijímanie notifikácií.]]> Zničili sme silu vedieť, kto ste. Aby vám vašu moc nikto nemohol vziať. @@ -583,14 +580,14 @@ Vymazať správy Vymazať správy po Vymazať profil - Aplikácia už možno beží alebo sa nesprávne vypla. Zapnúť aj tak? + Aplikácia už možno beží alebo sa nesprávne vypla. Spustiť aj tak? Relácia aplikácie pre každý kontakt a každého člena skupiny.\nUpozornenie: ak máte veľa spojení, spotreba vašej batérie a internetu môže byť podstatne vyššia a niektoré spojenia môžu zlyhať.]]> zmenil rolu %s na %s zmenil vašu rolu na %s Zmeniť rolu Zmeniť rolu? - Chyba pri zmene roli + Chyba pri zmene role Rozšíriť výber rolí Počiatočná rola člen @@ -664,10 +661,10 @@ Server vyžaduje overenie pre vytvorenie radov, skontrolujte heslo. Server vyžaduje overenie pre nahrávanie, skontrolujte heslo. Nastavte prístupovú frázu pre export - Aby ste odhalili svoj skrytý profil, zadajte celé heslo do vyhľadávacieho poľa na stránke profilov chatu. + Aby ste odhalili svoj skrytý profil, zadajte celé heslo do vyhľadávacieho poľa na stránke profilov chatov. Vaše prihlasovacie údaje môžu byť zaslané nešifrované. Android Keystore je použitý na bezpečné uloženie prístupovej frázy - umožňuje to fungovanie služby oznámení. - Android Keystore bude použitý na bezpečné uloženie prístupovej frázy potom ako reštartujete aplikáciu alebo zmeníte prístupovú frázu - umožní to fungovanie služby oznámení. + Android Keystore bude použitý na bezpečné uloženie prístupovej frázy po reštarte aplikácie alebo zmene prístupovej frázy - umožní to fungovanie služby oznámení. Upozornenie: ak stratíte vašu prístupovú frázu, NEBUDE možné ju obnoviť ani zmeniť.]]> Zmeniť prístupovú frázu k databáze? Potvrdiť novú prístupovú frázu… @@ -753,7 +750,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. @@ -836,7 +832,7 @@ Naskenovať QR kód.]]> Počítač bol odpojený Odpojiť počítač? - Chyba zobrazenia notifikácie, kontaktujte vývojárov. + Chyba pri zobrazovaní notifikácie, kontaktujte vývojárov. Počítač nájdený Nekompatibilná verzia (nový)]]> @@ -958,7 +954,7 @@ Skrytie obrazovky aplikácie v zobrazení nedávnych aplikácií. Vylepšenie ochrany súkromia a zabezpečenia Vylepšená konfigurácia serverov - Nezvratné mazanie správ + Nezvratné vymazanie správ Živé správy Max. 40 sekúnd, prijíma sa okamžite. Návrh správy @@ -978,7 +974,7 @@ Overenie zabezpečenia pripojenia Hlasové správy S voliteľnou uvítaciou správou. - Vaše kontakty môžu povoliť úplné mazanie správ. + Vaše kontakty môžu povoliť úplné vymazanie správ. Preverenie bezpečnosti Povoliť v priamych chatoch (BETA)! Šifrovanie uložených súborov a médií @@ -1124,7 +1120,6 @@ Súbory a médiá Status súboru: %s Súbor bude vymazaný zo serverov. - Filter Opraviť Opraviť Opraviť pripojenie @@ -1208,4 +1203,251 @@ Okamžité notifikácie Okamžité notifikácie! Okamžité notifikácie sú vypnuté! + duplikáty + Platnosť pozvánky vypršala! + pozvánka do skupiny %1$s + Pozvať + Pozvať + pozvaný + pozvané + pozval %1$s + Pozvať priateľov + Pozvať členov + Pozvať členov + Pozvať do chatu + Pozvať do skupiny + Nezvratné vymazanie správ je zakázané. + Nezvratné vymazanie správ je v tomto chate zakázané. + (toto zariadenie v%s)]]> + Veľký súbor! + Ďalšie informácie + Odkaz + Odkazy + Zoznam + Názov zoznamu... + ŽIVO + Živá správa! + Načítavam chaty… + Načítavam profil… + Načítavam súbor + Uistite sa, že konfigurácia proxy je správna. + Člen + Člen %1$s + Členovia môžu pridávať reakcie na správy. + chyba: %s + Chyba: %s + Zmieňte členov 👋 + správa + Správa + Správa preposlaná + Správa je príliš veľká! + Správa môže byť doručená neskôr až bude člen aktívny. + Reakcie na správy + Reakcie na správy sú zakázané. + Reakcie na správy sú v tomto chate zakázané. + Správy + Správy a súbory + end-to-end šifrovaním.]]> + Správy od %s budú zobrazené! + Správy od týchto členov budú zobrazené! + Tvar správ + Správy v tomto chate nebudú nikdy vymazané. + Text správy + Správa je príliš veľká + Mikrofón + minút + zmeškaný hovor + Zmeškaný hovor + nikdy + Nikdy + Nový chat + Nové v %s + nová správa + Nová správa + Nový server + OK + Otvoriť odkaz + Chyba pri prerušovaní zmeny adresy + Chyba pri pridávaní člena(ov) + Chyba pri pridávaní relé + Chyby + Chyba pri prijímaní podmienok + Chyba pri prijímaní žiadosti o kontakt + Chyba pri prijímaní člena + Chyba pri pridávaní relé + Chyba pri pridávaní serveru + Chyba pri blokovaní člena pre všetkých + Chyba pri zmene adresy + Chyba pri zmene profilu + Chyba pri zmene nastavení + Chyba pri pripájaní ku preposielaciemu serveru %1$s. Prosím, skúste to neskôr. + Pri pripájaní ku serveru používanému na príjem správ od tohto spojenia nastala nasledujúca chyba: %1$s. + Chyba pri vytváraní adresy + Chyba pri vytváraní kanálu + Chyba pri vytváraní zoznamu chatov + Chyba pri vytváraní odkazu na skupinu + Chyba pri vytváraní správy + Chyba pri vytváraní profilu! + Chyba pri vytváraní hlásenia + Chyba pri sťahovaní archívu + Chyba pri šifrovaní databázy + 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í chatu + Chyba pri otváraní skupiny + Chyba pri odmietaní žiadosti o kontakt + Chyba pri odstraňovaní člena + Chyba pri resetovaní štatistík + Chyba pri odosielaní pozvánky + Chyba pri odosielaní správy + Chyba pri nastavovaní adresy + Chyba pri zdieľaní adresy + Chyba pri zdieľaní kanálu + chyba pri zobrazovaní obsahu + chyba pri zobrazovaní správy + Chyby v konfigurácii serverov. + Chyba pri spustení chatu + Chyba pri zastavovaní chatu + Chyba pri vymazávaní chatu + Chyba pri vymazávaní kontaktu + Chyba pri vymazávaní žiadosti o kontakt + Chyba pri vymazávaní databázy + Chyba pri vymazávaní skupiny + Chyba pri vymazávaní odkazu skupiny + Chyba pri vymazávaní správy + Chyba pri vymazávaní súkromných poznámok + Chyba pri vymazávaní používateľského profilu + Chyba pri ukladaní databázy + Chyba pri ukladaní súboru + Chyba pri ukladaní profilu skupiny + Chyba pri ukladaní ICE serverov + Chyba pri ukladaní mena + Chyba pri ukladaní proxy + 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 + Chyba pri aktualizovaní serveru + Chyba pri aktualizovaní súkromia používateľa + Neznáma chyba databázy: %s + Neznáma chyba + povolené pre vás + Povoliť pre všetkých + Povoliť pre všetky skupiny + Povoliť náhľady odkazov? + Experimentálne funkcie + neplatný chat + neplatné dáta + Neplatný odkaz + Neplatný odkaz! + neplatný formát správy + Neplatné meno! + Neplatný QR kód + Neplatný QR kód + Neplatná adresa relé! + Neplatná meno relé! + Neplatná adresa serveru! + nie + Nie + Nie + Nie + Žiadne aktívne relé + Žiadne dostupné relé + Žiadna služba na pozadí + Nikto nesledoval vaše konverzácie. Nikto nevytváral mapu miest, kde ste boli. Súkromie nebolo funkciou - bol to spôsob života. + Nie je povolené žiadne relé. + Žiadne chaty + Žiadne chaty v zozname %s. + Žiadne chaty s členmi + bez e2e šifrovania + Žiadne filtrované chaty + Žiadne filtrované kontakty + Žiadna história + Žiadna správa + Žiadne relé + Notifikácie + Notifikácie a batéria + Archív starej databázy + Jednorazová pozvánka + Jednorazová pozvánka + Jednorazový odkaz + Naraz sa dá poslať iba 10 obrázkov + Naraz sa dá poslať iba 10 videí + Otvoriť + Otvoriť + Otvoriť nastavenia aplikácie + Otvoriť zmeny + Otvoriť kanál + Otvoriť chat + Otvoriť čistý odkaz + Otvoriť podmienky + Otvoriť priečinok databázy + Otvoriť externý odkaz? + Otvoriť celý odkaz + Otvoriť nastavenia serveru + Otvoriť nastavenia + Otvorte SimpleX Chat na prijatie hovoru + Otvoriť webový odkaz? + Otvoriť pomocou %s + 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 + Farby rozhrania + mesiacov + Viac + Viac súkromia + nové + Nový jednorazový odkaz + Nová žiadosť o kontakt + Poznámky + iné chyby + Majiteľ + peer-to-peer + Pravidelné + Pravidelné notifikácie + Pravidelné notifikácie sú vypnuté! + člen má starú verziu + Člen je neaktívny + Člen je vymazaný - nemôže prijať žiadosť + Členovia môžu chatovať so správcami. + Členovia môžu nezvratne vymazať odoslané správy. (24 hodín) + Členovia môžu posielať priame správy. + Členovia môžu posielať miznúce správy. + Členovia môžu posielať súbory a médiá. + Členovia môžu posielať SimpleX odkazy. + Členovia môžu posielať hlasové správy. + Súkromné poznámky + QR kód + Náhodný + Uložiť + Uložiť + uložené + Uložené + Uložené správy + Uložiť zoznam + Uložiť servery + Uložiť servery? + Uložiť nastavenia? + 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/tr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml index bacc84b0f7..7b05806ea6 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml @@ -1774,12 +1774,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 +1817,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,11 +1868,9 @@ 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ç @@ -2167,9 +2162,7 @@ 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 +2308,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 +2331,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 +2338,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 +2345,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 +2355,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 +2395,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 +2413,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 +2464,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 +2516,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 +2560,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 +2589,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 +2636,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 +2645,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 +2717,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 +2787,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 +2831,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..78a0b532df 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml @@ -1710,7 +1710,6 @@ Джерело повідомлення залишається приватним. Завжди Завжди використовуйте приватну маршрутизацію. - Режим маршрутизації повідомлень НЕ використовуйте приватну маршрутизацію. Ні НЕ надсилайте повідомлення напряму, навіть якщо ваш сервер або сервер призначення не підтримує приватну маршрутизацію. @@ -1738,7 +1737,6 @@ Учасники можуть надсилати посилання SimpleX. Звуки вхідного дзвінка Світлий режим - Запасний варіант маршрутизації повідомлень Маршрутизація приватних повідомлень переслано Інше @@ -1791,7 +1789,6 @@ Надсилайте повідомлення напряму, якщо ваш сервер або сервер призначення не підтримує приватну маршрутизацію. Встановлення теми за замовчуванням Надіслано відповідь - Показати список чату в новому вікні Використовуйте приватну маршрутизацію з невідомими серверами. Використовуйте приватну маршрутизацію з невідомими серверами, якщо IP-адреса не захищена. Фон шпалер @@ -1876,7 +1873,6 @@ Новий досвід чату 🎉 Адреса сервера Підтвердити видалення контакту? - підключитися Сервер переадресації %1$s не зміг з\'єднатися з цільовим сервером %2$s. Будь ласка, спробуйте пізніше. Повідомлення може бути доставлено пізніше, якщо учасник стане активним. Нічого не вибрано @@ -1991,7 +1987,6 @@ повідомлення дзвінок Зберегти розмову - відкрити пошук Ви все ще можете переглядати розмову з %1$s у списку чатів. Контакт видалено! @@ -2130,17 +2125,12 @@ Або поділитися приватно Оператори серверів Мережеві оператори - Умови будуть прийняті для ввімкнених операторів через 30 днів. Наприклад, якщо ваш контакт отримує повідомлення через сервер SimpleX Chat, ваш додаток доставлятиме їх через сервер Flux. - Виберіть мережевих операторів для використання. Ви можете налаштувати сервери за допомогою налаштувань. - Перегляньте пізніше - Оновлення Прийняті умови Умови будуть автоматично прийняті для увімкнених операторів: %s. Оператор мережі %s сервери - Вебсайт Ваші сервери Використовувати %s Використовувати сервери @@ -2187,7 +2177,6 @@ Продовжити Створити одноразове посилання Помилка оновлення сервера - для кращої конфіденційності метаданих. Децентралізація мережі Оператор Немає серверів для отримання повідомлень. @@ -2201,12 +2190,9 @@ Поділіться адресою SimpleX у соціальних мережах. SimpleX-адреси та одноразові посилання можна безпечно ділитися через будь-який месенджер. З\'єднання досягло ліміту недоставлених повідомлень, ваш контакт може бути офлайн. - Натисніть Створити адресу SimpleX у меню, щоб створити її пізніше. Додаток захищає вашу конфіденційність, використовуючи різних операторів у кожній розмові. Використовувати для повідомлень - Ви можете налаштувати операторів у налаштуваннях Мережі та серверів. Або імпортуйте архівний файл - Віддалені мобільні Пристрої Xiaomi: будь ласка, увімкніть Автозапуск у налаштуваннях системи, щоб сповіщення працювали.]]> Повідомлення занадто велике! Будь ласка, зменшіть розмір повідомлення або видаліть медіа та надішліть знову. @@ -2293,7 +2279,6 @@ Тільки відправник і модератори бачать це Повідомити Повідомити про порушення: тільки модератори групи побачать це. - Повідомлення буде архівоване для вас. Інша причина архівование повідомлення Архів @@ -2335,7 +2320,6 @@ Використовувати TCP-порт %1$s, якщо порт не вказано. Використовувати веб-порт Вимкнути звук - Непрочитані згадки Ви можете згадувати до %1$s учасників у кожному повідомленні! Усі звіти будуть архівовані для вас. Архівувати всі звіти? @@ -2485,7 +2469,6 @@ Надіслати запит Надіслати запит без повідомлення Надсилайте свої приватні відгуки до груп. - Відправлено вашому контакту після з\'єднання. Налаштуйте біографію профілю та вітальне повідомлення. Поділіться старою адресою Поділіться старим посиланням @@ -2550,11 +2533,9 @@ %1$d підписник %1$d підписники %1$s підтримував SimpleX Chat. Завершення дії значка %2$s. - Про підтверджено затверджений список активний - Додати Додати перемикач Додати перемикачі Додайте перемикачі для відновлення доставки повідомлень. @@ -2592,7 +2573,6 @@ Канал не має активних перемикачів. Будь ласка, спробуйте підключитися пізніше. Посилання на канал Посилання на канал - Учасники каналу Імʼя каналу Налаштування каналів Профіль каналу зберігається на пристроях підписників та на ретрансляторах чату. @@ -2622,8 +2602,6 @@ Підключитися підключено підключення - Для підключення за назвою каналу потрібна новіша версія додатка. - Для підключення за іменем контакту потрібна новіша версія додатка. Підключення не вдалося Підключитися через посилання або QR код Контакт @@ -2648,7 +2626,6 @@ Введіть URL веб-сторінки Помилка Помилка видалення повідомлення - Помилка відкриття каналу помилка: %s Помилка збереження профілю каналу Видалити реле @@ -2674,7 +2651,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..4b8ec8c4b7 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml @@ -537,7 +537,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 @@ -1007,7 +1006,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 +1050,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 @@ -1235,7 +1232,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. @@ -1387,7 +1383,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 @@ -1421,7 +1416,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 +1526,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 @@ -1617,7 +1610,6 @@ 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 +1620,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 +1741,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ệ @@ -1902,7 +1892,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. @@ -1977,7 +1966,6 @@ 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ệ! @@ -2067,7 +2055,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 @@ -2131,7 +2118,6 @@ 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! @@ -2204,7 +2190,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. @@ -2310,7 +2295,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 +2426,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 +2447,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 +2462,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 2d51120ac2..2d5ccbe4b9 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 @@ -1763,7 +1763,6 @@ 是 不 当你的服务器或目标服务器不支持私密路由时直接发送消息。 - 备用消息路由 显示消息状态 为了保护你的 IP 地址,私密路由使用你的 SMP 服务器来传送消息。 私密消息路由 @@ -1776,7 +1775,6 @@ 不直接发送消息,即便你的服务器或目标服务器不支持私密路由。 转发服务器:%1$s \n目标服务器错误:%2$s - 消息路由模式 未知服务器! 没有 Tor 或 VPN,这些 XFTP 中继可以看到你的 IP 地址: \n%1$s. @@ -1785,7 +1783,6 @@ 文件 应用将请求确认来自未知服务器的下载(.onion 或启用 SOCKS 代理时除外)。 个人资料主题 - 在新窗口中显示聊天列表 所有颜色模式 应用到 黑 @@ -1994,10 +1991,8 @@ 无法呼叫群成员 无法给群成员发消息 确认删除联系人? - 连接 邀请 没有过滤的联系人 - 打开 搜索 发送消息来开启通话。 你可以发消息给来自已存档联系人的 %1$s。 @@ -2123,11 +2118,7 @@ 或者私下分享 服务器运营方 网络运营方 - 30 天后将接受已启用的运营方的条款。 继续 - 稍后审阅 - 选择要使用的网络运营方。 - 更新 你可以通过设置配置服务器。 %s.]]> 将于下列日期自动接受已启用的运营方的条款:%s。 @@ -2137,7 +2128,6 @@ 网络运营方 运营方 %s 台服务器 - 网站 无法加载当前条款文本,你可以通过此链接审阅条款: 使用 %s 使用服务器 @@ -2180,7 +2170,6 @@ %s.]]> 在“网络&服务器”设置中启用 Flux,更好地保护元数据隐私。 接受条款出错 - 为了更好的元数据隐私。 添加服务器出错 无媒体和文件服务器。 更新服务器出错 @@ -2189,14 +2178,11 @@ - 在第一条未读消息上打开聊天.\n- 跳转到引用的消息. 审阅条款 SimpleX 地址或一次性链接? - 要稍后创建 SimpleX 地址,请在菜单中轻按“创建 SimpleX 地址” 当前聊天资料的新文件服务器 此消息被删除或尚未收到。 连接达到了未送达消息上限,你的联系人可能处于离线状态。 为了防止链接被替换,你可以比较联系人安全代码。 - 你可以在“网络和服务器”设置中配置运营方。 接受运营方条款的日期:%s - 远程移动设备 或者导入存档文件 小米设备:请在系统设置中开启“自动启动”让通知正常工作。]]> 消息太大! @@ -2294,7 +2280,6 @@ 垃圾信息 存档举报? 举报理由? - 将为你存档该举报。 存档了 %s 的举报 举报 1 个举报 @@ -2324,7 +2309,6 @@ 未指定端口时,使用 TCP 端口 %1$s。 使用 web 端口 全部静音 - 取消提及的已读状态 每条消息最多提及 %1$s 名成员! 将为你存档所有举报。 存档所有举报? @@ -2450,7 +2434,6 @@ 发送联络请求? 发送请求 发送无消息请求 - 连接后发送给你的联系人。 升级群链接? 升级 升级地址? @@ -2504,8 +2487,7 @@ 使用隐身个人资料 欢迎联系人👋 来自%1$s群的连接请求 - 此设置用于当前个人资料 - 来自群的联络请求 + 群内的联络请求 成员被删除——无法接受请求 只有你的联系人允许的情况下才允许文件和媒体。 允许你的联系人发送文件和媒体。 @@ -2536,7 +2518,6 @@ 移除并删除消息 所有消息 文件 - 筛选器 图片 链接 搜索文件 @@ -2567,7 +2548,6 @@ 频道 频道 频道链接 - 频道成员 频道名 将为所有订阅者删除频道 —— 此操作无法撤销! 将为你删除频道 —— 此操作无法撤销! @@ -2597,7 +2577,6 @@ 输入中继名… 添加中继出错 创建频道出错 - 打开频道出错 失败 失败 获取链接 @@ -2614,7 +2593,6 @@ 未启用聊天中继。 不是所有中继均已连接 打开频道 - 打开新频道 所有者 所有者和贡献者 预设中继地址 @@ -2792,7 +2770,6 @@ 顶部栏 你的新频道 %1$s 已连接到 %3$d 个中继中的 %2$d 个中继。\n如果取消,频道将被删除 —— 你可以再次创建它。 这是上次活跃的中继。删除它会阻止给订阅者传送消息。 - 添加 添加中继 添加中继 取消并删除频道 @@ -2831,12 +2808,9 @@ %1$d 名所有者 %1$d 名所有者和贡献者 %1$s 支持过 SimpleX Chat。该徽章已于 %2$s 过期。 - 关于 无法验证徽章 频道网页 聊天数据 - 通过频道名连接需要更新的应用版本。 - 通过联系人名称连接需要更新的应用版本。 联系人 贡献者 复制代码 @@ -2847,7 +2821,6 @@ 它将展示给订阅者并用来允许加载预览。 更多隐私 只有您上面的页面可以显示预览。 - 请升级应用 %s 给 SimpleX Chat 众筹投过钱。 %s 支持 SimpleX Chat。 订阅者 @@ -2855,8 +2828,6 @@ 该角色将更改为 "%s"。频道中的每个人都会收到通知。 此徽章无法被验证,可能不是真的。 此群组需要更新版本的应用。要加入请更新应用。 - 不支持的频道名 - 不支持的联系人名 未验证的徽章 所用的聊天中继不支持网页。 网页代码 @@ -2879,7 +2850,6 @@ 无有效链接 解析错误:%1$s 设置 SimpleX 名称 - SimpleX 名称 SimpleX 名称错误 SimpleX 名称未验证 SimpleX 名称 %1$s 已注册但没有有效链接。 @@ -2924,4 +2894,11 @@ SimpleX 公开名称 (测试) 文件服务器 文件服务器:%s + 群邀请 + 转发自 + %1$02d 小时 %2$02d 分钟 %3$02d 秒 + 众筹投资者可在销售开始前保留名称: + SimpleX 名称销售开始于 + 直到您可以注册 SimpleX 域名 + 更新应用来注册 SimpleX 域名 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..2909f01636 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 @@ -1437,7 +1437,6 @@ 禁止檔案和媒體! 檔案載入中 檔案錯誤 - 備用訊息路由 如果你或你的目標伺服器不支持私密路由,將不直接傳送訊息。 建立個人檔案 從另一台裝置轉移 @@ -1533,7 +1532,6 @@ 中止地址更改時出錯 顯示通知出錯,請聯繫開發者。 刪除並通知聯絡人 - 訊息路由模式 不使用私密路由。 要進行通話請授予一項或多項權限 在 Android 系統設定中找到此權限並手動授予權限。 @@ -1664,7 +1662,6 @@ 成員非活躍 訊息 訊息將被標記為刪除。收信人可以揭示這些訊息。 - 連接 訊息 停用 下載更新中,請不要關閉應用 @@ -1754,7 +1751,6 @@ 私密路由出錯 尚無直接連接,訊息由管理員轉發。 什麼也沒選中 - 開啟 私密路由 打開資料庫文件夾 私密訊息路由 @@ -1859,7 +1855,6 @@ 是 喇叭 解除封鎖 - 網站 透明度 重複 編輯 @@ -1867,7 +1862,6 @@ 選擇 使用者名稱 密碼 - 更新 繼續 已拒絕 系統 @@ -2028,7 +2022,6 @@ 聯絡人已刪除 未同步 聯絡人已停用 - 稍後審核 分享個人檔案 完整連結 已上載檔案 @@ -2077,7 +2070,6 @@ 你已封鎖 %s 連接未就緒。 更好的訊息日期。 - 為了更好的元資料隱私。 你可以再試一次。 使用匿名個人檔案 開啟聊天 @@ -2137,7 +2129,6 @@ 影片 檔案 連結 - 過濾器 %d 個舉報 已棄用的選項 無訂閱 @@ -2226,11 +2217,6 @@ 刪除聊天時發生錯誤 不支援的連線連結 此連結需要較新的應用程式版本。請升級應用程式,或請你的聯絡人傳送相容的連結。 - 不支援的頻道名稱 - 不支援的聯絡人名稱 - 透過頻道名稱連線需要較新的應用程式版本。 - 透過聯絡人名稱連線需要較新的應用程式版本。 - 請升級應用程式。 頻道暫時無法使用 頻道沒有啟用中的中繼。請稍後再嘗試加入。 需要更新應用程式 @@ -2250,7 +2236,6 @@ 應用程式電池用量 / 不受限制。]]> Xiaomi 裝置:請在系統設定中啟用自動啟動,以便通知正常運作。]]> 此訊息已刪除或尚未接收。 - 該檢舉報告將為你封存。 所有檢舉報告將為你封存。 金鑰錯誤或未知連線——此連線很可能已刪除。 伺服器地址與網路設定不相容。 @@ -2273,7 +2258,6 @@ 讓任何人都能聯絡你 列表 %s 中沒有聊天。 聯絡人需要接受… - 稍後可點選選單中的「建立 SimpleX 地址」來建立。 沒有可轉發的內容! 要轉發不含檔案的訊息嗎? 這些訊息在你選取後已被刪除。 @@ -2341,7 +2325,6 @@ 拒絕聯絡請求 傳送者不會收到通知。 成員已刪除,無法接受請求 - 未讀提及 所有列表的名稱和 emoji 都應不同。 所有聊天都將從列表 %s 中移除,且該列表會被刪除 在社交媒體上分享 SimpleX 地址。 @@ -2380,7 +2363,6 @@ 若要接收新版本通知,請開啟穩定版或 Beta 版的定期檢查。 顯示較慢的 API 呼叫 儲存設定時發生錯誤 - 連線後會傳送給你的聯絡人。 或私下分享 SimpleX 地址還是一次性連結? 新的一次性連結 @@ -2429,11 +2411,8 @@ 應用程式會在每個對話中使用不同營運商,以保護你的私隱。 啟用多於一個營運商時,沒有任何一方擁有足以得知誰與誰通訊的中繼資料。 例如,如果你的聯絡人透過 SimpleX Chat 伺服器接收訊息,你的應用程式會透過 Flux 伺服器傳送訊息。 - 選擇要使用的網路營運商。 這如何有助於私隱 你可以透過設定配置伺服器。 - 30 天後,將會接受已啟用營運商的條件。 - 你可以在「網路與伺服器」設定中配置營運商。 你的網路 網路路由器無法知道\n誰在和誰交談 設定路由器 @@ -2450,7 +2429,6 @@ 應用程式會要求確認來自未知檔案伺服器的下載(.onion 或啟用 SOCKS 代理時除外)。 未使用 Tor 或 VPN 時,你的 IP 地址會對檔案伺服器可見。 移除連結追蹤 - 此設定適用於你目前的個人檔案 這些設定適用於你目前的個人檔案 可在聯絡人和群組設定中覆寫這些設定。 已為 %d 個聯絡人啟用送達回條 @@ -2464,14 +2442,12 @@ 開啟乾淨連結 傳送送達回條給 來自群組的聯絡請求 - 關於 聯絡 支持此專案 聊天數據 說明與支援 更多私隱 進階設定 - 遠端行動裝置 你可以儲存匯出的封存檔。 你可以遷移匯出的數據庫。 部分檔案未匯出 @@ -2486,7 +2462,6 @@ 底部列 頂部列 在新視窗中顯示控制台 - 在新視窗中顯示聊天列表 如果你加入或建立了頻道,它們將永久停止運作。 要離開頻道嗎? 你將停止接收此頻道的訊息。聊天記錄將會保留。 @@ -2535,7 +2510,6 @@ 只有聊天擁有者可以變更偏好設定。 此群組有超過 %1$d 名成員,不會傳送送達回條。 連結 - 頻道成員 聊天中繼 要移除訂閱者嗎? 要刪除成員訊息嗎? @@ -2827,7 +2801,6 @@ 你的個人檔案 %1$s 將與頻道中繼和訂閱者共享。\n中繼可以存取頻道訊息。 設定中繼 失敗 - 新增 新增中繼 新增中繼 沒有可用的中繼 @@ -2843,10 +2816,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/common/src/desktopMain/kotlin/chat/simplex/common/other/videoplayer/SkiaBitmapVideoSurface.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/other/videoplayer/SkiaBitmapVideoSurface.kt index f5bba2d344..26748b8425 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/other/videoplayer/SkiaBitmapVideoSurface.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/other/videoplayer/SkiaBitmapVideoSurface.kt @@ -8,6 +8,7 @@ import org.jetbrains.skia.Bitmap import org.jetbrains.skia.ColorAlphaType import org.jetbrains.skia.ColorType import org.jetbrains.skia.ImageInfo +import uk.co.caprica.vlcj.media.VideoOrientation import uk.co.caprica.vlcj.player.base.MediaPlayer import uk.co.caprica.vlcj.player.embedded.videosurface.CallbackVideoSurface import uk.co.caprica.vlcj.player.embedded.videosurface.VideoSurface @@ -22,10 +23,42 @@ import javax.swing.SwingUtilities // https://github.com/JetBrains/compose-multiplatform/pull/3336/files internal class SkiaBitmapVideoSurface : VideoSurface(VideoSurfaceAdapters.getVideoSurfaceAdapter()) { + private companion object { + // A received file declares its own size, and vlc allocates the buffer we ask for here (and we copy + // it into a java array of the same size), so an unbounded request is an out-of-memory from a message. + // Above the budget the picture is scaled down keeping its aspect - 4096x4096 of RV32 is 64 MB + const val MAX_BUFFER_PIXELS = 4096L * 4096L + val transposedOrientations = setOf( + VideoOrientation.LEFT_TOP, + VideoOrientation.LEFT_BOTTOM, + VideoOrientation.RIGHT_TOP, + VideoOrientation.RIGHT_BOTTOM, + ) + + // Keeps the aspect, never returns a side below 1, and keeps width * height * 4 inside an Int + fun boundedSize(width: Int, height: Int): Pair { + val w = width.coerceAtLeast(1) + val h = height.coerceAtLeast(1) + val pixels = w.toLong() * h.toLong() + if (pixels <= MAX_BUFFER_PIXELS) return w to h + val scale = kotlin.math.sqrt(MAX_BUFFER_PIXELS.toDouble() / pixels.toDouble()) + var sw = (w * scale).toInt().coerceAtLeast(1) + var sh = (h * scale).toInt().coerceAtLeast(1) + // Scaling both sides assumes both shrink; a side pinned at 1 only shrinks the area linearly, + // so a 2_000_000_000 x 1 declaration would still get 45 times the budget. Divide the budget + // by the pinned side instead + if (sw.toLong() * sh.toLong() > MAX_BUFFER_PIXELS) { + if (sw >= sh) sw = (MAX_BUFFER_PIXELS / sh).toInt() else sh = (MAX_BUFFER_PIXELS / sw).toInt() + } + return sw to sh + } + } + private val videoSurface = SkiaBitmapVideoSurface() @Volatile private var mediaPlayer: MediaPlayer? = null private lateinit var imageInfo: ImageInfo private lateinit var frameBytes: ByteArray + @Volatile private var allocated = false private val skiaBitmap: Bitmap = Bitmap() private val composeBitmap = mutableStateOf(null) @@ -49,19 +82,37 @@ internal class SkiaBitmapVideoSurface : VideoSurface(VideoSurfaceAdapters.getVid val tracks = player?.media()?.info()?.videoTracks() val playingTrack = player?.video()?.track() val track = tracks?.firstOrNull { it.id() == playingTrack } ?: tracks?.singleOrNull() - this.sourceWidth = track?.width()?.takeIf { it > 0 } ?: sourceWidth - this.sourceHeight = track?.height()?.takeIf { it > 0 } ?: sourceHeight - return RV32BufferFormat(this.sourceWidth, this.sourceHeight) + // Both track sides or neither: one side from the track and the other from the padded size libvlc + // passed never described the same picture, and transposing such a pair compounds the mismatch + val trackW = track?.width() ?: 0 + val trackH = track?.height() ?: 0 + val useTrack = trackW > 0 && trackH > 0 + val width = if (useTrack) trackW else sourceWidth + val height = if (useTrack) trackH else sourceHeight + // The track carries the size before rotation, but vlc rotates the picture before it reaches + // this buffer, so for the transposed orientations the picture arrives with the sides swapped. + // Only when the track's own sides are used: the size libvlc passed is already rotated + val transposed = useTrack && (track?.orientation() in transposedOrientations) + val orientedWidth = if (transposed) height else width + val orientedHeight = if (transposed) width else height + val (w, h) = boundedSize(orientedWidth, orientedHeight) + this.sourceWidth = w + this.sourceHeight = h + return RV32BufferFormat(w, h) } override fun allocatedBuffers(buffers: Array) { - frameBytes = buffers[0].run { ByteArray(remaining()).also(::get) } + // rewind first, as in display: remaining() on an already-read buffer would size this short + frameBytes = buffers[0].run { rewind(); ByteArray(remaining()).also(::get) } imageInfo = ImageInfo( sourceWidth, sourceHeight, ColorType.BGRA_8888, ColorAlphaType.PREMUL, ) + // Last, and volatile: vlc calls this on its own thread while display reads imageInfo and + // frameBytes on the event thread, and this write is what publishes them to it + this@SkiaBitmapVideoSurface.allocated = true } } @@ -71,11 +122,35 @@ internal class SkiaBitmapVideoSurface : VideoSurface(VideoSurfaceAdapters.getVid nativeBuffers: Array, bufferFormat: BufferFormat, ) { + // The native buffer belongs to vlc and is only guaranteed to exist for the duration of this + // callback, so everything that touches it has to happen here, on vlc's thread - deferred code + // would read through a pointer vlc may have freed on a format change. Only the copy is done + // here; skia and compose are event-thread objects and get the private copy + if (!this@SkiaBitmapVideoSurface.allocated) return + val info = imageInfo + // imageInfo comes from the format that was last allocated and this frame from the format it was + // rendered with; they differ across a renegotiation, and the pixels would be read with the + // wrong stride, so display only what matches + if (bufferFormat.width != info.width || bufferFormat.height != info.height) return + val rowBytes = info.width.toLong() * 4 + val needed = rowBytes * info.height + val buffer = nativeBuffers[0] + // rewind first: the same buffer is reused for every frame, so its position is at the end of + // the previous read and remaining() would be 0 + buffer.rewind() + // Capture the array: a renegotiation replaces the field with one of another size before the + // deferred install runs, and info's geometry must be read against the array it was copied into. + // The next frame's copy can overwrite it while the install reads - a torn frame at worst, since + // the geometry checks above hold for both frames of the same format + val bytes = frameBytes + if (needed > bytes.size || buffer.remaining().toLong() < needed) return + buffer.get(bytes, 0, needed.toInt()) SwingUtilities.invokeLater { - nativeBuffers[0].rewind() - nativeBuffers[0].get(frameBytes) - skiaBitmap.installPixels(imageInfo, frameBytes, bufferFormat.width * 4) - composeBitmap.value = skiaBitmap.asComposeImageBitmap() + // installPixels reports whether skia took the pixels; publishing the bitmap when it did not + // would hand compose a bitmap with no pixels behind it + if (skiaBitmap.installPixels(info, bytes, rowBytes.toInt())) { + composeBitmap.value = skiaBitmap.asComposeImageBitmap() + } } } } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt index 768d2f421d..d9375f5d79 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt @@ -11,7 +11,6 @@ import uk.co.caprica.vlcj.media.Media import uk.co.caprica.vlcj.media.MediaEventAdapter import uk.co.caprica.vlcj.media.MediaParsedStatus import uk.co.caprica.vlcj.media.ParseFlag -import uk.co.caprica.vlcj.media.VideoOrientation import uk.co.caprica.vlcj.player.base.* import uk.co.caprica.vlcj.player.component.CallbackMediaPlayerComponent import uk.co.caprica.vlcj.player.component.EmbeddedMediaPlayerComponent @@ -232,7 +231,18 @@ actual class VideoPlayer actual constructor( player.media().startPaused(uri.toFile().absolutePath) val snap = withTimeoutOrNull(1500L) { while (surface.bitmap.value == null) delay(50) - surface.bitmap.value!!.toAwtImage() + // The render callback installs pixels into the surface bitmap on the event thread, so read it + // there too - converting off that thread races a resize on format renegotiation and segfaults + // inside skia while reading pixels of the previous, smaller buffer + val holder = java.util.concurrent.atomic.AtomicReference(null) + // invokeAndWait rethrows whatever the conversion threw, wrapped, and the callers of this have + // no handler; a frame that cannot be converted is a missing preview, not a failed send + try { + javax.swing.SwingUtilities.invokeAndWait { holder.set(surface.bitmap.value?.toAwtImage()) } + } catch (e: Exception) { + Log.e(TAG, "getBitmapFromVideo snapshot failed: ${e.stackTraceToString()}") + } + holder.get() } val orientation = player.media().info().videoTracks().firstOrNull()?.orientation() if (orientation == null) { @@ -242,17 +252,9 @@ actual class VideoPlayer actual constructor( return@withContext VideoPlayerInterface.PreviewAndDuration(preview = defaultPreview, timestamp = 0L, duration = 0L) } - val preview: ImageBitmap? = when (orientation) { - VideoOrientation.TOP_LEFT -> snap - VideoOrientation.TOP_RIGHT -> snap?.flip(false, true) - VideoOrientation.BOTTOM_LEFT -> snap?.flip(true, false) - VideoOrientation.BOTTOM_RIGHT -> snap?.rotate(180.0) - VideoOrientation.LEFT_TOP -> snap /* Transposed */ - VideoOrientation.LEFT_BOTTOM -> snap?.rotate(-90.0) - VideoOrientation.RIGHT_TOP -> snap?.rotate(90.0) - VideoOrientation.RIGHT_BOTTOM -> snap /* Anti-transposed */ - else -> snap - }?.toComposeImageBitmap() + // vlc applies the display matrix before the frame reaches the video surface, so the snapshot + // arrives upright; orienting it again here would undo that + val preview: ImageBitmap? = snap?.toComposeImageBitmap() val duration = player.duration.toLong() player.stop() putHelperPlayer(mediaComponent) diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.desktop.kt index bdfbf6863f..d60790b694 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.desktop.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.unit.Dp @@ -14,6 +15,8 @@ import java.awt.Window @Composable actual fun PlayerView(player: VideoPlayer, width: Dp, onClick: () -> Unit, onLongClick: () -> Unit, stop: () -> Unit) { Box { + // The preview this replaces while playing is drawn with FillWidth, so a video smaller than the + // item width has to grow the same way here - Fit would leave it at its own size in the middle SurfaceFromPlayer(player, Modifier .width(width) @@ -21,7 +24,8 @@ actual fun PlayerView(player: VideoPlayer, width: Dp, onClick: () -> Unit, onLon onLongClick = onLongClick, onClick = { if (player.player.isPlaying) stop() else onClick() } ) - .onRightClick(onLongClick) + .onRightClick(onLongClick), + contentScale = ContentScale.FillWidth ) } } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt index 583aa8f52f..9fb4afbf7d 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt @@ -43,7 +43,7 @@ actual fun FullScreenVideoView(player: VideoPlayer, modifier: Modifier, close: ( } @Composable -fun BoxScope.SurfaceFromPlayer(player: VideoPlayer, modifier: Modifier) { +fun BoxScope.SurfaceFromPlayer(player: VideoPlayer, modifier: Modifier, contentScale: ContentScale = ContentScale.Fit) { val surface = remember { SkiaBitmapVideoSurface().also { player.player.videoSurface().set(it) @@ -54,7 +54,7 @@ fun BoxScope.SurfaceFromPlayer(player: VideoPlayer, modifier: Modifier) { bitmap, modifier = modifier.align(Alignment.Center), contentDescription = null, - contentScale = ContentScale.Fit, + contentScale = contentScale, alignment = Alignment.Center, ) } diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ChatItemsLoaderTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ChatItemsLoaderTest.kt new file mode 100644 index 0000000000..3337839ff0 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ChatItemsLoaderTest.kt @@ -0,0 +1,65 @@ +package chat.simplex.app + +import chat.simplex.common.model.* +import chat.simplex.common.platform.chatModel +import chat.simplex.common.views.chat.processLoadedChat +import chat.simplex.common.model.replaceAll +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlin.test.Test +import kotlin.test.assertEquals + +class ChatItemsLoaderTest { + + private fun groupChat(groupId: Long, itemIds: List): Chat = + Chat( + remoteHostId = null, + chatInfo = ChatInfo.Group(GroupInfo.sampleData.copy(groupId = groupId), groupChatScope = null), + chatItems = itemIds.map { ChatItem.getSampleData(it, CIDirection.GroupRcv(GroupMember.sampleData)) } + ) + + private suspend fun openChatWithItems(itemIds: List): Pair { + val chatsCtx = ChatModel.ChatsContext(null) + val opened = groupChat(groupId = 2, itemIds = itemIds) + withContext(Dispatchers.Main) { + chatsCtx.chatItems.replaceAll(opened.chatItems) + chatModel.chatId.value = opened.chatInfo.id + } + return chatsCtx to opened + } + + private fun itemIds(chatsCtx: ChatModel.ChatsContext): List = chatsCtx.chatItems.value.map { it.id } + + @Test + fun lastPageLoadedForAnotherChatIsNotAddedToOpenedChat() = runBlocking { + val (chatsCtx, _) = openChatWithItems(listOf(101, 102)) + val anotherChat = groupChat(groupId = 1, itemIds = listOf(201, 202)) + processLoadedChat(chatsCtx, anotherChat, NavigationInfo(), ChatPagination.Last(2), openAroundItemId = null) + assertEquals(listOf(101L, 102L), itemIds(chatsCtx)) + } + + @Test + fun lastPageLoadedForOpenedChatIsAdded() = runBlocking { + val (chatsCtx, _) = openChatWithItems(listOf(101, 102)) + val sameChat = groupChat(groupId = 2, itemIds = listOf(103, 104)) + processLoadedChat(chatsCtx, sameChat, NavigationInfo(), ChatPagination.Last(2), openAroundItemId = null) + assertEquals(listOf(101L, 102L, 103L, 104L), itemIds(chatsCtx)) + } + + @Test + fun beforePageLoadedForAnotherChatIsNotAddedToOpenedChat() = runBlocking { + val (chatsCtx, _) = openChatWithItems(listOf(101, 102)) + val anotherChat = groupChat(groupId = 1, itemIds = listOf(201, 202)) + processLoadedChat(chatsCtx, anotherChat, NavigationInfo(), ChatPagination.Before(101, 2), openAroundItemId = null) + assertEquals(listOf(101L, 102L), itemIds(chatsCtx)) + } + + @Test + fun aroundPageLoadedForAnotherChatIsNotAddedToOpenedChat() = runBlocking { + val (chatsCtx, _) = openChatWithItems(listOf(101, 102)) + val anotherChat = groupChat(groupId = 1, itemIds = listOf(201, 202)) + processLoadedChat(chatsCtx, anotherChat, NavigationInfo(), ChatPagination.Around(101, 2), openAroundItemId = null) + assertEquals(listOf(101L, 102L), itemIds(chatsCtx)) + } +} diff --git a/apps/multiplatform/external/nanohttpd/build.gradle.kts b/apps/multiplatform/external/nanohttpd/build.gradle.kts index fb24922208..7841d8f803 100644 --- a/apps/multiplatform/external/nanohttpd/build.gradle.kts +++ b/apps/multiplatform/external/nanohttpd/build.gradle.kts @@ -26,16 +26,25 @@ java { targetCompatibility = jvmVersion } -// Without this the jar records the build machine's timestamps, file order and file modes, -// which makes the desktop packages unreproducible -tasks.jar { - // Checked here and not during configuration, so that Android builds, which don't use nanohttpd, - // work without the submodule +val upstreamSources = sourceSets.main.get().java.matching { include("org/nanohttpd/**") } + +// compileJava and jar silently succeed without the sources, so the check needs its own task. +// It cannot run during configuration, Android builds must work without the submodule. +val checkUpstreamSources by tasks.registering { doFirst { - if (!upstream.file("core/src/main/java").asFile.isDirectory) { + if (upstreamSources.isEmpty) { throw GradleException("nanohttpd sources are missing, run: git submodule update --init --recursive") } } +} + +tasks.compileJava { + dependsOn(checkUpstreamSources) +} + +// Without this the jar records the build machine's timestamps, file order and file modes, +// which makes the desktop packages unreproducible +tasks.jar { isPreserveFileTimestamps = false isReproducibleFileOrder = true filePermissions { unix("644") } diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index a904d2859b..b7807cb4c0 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.1 -android.version_code=374 +android.version_name=7.1-beta.3 +android.version_code=378 -desktop.version_name=7.1-beta.1 -desktop.version_code=158 +desktop.version_name=7.1-beta.3 +desktop.version_code=161 kotlin.version=2.1.20 gradle.plugin.version=8.7.0 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 a593e1074e..8f8b45a690 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -54,9 +54,9 @@ import Directory.Rpc 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) @@ -232,7 +232,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_ @@ -284,7 +284,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} @@ -295,7 +295,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 () @@ -330,7 +330,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, serviceRequestsInFlight} user@User {userId} cc = \case +directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, ownersGroup, searchResults, prohibitedToObserver, alwaysCaptcha, alwaysObserver} env@ServiceState {searchRequests, serviceRequestsInFlight} 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 @@ -761,7 +761,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 diff --git a/apps/simplex-directory-service/src/Directory/Store.hs b/apps/simplex-directory-service/src/Directory/Store.hs index 6b2d776b52..490c6415c0 100644 --- a/apps/simplex-directory-service/src/Directory/Store.hs +++ b/apps/simplex-directory-service/src/Directory/Store.hs @@ -68,7 +68,7 @@ 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 (..)) 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/20260819-simplex-chat-crowdfunding.md b/blog/20260819-simplex-chat-crowdfunding.md index b0114d73da..93bf90fc8c 100644 --- a/blog/20260819-simplex-chat-crowdfunding.md +++ b/blog/20260819-simplex-chat-crowdfunding.md @@ -53,6 +53,7 @@ The web solved this by letting anyone build any experience on one open platform. SimpleX Chat is the company that builds SimpleX Network, and now you can invest from $100 and get a stake in the company. If you invest $500 or more, you receive [a public SimpleX name](./20260722-simplex-public-names.md) on SimpleX Network: +- for 7 years, if you invest by September 22, - for 5 years for early bird investors, - for 3 years after that. diff --git a/blog/20260908-nick-rogers-why-i-backed-simplex-chat.md b/blog/20260908-nick-rogers-why-i-backed-simplex-chat.md new file mode 100644 index 0000000000..a29b1bddb5 --- /dev/null +++ b/blog/20260908-nick-rogers-why-i-backed-simplex-chat.md @@ -0,0 +1,81 @@ +--- +layout: layouts/article.html +title: "Nick Rogers: Why I Backed SimpleX Chat in 2022, and Why I Doubled Down" +date: 2026-09-08 +preview: "Blog post by Nick Rogers, early investor and advisor of SimpleX Chat, founder of Fieldwork, former CPO & CTO of Stream." +permalink: "/blog/20260908-nick-rogers-why-i-backed-simplex-chat.html" +--- + +# Why I Backed SimpleX Chat in 2022, and Why I Doubled Down + +**Published:** Sep 08, 2026 + +*By [Nick Rogers](https://x.com/nickwd), Early Investor and Advisor, SimpleX Chat | Founder, Fieldwork | former CPO & CTO, Stream (Wagestream)* + +--- + +When I first met Evgeny at Wagestream (now Stream) back in 2020, he was one of the most unique people I had ever encountered in technology. + +He was fiercely, relentlessly honest. In a tech industry where corporate diplomacy usually trumps candour, that honesty struck me as genuine bravery. Evgeny was never afraid to speak truth to authority, and he held an uncompromisingly high bar for technical truth. + +In late 2021, when he decided to step down as VP of Engineering to build a brand new messaging protocol from scratch, I took over as VP of Engineering. + +I knew how massive the shoes were that I had to fill. But more than that, I knew that whatever Evgeny set out to build next, he would pursue with absolute conviction. + +Most people in software already know Evgeny without realizing it. He is the creator of **Ajv** (Another JSON Schema Validator), one of the most widely used open source libraries in the JavaScript ecosystem, downloaded over a billion times each month by virtually every major technology company on earth. When someone has already built foundational infrastructure that the entire web secretly depends on, you take notice when they turn their attention to a new problem. + +### The 2022 Bet: Backing a Protocol Before It Had Users + +When Evgeny first pitched me on **SimpleX Chat**, his ambition was enormous: build an alternative to WhatsApp and Telegram that fundamentally eliminated user identifiers. + +No phone numbers. No email addresses. No usernames. Not even a persistent public key or random user ID. + +My first thought was that competing with WhatsApp was wildly ambitious. But as an engineering leader, I also knew there was a massive, structural gap in the market. + +People assume end-to-end encryption solves privacy. It does not. WhatsApp, Telegram, Signal, and Matrix still rely on centralized account registries or user identifiers. Even when the message payload is encrypted, the platforms still collect, analyze, and classify the metadata: who you are, who you talk to, at what time, and from where. Between on-device classification, advertising networks, and platform-level censorship, modern messaging is fundamentally broken at the protocol layer. + +When I wrote one of the first angel checks into SimpleX's SEIS round in early 2022 (at a $5M valuation), there were no mobile app store rankings. There was barely a user base. It was essentially a Haskell terminal prototype on GitHub and Evgeny's mathematical conviction that you could route messages over isolated, unidirectional queues without ever tracking who was talking to whom. + +I invested because I believed in the architectural insight, and because I knew Evgeny was stubborn enough to work on this until it succeeded. + +### Four Years Later: The Inflection Point + +Over the last four years, as an investor and advisor, I have watched that initial command-line prototype grow into a global network: + +* **Almost 500,000 Monthly Active Users** (up from zero when I invested). +* **3 Million+ App Downloads** and **over 20 Million messages per day**. +* **Zero paid marketing spend.** + +SimpleX has reached this size because it started with the people who need uncompromising privacy the most: journalists, whistleblowers, developers, cryptographers, and sovereign communities. + +More importantly, it has evolved from a 1:1 private messenger into a platform for **sovereign publishing and broadcast channels**. Platforms like Telegram banned tens of millions of channels in 2025 alone, proving to creators and communities that they do not own their audience on centralized platforms. On SimpleX channels, publishers cannot be revoked, subscribers cannot be tracked, and content cannot be taken down centrally. + +### Why the Commercial Model Works: Scale, Low ARPU, and Organic Demand + +A common criticism of privacy tools is that they make great philosophical statements but terrible businesses. + +I take the opposite view for two reasons: + +1. **Unsolicited willingness to pay:** Before SimpleX even introduced paid commercial tiers, users voluntarily gave over **$650,000 in unsolicited donations**. When a community gives you hundreds of thousands of dollars purely to support the software, you have a level of organic product love and willingness to pay that most consumer startups spend tens of millions of venture dollars trying to manufacture. +2. **Viral dynamics and low ARPU:** Messaging apps are inherently viral networks. Once a protocol achieves scale, building a high-margin, profitable business is straightforward even at very low average revenue per user (ARPU). + +SimpleX is not monetizing through surveillance advertising. It is monetizing through core infrastructure utilities: +* **SimpleX Domains (`.simplex` public names launching Dec 12):** Memorable names for public channels and contact addresses, providing recurring utility revenue. +* **Paid Supporter Badges and Dedicated Relays:** High-throughput infrastructure for power users and enterprise teams. +* **Enterprise Messaging:** Allowing businesses to talk to customers via their websites, without any privacy compromises of existing solutions like Intercom or Drift (especially important for sectors that require anonymity by law). + +### Why I Doubled Down, and Why You Should Join the Wefunder Round + +Messaging is the most fundamental layer of human communication on the internet. It should look more like email: an open, decentralized protocol that nobody owns, rather than a walled garden controlled by Big Tech. + +When Evgeny opened an insider round in mid-2026, I immediately wrote another check. And when he decided to take this round to **Wefunder** ([wefunder.com/simplexchat](https://wefunder.com/simplexchat?utm_source=nr_post)), I was proud to represent the community of investors backing him. + +In startup investing, you back two things: **the founder and the TAM**. + +The market for private, sovereign communications is enormous. And Evgeny is the rarest kind of founder: technically brilliant, relentlessly honest, and stubborn enough to see an audacious mission through to the end. + +If you believe that the future of communication should belong to individuals rather than centralized platforms, I encourage you to join us as an investor. + +👉 **Invest in SimpleX Chat on Wefunder:** [wefunder.com/simplexchat](https://wefunder.com/simplexchat?utm_source=nr_post) + +*(Note: Anyone investing $500 or more before September 22 secures their `.simplex` public name for 7 years prior to the public launch.)* diff --git a/bots/api/COMMANDS.md b/bots/api/COMMANDS.md index d88181b778..21f67fe4d2 100644 --- a/bots/api/COMMANDS.md +++ b/bots/api/COMMANDS.md @@ -74,11 +74,16 @@ This file is generated automatically. [Service commands](#service-commands) - [APISendServiceResponse](#apisendserviceresponse) +- [APIRejectServiceRequest](#apirejectservicerequest) [Chat management](#chat-management) - [StartChat](#startchat) - [APIStopChat](#apistopchat) +[Remote control commands](#remote-control-commands) +- [ConnectRemoteCtrl](#connectremotectrl) +- [VerifyRemoteCtrlSession](#verifyremotectrlsession) + --- @@ -231,6 +236,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 +514,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**: @@ -1609,6 +1618,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 +1818,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 +1890,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" @@ -2310,6 +2347,44 @@ ChatCmdError: Command error (only used in WebSockets API). --- +### APIRejectServiceRequest + +Reject a received service request. With a reason the requester fails fast; without it the request is dropped and the requester waits out its timeout. + +*Network usage*: background. + +**Parameters**: +- userId: int64 +- requestId: string +- rejectionReason: string? + +**Syntax**: + +``` +/_reject_service_request [ ] +``` + +```javascript +'/_reject_service_request ' + userId + ' ' + requestId + (rejectionReason ? ' ' + rejectionReason : '') // JavaScript +``` + +```python +'/_reject_service_request ' + str(userId) + ' ' + requestId + ((' ' + rejectionReason) if rejectionReason is not None else '') # Python +``` + +**Responses**: + +CmdOk: Ok. +- type: "cmdOk" +- user_: [User](./TYPES.md#user)? + +ChatCmdError: Command error (only used in WebSockets API). +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + +--- + + ## Chat management These commands should not be used with CLI-based bots @@ -2369,3 +2444,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 665dd26889..e79f8638ad 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) @@ -158,6 +163,7 @@ This file is generated automatically. - [ProxyError](#proxyerror) - [PublicGroupAccess](#publicgroupaccess) - [PublicGroupData](#publicgroupdata) +- [PublicGroupKeys](#publicgroupkeys) - [PublicGroupProfile](#publicgroupprofile) - [RCErrorType](#rcerrortype) - [RatchetSyncState](#ratchetsyncstate) @@ -169,8 +175,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) @@ -385,6 +395,17 @@ BadSignature: - type: "badSignature" +--- + +## AppVersionRange + +Remote controller app version range (min and max as version strings). + +**Record type**: +- minVersion: string +- maxVersion: string + + --- ## AutoAccept @@ -399,7 +420,7 @@ BadSignature: **Record type**: - badgeType: [BadgeType](#badgetype) -- badgeExpiry: UTCTime? +- badgeExpiry: UTCTime - badgeExtra: string @@ -414,6 +435,98 @@ 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 + +**Discriminated union type**: + +BadRequest: +- type: "badRequest" + +UnsupportedVersion: +- type: "unsupportedVersion" + +UnknownPurchaseKey: +- type: "unknownPurchaseKey" + +UnknownOfferId: +- type: "unknownOfferId" + +OfferDisabled: +- type: "offerDisabled" + +OfferMismatch: +- type: "offerMismatch" + +ProductUnavailable: +- type: "productUnavailable" + +PaymentNotEntitled: +- type: "paymentNotEntitled" + +PaymentPending: +- type: "paymentPending" + +ProviderUnavailable: +- type: "providerUnavailable" + +RateLimited: +- type: "rateLimited" + +CodeInvalid: +- type: "codeInvalid" + +CodeUsed: +- type: "codeUsed" + +CodeExpired: +- type: "codeExpired" + +ReceiptInvalid: +- type: "receiptInvalid" + +ReceiptUsed: +- type: "receiptUsed" + +Internal: +- type: "internal" + +Unknown: +- type: "unknown" +- : string + + --- ## BadgeStatus @@ -747,6 +860,8 @@ LocalRcv: - fileSource: [CryptoFile](#cryptofile)? - fileStatus: [CIFileStatus](#cifilestatus) - fileProtocol: [FileProtocol](#fileprotocol) +- fileExpires: UTCTime? +- fileProhibited: [FileProhibited](#fileprohibited)? --- @@ -1340,6 +1455,10 @@ CommandError: - type: "commandError" - message: string +BadgeRedeemError: +- type: "badgeRedeemError" +- badgeRedeemError: [BadgeRedeemError](#badgeredeemerror) + AgentCommandError: - type: "agentCommandError" - message: string @@ -1965,6 +2084,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 @@ -2124,6 +2255,16 @@ NO_FILE: - fileConnReq: string? - fileInline: [InlineFileMode](#inlinefilemode)? - fileDescr: [FileDescr](#filedescr)? +- fileBadge: [BadgeProof](#badgeproof)? + + +--- + +## FileProhibited + +**Record type**: +- maxSize: int64 +- badgeStatus: [BadgeStatus](#badgestatus)? --- @@ -2403,8 +2544,7 @@ MemberSupport: ## GroupKeys **Record type**: -- publicGroupId: string -- groupRootKey: [GroupRootKey](#grouprootkey) +- publicGroupKeys: [PublicGroupKeys](#publicgroupkeys)? - memberPrivKey: string @@ -3273,6 +3413,15 @@ NO_SESSION: - publicMemberCount: int64 +--- + +## PublicGroupKeys + +**Record type**: +- publicGroupId: string +- groupRootKey: [GroupRootKey](#grouprootkey) + + --- ## PublicGroupProfile @@ -3441,6 +3590,7 @@ Cancelled: - fileId: int64 - xftpRcvFile: [XFTPRcvFile](#xftprcvfile)? - fileInvitation: [FileInvitation](#fileinvitation) +- fileProhibited: [FileProhibited](#fileprohibited)? - fileStatus: [RcvFileStatus](#rcvfilestatus) - fileType: [FileType](#filetype) - rcvFileInline: [InlineFileMode](#inlinefilemode)? @@ -3548,6 +3698,15 @@ ParseError: - webDomain: string? +--- + +## RelayConnectionResult + +**Record type**: +- relayMember: [GroupMember](#groupmember) +- relayError: [ChatError](#chaterror)? + + --- ## RelayProfile @@ -3573,6 +3732,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 diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index d126ff1844..7efae4bffc 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") ] ), @@ -141,7 +141,7 @@ chatCommandsDocsData = -- `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_"), + ("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"), @@ -194,7 +194,8 @@ chatCommandsDocsData = ), ( "Service commands", "Bots with a double ratchet address can answer service requests.", - [ ("APISendServiceResponse", [], "Send a reply to a received service request. Returns the connection ID that correlates the reply delivery event.", ["CRServiceReplyAccepted", "CRChatCmdError"], [], Just UNBackground, "/_service_response " <> Param "userId" <> " " <> Param "requestId" <> " " <> Json "responseData") + [ ("APISendServiceResponse", [], "Send a reply to a received service request. Returns the connection ID that correlates the reply delivery event.", ["CRServiceReplyAccepted", "CRChatCmdError"], [], Just UNBackground, "/_service_response " <> Param "userId" <> " " <> Param "requestId" <> " " <> Json "responseData"), + ("APIRejectServiceRequest", [], "Reject a received service request. With a reason the requester fails fast; without it the request is dropped and the requester waits out its timeout.", ["CRCmdOk", "CRChatCmdError"], [], Just UNBackground, "/_reject_service_request " <> Param "userId" <> " " <> Param "requestId" <> Optional "" (" " <> Param "$0") "rejectionReason") ] ), ( "Chat management", @@ -202,6 +203,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 +343,7 @@ undocumentedCommands = [ "APIAbortSwitchContact", "APIAbortSwitchGroupMember", "APIAcceptConditions", + "APIAckBadgeAlert", "APIActivateChat", "APIAddGroupShortLink", "APIAddMyAddressShortLink", @@ -371,6 +379,7 @@ undocumentedCommands = "APIExportArchive", "APIForwardChatItems", "APIGetAppSettings", + "APIGetBadgeState", "APIGetCallInvitations", "APIGetChat", "APIGetChatContentTypes", @@ -398,6 +407,7 @@ undocumentedCommands = "APIPlanForwardChatItems", "APIPrepareContact", "APIPrepareGroup", + "APIRedeemBadgeCode", "APIRegisterToken", "APIRejectCall", "APIReorderChatTags", @@ -452,7 +462,6 @@ undocumentedCommands = "APIVerifyToken", "CheckChatRunning", "ConfirmRemoteCtrl", - "ConnectRemoteCtrl", "CustomChatCommand", "DebugEvent", "DebugLocks", @@ -499,6 +508,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 76f1ddb76b..5d01c03a39 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,8 @@ undocumentedResponses = "CRAppSettings", "CRArchiveExported", "CRArchiveImported", + "CRBadgeRedeemed", + "CRBadgeState", "CRBroadcastSent", "CRCallInvitations", "CRChatCleared", @@ -188,13 +195,10 @@ undocumentedResponses = "CRQueueInfo", "CRRcvStandaloneFileCreated", "CRReactionMembers", - "CRRemoteCtrlConnected", - "CRRemoteCtrlConnecting", "CRRemoteCtrlList", "CRRemoteFileStored", "CRRemoteHostList", "CRRemoteHostStarted", - "CRSentInvitationToContact", "CRServerOperatorConditions", "CRServerTestResult", "CRServiceResponse", @@ -202,8 +206,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 e0af1f3ff5..70777afa21 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.Types import Simplex.Chat.Types.Preferences @@ -49,6 +50,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 (..)) @@ -211,8 +213,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, STUnion, "BSE", [], "", ""), (sti @BlockingInfo, STRecord, "", [], "", ""), (sti @BlockingReason, STEnum, "BR", [], "", ""), (sti @BrokerErrorType, STUnion, "", [], "", ""), @@ -243,6 +248,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, "", [], "", ""), @@ -270,6 +276,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, "", [], "", ""), @@ -342,6 +349,7 @@ chatTypesDocsData = (sti @ProxyError, STUnion, "", [], "", ""), (sti @PublicGroupAccess, STRecord, "", [], "", ""), (sti @PublicGroupData, STRecord, "", [], "", ""), + (sti @PublicGroupKeys, STRecord, "", [], "", ""), (sti @PublicGroupProfile, STRecord, "", [], "", ""), (sti @RatchetSyncState, STEnum, "RS", [], "", ""), (sti @RCErrorType, STUnion, "RCE", [], "", ""), @@ -353,8 +361,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, "", [], "", ""), @@ -440,6 +452,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 @@ -470,6 +484,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 @@ -497,6 +512,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 @@ -576,6 +592,7 @@ deriving instance Generic ProxyClientError deriving instance Generic ProxyError deriving instance Generic PublicGroupAccess deriving instance Generic PublicGroupData +deriving instance Generic PublicGroupKeys deriving instance Generic PublicGroupProfile deriving instance Generic RatchetSyncState deriving instance Generic RCErrorType @@ -587,8 +604,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/cabal.project b/cabal.project index fa144d6780..81a7dc7e6f 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 0d3cf39c27aec1c51c88d7bf3d6762bc22278967 + tag: ea43df2349f6d3dedd60d5e4aed21fd99316cad9 source-repository-package type: git diff --git a/docs/CHAT-RELAY.md b/docs/CHAT-RELAY.md index a06c06026f..38c88d560e 100644 --- a/docs/CHAT-RELAY.md +++ b/docs/CHAT-RELAY.md @@ -1,6 +1,7 @@ --- title: Hosting your own Chat Relay -revision: 16.07.2026 +revision: 28.07.2026 +templateEngineOverride: md --- # Hosting your own Chat Relay @@ -20,6 +21,7 @@ This guide explains how to set up a chat relay on a Linux server, how to run it, - [Relay options](#relay-options) - [Get the relay address](#get-the-relay-address) - [Run relay commands](#run-relay-commands) +- [Run with Docker](#run-with-docker) - [Channel web previews](#channel-web-previews) - [Relay web options](#relay-web-options) - [Serve the previews with Caddy](#serve-the-previews-with-caddy) @@ -126,6 +128,73 @@ simplex-chat-relay -d /home/relay/relay -e "/set profile image file /home/relay/ systemctl start simplex-relay ``` +## Run with Docker + +The relay can also be built and run with Docker Compose, using PostgreSQL for storage. The files are in [`scripts/relay`](https://github.com/simplex-chat/simplex-chat/tree/master/scripts/relay). + +1. Clone the repository and switch to the relay directory: + + ```sh + git clone https://github.com/simplex-chat/simplex-chat + cd simplex-chat/scripts/relay + ``` + +2. Copy the example environment file, then set `RELAY_NAME`, `RELAY_WEB_DOMAIN` and `POSTGRES_PASSWORD` in it: + + ```sh + cp .env.example .env + ``` + +3. Create the directory for the previews and the CORS file, owned by the container's user (UID `1000`): + + ```sh + mkdir -p /var/www/relay-web-channels/channel + chown -R 1000:1000 /var/www/relay-web-channels + chmod 0755 /var/www/relay-web-channels + ``` + +4. Create the output directory for the relay address, then build and start: + + ```sh + mkdir -p out && chown 1000:1000 out + docker compose build + docker compose up -d + ``` + + The first build compiles from source and takes a while. + +5. Read the relay address, written on the first start: + + ```sh + cat out/relay-address.txt + ``` + +To give the relay a picture, put a small `.png`/`.jpg`/`.jpeg` file (large images are rejected) next to the compose file and add a `docker-compose.override.yml`: + +```yaml +services: + relay: + environment: + RELAY_IMAGE_FILE: /avatar.png + volumes: + - ./avatar.png:/avatar.png:ro +``` + +To run a one-off command against the relay's database, override the entrypoint: + +```sh +docker compose run --rm --entrypoint sh relay -c \ + 'simplex-chat-relay -d "$DB_CONN" -e "/set profile image file /avatar.png"' +``` + +Relay metrics from the database are published by [sql_exporter](https://github.com/burningalchemist/sql_exporter) on `127.0.0.1:9399/metrics`, with the queries in `sql_exporter.yml`. + +Relay database live in PostgreSQL docker volume. To print the full path to PostgreSQL database, execute in the host: + +```sh +docker volume inspect simplex-chat-relay_pgdata --format '{{.Mountpoint}}' +``` + ## Channel web previews Chat relays can render recent messages of its public channels as JSON files, which can be served over HTTPS using a web server to create channel web previews. This is optional. @@ -219,11 +288,9 @@ Create `/etc/systemd/system/simplex-cors-sync.service`: ```ini [Unit] Description=Sync SimpleX relay CORS config to Caddy -StartLimitIntervalSec=30 -StartLimitBurst=10 +StartLimitIntervalSec=0 [Service] Type=oneshot -ExecStartPre=/bin/sleep 2 ExecStart=/usr/local/bin/simplex-cors-sync.sh ``` @@ -236,6 +303,7 @@ After=caddy.service [Path] PathChanged=/var/www/relay-web-channels/cors.conf Unit=simplex-cors-sync.service +TriggerLimitIntervalSec=0 [Install] WantedBy=multi-user.target ``` 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/flake.nix b/flake.nix index fdd041bd88..0863222c92 100644 --- a/flake.nix +++ b/flake.nix @@ -405,6 +405,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" @@ -526,6 +527,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..a7a66992a6 100644 --- a/libsimplex.dll.def +++ b/libsimplex.dll.def @@ -15,6 +15,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 4217b0e8fa..7fdad94741 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.1", + "version": "0.11.3", "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..a062760642 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) } } @@ -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) } } @@ -866,6 +874,22 @@ export namespace APISendServiceResponse { } } +// Reject a received service request. With a reason the requester fails fast; without it the request is dropped and the requester waits out its timeout. +// Network usage: background. +export interface APIRejectServiceRequest { + userId: number // int64 + requestId: string + rejectionReason?: string +} + +export namespace APIRejectServiceRequest { + export type Response = CR.CmdOk | CR.ChatCmdError + + export function cmdString(self: APIRejectServiceRequest): string { + return '/_reject_service_request ' + self.userId + ' ' + self.requestId + (self.rejectionReason ? ' ' + self.rejectionReason : '') + } +} + // Chat management // These commands should not be used with CLI-based bots @@ -897,3 +921,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 cae52090e7..170f5c5504 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 @@ -230,7 +236,7 @@ export interface AutoAccept { export interface BadgeInfo { badgeType: BadgeType - badgeExpiry?: string // ISO-8601 timestamp + badgeExpiry: string // ISO-8601 timestamp badgeExtra: string } @@ -241,6 +247,179 @@ 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 type BadgeServiceErrorCode = + | BadgeServiceErrorCode.BadRequest + | BadgeServiceErrorCode.UnsupportedVersion + | BadgeServiceErrorCode.UnknownPurchaseKey + | BadgeServiceErrorCode.UnknownOfferId + | BadgeServiceErrorCode.OfferDisabled + | BadgeServiceErrorCode.OfferMismatch + | BadgeServiceErrorCode.ProductUnavailable + | BadgeServiceErrorCode.PaymentNotEntitled + | BadgeServiceErrorCode.PaymentPending + | BadgeServiceErrorCode.ProviderUnavailable + | BadgeServiceErrorCode.RateLimited + | BadgeServiceErrorCode.CodeInvalid + | BadgeServiceErrorCode.CodeUsed + | BadgeServiceErrorCode.CodeExpired + | BadgeServiceErrorCode.ReceiptInvalid + | BadgeServiceErrorCode.ReceiptUsed + | BadgeServiceErrorCode.Internal + | BadgeServiceErrorCode.Unknown + +export namespace BadgeServiceErrorCode { + export type Tag = + | "badRequest" + | "unsupportedVersion" + | "unknownPurchaseKey" + | "unknownOfferId" + | "offerDisabled" + | "offerMismatch" + | "productUnavailable" + | "paymentNotEntitled" + | "paymentPending" + | "providerUnavailable" + | "rateLimited" + | "codeInvalid" + | "codeUsed" + | "codeExpired" + | "receiptInvalid" + | "receiptUsed" + | "internal" + | "unknown" + + interface Interface { + type: Tag + } + + export interface BadRequest extends Interface { + type: "badRequest" + } + + export interface UnsupportedVersion extends Interface { + type: "unsupportedVersion" + } + + export interface UnknownPurchaseKey extends Interface { + type: "unknownPurchaseKey" + } + + export interface UnknownOfferId extends Interface { + type: "unknownOfferId" + } + + export interface OfferDisabled extends Interface { + type: "offerDisabled" + } + + export interface OfferMismatch extends Interface { + type: "offerMismatch" + } + + export interface ProductUnavailable extends Interface { + type: "productUnavailable" + } + + export interface PaymentNotEntitled extends Interface { + type: "paymentNotEntitled" + } + + export interface PaymentPending extends Interface { + type: "paymentPending" + } + + export interface ProviderUnavailable extends Interface { + type: "providerUnavailable" + } + + export interface RateLimited extends Interface { + type: "rateLimited" + } + + export interface CodeInvalid extends Interface { + type: "codeInvalid" + } + + export interface CodeUsed extends Interface { + type: "codeUsed" + } + + export interface CodeExpired extends Interface { + type: "codeExpired" + } + + export interface ReceiptInvalid extends Interface { + type: "receiptInvalid" + } + + export interface ReceiptUsed extends Interface { + type: "receiptUsed" + } + + export interface Internal extends Interface { + type: "internal" + } + + export interface Unknown extends Interface { + type: "unknown" + : string + } +} + export enum BadgeStatus { Active = "active", Expired = "expired", @@ -692,6 +871,8 @@ export interface CIFile { fileSource?: CryptoFile fileStatus: CIFileStatus fileProtocol: FileProtocol + fileExpires?: string // ISO-8601 timestamp + fileProhibited?: FileProhibited } export type CIFileStatus = @@ -1145,6 +1326,7 @@ export type ChatErrorType = | ChatErrorType.AgentVersion | ChatErrorType.AgentNoSubResult | ChatErrorType.CommandError + | ChatErrorType.BadgeRedeemError | ChatErrorType.AgentCommandError | ChatErrorType.InvalidFileDescription | ChatErrorType.ConnectionIncognitoChangeProhibited @@ -1224,6 +1406,7 @@ export namespace ChatErrorType { | "agentVersion" | "agentNoSubResult" | "commandError" + | "badgeRedeemError" | "agentCommandError" | "invalidFileDescription" | "connectionIncognitoChangeProhibited" @@ -1568,6 +1751,11 @@ export namespace ChatErrorType { message: string } + export interface BadgeRedeemError extends Interface { + type: "badgeRedeemError" + badgeRedeemError: BadgeRedeemError + } + export interface AgentCommandError extends Interface { type: "agentCommandError" message: string @@ -2209,6 +2397,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 @@ -2426,6 +2621,12 @@ export interface FileInvitation { fileConnReq?: string fileInline?: InlineFileMode fileDescr?: FileDescr + fileBadge?: BadgeProof +} + +export interface FileProhibited { + maxSize: number // int64 + badgeStatus?: BadgeStatus } export enum FileProtocol { @@ -2699,8 +2900,7 @@ export interface GroupInfo { } export interface GroupKeys { - publicGroupId: string - groupRootKey: GroupRootKey + publicGroupKeys?: PublicGroupKeys memberPrivKey: string } @@ -3547,6 +3747,11 @@ export interface PublicGroupData { publicMemberCount: number // int64 } +export interface PublicGroupKeys { + publicGroupId: string + groupRootKey: GroupRootKey +} + export interface PublicGroupProfile { groupType: GroupType groupLink: string @@ -3783,6 +3988,7 @@ export interface RcvFileTransfer { fileId: number // int64 xftpRcvFile?: XFTPRcvFile fileInvitation: FileInvitation + fileProhibited?: FileProhibited fileStatus: RcvFileStatus fileType: FileType rcvFileInline?: InlineFileMode @@ -3945,6 +4151,11 @@ export interface RelayCapabilities { webDomain?: string } +export interface RelayConnectionResult { + relayMember: GroupMember + relayError?: ChatError +} + export interface RelayProfile { displayName: string fullName: string @@ -3962,6 +4173,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", diff --git a/packages/simplex-chat-nodejs/package.json b/packages/simplex-chat-nodejs/package.json index 51311cfb5e..0960334edc 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.1", + "version": "7.1.0-beta.3", "main": "dist/index.js", "types": "dist/index.d.ts", "files": [ @@ -24,7 +24,7 @@ "docs": "typedoc" }, "dependencies": { - "@simplex-chat/types": "^0.11.1", + "@simplex-chat/types": "^0.11.3", "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/download-libs.js b/packages/simplex-chat-nodejs/src/download-libs.js index 727e5164d5..0acff40daf 100644 --- a/packages/simplex-chat-nodejs/src/download-libs.js +++ b/packages/simplex-chat-nodejs/src/download-libs.js @@ -4,7 +4,7 @@ const path = require('path'); const extract = require('extract-zip'); const GITHUB_REPO = 'simplex-chat/simplex-chat-libs'; -const RELEASE_TAG = 'v7.1.0-beta.1'; +const RELEASE_TAG = 'v7.1.0-beta.3'; const BACKEND = (process.env.SIMPLEX_BACKEND || process.env.npm_config_simplex_backend || 'sqlite').toLowerCase(); if (BACKEND !== 'sqlite' && BACKEND !== 'postgres') { diff --git a/packages/simplex-chat-python/src/simplex_chat/_version.py b/packages/simplex-chat-python/src/simplex_chat/_version.py index 091488e2af..e3f2fa5338 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.0b1" # PEP 440 — read by hatchling for wheel metadata -LIBS_VERSION = "7.1.0-beta.1" # simplex-chat-libs release tag (no 'v' prefix) +__version__ = "7.1.0b3" # PEP 440 — read by hatchling for wheel metadata +LIBS_VERSION = "7.1.0-beta.3" # simplex-chat-libs release tag (no 'v' prefix) 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..d10ed0cc04 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 @@ -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 @@ -759,6 +768,20 @@ def APISendServiceResponse_cmd_string(self: APISendServiceResponse) -> str: APISendServiceResponse_Response = CR.ServiceReplyAccepted | CR.ChatCmdError +# Reject a received service request. With a reason the requester fails fast; without it the request is dropped and the requester waits out its timeout. +# Network usage: background. +class APIRejectServiceRequest(TypedDict): + userId: int # int64 + requestId: str + rejectionReason: NotRequired[str] + + +def APIRejectServiceRequest_cmd_string(self: APIRejectServiceRequest) -> str: + return '/_reject_service_request ' + str(self['userId']) + ' ' + self['requestId'] + ((' ' + self.get('rejectionReason')) if self.get('rejectionReason') is not None else '') + +APIRejectServiceRequest_Response = CR.CmdOk | CR.ChatCmdError + + # Chat management # These commands should not be used with CLI-based bots @@ -787,3 +810,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 512a7464db..38e0318af1 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -165,12 +165,18 @@ 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 class BadgeInfo(TypedDict): badgeType: "BadgeType" - badgeExpiry: NotRequired[str] # ISO-8601 timestamp + badgeExpiry: str # ISO-8601 timestamp badgeExtra: str class BadgeProof(TypedDict): @@ -179,6 +185,119 @@ 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"] + +class BadgeServiceErrorCode_badRequest(TypedDict): + type: Literal["badRequest"] + +class BadgeServiceErrorCode_unsupportedVersion(TypedDict): + type: Literal["unsupportedVersion"] + +class BadgeServiceErrorCode_unknownPurchaseKey(TypedDict): + type: Literal["unknownPurchaseKey"] + +class BadgeServiceErrorCode_unknownOfferId(TypedDict): + type: Literal["unknownOfferId"] + +class BadgeServiceErrorCode_offerDisabled(TypedDict): + type: Literal["offerDisabled"] + +class BadgeServiceErrorCode_offerMismatch(TypedDict): + type: Literal["offerMismatch"] + +class BadgeServiceErrorCode_productUnavailable(TypedDict): + type: Literal["productUnavailable"] + +class BadgeServiceErrorCode_paymentNotEntitled(TypedDict): + type: Literal["paymentNotEntitled"] + +class BadgeServiceErrorCode_paymentPending(TypedDict): + type: Literal["paymentPending"] + +class BadgeServiceErrorCode_providerUnavailable(TypedDict): + type: Literal["providerUnavailable"] + +class BadgeServiceErrorCode_rateLimited(TypedDict): + type: Literal["rateLimited"] + +class BadgeServiceErrorCode_codeInvalid(TypedDict): + type: Literal["codeInvalid"] + +class BadgeServiceErrorCode_codeUsed(TypedDict): + type: Literal["codeUsed"] + +class BadgeServiceErrorCode_codeExpired(TypedDict): + type: Literal["codeExpired"] + +class BadgeServiceErrorCode_receiptInvalid(TypedDict): + type: Literal["receiptInvalid"] + +class BadgeServiceErrorCode_receiptUsed(TypedDict): + type: Literal["receiptUsed"] + +class BadgeServiceErrorCode_internal(TypedDict): + type: Literal["internal"] + +class BadgeServiceErrorCode_unknown(TypedDict): + type: Literal["unknown"] + : str + +BadgeServiceErrorCode = ( + BadgeServiceErrorCode_badRequest + | BadgeServiceErrorCode_unsupportedVersion + | BadgeServiceErrorCode_unknownPurchaseKey + | BadgeServiceErrorCode_unknownOfferId + | BadgeServiceErrorCode_offerDisabled + | BadgeServiceErrorCode_offerMismatch + | BadgeServiceErrorCode_productUnavailable + | BadgeServiceErrorCode_paymentNotEntitled + | BadgeServiceErrorCode_paymentPending + | BadgeServiceErrorCode_providerUnavailable + | BadgeServiceErrorCode_rateLimited + | BadgeServiceErrorCode_codeInvalid + | BadgeServiceErrorCode_codeUsed + | BadgeServiceErrorCode_codeExpired + | BadgeServiceErrorCode_receiptInvalid + | BadgeServiceErrorCode_receiptUsed + | BadgeServiceErrorCode_internal + | BadgeServiceErrorCode_unknown +) + +BadgeServiceErrorCode_Tag = Literal["badRequest", "unsupportedVersion", "unknownPurchaseKey", "unknownOfferId", "offerDisabled", "offerMismatch", "productUnavailable", "paymentNotEntitled", "paymentPending", "providerUnavailable", "rateLimited", "codeInvalid", "codeUsed", "codeExpired", "receiptInvalid", "receiptUsed", "internal", "unknown"] + BadgeStatus = Literal["active", "expired", "expiredOld", "failed", "unknownKey"] BadgeType = Literal["supporter", "legend", "investor"] @@ -481,6 +600,8 @@ class CIFile(TypedDict): fileSource: NotRequired["CryptoFile"] fileStatus: "CIFileStatus" fileProtocol: "FileProtocol" + fileExpires: NotRequired[str] # ISO-8601 timestamp + fileProhibited: NotRequired["FileProhibited"] class CIFileStatus_sndStored(TypedDict): type: Literal["sndStored"] @@ -1021,6 +1142,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 @@ -1119,6 +1244,7 @@ ChatErrorType = ( | ChatErrorType_agentVersion | ChatErrorType_agentNoSubResult | ChatErrorType_commandError + | ChatErrorType_badgeRedeemError | ChatErrorType_agentCommandError | ChatErrorType_invalidFileDescription | ChatErrorType_connectionIncognitoChangeProhibited @@ -1129,7 +1255,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", "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"] @@ -1559,6 +1685,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 @@ -1711,6 +1844,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"] @@ -1897,8 +2035,7 @@ class GroupInfo(TypedDict): groupDomainVerified: NotRequired[bool] class GroupKeys(TypedDict): - publicGroupId: str - groupRootKey: "GroupRootKey" + publicGroupKeys: NotRequired["PublicGroupKeys"] memberPrivKey: str class GroupLink(TypedDict): @@ -2489,6 +2626,10 @@ class PublicGroupAccess(TypedDict): class PublicGroupData(TypedDict): publicMemberCount: int # int64 +class PublicGroupKeys(TypedDict): + publicGroupId: str + groupRootKey: "GroupRootKey" + class PublicGroupProfile(TypedDict): groupType: "GroupType" groupLink: str @@ -2653,6 +2794,7 @@ class RcvFileTransfer(TypedDict): fileId: int # int64 xftpRcvFile: NotRequired["XFTPRcvFile"] fileInvitation: "FileInvitation" + fileProhibited: NotRequired["FileProhibited"] fileStatus: "RcvFileStatus" fileType: "FileType" rcvFileInline: NotRequired["InlineFileMode"] @@ -2766,6 +2908,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 @@ -2774,6 +2920,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): 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-24-desktop-rotated-video-playback.md b/plans/2026-08-24-desktop-rotated-video-playback.md new file mode 100644 index 0000000000..0ef27efbaf --- /dev/null +++ b/plans/2026-08-24-desktop-rotated-video-playback.md @@ -0,0 +1,109 @@ +# Desktop: rotated videos squashed on playback, preview rotated twice, snapshot crash + +## Problem + +A video carrying rotation metadata - what a phone records in portrait - is squashed when it +plays in a chat item on desktop, while its preview looks correct. Re-sending such a video from +desktop produces a preview that is wrong for every recipient. Attaching one can take the app +down with a SIGSEGV inside skia. Separately, any video smaller than the width of the message +item plays at its own size in the middle of the item instead of filling it, though its preview +fills the width. + +## Cause + +Three defects, of different ages, on the path a frame takes from libvlc to the screen. + +**The buffer is sized from the wrong dimensions.** `SkiaBitmapVideoSurface` asks libvlc for a +buffer of `track.width() x track.height()`. The track carries the size before rotation, but vlc +applies the display matrix before the frame reaches the vmem callback, so the picture arriving +is transposed with respect to the buffer, and vlc stretches it to fill. Measured with a +1920x1080 HEVC file whose display matrix is -90: + +| source | value | +| -------------------------- | -------------------- | +| size libvlc offers | 1088x1920 (rotated) | +| `track.width()/height()` | 1920x1080 (coded) | +| buffer requested before fix | 1920x1080 | + +Asking for the track size was introduced to drop the decoder's padding (#7391); it is right for +an unrotated video and wrong for a rotated one, because it discards the orientation libvlc had +already applied. + +**The preview is oriented twice.** `previewAndDuration` takes a snapshot from the same surface +and then rotates it by hand. The snapshot arrives at 1080x1920 and already upright - dumping it +to a PNG confirms the content, not just the dimensions - and the manual rotation turns it back +to 1920x1080. Before this change the two errors cancelled: a wrongly shaped buffer plus a +manual rotation produced a preview that looked right, which is why the preview was correct while +playback was not. + +**The snapshot races the render callback.** The render callback installs pixels into the shared +bitmap on the event thread; the snapshot converted it on the preview thread. The format is +renegotiated several times per file, so a resize between `installPixels` and `readPixels` makes +skia read past the end of the buffer: + +``` +SIGSEGV ... C [libskiko-linux-x64.so+0x1e7807] sse2::load_8888(...) + at org.jetbrains.skia.Bitmap.readPixels + at chat.simplex.common.platform.VideoPlayer$Companion$getBitmapFromVideo$2$snap$1 +``` + +**Small videos do not fill the item.** The preview is drawn with `ContentScale.FillWidth` and +the playback surface with `ContentScale.Fit`. `Fit` never exceeds the height of the box, so a +320x240 video stays at its own size, centred, while its preview fills the width. This is only +visible for sources narrower than the item. + +## Fix + +Swap the requested width and height for the four transposed orientations, so the buffer matches +the picture vlc delivers, and keep the track size otherwise so the padding fix still holds. +Drop the manual orientation handling from the preview, since the frame is already upright. Read +the snapshot on the event thread, where the render callback writes it. Draw the inline playback +surface with `FillWidth`, as its preview is drawn. + +## Bounds + +The dimensions come from a received file, so they are attacker-chosen and are treated as such. + +- Both track sides are used or neither. One side from the track beside the other from libvlc's + padded size never described the same picture, and transposing such a pair compounds it. +- The requested area is capped, scaling down and keeping the aspect where both sides can shrink; + a side pinned at 1 takes the whole budget on the other side instead, since scaling cannot keep + the aspect of a 2000000000x1 declaration and hold the area at once. An unbounded request is an + out-of-memory from a message: 16000x16000 is 1 GB of RV32, requested from vlc and copied into + a java array of the same size. The cap also keeps `width * height * 4` inside an `Int`. +- Neither side can be zero, so a 1x4000 or 4000x1 file cannot produce an empty buffer. +- The sides are only swapped when the track's own sides are used. The size libvlc passes is + already rotated, so swapping that pair would recreate the squash for a file that declares a + rotation and a zero-sized track. +- A frame is dropped rather than displayed when it does not fill the bitmap skia is told to + read, when the format it was rendered with is not the one the bitmap was sized by, and before + any buffer has been allocated. The checks and the copy run inside the render callback, on + vlc's thread: the native buffer is only guaranteed to exist for the duration of the callback, + so code deferred to another thread would read through a pointer vlc may have freed on a format + change. Only the copied frame is handed to the event thread. +- The bitmap is published only when skia reports that it took the pixels, and a snapshot that + cannot be converted is logged and left empty rather than thrown into callers that have no + handler for it. + +## Testing + +Fifteen files covering 320x240 to 3840x2160, square, odd, and 1234x567 sizes, h264, vp9, av1 +and hevc, unrotated and 90/180/270, plus 1x4000, 4000x1 and 16000x16000. Checked that rotated +videos play upright and preview upright, that a re-sent video keeps its shape, that attaching +does not crash, that a 320x240 video fills the item, that the AV1 padding fix still holds, and +that the 16000x16000 file is scaled to the cap instead of allocating a gigabyte. + +## Android + +None of these reach android. The buffer format callback is desktop only - android renders +through exoplayer's `StyledPlayerView`, with no buffer for us to size - and its preview comes +from `MediaMetadataRetriever.getFrameAtTime`, which returns an oriented frame and is not +rotated again. The event thread race is skia and swing. Android already fills the item width +with `RESIZE_MODE_FIXED_WIDTH`, which is what the `FillWidth` change gives desktop. + +## Not addressed + +`CIVideoView` bounds the item's aspect ratio above at 2.33 but not below, so a 4000x1 video +still lays out with a height that rounds to zero. The snapshot's `invokeAndWait` is not +cancellable, so its 1.5s timeout cannot interrupt a wedged event thread. Both are outside the +functions this change touches. 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-08-28-file-expiry-display.md b/plans/2026-08-28-file-expiry-display.md new file mode 100644 index 0000000000..3b6742c430 --- /dev/null +++ b/plans/2026-08-28-file-expiry-display.md @@ -0,0 +1,74 @@ +# File expiry display + +Show the sender and recipients when an XFTP file stops being downloadable. + +The value is the storage expiry the server grants. The agent already reports it in `SFDONE` as `Maybe GrantedStorageTime` (`GSTExpires { epochSeconds }`, absolute UTC). No simplexmq change. `Nothing` means unknown (a server or chunk below the storage-time version). + +## Protocol + +In `Simplex.Chat.Protocol`: + +- add optional `fileExpires :: Maybe UTCTime` to `XMsgFileDescr` +- encode with `"fileExpires" .=? fileExpires`, decode with `opt "fileExpires"` +- no chat version bump; an older app skips the field, an older sender omits it + +## Chat item file + +In `Simplex.Chat.Messages`: + +- add `fileExpires :: Maybe UTCTime` to `CIFile` +- add no `CIFileStatus`; the app derives the expired state from `fileExpires < now` + +## Store + +In the SQLite and PostgreSQL stores: + +- add a nullable `file_expires_at` column to `files`, typed as the other `files` timestamps; migration in both stores +- add `setFileExpires :: DB.Connection -> User -> FileTransferId -> UTCTime -> IO ()` +- read `file_expires_at` into `CIFile` in the file-row queries + +## Sender + +In `Simplex.Chat.Library.Subscriber`, `SFDONE` handler: + +- bind the granted time (currently dropped) +- convert `GSTExpires epochSeconds` to `UTCTime` and store it with `setFileExpires` +- thread the expiry through `sendFileDescriptions` into each `XMsgFileDescr` it builds + +## Recipient + +In `Simplex.Chat.Library.Subscriber`, `XMsgFileDescr` handling (direct and group): + +- take the received `fileExpires` and store it with `setFileExpires` + +## Apps + +Received files show the expiry; sent files are unchanged, they keep the checkmark. A tap still attempts the download: the receive actions, the download overlays, and the "Download file" menu item stay as they are. + +Model: + +- `CIFile` gets `fileExpires` — `Date?` in `SimpleXChat/ChatTypes.swift`, `Instant?` in `model/ChatModel.kt` +- `CIFile` gets `expired`, beside `loaded`: `fileExpires` is set and has passed + +Message information, in `ChatItemInfoView`, a row after "Disappears at": + +- "File can be received until