diff --git a/.gitignore b/.gitignore index 035d24c6cd..4f73442c36 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,7 @@ website/src/images/ website/src/js/lottie.min.js website/src/js/ethers.* website/src/js/directory.js +website/src/js/page.js website/src/js/channel-preview.js website/src/js/simplex-lib.js website/src/file-assets/ diff --git a/README.md b/README.md index f7c2ccd514..fa4247caf8 100644 --- a/README.md +++ b/README.md @@ -220,6 +220,8 @@ You can use SimpleX with your own servers and still communicate with people usin Recent and important updates: +[Sep 19, 2026. SimpleX Supporter Badges — Send Larger Files That Stay Available Longer, Without Being Identified](./blog/20260919-simplex-supporter-badges.md) + [Aug 20, 2026. Equity Crowdfunding Launched - You Can Get a Stake in SimpleX Chat](./blog/20260819-simplex-chat-crowdfunding.md) [Jul 22, 2026. SimpleX Public Names — a Name Nobody Can Take From You](./blog/20260722-simplex-public-names.md) diff --git a/apps/ios/CODE.md b/apps/ios/CODE.md index 5a8356f656..e6372a9e33 100644 --- a/apps/ios/CODE.md +++ b/apps/ios/CODE.md @@ -166,6 +166,7 @@ After completing all changes (code + documentation), you MUST run an adversarial | Shared/SimpleXApp.swift | spec/architecture.md | product/flows/onboarding.md | | Shared/AppDelegate.swift | spec/services/notifications.md | product/flows/onboarding.md | | Shared/Views/ChatList/ChatListView.swift | spec/client/chat-list.md | product/views/chat-list.md | +| Shared/Views/ChatList/GetStakeBanner.swift | spec/client/chat-list.md | product/views/chat-list.md | | Shared/Views/Chat/ChatView.swift | spec/client/chat-view.md | product/views/chat.md | | Shared/Views/Chat/ComposeMessage/ComposeView.swift | spec/client/compose.md | product/views/chat.md | | Shared/Views/Chat/ChatItem/ | spec/client/chat-view.md | product/views/chat.md | diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index 432ac44511..d5218aec52 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -195,6 +195,7 @@ enum ChatCommand: ChatCmdProtocol { // badges case apiRedeemBadgeCode(userId: Int64, code: String) case apiGetBadgeState(userId: Int64) + case apiGetBadgeLedger(userId: Int64, badgePurchaseId: Int64) case apiAckBadgeAlert(userId: Int64, badgePurchaseId: Int64, alertKind: BadgeAlertKind, snooze: Bool, episode: String) // misc case showVersion @@ -423,6 +424,7 @@ enum ChatCommand: ChatCmdProtocol { case let .apiStandaloneFileInfo(link): return "/_download info \(link)" case let .apiRedeemBadgeCode(userId, code): return "/_redeem_badge_code \(userId) \(code)" case let .apiGetBadgeState(userId): return "/_badge state \(userId)" + case let .apiGetBadgeLedger(userId, badgePurchaseId): return "/_badge ledger \(userId) \(badgePurchaseId)" case let .apiAckBadgeAlert(userId, badgePurchaseId, alertKind, snooze, episode): return "/_badge ack \(userId) \(badgePurchaseId) \(badgeAlertKindParam(alertKind)) \(onOff(snooze)) \(episode)" case .showVersion: return "/version" @@ -615,6 +617,7 @@ enum ChatCommand: ChatCmdProtocol { case .apiStandaloneFileInfo: return "apiStandaloneFileInfo" case .apiRedeemBadgeCode: return "apiRedeemBadgeCode" case .apiGetBadgeState: return "apiGetBadgeState" + case .apiGetBadgeLedger: return "apiGetBadgeLedger" case .apiAckBadgeAlert: return "apiAckBadgeAlert" case .showVersion: return "showVersion" case .getAgentSubsTotal: return "getAgentSubsTotal" @@ -706,6 +709,7 @@ enum ChatCommand: ChatCmdProtocol { case .subscriptionEnded: "subscription_ended" case .prepaidEnding: "prepaid_ending" case .supportEnded: "support_ended" + case .issueFailed: "issue_failed" } } @@ -1055,6 +1059,7 @@ enum ChatResponse2: Decodable, ChatAPIResult { // the full user, not UserRef: its profile carries the badge that setUserBadge just stored case badgeRedeemed(user: User, redeemedBadge: LocalBadge, newBadge: Bool, badgeState: BadgeState?) case badgeState(user: UserRef, badgeState: BadgeState?) + case badgeLedger(user: UserRef, badgeLedger: [StatementEntry]) var responseType: String { switch self { @@ -1108,6 +1113,7 @@ enum ChatResponse2: Decodable, ChatAPIResult { case .appSettings: "appSettings" case .badgeRedeemed: "badgeRedeemed" case .badgeState: "badgeState" + case .badgeLedger: "badgeLedger" } } @@ -1163,6 +1169,7 @@ enum ChatResponse2: Decodable, ChatAPIResult { case let .appSettings(appSettings): return String(describing: appSettings) case let .badgeRedeemed(u, redeemedBadge, newBadge, badgeState): return withUser(u, "redeemedBadge: \(String(describing: redeemedBadge))\nnewBadge: \(newBadge)\nbadgeState: \(String(describing: badgeState))") case let .badgeState(u, badgeState): return withUser(u, String(describing: badgeState)) + case let .badgeLedger(u, badgeLedger): return withUser(u, String(describing: badgeLedger)) } } } diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 5aae0755bf..1b02516e2d 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -400,6 +400,13 @@ class BadgeModel: ObservableObject { } } +enum ChatListBanner { + case badgeExpired + case badgeIssueFailed + case badgePitch + case getStake +} + // Spec: spec/state.md#ChatModel final class ChatModel: ObservableObject { @Published var onboardingStage: OnboardingStage? @@ -480,6 +487,14 @@ final class ChatModel: ObservableObject { var filesToDelete: Set = [] + // the banner kind the chat list showed this app session: it keeps the slot until restart, so dismissing it never puts + // another in its place; only the badge alert shows regardless. Set while rendering, so not published. + var chatListBanner: ChatListBanner? + + func bannerSlotFree(for banner: ChatListBanner) -> Bool { + chatListBanner == nil || chatListBanner == banner + } + static let shared = ChatModel() let im = ItemsModel.shared diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index a63f6e3cb9..74fb78cc75 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -2220,12 +2220,7 @@ func redeemErrorText(_ error: Error) -> String { 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 .serviceError(code): if let text = badgeServiceErrorText(code) { return text } 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") @@ -2246,6 +2241,12 @@ func apiGetBadgeStateSync(_ userId: Int64) throws -> BadgeState? { throw r.unexpected } +func apiGetBadgeLedger(_ userId: Int64, _ badgePurchaseId: Int64) async throws -> [StatementEntry] { + let r: ChatResponse2 = try await chatSendCmd(.apiGetBadgeLedger(userId: userId, badgePurchaseId: badgePurchaseId)) + if case let .badgeLedger(_, badgeLedger) = r { return badgeLedger } + throw r.unexpected +} + func apiAckBadgeAlert(_ userId: Int64, _ badgePurchaseId: Int64, _ alertKind: BadgeAlertKind, snooze: Bool, episode: String) async throws -> BadgeState? { let r: ChatResponse2 = try await chatSendCmd(.apiAckBadgeAlert(userId: userId, badgePurchaseId: badgePurchaseId, alertKind: alertKind, snooze: snooze, episode: episode)) if case let .badgeState(_, badgeState) = r { return badgeState } diff --git a/apps/ios/Shared/Views/Badges/BadgesHowItWorksView.swift b/apps/ios/Shared/Views/Badges/BadgesHowItWorksView.swift index bca1f7da40..5758ceb12c 100644 --- a/apps/ios/Shared/Views/Badges/BadgesHowItWorksView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesHowItWorksView.swift @@ -14,7 +14,7 @@ struct BadgesHowItWorksView: View { var body: some View { VStack(alignment: .leading) { - Text("How private badges work") + Text("How badges protect your privacy") .font(.largeTitle) .bold() .foregroundColor(theme.colors.primary) @@ -22,9 +22,10 @@ struct BadgesHowItWorksView: View { .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.") + Text("A badge is an anonymous credential stored in your profile on your device. This credential is never sent to anyone.") + Text("To prove to a contact or a server that you have a badge, the app generates a new proof that reveals only the badge type and the expiry date, rounded to a week.") + Text("Nobody can link two different proofs to each other or to the purchase.") + ExternalLink("Read more in our blog.", destination: URL(string: "https://simplex.chat/blog/20260919-simplex-supporter-badges.html")!) } .lineLimit(nil) .fixedSize(horizontal: false, vertical: true) diff --git a/apps/ios/Shared/Views/Badges/BadgesLedgerView.swift b/apps/ios/Shared/Views/Badges/BadgesLedgerView.swift new file mode 100644 index 0000000000..a89bcb1299 --- /dev/null +++ b/apps/ios/Shared/Views/Badges/BadgesLedgerView.swift @@ -0,0 +1,156 @@ +// +// BadgesLedgerView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 17.09.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct BadgesLedgerView: View { + @EnvironmentObject var theme: AppTheme + @EnvironmentObject var chatModel: ChatModel + let badgeState: BadgeState + @State private var entries: [StatementEntry]? = nil + @State private var expanded: Set = [] + + var body: some View { + List { + if let entries { + Section { + if entries.isEmpty { + Text("No entries") + .foregroundColor(theme.colors.secondary) + } else { + ForEach(entries, id: \.entryId) { entry in + ledgerRow(entry) + } + } + } + } + } + .navigationTitle("Badge ledger") + .navigationBarTitleDisplayMode(.inline) + .modifier(ThemedBackground(grouped: true)) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { showShareSheet(items: [ledgerShareText()]) } label: { + Image(systemName: "square.and.arrow.up") + } + .disabled(entries?.isEmpty ?? true) + } + } + .onAppear(perform: loadLedger) + } + + @ViewBuilder private func ledgerRow(_ entry: StatementEntry) -> some View { + let isExpanded = expanded.contains(entry.entryId) + Button { + withAnimation { + if isExpanded { expanded.remove(entry.entryId) } else { expanded.insert(entry.entryId) } + } + } label: { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(entry.entryType.text) + Text(dateText(entry.createdAt)) + .font(.caption) + .foregroundColor(theme.colors.secondary) + } + Spacer() + Text(changeText(entry)) + .foregroundStyle(.secondary) + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .foregroundColor(theme.colors.secondary) + } + } + .foregroundColor(theme.colors.onBackground) + if isExpanded { + ForEach(entryFields(entry), id: \.0) { field in + infoRow(Text(field.0), field.1).padding(.leading, 24) + } + } + } + + private func changeText(_ entry: StatementEntry) -> String { + let n = entry.changeMonths + let months = abs(n) == 1 + ? String.localizedStringWithFormat(NSLocalizedString("%d month", comment: "time interval"), n) + : String.localizedStringWithFormat(NSLocalizedString("%d months", comment: "time interval"), n) + return n > 0 ? "+" + months : months + } + + private func entryFields(_ entry: StatementEntry) -> [(String, String)] { + var fields = [ + (NSLocalizedString("Date", comment: "ledger entry field"), dateTimeText(entry.createdAt)), + (NSLocalizedString("Balance", comment: "ledger entry field"), "\(entry.balanceMonths)"), + (NSLocalizedString("Balance start", comment: "ledger entry field"), dateTimeText(entry.balanceStartTs)), + (NSLocalizedString("Anchor", comment: "ledger entry field"), dateTimeText(entry.balanceAnchorTs)), + (NSLocalizedString("Badge type", comment: "ledger entry field"), entry.balanceBadgeType.text) + ] + if let pausedSince = entry.wasPausedSince { + fields.append((NSLocalizedString("Paused since", comment: "ledger entry field"), dateTimeText(pausedSince))) + } + fields.append((NSLocalizedString("Entry ID", comment: "ledger entry field"), entry.entryId)) + if let payload = payloadField(entry.entryType) { + fields.append(payload) + } + return fields + } + + private func payloadField(_ entryType: StatementEntryType) -> (String, String)? { + switch entryType { + case let .credit(credit): + switch credit { + case let .payment(invoiceId): + return invoiceId.map { (NSLocalizedString("Invoice ID", comment: "ledger entry field"), $0) } + case let .charge(chargeId): + return (NSLocalizedString("Charge ID", comment: "ledger entry field"), chargeId) + case let .transferIn(fromPurchaseKey): + return (NSLocalizedString("From purchase key", comment: "ledger entry field"), fromPurchaseKey) + case .code, .support, .opening, .unknown: + return nil + } + case let .debit(debit): + switch debit { + case let .upgrade(toPurchaseKey), let .transferOut(toPurchaseKey): + return (NSLocalizedString("To purchase key", comment: "ledger entry field"), toPurchaseKey) + case .refund, .support, .badge, .lapse, .unknown: + return nil + } + } + } + + // the JSON as core sent it: English field names and ISO dates, for support + private func ledgerShareText() -> String { + let encoder = getJSONEncoder() + encoder.outputFormatting = .prettyPrinted + let data = (try? encoder.encode(entries ?? [])) ?? Data() + return String(decoding: data, as: UTF8.self) + } + + private func dateText(_ date: Date) -> String { + DateFormatter.localizedString(from: date, dateStyle: .medium, timeStyle: .none) + } + + private func dateTimeText(_ date: Date) -> String { + DateFormatter.localizedString(from: date, dateStyle: .medium, timeStyle: .short) + } + + private func loadLedger() { + guard let user = chatModel.currentUser else { return } + Task { + do { + let ledger = try await apiGetBadgeLedger(user.userId, badgeState.badgePurchaseId) + await MainActor.run { entries = ledger } + } catch let e { + logger.error("apiGetBadgeLedger error: \(responseError(e))") + await MainActor.run { + showErrorAlert(e, NSLocalizedString("Error", comment: "")) + } + } + } + } +} diff --git a/apps/ios/Shared/Views/Badges/BadgesRedeemCodeView.swift b/apps/ios/Shared/Views/Badges/BadgesRedeemCodeView.swift index 16e1796e96..6ea0c58edb 100644 --- a/apps/ios/Shared/Views/Badges/BadgesRedeemCodeView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesRedeemCodeView.swift @@ -80,7 +80,7 @@ struct BadgesRedeemCodeView: View { .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) - Text("Paste the code from your receipt.") + Text("Paste the code you received.") .font(.body) .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) diff --git a/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift b/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift index 2fc904197d..55d05f2615 100644 --- a/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift @@ -29,7 +29,7 @@ struct BadgesSupportSimplexView: View { .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.") + Text("Get a badge to send larger files (2-5GB) that stay available longer (7-21 days), and to show it on your profile.") .font(.body) .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) @@ -110,7 +110,7 @@ struct BadgesSupportSimplexView: View { Button { howItWorksActive = true } label: { HStack(spacing: 4) { Image(systemName: "info.circle") - Text("How private badges work").fontWeight(.medium) + Text("How badges protect your privacy").fontWeight(.medium) } .font(.body) } diff --git a/apps/ios/Shared/Views/Badges/BadgesYourBadgeView.swift b/apps/ios/Shared/Views/Badges/BadgesYourBadgeView.swift index 07b6d7a3dc..60c6a0ab77 100644 --- a/apps/ios/Shared/Views/Badges/BadgesYourBadgeView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesYourBadgeView.swift @@ -11,6 +11,9 @@ import SimpleXChat struct BadgesYourBadgeView: View { @EnvironmentObject var theme: AppTheme + @EnvironmentObject var chatModel: ChatModel + @Environment(\.dismiss) private var dismiss + @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false let badgeState: BadgeState var showsAsSheet: Bool = false @@ -46,7 +49,56 @@ struct BadgesYourBadgeView: View { .modifier(ThemedBackground()) } label: { settingsRow("info.circle", color: theme.colors.secondary) { - Text("How private badges work") + Text("How badges protect your privacy") + } + } + } + if let issueError = badgeState.issueError { + Section { + Text(issueError.reason.text) + .foregroundColor(theme.colors.secondary) + infoRow("Since", badgeTimestamp(issueError.failedSince)) + if issueError.lastAttemptAt != issueError.failedSince { + infoRow("Last attempt", badgeTimestamp(issueError.lastAttemptAt)) + } + settingsRow("number", color: theme.colors.secondary) { + Button("Contact SimpleX team") { + dismiss() + DispatchQueue.main.async { + // simplexTeamURL targets this same app; route to the in-app connect flow + ChatModel.shared.appOpenUrl = simplexTeamURL + } + } + } + } header: { + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle") + .foregroundColor(.red) + Text("Error") + } + } + } + if developerTools { + Section(header: Text("Credential").foregroundColor(theme.colors.secondary)) { + if let badge = chatModel.currentUser?.profile.localBadge { + infoRow("Status", badge.status.rawValue) + infoRow("Expires", badgeTimestamp(badge.badge.badgeExpiry)) + } + infoRow("Months left", "\(badgeState.monthsLeft)") + infoRow("Purchase ID", "\(badgeState.badgePurchaseId)") + if let nextWakeAt = badgeState.nextWakeAt { + infoRow("Next check", badgeTimestamp(nextWakeAt)) + } + if let issueError = badgeState.issueError { + infoRow("Error", issueError.reason.tag) + } + Button("Copy purchase key") { + UIPasteboard.general.string = badgeState.purchaseKey + } + NavigationLink { + BadgesLedgerView(badgeState: badgeState) + } label: { + Text("Badge ledger") } } } @@ -57,6 +109,10 @@ struct BadgesYourBadgeView: View { .navigationBarTitleDisplayMode(showsAsSheet ? .inline : .large) .modifier(ThemedBackground(grouped: true)) } + + private func badgeTimestamp(_ date: Date) -> String { + DateFormatter.localizedString(from: date, dateStyle: .medium, timeStyle: .short) + } } struct BadgeSummary: View { diff --git a/apps/ios/Shared/Views/Badges/BadgesYourLevelView.swift b/apps/ios/Shared/Views/Badges/BadgesYourLevelView.swift index eb2396c655..d47bcaa0a7 100644 --- a/apps/ios/Shared/Views/Badges/BadgesYourLevelView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesYourLevelView.swift @@ -162,7 +162,7 @@ struct BadgesYourLevelView: View { } label: { HStack(spacing: 4) { Image(systemName: "info.circle") - Text("How private badges work").fontWeight(.medium) + Text("How badges protect your privacy").fontWeight(.medium) } .font(.body) } diff --git a/apps/ios/Shared/Views/Badges/SupportSimpleXBanner.swift b/apps/ios/Shared/Views/Badges/SupportSimpleXBanner.swift index dad3bb1b0a..507be8ce5e 100644 --- a/apps/ios/Shared/Views/Badges/SupportSimpleXBanner.swift +++ b/apps/ios/Shared/Views/Badges/SupportSimpleXBanner.swift @@ -13,18 +13,16 @@ 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" + var subtitle: LocalizedStringKey = "Get badge + better files" + var warning: Bool = false + var showDismiss: Bool = true 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 + // the card's own height, for centring the fallback hero; 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 @@ -41,7 +39,7 @@ struct SupportSimpleXBanner: View { VStack(alignment: .leading, spacing: 4) { Text(title) .font(.headline) - .foregroundColor(theme.colors.primary) + .foregroundColor(warning ? .red : theme.colors.primary) .lineLimit(2) Text(subtitle) .font(.subheadline) @@ -50,12 +48,7 @@ struct SupportSimpleXBanner: View { } Spacer(minLength: heroWidth + heroTrailingPadding + textToHeroGap) } - .padding(.leading, cardLeadingPadding) - .padding(.trailing, cardTrailingPadding) - .padding(.vertical, 12) - .frame(minHeight: cardHeight) - .background(gradientBackground()) - .clipShape(RoundedRectangle(cornerRadius: cardCornerRadius)) + .modifier(BannerCard()) } .buttonStyle(.plain) .overlay(alignment: .bottomTrailing) { @@ -64,15 +57,9 @@ struct SupportSimpleXBanner: View { .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) + if showDismiss { + BannerDismissButton(onDismiss: onDismiss) + } } } @@ -94,25 +81,6 @@ struct SupportSimpleXBanner: View { .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 { diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 9ccb4436de..48eb6ce014 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -158,6 +158,7 @@ struct ChatListView: View { @EnvironmentObject var theme: AppTheme @Binding var activeUserPickerSheet: UserPickerSheet? @State private var showNewChatSheet = false + @State private var showGetStakeSheet = false @State private var searchMode = false @FocusState private var searchFocussed @State private var searchText = "" @@ -177,6 +178,9 @@ struct ChatListView: View { @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_SUPPORTER_BANNER_TAPPED) private var supporterBannerTapped = false + @AppStorage(DEFAULT_GET_STAKE_BANNER_TAPPED) private var getStakeBannerTapped = false + @AppStorage(DEFAULT_GET_STAKE_BANNER_DISMISSED) private var getStakeBannerDismissed = false @AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial @State private var showBadgesSheet = false @@ -221,6 +225,9 @@ struct ChatListView: View { .modifier(ThemedBackground()) } } + .appSheet(isPresented: $showGetStakeSheet) { + GetStakeView(fromSettings: false, showFirstImage: true) + } .onChange(of: activeUserPickerSheet) { if $0 != nil { DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { @@ -376,19 +383,25 @@ 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 && !supportEnded + !addressCreationCardShown && !chatModel.chats.isEmpty && !hasConversations && !supportEnded && !badgeIssueFailed } 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 var badgeIssueFailed: Bool { + badgeModel.alert?.kind == .issueFailed && badgeModel.userId == chatModel.currentUser?.userId } - private func showSupportEndedDismissAlert() { - showAlert(NSLocalizedString("Support ended", comment: "alert title")) { + // false until the badge state loads: if the pitch rendered before that, it would lock the slot, and a supporter's badge + // arriving a moment later would hide it, leaving the slot empty for the session + private var noShownBadge: Bool { + badgeModel.badgeState?.shown != true && badgeModel.userId == chatModel.currentUser?.userId + } + + private func showBadgeAlertDismissAlert(_ title: String) { + showAlert(title) { [ UIAlertAction(title: NSLocalizedString("Remind me later", comment: "alert button"), style: .default) { _ in Task { await ackBadgeAlert(snooze: true) } @@ -443,8 +456,16 @@ struct ChatListView: View { if directorySearch.showResults { List { directoryRows() }.listStyle(.plain) } else { - ConnectOnboardingView() - .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) + VStack(spacing: 0) { + ConnectOnboardingView() + if chatModel.bannerSlotFree(for: .getStake) && isInUS && !getStakeBannerDismissed { + GetStakeBanner(showDismiss: false, onTap: openGetStake, onDismiss: {}) + .padding(.horizontal, 20) + .padding(.bottom, 8) + .onAppear { chatModel.chatListBanner = .getStake } + } + } + .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) } } .modifier(ThemedBackground()) @@ -476,6 +497,11 @@ struct ChatListView: View { } } + private func openGetStake() { + getStakeBannerTapped = true + showGetStakeSheet = true + } + private var chatListContent: some View { let cs = filteredChats() return ZStack { @@ -508,19 +534,38 @@ struct ChatListView: View { // 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).", + title: "Your badge expired", + subtitle: "Your badge expired on \(alert.dateText).", onTap: { showBadgesSheet = true }, - onDismiss: showSupportEndedDismissAlert + onDismiss: { showBadgeAlertDismissAlert(NSLocalizedString("Your badge expired", comment: "alert title")) } ) .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 { + .onAppear { chatModel.chatListBanner = .badgeExpired } + } else if badgeIssueFailed { SupportSimpleXBanner( + title: "Badge renewal failed", + subtitle: "Tap for details", + warning: true, onTap: { showBadgesSheet = true }, + onDismiss: { showBadgeAlertDismissAlert(NSLocalizedString("Badge renewal failed", comment: "alert title")) } + ) + .padding(.vertical, 3) + .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + .zIndex(1) + .onAppear { chatModel.chatListBanner = .badgeIssueFailed } + } else if chatModel.bannerSlotFree(for: .badgePitch) && !supporterBannerShown && noShownBadge && chatModel.chats.count > 3 { + SupportSimpleXBanner( + showDismiss: supporterBannerTapped, + onTap: { + supporterBannerTapped = true + showBadgesSheet = true + }, onDismiss: showSupportSimpleXDismissAlert ) .padding(.vertical, 3) @@ -528,6 +573,19 @@ struct ChatListView: View { .listRowSeparator(.hidden) .listRowBackground(Color.clear) .zIndex(1) + .onAppear { chatModel.chatListBanner = .badgePitch } + } else if chatModel.bannerSlotFree(for: .getStake) && isInUS && !getStakeBannerDismissed { + GetStakeBanner( + showDismiss: getStakeBannerTapped && !chatModel.chats.isEmpty, + onTap: openGetStake, + onDismiss: { withAnimation { getStakeBannerDismissed = true } } + ) + .padding(.vertical, 3) + .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + .zIndex(1) + .onAppear { chatModel.chatListBanner = .getStake } } if #available(iOS 16.0, *) { ForEach(cs, id: \.viewId) { chat in diff --git a/apps/ios/Shared/Views/ChatList/GetStakeBanner.swift b/apps/ios/Shared/Views/ChatList/GetStakeBanner.swift new file mode 100644 index 0000000000..c9924223b5 --- /dev/null +++ b/apps/ios/Shared/Views/ChatList/GetStakeBanner.swift @@ -0,0 +1,111 @@ +// +// GetStakeBanner.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 21.09.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +// Spec: spec/client/chat-list.md#GetStakeBanner +struct GetStakeBanner: View { + @EnvironmentObject var theme: AppTheme + @Environment(\.colorScheme) var colorScheme: ColorScheme + var showDismiss: Bool + let onTap: () -> Void + let onDismiss: () -> Void + + var body: some View { + ZStack(alignment: .topTrailing) { + Button(action: onTap) { + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 4) { + Text("Get a stake in SimpleX Chat!") + .font(.headline) + .foregroundColor(theme.colors.primary) + .lineLimit(2) + Text("Invest on Wefunder from $100") + .font(.subheadline) + .foregroundColor(theme.colors.onBackground) + .lineLimit(2) + } + Spacer(minLength: 6) + Image(colorScheme == .light ? "decentralized" : "decentralized-light") + .resizable() + .scaledToFit() + .frame(width: 37, height: 37) + .padding(.trailing, showDismiss ? 32 : 10) + } + .modifier(BannerCard()) + } + .buttonStyle(.plain) + + if showDismiss { + BannerDismissButton(onDismiss: onDismiss) + } + } + } +} + +struct BannerCard: ViewModifier { + @Environment(\.colorScheme) var colorScheme: ColorScheme + + // grows with Dynamic Type but never shrinks below the default, so small-font users see the same card + @ScaledMetric(relativeTo: .body) private var scaledCardHeight: CGFloat = 72 + private var cardHeight: CGFloat { max(72, scaledCardHeight) } + + func body(content: Content) -> some View { + content + // the leading padding matches OneHandUICard's segment icon, so the text aligns with it in the list + .padding(.leading, 16) + .padding(.trailing, 8) + .padding(.vertical, 12) + .frame(minHeight: cardHeight) + .background(gradientBackground()) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } + + private func gradientBackground() -> some View { + // Asymmetric scale: start (dark end) pushed further below the card than the end (warm) is + // above, so the card's middle lands at the bright/mid-transition stop instead of the dark + // navy region. Keeps the small warm accent at top-right. + GeometryReader { geo in + let aspect = max(geo.size.height, 1) / max(geo.size.width, 1) + let startScale: CGFloat = colorScheme == .light ? 2.5 : 3.0 + let endScale: CGFloat = colorScheme == .light ? 1.7 : 2.1 + let gp = OnboardingCardView.gradientPoints(aspectRatio: aspect, scale: 1.0) + let start = UnitPoint(x: 0.5 + (gp.start.x - 0.5) * startScale, y: 0.5 + (gp.start.y - 0.5) * startScale) + let end = UnitPoint(x: 0.5 + (gp.end.x - 0.5) * endScale, y: 0.5 + (gp.end.y - 0.5) * endScale) + return LinearGradient( + stops: colorScheme == .light ? OnboardingCardView.lightStops : OnboardingCardView.darkStops, + startPoint: start, + endPoint: end + ) + } + } +} + +struct BannerDismissButton: View { + @EnvironmentObject var theme: AppTheme + @Environment(\.colorScheme) var colorScheme: ColorScheme + let onDismiss: () -> Void + + var body: some View { + Image(systemName: "multiply") + .foregroundColor(colorScheme == .dark ? theme.colors.onBackground : theme.colors.secondary) + .frame(width: 12, height: 12) + .padding(.top, 12) + .padding(.bottom, 4) + .padding(.trailing, 16) + .padding(.leading, 4) + .contentShape(Rectangle()) + .onTapGesture(perform: onDismiss) + } +} + +#Preview { + GetStakeBanner(showDismiss: true, onTap: {}, onDismiss: {}) + .padding() +} diff --git a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift index 2fd21e39dd..6cafb78043 100644 --- a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift +++ b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift @@ -845,7 +845,7 @@ fileprivate struct InvestInSimpleXChat: View { } .frame(maxWidth: .infinity, alignment: .leading) .sheet(isPresented: $showGetStakeSheet) { - GetStakeView(fromSettings: false) + GetStakeView(fromSettings: false, showFirstImage: false) } } } @@ -885,6 +885,7 @@ struct GetStakeView: View { @Environment(\.dismiss) var dismiss: DismissAction @EnvironmentObject var chatModel: ChatModel var fromSettings: Bool + var showFirstImage: Bool var body: some View { ZoomablePageView { @@ -894,7 +895,7 @@ struct GetStakeView: View { .bold() .fixedSize(horizontal: false, vertical: true) .if(!fromSettings) { $0.padding(.top) } - if fromSettings { + if showFirstImage { slideImage(getStakeSlides[0]) } (Text(verbatim: getStakeSlides[0].text) + Text(verbatim: " Learn more and invest on Wefunder.").bold().foregroundColor(.accentColor)) diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 7b86329eb0..0304f875b4 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -58,6 +58,9 @@ 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_SUPPORTER_BANNER_TAPPED = "supporterBannerTapped" +let DEFAULT_GET_STAKE_BANNER_TAPPED = "getStakeBannerTapped" +let DEFAULT_GET_STAKE_BANNER_DISMISSED = "getStakeBannerDismissed" let DEFAULT_TOOLBAR_MATERIAL = "toolbarMaterial" let DEFAULT_CONNECT_VIA_LINK_TAB = "connectViaLinkTab" let DEFAULT_LIVE_MESSAGE_ALERT_SHOWN = "liveMessageAlertShown" @@ -121,6 +124,9 @@ let appDefaults: [String: Any] = [ DEFAULT_ADDRESS_CREATION_CARD_SHOWN: false, DEFAULT_DIRECTORY_SEARCH_ALERT_SHOWN: false, DEFAULT_SUPPORTER_BANNER_SHOWN: false, + DEFAULT_SUPPORTER_BANNER_TAPPED: false, + DEFAULT_GET_STAKE_BANNER_TAPPED: false, + DEFAULT_GET_STAKE_BANNER_DISMISSED: false, DEFAULT_TOOLBAR_MATERIAL: ToolbarMaterial.defaultMaterial, DEFAULT_CONNECT_VIA_LINK_TAB: ConnectViaLinkTab.scan.rawValue, DEFAULT_LIVE_MESSAGE_ALERT_SHOWN: false, @@ -154,6 +160,9 @@ let hintDefaults = [ DEFAULT_ADDRESS_CREATION_CARD_SHOWN, DEFAULT_DIRECTORY_SEARCH_ALERT_SHOWN, DEFAULT_SUPPORTER_BANNER_SHOWN, + DEFAULT_SUPPORTER_BANNER_TAPPED, + DEFAULT_GET_STAKE_BANNER_TAPPED, + DEFAULT_GET_STAKE_BANNER_DISMISSED, DEFAULT_LIVE_MESSAGE_ALERT_SHOWN, DEFAULT_SIGN_MESSAGE_ALERT_SHOWN, DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE, @@ -406,7 +415,7 @@ struct SettingsView: View { if isInUS { Section(header: Text("You can now invest in SimpleX Chat").foregroundColor(theme.colors.secondary)) { NavigationLink { - GetStakeView(fromSettings: true) + GetStakeView(fromSettings: true, showFirstImage: true) .navigationBarTitle("", displayMode: .inline) } label: { settingsRow("dollarsign.circle", color: theme.colors.secondary) { Text("Crowdfunding on Wefunder") } diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 452ca22627..396d94fb54 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -155,6 +155,7 @@ 6442E0BE2880182D00CEC0F9 /* GroupChatInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6442E0BD2880182D00CEC0F9 /* GroupChatInfoView.swift */; }; 64466DCC29FFE3E800E3D48D /* MailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64466DCB29FFE3E800E3D48D /* MailView.swift */; }; 6448BBB628FA9D56000D2AB9 /* GroupLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6448BBB528FA9D56000D2AB9 /* GroupLinkView.swift */; }; + 644CC229305BFAE400D2A571 /* BadgesLedgerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 644CC228305BFAE400D2A571 /* BadgesLedgerView.swift */; }; 644EFFDE292BCD9D00525D5B /* ComposeVoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 644EFFDD292BCD9D00525D5B /* ComposeVoiceView.swift */; }; 644EFFE0292CFD7F00525D5B /* CIVoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 644EFFDF292CFD7F00525D5B /* CIVoiceView.swift */; }; 644EFFE2292D089800525D5B /* FramedCIVoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 644EFFE1292D089800525D5B /* FramedCIVoiceView.swift */; }; @@ -164,6 +165,7 @@ 646BB38E283FDB6D001CE359 /* LocalAuthenticationUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */; }; 647B15E82F4C8D2500EB431E /* AddChannelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647B15E72F4C8D2500EB431E /* AddChannelView.swift */; }; 647B15EA2F4C8D5100EB431E /* ChatRelayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647B15E92F4C8D5100EB431E /* ChatRelayView.swift */; }; + 647C9E293061362A0032110E /* GetStakeBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647C9E283061362A0032110E /* GetStakeBanner.swift */; }; 647F090E288EA27B00644C40 /* GroupMemberInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */; }; 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; }; 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; }; @@ -191,8 +193,8 @@ 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; }; 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829982D54AEED006B9E89 /* libgmp.a */; }; 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829992D54AEEE006B9E89 /* libffi.a */; }; - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-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 */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.7-KLlSPSHmTF28mDmnoCMp3f-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.7-KLlSPSHmTF28mDmnoCMp3f-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.7-KLlSPSHmTF28mDmnoCMp3f.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.7-KLlSPSHmTF28mDmnoCMp3f.a */; }; 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */; }; 64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; }; 64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; }; @@ -547,6 +549,7 @@ 6442E0BD2880182D00CEC0F9 /* GroupChatInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupChatInfoView.swift; sourceTree = ""; }; 64466DCB29FFE3E800E3D48D /* MailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailView.swift; sourceTree = ""; }; 6448BBB528FA9D56000D2AB9 /* GroupLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupLinkView.swift; sourceTree = ""; }; + 644CC228305BFAE400D2A571 /* BadgesLedgerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesLedgerView.swift; sourceTree = ""; }; 644EFFDD292BCD9D00525D5B /* ComposeVoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeVoiceView.swift; sourceTree = ""; }; 644EFFDF292CFD7F00525D5B /* CIVoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIVoiceView.swift; sourceTree = ""; }; 644EFFE1292D089800525D5B /* FramedCIVoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FramedCIVoiceView.swift; sourceTree = ""; }; @@ -556,6 +559,7 @@ 646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAuthenticationUtils.swift; sourceTree = ""; }; 647B15E72F4C8D2500EB431E /* AddChannelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddChannelView.swift; sourceTree = ""; }; 647B15E92F4C8D5100EB431E /* ChatRelayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatRelayView.swift; sourceTree = ""; }; + 647C9E283061362A0032110E /* GetStakeBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetStakeBanner.swift; sourceTree = ""; }; 647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMemberInfoView.swift; sourceTree = ""; }; 648010AA281ADD15009009B9 /* CIFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIFileView.swift; sourceTree = ""; }; 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = ""; }; @@ -584,8 +588,8 @@ 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = ""; }; 64C829982D54AEED006B9E89 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 64C829992D54AEEE006B9E89 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-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 = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.7-KLlSPSHmTF28mDmnoCMp3f-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.7-KLlSPSHmTF28mDmnoCMp3f-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.7-KLlSPSHmTF28mDmnoCMp3f.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.7-KLlSPSHmTF28mDmnoCMp3f.a"; sourceTree = ""; }; 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = ""; }; 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = ""; }; @@ -760,8 +764,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.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 */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.7-KLlSPSHmTF28mDmnoCMp3f-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.7-KLlSPSHmTF28mDmnoCMp3f.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -848,8 +852,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.6-ErPlbJcf1H358Yh01XLorw-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.6-ErPlbJcf1H358Yh01XLorw.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.7-KLlSPSHmTF28mDmnoCMp3f-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.7-KLlSPSHmTF28mDmnoCMp3f.a */, ); path = Libraries; sourceTree = ""; @@ -1055,6 +1059,7 @@ 5CB9250B27A942F300ACCCDD /* ChatList */ = { isa = PBXGroup; children = ( + 647C9E283061362A0032110E /* GetStakeBanner.swift */, 5C2E260A27A30CFA00F70299 /* ChatListView.swift */, E5D5A0042F9B0000AAAA0001 /* DirectorySearchView.swift */, 5C5346A727B59A6A004DF848 /* ChatHelp.swift */, @@ -1218,6 +1223,7 @@ 64C03BF8302F423300072BDE /* Badges */ = { isa = PBXGroup; children = ( + 644CC228305BFAE400D2A571 /* BadgesLedgerView.swift */, 64EB8A9A3054347A0089FFDF /* BadgesView.swift */, 64C03BF0302F423300072BDE /* BadgesHowItWorksView.swift */, 64C03BF1302F423300072BDE /* BadgesPayView.swift */, @@ -1585,6 +1591,7 @@ 5CB0BA9A2827FD8800B3292C /* HowItWorks.swift in Sources */, 5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */, 644EFFE0292CFD7F00525D5B /* CIVoiceView.swift in Sources */, + 644CC229305BFAE400D2A571 /* BadgesLedgerView.swift in Sources */, 647B15EA2F4C8D5100EB431E /* ChatRelayView.swift in Sources */, 6432857C2925443C00FBE5C8 /* GroupPreferencesView.swift in Sources */, 8CC317462D4FEBA800292A20 /* ScrollViewCells.swift in Sources */, @@ -1740,6 +1747,7 @@ 5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */, 64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */, CE176F202C87014C00145DBC /* InvertedForegroundStyle.swift in Sources */, + 647C9E293061362A0032110E /* GetStakeBanner.swift in Sources */, 5CEBD7482A5F115D00665FE2 /* SetDeliveryReceiptsView.swift in Sources */, 5C9C2DA7289957AE00CC63B1 /* AdvancedNetworkSettings.swift in Sources */, 5CADE79A29211BB900072E13 /* PreferencesView.swift in Sources */, @@ -2139,7 +2147,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 356; + CURRENT_PROJECT_VERSION = 357; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2189,7 +2197,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 356; + CURRENT_PROJECT_VERSION = 357; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2231,7 +2239,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 356; + CURRENT_PROJECT_VERSION = 357; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2251,7 +2259,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 356; + CURRENT_PROJECT_VERSION = 357; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2276,7 +2284,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 356; + CURRENT_PROJECT_VERSION = 357; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2313,7 +2321,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 356; + CURRENT_PROJECT_VERSION = 357; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2350,7 +2358,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 356; + CURRENT_PROJECT_VERSION = 357; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2401,7 +2409,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 356; + CURRENT_PROJECT_VERSION = 357; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2455,7 +2463,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 356; + CURRENT_PROJECT_VERSION = 357; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2489,7 +2497,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 356; + CURRENT_PROJECT_VERSION = 357; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 5c00638da2..0913842780 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -946,6 +946,20 @@ public enum BadgeServiceErrorCode: Decodable, Hashable { } } +public func badgeServiceErrorText(_ code: BadgeServiceErrorCode) -> String? { + switch code { + case .codeInvalid: NSLocalizedString("This code was not recognized.", comment: "alert message") + case .codeUsed: NSLocalizedString("This code has already been used.", comment: "alert message") + case .codeExpired: NSLocalizedString("This code has expired.", comment: "alert message") + case .rateLimited: NSLocalizedString("Too many attempts. Please try again later.", comment: "alert message") + case .unsupportedVersion: NSLocalizedString("This app version is too old for the badge service. Please update the app.", comment: "alert message") + case .unknownPurchaseKey: NSLocalizedString("The badge service does not recognize this badge.", comment: "alert message") + case .internalError: NSLocalizedString("The badge service reported an internal error.", comment: "alert message") + case .badRequest, .unknownOfferId, .offerDisabled, .offerMismatch, .productUnavailable, + .paymentNotEntitled, .paymentPending, .providerUnavailable, .receiptInvalid, .receiptUsed, .unknown: nil + } +} + public enum StoreError: Decodable, Hashable { case duplicateName case userNotFound(userId: Int64) diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index c6e3bd5d74..84275caf64 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -324,8 +324,10 @@ public struct LocalBadge: Codable, Hashable { // 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 { +// Decodable only: BadgeIssueFailure below is, and nothing encodes badge state. +public struct BadgeState: Decodable, Hashable { public var badgePurchaseId: Int64 + public var purchaseKey: String public var badgeType: BadgeType public var shown: Bool public var monthsLeft: Int @@ -333,10 +335,208 @@ public struct BadgeState: Codable, Hashable { public var renewsAt: Date? public var willRenew: Bool public var alert: BadgeAlert? + public var issueError: BadgeIssueError? + public var nextWakeAt: Date? public var paidThroughText: String { badgeDateText(paidThrough) } } +public struct BadgeIssueError: Decodable, Hashable { + public var failedSince: Date + public var lastAttemptAt: Date + public var reason: BadgeIssueFailure +} + +public enum BadgeIssueFailure: Decodable, Hashable { + // retryable is the service's own view of transience: it gave retryAfter + case serviceError(code: BadgeServiceErrorCode, retryable: Bool) + case serviceTimeout + case network(agentError: String) + case invalidCredential + case unexpected(message: String) + + public var text: String { + switch self { + case let .serviceError(code, _): + badgeServiceErrorText(code) + ?? String.localizedStringWithFormat(NSLocalizedString("The badge service refused the renewal: %@", comment: "badge renewal error"), code.text) + case .serviceTimeout: NSLocalizedString("The badge service did not respond.", comment: "badge renewal error") + case .network: NSLocalizedString("The badge service could not be reached.", comment: "badge renewal error") + case .invalidCredential: NSLocalizedString("The badge issued by the service cannot be verified.", comment: "badge renewal error") + case let .unexpected(message): + String.localizedStringWithFormat(NSLocalizedString("Unexpected error: %@", comment: "badge renewal error"), message) + } + } + + // the stored form, for support + public var tag: String { + switch self { + case let .serviceError(code, retryable): "serviceError \(retryable ? "retry" : "final") \(code.text)" + case .serviceTimeout: "serviceTimeout" + case let .network(agentError): "network \(agentError)" + case .invalidCredential: "invalidCredential" + case let .unexpected(message): "unexpected \(message)" + } + } +} + +public struct StatementEntry: Codable, Hashable { + public var entryId: String + public var changeMonths: Int + public var balanceMonths: Int + public var balanceStartTs: Date + public var balanceAnchorTs: Date + public var balanceBadgeType: BadgeType + public var wasPausedSince: Date? + public var createdAt: Date + public var entryType: StatementEntryType +} + +public enum StatementEntryType: Codable, Hashable { + case credit(StatementCreditType) + case debit(StatementDebitType) + + enum CodingKeys: String, CodingKey { + case type + case credit + case debit + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + switch type { + case "credit": self = .credit(try container.decode(StatementCreditType.self, forKey: .credit)) + case "debit": self = .debit(try container.decode(StatementDebitType.self, forKey: .debit)) + default: throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "unknown entry type \(type)") + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case let .credit(c): + try container.encode("credit", forKey: .type) + try container.encode(c, forKey: .credit) + case let .debit(d): + try container.encode("debit", forKey: .type) + try container.encode(d, forKey: .debit) + } + } + + public var text: String { + switch self { + case let .credit(c): c.text + case let .debit(d): d.text + } + } +} + +// the service is deployed ahead of clients, so a type this version does not know keeps its tag +public enum StatementCreditType: Codable, Hashable { + case payment(invoiceId: String?) + case code + case charge(chargeId: String) + case support + case transferIn(fromPurchaseKey: String) + case opening + case unknown(type: String) + + enum CodingKeys: String, CodingKey { + case type + case invoiceId + case chargeId + case fromPurchaseKey + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + switch type { + case "payment": self = .payment(invoiceId: try container.decodeIfPresent(String.self, forKey: .invoiceId)) + case "code": self = .code + case "charge": self = .charge(chargeId: try container.decode(String.self, forKey: .chargeId)) + case "support": self = .support + case "transferIn": self = .transferIn(fromPurchaseKey: try container.decode(String.self, forKey: .fromPurchaseKey)) + case "opening": self = .opening + default: self = .unknown(type: type) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(text, forKey: .type) + switch self { + case let .payment(invoiceId): try container.encodeIfPresent(invoiceId, forKey: .invoiceId) + case let .charge(chargeId): try container.encode(chargeId, forKey: .chargeId) + case let .transferIn(fromPurchaseKey): try container.encode(fromPurchaseKey, forKey: .fromPurchaseKey) + case .code, .support, .opening, .unknown: () + } + } + + public var text: String { + switch self { + case .payment: "payment" + case .code: "code" + case .charge: "charge" + case .support: "support" + case .transferIn: "transferIn" + case .opening: "opening" + case let .unknown(type): type + } + } +} + +public enum StatementDebitType: Codable, Hashable { + case refund + case upgrade(toPurchaseKey: String) + case transferOut(toPurchaseKey: String) + case support + case badge + case lapse + case unknown(type: String) + + enum CodingKeys: String, CodingKey { + case type + case toPurchaseKey + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + switch type { + case "refund": self = .refund + case "upgrade": self = .upgrade(toPurchaseKey: try container.decode(String.self, forKey: .toPurchaseKey)) + case "transferOut": self = .transferOut(toPurchaseKey: try container.decode(String.self, forKey: .toPurchaseKey)) + case "support": self = .support + case "badge": self = .badge + case "lapse": self = .lapse + default: self = .unknown(type: type) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(text, forKey: .type) + switch self { + case let .upgrade(toPurchaseKey), let .transferOut(toPurchaseKey): try container.encode(toPurchaseKey, forKey: .toPurchaseKey) + case .refund, .support, .badge, .lapse, .unknown: () + } + } + + public var text: String { + switch self { + case .refund: "refund" + case .upgrade: "upgrade" + case .transferOut: "transferOut" + case .support: "support" + case .badge: "badge" + case .lapse: "lapse" + case let .unknown(type): type + } + } +} + public struct BadgeAlert: Codable, Hashable { public var kind: BadgeAlertKind public var episode: String @@ -361,6 +561,7 @@ public enum BadgeAlertKind: String, Codable, Hashable { case subscriptionEnded case prepaidEnding case supportEnded + case issueFailed } // the wire proof carried on a profile - opaque to the UI, only round-tripped back to the core (apiPrepareContact) diff --git a/apps/ios/product/concepts.md b/apps/ios/product/concepts.md index 6d63ee2faf..89884eab43 100644 --- a/apps/ios/product/concepts.md +++ b/apps/ios/product/concepts.md @@ -19,7 +19,7 @@ This document provides a structured mapping between product-level concepts, thei | # | Concept | Product Docs | Spec Docs | Source Files (Swift) | Source Files (Haskell) | |---|---------|-------------|-----------|---------------------|----------------------| -| 1 | Chat List | [views/chat-list.md](views/chat-list.md), [views/onboarding.md](views/onboarding.md) | [spec/client/chat-list.md](../spec/client/chat-list.md) | `Shared/Views/ChatList/ChatListView.swift` | `Controller.hs` (`APIGetChats`) | +| 1 | Chat List | [views/chat-list.md](views/chat-list.md), [views/onboarding.md](views/onboarding.md) | [spec/client/chat-list.md](../spec/client/chat-list.md) | `Shared/Views/ChatList/ChatListView.swift`, `Shared/Views/ChatList/GetStakeBanner.swift` | `Controller.hs` (`APIGetChats`) | | 2 | Direct Chat | [views/chat.md](views/chat.md), [flows/messaging.md](flows/messaging.md) | [spec/client/chat-view.md](../spec/client/chat-view.md) | `Shared/Views/Chat/ChatView.swift`, `ChatInfoView.swift` | `Types.hs` (`Contact`), `Messages.hs` | | 3 | Group Chat | [views/chat.md](views/chat.md), [views/group-info.md](views/group-info.md) | [spec/client/chat-view.md](../spec/client/chat-view.md) | `Shared/Views/Chat/ChatView.swift`, `Group/GroupChatInfoView.swift` | `Types.hs` (`GroupInfo`, `GroupMember`) | | 4 | Message Composition | [views/chat.md](views/chat.md) | [spec/client/compose.md](../spec/client/compose.md) | `ComposeMessage/ComposeView.swift`, `SendMessageView.swift` | `Controller.hs` (`APISendMessages`) | diff --git a/apps/ios/product/views/chat-list.md b/apps/ios/product/views/chat-list.md index 04d19bef9e..dee9e1d427 100644 --- a/apps/ios/product/views/chat-list.md +++ b/apps/ios/product/views/chat-list.md @@ -93,6 +93,7 @@ When a relay address link (`/r` path) is opened via URL deep link, `ContentView. - **One-hand UI card** (`OneHandUICard`): Dismissible card shown to introduce bottom toolbar mode - **Address creation card** (`AddressCreationCard`): Prompts user to create a SimpleX address +- **Crowdfunding banner** (`GetStakeBanner`): Shown to users in the US only, above the chats and below the onboarding pages. Tapping it opens `GetStakeView` (invest on Wefunder). The dismiss X appears once the banner has been tapped and there is at least one chat; dismissing hides it until hints are reset ### Pull-to-Refresh @@ -103,7 +104,7 @@ Triggers `reconnectAllServers()` after user confirmation alert ("Reconnect serve | State | Behavior | |---|---| | Chat database not started | Settings row shows exclamation icon; chat running == false disables interactions | -| No chats | `ChatHelp` view displayed with onboarding guidance | +| No conversations yet | `ConnectOnboardingView` pages replace the list, with the crowdfunding banner below them where it applies | | Connection in progress | `ConnectProgressManager` overlay with connecting text | | Search with no results | Empty list with no special empty-state view | @@ -127,4 +128,5 @@ Triggers `reconnectAllServers()` after user confirmation alert ("Reconnect serve - `Shared/Views/ChatList/ContactRequestView.swift` -- Contact request row rendering - `Shared/Views/ChatList/ContactConnectionView.swift` -- Pending connection row rendering - `Shared/Views/ChatList/OneHandUICard.swift` -- One-hand UI introduction card +- `Shared/Views/ChatList/GetStakeBanner.swift` -- Wefunder crowdfunding banner and shared banner card chrome - `Shared/Views/ChatList/ServersSummaryView.swift` -- Server subscription summary diff --git a/apps/ios/spec/client/chat-list.md b/apps/ios/spec/client/chat-list.md index d35de1f80a..2087d0dee3 100644 --- a/apps/ios/spec/client/chat-list.md +++ b/apps/ios/spec/client/chat-list.md @@ -20,6 +20,7 @@ 7. [Swipe Actions](#7-swipe-actions) 8. [UserPicker](#8-userpicker) 9. [Floating Action Button](#9-floating-action-button) +10. [Crowdfunding Banner](#10-crowdfunding-banner) --- @@ -53,7 +54,7 @@ ChatListView --- -## 2. [`ChatListView`](../../Shared/Views/ChatList/ChatListView.swift#L142) {#2-chatlistview} +## 2. [`ChatListView`](../../Shared/Views/ChatList/ChatListView.swift#L154) {#2-chatlistview} **File**: `Shared/Views/ChatList/ChatListView.swift` @@ -62,7 +63,7 @@ The root list view. Key responsibilities: ### Data Source - Reads `ChatModel.shared.chats` (all conversations) - Applies active filter from `ChatTagsModel.shared.activeFilter` -- Applies search query filtering via [`filteredChats()`](../../Shared/Views/ChatList/ChatListView.swift#L480) +- Applies search query filtering via [`filteredChats()`](../../Shared/Views/ChatList/ChatListView.swift#L555) - Sorts by last activity (most recent first), with pinned chats at top ### Layout @@ -79,11 +80,11 @@ The root list view. Key responsibilities: | Function | Line | Description | |----------|------|-------------| -| [`body`](../../Shared/Views/ChatList/ChatListView.swift#L168) | 163 | Main view body | -| [`filteredChats()`](../../Shared/Views/ChatList/ChatListView.swift#L480) | 472 | Applies active filter and search to chat list | -| [`searchString()`](../../Shared/Views/ChatList/ChatListView.swift#L523) | 514 | Normalizes search text for comparison | -| [`unreadBadge()`](../../Shared/Views/ChatList/ChatListView.swift#L454) | 448 | Renders unread count circle badge | -| [`stopAudioPlayer()`](../../Shared/Views/ChatList/ChatListView.swift#L474) | 467 | Stops any playing voice message | +| [`body`](../../Shared/Views/ChatList/ChatListView.swift#L183) | 183 | Main view body | +| [`filteredChats()`](../../Shared/Views/ChatList/ChatListView.swift#L555) | 555 | Applies active filter and search to chat list | +| [`searchString()`](../../Shared/Views/ChatList/ChatListView.swift#L598) | 598 | Normalizes search text for comparison | +| [`unreadBadge()`](../../Shared/Views/ChatList/ChatListView.swift#L529) | 529 | Renders unread count circle badge | +| [`stopAudioPlayer()`](../../Shared/Views/ChatList/ChatListView.swift#L549) | 549 | Stops any playing voice message | --- @@ -171,7 +172,7 @@ Horizontal scrolling tab bar below the navigation bar. Tabs: | Group Reports | `.presetTag(.groupReports)` | Groups with pending reports | | User tags | `.userTag(ChatTag)` | User-defined custom tags | -Filter matching is handled by [`presetTagMatchesChat()`](../../Shared/Views/ChatList/ChatListView.swift#L910) (L910) and the in-view [`TagsView`](../../Shared/Views/ChatList/ChatListView.swift#L705) struct (L705). +Filter matching is handled by [`presetTagMatchesChat()`](../../Shared/Views/ChatList/ChatListView.swift#L1134) and the in-view [`TagsView`](../../Shared/Views/ChatList/ChatListView.swift#L928) struct. ### ChatTagsModel State @@ -194,9 +195,9 @@ class ChatTagsModel: ObservableObject { | Type | File | Line | Description | |------|------|------|-------------| -| [`PresetTag`](../../Shared/Views/ChatList/ChatListView.swift#L36) | ChatListView.swift | 34 | Enum of built-in filter categories | -| [`ActiveFilter`](../../Shared/Views/ChatList/ChatListView.swift#L52) | ChatListView.swift | 49 | Enum wrapping preset, user-tag, or unread filter | -| [`setActiveFilter()`](../../Shared/Views/ChatList/ChatListView.swift#L889) | ChatListView.swift | 878 | Applies a filter and persists selection | +| [`PresetTag`](../../Shared/Views/ChatList/ChatListView.swift#L36) | ChatListView.swift | 36 | Enum of built-in filter categories | +| [`ActiveFilter`](../../Shared/Views/ChatList/ChatListView.swift#L53) | ChatListView.swift | 53 | Enum wrapping preset, user-tag, or unread filter | +| [`setActiveFilter()`](../../Shared/Views/ChatList/ChatListView.swift#L1113) | ChatListView.swift | 1113 | Applies a filter and persists selection | ### Tag Management Commands - `apiCreateChatTag(tag: ChatTagData)` -- create tag @@ -211,7 +212,7 @@ class ChatTagsModel: ObservableObject { Search is available via pull-down gesture or search button in the navigation bar. -**Search bar UI:** [`ChatListSearchBar`](../../Shared/Views/ChatList/ChatListView.swift#L587) (ChatListView.swift L578) +**Search bar UI:** [`ChatListSearchBar`](../../Shared/Views/ChatList/ChatListView.swift#L662) ### Filtering Logic - Filters `ChatModel.chats` by matching search text against: @@ -219,7 +220,7 @@ Search is available via pull-down gesture or search button in the navigation bar - `chatInfo.localAlias` (local alias) - `chatInfo.fullName` (full name) - For deeper message content search, uses `apiGetChat(chatId:, search:)` parameter -- Core logic in [`filteredChats()`](../../Shared/Views/ChatList/ChatListView.swift#L480) (L480) and [`searchString()`](../../Shared/Views/ChatList/ChatListView.swift#L523) (L523) +- Core logic in [`filteredChats()`](../../Shared/Views/ChatList/ChatListView.swift#L555) and [`searchString()`](../../Shared/Views/ChatList/ChatListView.swift#L598) ### Search Results - Matching chats are displayed in the same list format @@ -279,11 +280,49 @@ The FAB (floating action button) in the bottom-right corner opens the new chat f --- +## 10. [`GetStakeBanner`](../../Shared/Views/ChatList/GetStakeBanner.swift#L13) {#10-crowdfunding-banner} + +**File**: `Shared/Views/ChatList/GetStakeBanner.swift` + +Gradient card inviting the user to invest on Wefunder. Shown only when [`isInUS`](../../Shared/Views/Onboarding/WhatsNewView.swift#L45) — the same condition that gates the Wefunder row in settings — and only while `DEFAULT_GET_STAKE_BANNER_DISMISSED` is false. + +### Placement + +| Where | Condition | Layout | +|-------|-----------|--------| +| Chat list | rendered whenever [`chatListContent`](../../Shared/Views/ChatList/ChatListView.swift#L413) is, in the `List` after `OneHandUICard` and before the chats | `.padding(.vertical, 3)`, flipped for one-hand UI, `.zIndex(1)` | +| Onboarding | below [`ConnectOnboardingView`](../../Shared/Views/NewChat/OnboardingCards.swift#L135) when `shouldShowOnboarding` | `.padding(.horizontal, 20)` (the onboarding cards' margin), `.padding(.bottom, 8)` | + +The list has a single banner slot, filled by an `if`/`else if` chain in priority order: the support-ended alert (`supportEnded`), the renewal-failure alert (`badgeIssueFailed`), the pitch, then the Wefunder banner. Each banner records itself in `ChatModel.chatListBanner` (`.badgeExpired`, `.badgeIssueFailed`, `.badgePitch`, `.getStake`) in its `onAppear`, and the pitch and Wefunder conditions start with `chatModel.bannerSlotFree(for:)` — true only while nothing else was shown this app session — so dismissing a banner never puts another in its place until restart. The alerts have no such check: an alert takes the slot whenever present, and once shown it holds it. The pitch also requires `noShownBadge`, false until `BadgeModel` holds the current user's state, so it cannot take the slot from a supporter whose badge loads a moment later. The onboarding placement applies the same `bannerSlotFree` check and records `.getStake`. + +In the onboarding branch the `.scaleEffect` and `ThemedBackground` are applied to the enclosing `VStack` rather than to each child, so the banner stays below the pages in both toolbar modes. + +### Dismissal + +| Default | Set by | Effect | +|---------|--------|--------| +| `DEFAULT_GET_STAKE_BANNER_TAPPED` | [`openGetStake()`](../../Shared/Views/ChatList/ChatListView.swift#L408) | the dismiss X appears from then on, while there are chats | +| `DEFAULT_GET_STAKE_BANNER_DISMISSED` | the dismiss X | hides the banner in both placements | +| `DEFAULT_SUPPORTER_BANNER_TAPPED` | tapping the supporter pitch | the pitch's dismiss X appears from then on | +| `DEFAULT_SUPPORTER_BANNER_SHOWN` | the pitch's dismiss X, through its "You can support SimpleX later in Settings." alert, and a successful code redemption | hides the pitch | + +The two badge alert banners always offer the X; only the pitch waits to be tapped once, so a user who has not looked at it cannot dismiss it unseen. + +Both are in `hintDefaults`, so "Reset all hints" in the developer settings restores the banner. The X is never offered in the onboarding branch, so the banner cannot be dismissed before the user has a chat. + +Tapping the card opens [`GetStakeView`](../../Shared/Views/Onboarding/WhatsNewView.swift#L834) as an `appSheet`. + +### Shared card chrome + +`BannerCard` (a `ViewModifier`: paddings, minimum height scaled by Dynamic Type, gradient background, rounded corners) and `BannerDismissButton` are declared separately from `GetStakeBanner` so other banners can adopt the same chrome. The gradient reuses `OnboardingCardView.gradientPoints`, `lightStops` and `darkStops`. + +--- + ## Source Files | File | Path | Key struct | Line | |------|------|------------|------| -| Chat list view | [`ChatListView.swift`](../../Shared/Views/ChatList/ChatListView.swift) | `ChatListView` | [138](../../Shared/Views/ChatList/ChatListView.swift#L142) | +| Chat list view | [`ChatListView.swift`](../../Shared/Views/ChatList/ChatListView.swift) | `ChatListView` | [154](../../Shared/Views/ChatList/ChatListView.swift#L154) | | Chat preview row | [`ChatPreviewView.swift`](../../Shared/Views/ChatList/ChatPreviewView.swift) | `ChatPreviewView` | [12](../../Shared/Views/ChatList/ChatPreviewView.swift#L13) | | Navigation link wrapper | [`ChatListNavLink.swift`](../../Shared/Views/ChatList/ChatListNavLink.swift) | `ChatListNavLink` | [43](../../Shared/Views/ChatList/ChatListNavLink.swift#L44) | | Tag filter tabs | [`TagListView.swift`](../../Shared/Views/ChatList/TagListView.swift) | `TagListView` | [19](../../Shared/Views/ChatList/TagListView.swift#L20) | @@ -294,3 +333,4 @@ The FAB (floating action button) in the bottom-right corner opens the new chat f | Contact connection view | [`ContactConnectionView.swift`](../../Shared/Views/ChatList/ContactConnectionView.swift) | | | | Server summary | [`ServersSummaryView.swift`](../../Shared/Views/ChatList/ServersSummaryView.swift) | | | | One-hand UI card | [`OneHandUICard.swift`](../../Shared/Views/ChatList/OneHandUICard.swift) | | | +| Crowdfunding banner | [`GetStakeBanner.swift`](../../Shared/Views/ChatList/GetStakeBanner.swift) | `GetStakeBanner` | [13](../../Shared/Views/ChatList/GetStakeBanner.swift#L13) | diff --git a/apps/ios/spec/impact.md b/apps/ios/spec/impact.md index 74acec789e..35528f30d6 100644 --- a/apps/ios/spec/impact.md +++ b/apps/ios/spec/impact.md @@ -52,6 +52,7 @@ | Shared/SimpleXApp.swift | PC1 through PC31 | High | App entry point — initialization affects everything | | Shared/AppDelegate.swift | PC18 | Medium | Push notification registration | | Shared/Views/ChatList/ChatListView.swift | PC1, PC28 | High | Main screen rendering and filtering | +| Shared/Views/ChatList/GetStakeBanner.swift | PC1 | Low | Crowdfunding banner and shared banner card chrome | | Shared/Views/Chat/ChatView.swift | PC2, PC3, PC4, PC5, PC6, PC7, PC8, PC9, PC11, PC31 | High | Core conversation UI — most messaging features, channel message rendering | | Shared/Views/Chat/ComposeMessage/ComposeView.swift | PC4, PC6, PC9, PC11, PC31 | High | Message composition — send path for all messages, channel sendAsGroup | | Shared/Views/Chat/ChatItem/ | PC2, PC3, PC5, PC7, PC8, PC9, PC10, PC11 | Medium | Individual message rendering components | diff --git a/apps/ios/spec/state.md b/apps/ios/spec/state.md index db16aa2936..88f798666c 100644 --- a/apps/ios/spec/state.md +++ b/apps/ios/spec/state.md @@ -163,6 +163,7 @@ ChatTagsModel (singleton -- filter state) |----------|------|-------------|------| | `messageDelivery` | `[Int64: () -> Void]` | Pending delivery confirmation callbacks | [L426](../Shared/Model/ChatModel.swift#L426) | | `filesToDelete` | `Set` | Files queued for deletion | [L428](../Shared/Model/ChatModel.swift#L428) | +| `chatListBanner` | `ChatListBanner?` | The banner kind the chat list showed this app session; `bannerSlotFree(for:)` tells whether a kind may take the slot (see [chat-list.md](client/chat-list.md)) | [L474](../Shared/Model/ChatModel.swift#L474) | | `im` | `ItemsModel` | Reference to `ItemsModel.shared` | [L432](../Shared/Model/ChatModel.swift#L432) | ### Key Methods diff --git a/apps/multiplatform/CODE.md b/apps/multiplatform/CODE.md index 67fa676414..30462d18e8 100644 --- a/apps/multiplatform/CODE.md +++ b/apps/multiplatform/CODE.md @@ -240,6 +240,7 @@ desktop/src/jvmMain/kotlin/chat/simplex/desktop/ -- Desktop app (1 file) | common/.../common/ui/theme/Theme.kt | spec/services/theme.md | product/views/settings.md | | common/.../common/ui/theme/Color.kt | spec/services/theme.md | product/views/settings.md | | common/.../common/views/chatlist/ChatListView.kt | spec/client/chat-list.md | product/views/chat-list.md | +| common/.../common/views/chatlist/GetStakeBanner.kt | spec/client/chat-list.md | product/views/chat-list.md | | common/.../common/views/chatlist/ChatListNavLinkView.kt | spec/client/chat-list.md | product/views/chat-list.md | | common/.../common/views/chatlist/ChatPreviewView.kt | spec/client/chat-list.md | product/views/chat-list.md | | common/.../common/views/chatlist/UserPicker.kt | spec/client/chat-list.md | product/views/chat-list.md | diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt index cb91c386ce..338e602a45 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt @@ -397,9 +397,11 @@ fun CenterPartOfScreen() { } when (currentChatId.value) { null -> { - if (shouldShowOnboarding()) { + if (rememberUpdatedState(ModalManager.center.hasModalsOpen()).value) { + ModalManager.center.showInView() + } else if (shouldShowOnboarding()) { ConnectOnboardingView() - } else if (!rememberUpdatedState(ModalManager.center.hasModalsOpen()).value) { + } else { Box( Modifier .fillMaxSize() @@ -408,8 +410,6 @@ fun CenterPartOfScreen() { ) { Text(stringResource(if (chatModel.desktopNoUserNoRemote) MR.strings.no_connected_mobile else MR.strings.no_selected_chat)) } - } else { - ModalManager.center.showInView() } } else -> ChatView(chatsCtx = chatModel.chatsContext, currentChatId) {} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index e314d05e15..13896cd301 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 @@ -144,6 +144,8 @@ object BadgeModel { this.rhId.value == rhId && this.userId.value == userId } +enum class ChatListBanner { BadgeExpired, BadgeIssueFailed, BadgePitch, GetStake } + /* * Without this annotation an animation from ChatList to ChatView has 1 frame per the whole animation. Don't delete it * */ @@ -213,6 +215,12 @@ object ChatModel { // Needed to apply black color to left/right cutout area on Android val fullscreenGalleryVisible = mutableStateOf(false) + // the banner kind the chat list showed this app session: it keeps the slot until restart, so dismissing it never puts + // another in its place; only the badge alert shows regardless. Set while rendering, so not a state. + var chatListBanner: ChatListBanner? = null + + fun bannerSlotFree(banner: ChatListBanner): Boolean = chatListBanner == null || chatListBanner == banner + // preferences val notificationPreviewMode by lazy { mutableStateOf( @@ -2239,17 +2247,185 @@ data class LocalBadge( @Serializable data class BadgeState( val badgePurchaseId: Long, + val purchaseKey: String, val badgeType: BadgeType, val shown: Boolean, val monthsLeft: Int, val paidThrough: Instant, val renewsAt: Instant? = null, val willRenew: Boolean, - val alert: BadgeAlert? = null + val alert: BadgeAlert? = null, + val issueError: BadgeIssueError? = null, + val nextWakeAt: Instant? = null ) { val paidThroughText: String get() = badgeDateText(paidThrough) } +@Serializable +data class BadgeIssueError( + val failedSince: Instant, + val lastAttemptAt: Instant, + val reason: BadgeIssueFailure +) + +@Serializable +sealed class BadgeIssueFailure { + // retryable is the service's own view of transience: it gave retryAfter + @Serializable @SerialName("serviceError") data class ServiceError(val code: BadgeServiceErrorCode, val retryable: Boolean) : BadgeIssueFailure() + @Serializable @SerialName("serviceTimeout") object ServiceTimeout : BadgeIssueFailure() + @Serializable @SerialName("network") data class Network(val agentError: String) : BadgeIssueFailure() + @Serializable @SerialName("invalidCredential") object InvalidCredential : BadgeIssueFailure() + @Serializable @SerialName("unexpected") data class Unexpected(val message: String) : BadgeIssueFailure() + + val text: String get() = when (this) { + is ServiceError -> badgeServiceErrorText(code) ?: String.format(generalGetString(MR.strings.badges_error_service_refused), code.text) + is ServiceTimeout -> generalGetString(MR.strings.badges_error_no_response) + is Network -> generalGetString(MR.strings.badges_error_unreachable) + is InvalidCredential -> generalGetString(MR.strings.badges_error_credential_invalid) + is Unexpected -> String.format(generalGetString(MR.strings.badges_error_unexpected), message) + } + + // the stored form, for support + val tag: String get() = when (this) { + is ServiceError -> "serviceError ${if (retryable) "retry" else "final"} ${code.text}" + is ServiceTimeout -> "serviceTimeout" + is Network -> "network $agentError" + is InvalidCredential -> "invalidCredential" + is Unexpected -> "unexpected $message" + } +} + +@Serializable +data class StatementEntry( + val entryId: String, + val changeMonths: Int, + val balanceMonths: Int, + val balanceStartTs: Instant, + val balanceAnchorTs: Instant, + val balanceBadgeType: BadgeType, + val wasPausedSince: Instant? = null, + val createdAt: Instant, + val entryType: StatementEntryType +) + +@Serializable +sealed class StatementEntryType { + @Serializable @SerialName("credit") data class Credit(val credit: StatementCreditType): StatementEntryType() + @Serializable @SerialName("debit") data class Debit(val debit: StatementDebitType): StatementEntryType() + + val text: String + get() = when (this) { + is Credit -> credit.text + is Debit -> debit.text + } +} + +// the service is deployed ahead of clients, so a type this version does not know keeps its tag +@Serializable(with = StatementCreditTypeSerializer::class) +sealed class StatementCreditType { + @Serializable data class Payment(val invoiceId: String? = null): StatementCreditType() + object Code: StatementCreditType() + @Serializable data class Charge(val chargeId: String): StatementCreditType() + object Support: StatementCreditType() + @Serializable data class TransferIn(val fromPurchaseKey: String): StatementCreditType() + object Opening: StatementCreditType() + data class Unknown(val type: String): StatementCreditType() + + val text: String + get() = when (this) { + is Payment -> "payment" + is Code -> "code" + is Charge -> "charge" + is Support -> "support" + is TransferIn -> "transferIn" + is Opening -> "opening" + is Unknown -> type + } +} + +object StatementCreditTypeSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("StatementCreditType") + + override fun deserialize(decoder: Decoder): StatementCreditType { + require(decoder is JsonDecoder) + val json = decoder.decodeJsonElement().jsonObject + return when (val type = json["type"]?.jsonPrimitive?.content ?: "") { + "payment" -> decoder.json.decodeFromJsonElement(json) + "code" -> StatementCreditType.Code + "charge" -> decoder.json.decodeFromJsonElement(json) + "support" -> StatementCreditType.Support + "transferIn" -> decoder.json.decodeFromJsonElement(json) + "opening" -> StatementCreditType.Opening + else -> StatementCreditType.Unknown(type) + } + } + + override fun serialize(encoder: Encoder, value: StatementCreditType) { + require(encoder is JsonEncoder) + encoder.encodeJsonElement(buildJsonObject { + put("type", value.text) + when (value) { + is StatementCreditType.Payment -> value.invoiceId?.let { put("invoiceId", it) } + is StatementCreditType.Charge -> put("chargeId", value.chargeId) + is StatementCreditType.TransferIn -> put("fromPurchaseKey", value.fromPurchaseKey) + is StatementCreditType.Code, is StatementCreditType.Support, is StatementCreditType.Opening, is StatementCreditType.Unknown -> {} + } + }) + } +} + +@Serializable(with = StatementDebitTypeSerializer::class) +sealed class StatementDebitType { + object Refund: StatementDebitType() + @Serializable data class Upgrade(val toPurchaseKey: String): StatementDebitType() + @Serializable data class TransferOut(val toPurchaseKey: String): StatementDebitType() + object Support: StatementDebitType() + object Badge: StatementDebitType() + object Lapse: StatementDebitType() + data class Unknown(val type: String): StatementDebitType() + + val text: String + get() = when (this) { + is Refund -> "refund" + is Upgrade -> "upgrade" + is TransferOut -> "transferOut" + is Support -> "support" + is Badge -> "badge" + is Lapse -> "lapse" + is Unknown -> type + } +} + +object StatementDebitTypeSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("StatementDebitType") + + override fun deserialize(decoder: Decoder): StatementDebitType { + require(decoder is JsonDecoder) + val json = decoder.decodeJsonElement().jsonObject + return when (val type = json["type"]?.jsonPrimitive?.content ?: "") { + "refund" -> StatementDebitType.Refund + "upgrade" -> decoder.json.decodeFromJsonElement(json) + "transferOut" -> decoder.json.decodeFromJsonElement(json) + "support" -> StatementDebitType.Support + "badge" -> StatementDebitType.Badge + "lapse" -> StatementDebitType.Lapse + else -> StatementDebitType.Unknown(type) + } + } + + override fun serialize(encoder: Encoder, value: StatementDebitType) { + require(encoder is JsonEncoder) + encoder.encodeJsonElement(buildJsonObject { + put("type", value.text) + when (value) { + is StatementDebitType.Upgrade -> put("toPurchaseKey", value.toPurchaseKey) + is StatementDebitType.TransferOut -> put("toPurchaseKey", value.toPurchaseKey) + is StatementDebitType.Refund, is StatementDebitType.Support, is StatementDebitType.Badge, is StatementDebitType.Lapse, is StatementDebitType.Unknown -> {} + } + }) + } +} + @Serializable data class BadgeAlert( val kind: BadgeAlertKind, @@ -2269,7 +2445,8 @@ enum class BadgeAlertKind { @SerialName("paymentIssue") PaymentIssue, @SerialName("subscriptionEnded") SubscriptionEnded, @SerialName("prepaidEnding") PrepaidEnding, - @SerialName("supportEnded") SupportEnded + @SerialName("supportEnded") SupportEnded, + @SerialName("issueFailed") IssueFailed } private fun badgeDateText(date: Instant): String { 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 e715f6224f..c039875eae 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 @@ -193,6 +193,9 @@ class AppPreferences { 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 supporterBannerTapped = mkBoolPreference(SHARED_PREFS_SUPPORTER_BANNER_TAPPED, false) + val getStakeBannerTapped = mkBoolPreference(SHARED_PREFS_GET_STAKE_BANNER_TAPPED, false) + val getStakeBannerDismissed = mkBoolPreference(SHARED_PREFS_GET_STAKE_BANNER_DISMISSED, false) val showMuteProfileAlert = mkBoolPreference(SHARED_PREFS_SHOW_MUTE_PROFILE_ALERT, true) val showReportsInSupportChatAlert = mkBoolPreference(SHARED_PREFS_SHOW_REPORTS_IN_SUPPORT_CHAT_ALERT, true) val appLanguage = mkStrPreference(SHARED_PREFS_APP_LANGUAGE, null) @@ -275,6 +278,9 @@ class AppPreferences { hintPref(oneHandUICardShown, false), hintPref(addressCreationCardShown, false), hintPref(supporterBannerShown, false), + hintPref(supporterBannerTapped, false), + hintPref(getStakeBannerTapped, false), + hintPref(getStakeBannerDismissed, false), hintPref(liveMessageAlertShown, false), hintPref(signMessageAlertShown, false), hintPref(showHiddenProfilesNotice, true), @@ -467,6 +473,9 @@ class AppPreferences { 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_SUPPORTER_BANNER_TAPPED = "SupporterBannerTapped" + private const val SHARED_PREFS_GET_STAKE_BANNER_TAPPED = "GetStakeBannerTapped" + private const val SHARED_PREFS_GET_STAKE_BANNER_DISMISSED = "GetStakeBannerDismissed" private const val SHARED_PREFS_SHOW_MUTE_PROFILE_ALERT = "ShowMuteProfileAlert" private const val SHARED_PREFS_SHOW_REPORTS_IN_SUPPORT_CHAT_ALERT = "ShowReportsInSupportChatAlert" private const val SHARED_PREFS_STORE_DB_PASSPHRASE = "StoreDBPassphrase" @@ -585,14 +594,7 @@ object ChatController { 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.ServiceError -> badgeServiceErrorText(e.serviceError)?.let { return it } 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) } @@ -606,6 +608,12 @@ object ChatController { throw Exception("apiGetBadgeState: unexpected ${r.responseType}") } + suspend fun apiGetBadgeLedger(rh: Long?, userId: Long, badgePurchaseId: Long): List { + val r = sendCmd(rh, CC.ApiGetBadgeLedger(userId, badgePurchaseId)) + if (r is API.Result && r.res is CR.BadgeLedger) return r.res.badgeLedger + throw Exception("apiGetBadgeLedger: unexpected ${r.responseType}") + } + suspend fun apiAckBadgeAlert(rh: Long?, userId: Long, badgePurchaseId: Long, alertKind: BadgeAlertKind, snooze: Boolean, episode: String): BadgeState? { val r = sendCmd(rh, CC.ApiAckBadgeAlert(userId, badgePurchaseId, alertKind, snooze, episode)) if (r is API.Result && r.res is CR.BadgeStateR) return r.res.badgeState @@ -4070,6 +4078,7 @@ sealed class CC { // badges class ApiRedeemBadgeCode(val userId: Long, val code: String): CC() class ApiGetBadgeState(val userId: Long): CC() + class ApiGetBadgeLedger(val userId: Long, val badgePurchaseId: Long): CC() class ApiAckBadgeAlert(val userId: Long, val badgePurchaseId: Long, val alertKind: BadgeAlertKind, val snooze: Boolean, val episode: String): CC() // misc class ShowVersion(): CC() @@ -4298,6 +4307,7 @@ sealed class CC { is ApiStandaloneFileInfo -> "/_download info $url" is ApiRedeemBadgeCode -> "/_redeem_badge_code $userId $code" is ApiGetBadgeState -> "/_badge state $userId" + is ApiGetBadgeLedger -> "/_badge ledger $userId $badgePurchaseId" is ApiAckBadgeAlert -> "/_badge ack $userId $badgePurchaseId ${badgeAlertKindParam(alertKind)} ${onOff(snooze)} $episode" is ShowVersion -> "/version" is ResetAgentServersStats -> "/reset servers stats" @@ -4482,6 +4492,7 @@ sealed class CC { is ApiStandaloneFileInfo -> "apiStandaloneFileInfo" is ApiRedeemBadgeCode -> "apiRedeemBadgeCode" is ApiGetBadgeState -> "apiGetBadgeState" + is ApiGetBadgeLedger -> "apiGetBadgeLedger" is ApiAckBadgeAlert -> "apiAckBadgeAlert" is ShowVersion -> "showVersion" is ResetAgentServersStats -> "resetAgentServersStats" @@ -4548,6 +4559,7 @@ private fun badgeAlertKindParam(kind: BadgeAlertKind): String = when (kind) { BadgeAlertKind.SubscriptionEnded -> "subscription_ended" BadgeAlertKind.PrepaidEnding -> "prepaid_ending" BadgeAlertKind.SupportEnded -> "support_ended" + BadgeAlertKind.IssueFailed -> "issue_failed" } @Serializable @@ -6873,6 +6885,7 @@ sealed class CR { // the full user, not UserRef: its profile carries the badge that setUserBadge just stored @Serializable @SerialName("badgeRedeemed") class BadgeRedeemed(val user: User, val redeemedBadge: LocalBadge, val newBadge: Boolean, val badgeState: BadgeState?): CR() @Serializable @SerialName("badgeState") class BadgeStateR(val user: UserRef, val badgeState: BadgeState?): CR() + @Serializable @SerialName("badgeLedger") class BadgeLedger(val user: UserRef, val badgeLedger: List): CR() @Serializable @SerialName("badgeChanged") class BadgeChanged(val user: User, val badgeState: BadgeState?): CR() @Serializable @SerialName("badgeAlert") class BadgeAlertR(val user: UserRef, val badgeAlert: BadgeAlert): CR() // general @@ -7063,6 +7076,7 @@ sealed class CR { is AppSettingsR -> "appSettings" is BadgeRedeemed -> "badgeRedeemed" is BadgeStateR -> "badgeState" + is BadgeLedger -> "badgeLedger" is BadgeChanged -> "badgeChanged" is BadgeAlertR -> "badgeAlert" is Response -> "* $type" @@ -7270,6 +7284,7 @@ sealed class CR { is AppSettingsR -> json.encodeToString(appSettings) is BadgeRedeemed -> withUser(user, "redeemedBadge: ${json.encodeToString(redeemedBadge)}\nnewBadge: $newBadge\nbadgeState: ${json.encodeToString(badgeState)}") is BadgeStateR -> withUser(user, json.encodeToString(badgeState)) + is BadgeLedger -> withUser(user, json.encodeToString(badgeLedger)) is BadgeChanged -> withUser(user, json.encodeToString(badgeState)) is BadgeAlertR -> withUser(user, json.encodeToString(badgeAlert)) is Response -> json @@ -7388,6 +7403,17 @@ sealed class BadgeServiceErrorCode { } } +fun badgeServiceErrorText(code: BadgeServiceErrorCode): String? = when (code) { + is BadgeServiceErrorCode.CodeInvalid -> generalGetString(MR.strings.badges_error_code_invalid) + is BadgeServiceErrorCode.CodeUsed -> generalGetString(MR.strings.badges_error_code_used) + is BadgeServiceErrorCode.CodeExpired -> generalGetString(MR.strings.badges_error_code_expired) + is BadgeServiceErrorCode.RateLimited -> generalGetString(MR.strings.badges_error_rate_limited) + is BadgeServiceErrorCode.UnsupportedVersion -> generalGetString(MR.strings.badges_error_unsupported_version) + is BadgeServiceErrorCode.UnknownPurchaseKey -> generalGetString(MR.strings.badges_error_unknown_purchase) + is BadgeServiceErrorCode.Internal -> generalGetString(MR.strings.badges_error_service_internal) + else -> null +} + object BadgeServiceErrorCodeSerializer : KSerializer { override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("BadgeServiceErrorCode", PrimitiveKind.STRING) override fun deserialize(decoder: Decoder): BadgeServiceErrorCode = 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 index 7c16bee763..cb300b183d 100644 --- 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 @@ -10,9 +10,9 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import dev.icerock.moko.resources.compose.stringResource import chat.simplex.common.platform.ColumnWithScrollBar +import chat.simplex.common.views.onboarding.ReadableTextWithLink import chat.simplex.res.MR -// TODO [badges]: replace lorem ipsum with the real copy once the badge protocol and privacy properties are documented. @Composable fun BadgesHowItWorksView() { ColumnWithScrollBar( @@ -30,5 +30,6 @@ fun BadgesHowItWorksView() { Text(stringResource(MR.strings.badges_how_it_works_p1), style = MaterialTheme.typography.body1) Text(stringResource(MR.strings.badges_how_it_works_p2), style = MaterialTheme.typography.body1) Text(stringResource(MR.strings.badges_how_it_works_p3), style = MaterialTheme.typography.body1) + ReadableTextWithLink(MR.strings.badges_how_it_works_read_more_with_link, "https://simplex.chat/blog/20260919-simplex-supporter-badges.html") } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesLedgerView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesLedgerView.kt new file mode 100644 index 0000000000..2783727621 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesLedgerView.kt @@ -0,0 +1,158 @@ +package chat.simplex.common.views.badges + +import InfoRow +import SectionBottomSpacer +import SectionItemView +import SectionView +import itemHPadding +import androidx.compose.foundation.layout.* +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.encodeToString +import chat.simplex.common.model.BadgeState +import chat.simplex.common.model.StatementCreditType +import chat.simplex.common.model.StatementDebitType +import chat.simplex.common.model.StatementEntry +import chat.simplex.common.model.StatementEntryType +import chat.simplex.common.model.json +import chat.simplex.common.model.localDate +import chat.simplex.common.model.localTimestamp +import chat.simplex.common.platform.ColumnWithScrollBar +import chat.simplex.common.platform.Log +import chat.simplex.common.platform.TAG +import chat.simplex.common.platform.chatModel +import chat.simplex.common.platform.shareText +import chat.simplex.common.views.helpers.AlertManager +import chat.simplex.common.views.helpers.AppBarTitle +import chat.simplex.common.views.helpers.ModalView +import chat.simplex.common.views.helpers.ShareButton +import chat.simplex.common.views.helpers.generalGetString +import chat.simplex.common.views.helpers.withBGApi +import chat.simplex.res.MR +import kotlin.math.abs + +@Composable +fun BadgesLedgerView(badgeState: BadgeState, close: () -> Unit) { + val entries = remember { mutableStateOf?>(null) } + val clipboard = LocalClipboardManager.current + + LaunchedEffect(Unit) { + val user = chatModel.currentUser.value ?: return@LaunchedEffect + withBGApi { + try { + val ledger = chatModel.controller.apiGetBadgeLedger(chatModel.remoteHostId(), user.userId, badgeState.badgePurchaseId) + withContext(Dispatchers.Main) { entries.value = ledger } + } catch (e: Exception) { + Log.e(TAG, "apiGetBadgeLedger: ${e.message}") + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error), e.message) + } + } + } + + ModalView( + close, + cardScreen = true, + endButtons = { + val loaded = entries.value + if (!loaded.isNullOrEmpty()) { + ShareButton { clipboard.shareText(ledgerShareText(loaded)) } + } + } + ) { + ColumnWithScrollBar { + AppBarTitle(stringResource(MR.strings.badges_ledger)) + val loaded = entries.value + if (loaded != null) { + SectionView { + if (loaded.isEmpty()) { + SectionItemView { + Text(stringResource(MR.strings.badges_ledger_no_entries), color = MaterialTheme.colors.secondary) + } + } else { + loaded.forEach { LedgerRow(it) } + } + } + } + SectionBottomSpacer() + } + } +} + +@Composable +private fun LedgerRow(entry: StatementEntry) { + val expanded = remember(entry.entryId) { mutableStateOf(false) } + SectionItemView(click = { expanded.value = !expanded.value }) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(entry.entryType.text) + Text(localDate(entry.createdAt), color = MaterialTheme.colors.secondary, fontSize = 12.sp) + } + Text(changeText(entry), color = MaterialTheme.colors.secondary) + Icon( + painterResource(if (expanded.value) MR.images.ic_chevron_up else MR.images.ic_chevron_down), + contentDescription = null, + tint = MaterialTheme.colors.secondary, + modifier = Modifier.size(20.dp) + ) + } + } + if (expanded.value) { + val indented = PaddingValues(start = 24.dp + itemHPadding, end = itemHPadding) + entryFields(entry).forEach { (label, value) -> InfoRow(label, value, padding = indented) } + } +} + +private fun changeText(entry: StatementEntry): String { + val n = entry.changeMonths + val months = String.format(generalGetString(if (abs(n) == 1) MR.strings.ttl_month else MR.strings.ttl_months), n) + return if (n > 0) "+$months" else months +} + +private fun entryFields(entry: StatementEntry): List> { + val fields = mutableListOf( + generalGetString(MR.strings.badges_ledger_date) to localTimestamp(entry.createdAt), + generalGetString(MR.strings.badges_ledger_balance) to entry.balanceMonths.toString(), + generalGetString(MR.strings.badges_ledger_balance_start) to localTimestamp(entry.balanceStartTs), + generalGetString(MR.strings.badges_ledger_anchor) to localTimestamp(entry.balanceAnchorTs), + generalGetString(MR.strings.badges_ledger_badge_type) to entry.balanceBadgeType.text, + ) + val pausedSince = entry.wasPausedSince + if (pausedSince != null) { + fields.add(generalGetString(MR.strings.badges_ledger_paused_since) to localTimestamp(pausedSince)) + } + fields.add(generalGetString(MR.strings.badges_ledger_entry_id) to entry.entryId) + payloadField(entry.entryType)?.let { fields.add(it) } + return fields +} + +private fun payloadField(entryType: StatementEntryType): Pair? = when (entryType) { + is StatementEntryType.Credit -> when (val credit = entryType.credit) { + is StatementCreditType.Payment -> credit.invoiceId?.let { generalGetString(MR.strings.badges_ledger_invoice_id) to it } + is StatementCreditType.Charge -> generalGetString(MR.strings.badges_ledger_charge_id) to credit.chargeId + is StatementCreditType.TransferIn -> generalGetString(MR.strings.badges_ledger_from_purchase_key) to credit.fromPurchaseKey + is StatementCreditType.Code, is StatementCreditType.Support, is StatementCreditType.Opening, is StatementCreditType.Unknown -> null + } + is StatementEntryType.Debit -> when (val debit = entryType.debit) { + is StatementDebitType.Upgrade -> generalGetString(MR.strings.badges_ledger_to_purchase_key) to debit.toPurchaseKey + is StatementDebitType.TransferOut -> generalGetString(MR.strings.badges_ledger_to_purchase_key) to debit.toPurchaseKey + is StatementDebitType.Refund, is StatementDebitType.Support, is StatementDebitType.Badge, is StatementDebitType.Lapse, is StatementDebitType.Unknown -> null + } +} + +// the JSON as core sent it: English field names and ISO dates, for support +private fun ledgerShareText(entries: List): String = json.encodeToString(entries) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourBadgeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourBadgeView.kt index c17559d5b0..34a59343ce 100644 --- 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 @@ -1,5 +1,7 @@ package chat.simplex.common.views.badges +import InfoRow +import SectionItemView import SectionSpacer import SectionTextFooter import SectionView @@ -10,19 +12,28 @@ import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import chat.simplex.common.model.BadgeState +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.model.localTimestamp import chat.simplex.common.platform.ColumnWithScrollBar +import chat.simplex.common.platform.chatModel import chat.simplex.common.ui.theme.DEFAULT_PADDING import chat.simplex.common.views.helpers.AppBarTitle import chat.simplex.common.views.helpers.ModalManager import chat.simplex.common.views.helpers.badgeImage import chat.simplex.common.views.helpers.badgeTypeName +import chat.simplex.common.views.helpers.openVerifiedSimplexUri import chat.simplex.common.views.usersettings.SettingsActionItem +import chat.simplex.common.views.usersettings.simplexTeamUri import chat.simplex.res.MR @Composable @@ -47,6 +58,52 @@ fun BadgesYourBadgeView(badgeState: BadgeState) { ) } SectionSpacer() + val issueError = badgeState.issueError + if (issueError != null) { + val uriHandler = LocalUriHandler.current + SectionView(title = stringResource(MR.strings.error), icon = painterResource(MR.images.ic_warning), iconTint = Color.Red, leadingIcon = true) { + SectionItemView { + Text(issueError.reason.text, color = MaterialTheme.colors.secondary) + } + InfoRow(stringResource(MR.strings.badges_error_since), localTimestamp(issueError.failedSince)) + if (issueError.lastAttemptAt != issueError.failedSince) { + InfoRow(stringResource(MR.strings.badges_error_last_attempt), localTimestamp(issueError.lastAttemptAt)) + } + SettingsActionItem( + painterResource(MR.images.ic_tag), + stringResource(MR.strings.badges_contact_team), + { uriHandler.openVerifiedSimplexUri(simplexTeamUri) }, + textColor = MaterialTheme.colors.primary + ) + } + SectionSpacer() + } + if (appPrefs.developerTools.get()) { + val clipboard = LocalClipboardManager.current + SectionView(stringResource(MR.strings.badges_credential)) { + val badge = chatModel.currentUser.value?.profile?.localBadge + if (badge != null) { + InfoRow(stringResource(MR.strings.badges_credential_status), badge.status.name) + InfoRow(stringResource(MR.strings.badges_credential_expires), localTimestamp(badge.badge.badgeExpiry)) + } + InfoRow(stringResource(MR.strings.badges_credential_months_left), badgeState.monthsLeft.toString()) + InfoRow(stringResource(MR.strings.badges_credential_purchase_id), badgeState.badgePurchaseId.toString()) + val nextWakeAt = badgeState.nextWakeAt + if (nextWakeAt != null) { + InfoRow(stringResource(MR.strings.badges_credential_next_check), localTimestamp(nextWakeAt)) + } + if (issueError != null) { + InfoRow(stringResource(MR.strings.error), issueError.reason.tag) + } + SectionItemView({ clipboard.setText(AnnotatedString(badgeState.purchaseKey)) }) { + Text(stringResource(MR.strings.badges_copy_purchase_key), color = MaterialTheme.colors.primary) + } + SectionItemView({ ModalManager.start.showCustomModal { close -> BadgesLedgerView(badgeState, close) } }) { + Text(stringResource(MR.strings.badges_ledger)) + } + } + SectionSpacer() + } } } 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 index 94203e2169..47a4d3b152 100644 --- 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 @@ -2,48 +2,38 @@ 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.graphics.Color 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.chatlist.BannerDismissButton +import chat.simplex.common.views.chatlist.bannerCard 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), + warning: Boolean = false, + showDismiss: Boolean = true, 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. + // the card's own height, for centring the fallback hero; 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 @@ -52,24 +42,15 @@ fun SupportSimpleXBanner( 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 } + .bannerCard(onTap) .padding( - start = cardLeadingPadding, + start = 16.dp, end = cardTrailingPadding + heroWidth + heroTrailingPadding + textToHeroGap, top = 12.dp, bottom = 12.dp @@ -81,7 +62,7 @@ fun SupportSimpleXBanner( title, style = MaterialTheme.typography.body1, fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colors.primary, + color = if (warning) Color.Red else MaterialTheme.colors.primary, maxLines = 2, overflow = TextOverflow.Ellipsis ) @@ -95,19 +76,9 @@ fun SupportSimpleXBanner( } } - // 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) - ) + if (showDismiss) { + BannerDismissButton(Modifier.align(Alignment.TopEnd), onDismiss) + } } HeroThumbnail( @@ -149,24 +120,3 @@ private fun HeroThumbnail(heroWidth: Dp, heroVisibleHeight: Dp, cardHeight: Dp, ) } } - -// Geometry-aware gradient with asymmetric scale: start (dark) pushed further below the card than -// end (warm) is above, so card-middle lands at the bright/mid-transition stop, not the dark region. -private fun gradientBrush(isDark: Boolean, size: IntSize): Brush { - val stops = if (isDark) darkStops else lightStops - if (size.width == 0 || size.height == 0) return Brush.linearGradient(colorStops = stops) - val w = size.width.toFloat() - val h = size.height.toFloat() - val startScale = if (isDark) 3.0f else 2.5f - val endScale = if (isDark) 2.1f else 1.7f - val gp = gradientPoints(h / w, 1.0f) - val sx = 0.5f + (gp.startX - 0.5f) * startScale - val sy = 0.5f + (gp.startY - 0.5f) * startScale - val ex = 0.5f + (gp.endX - 0.5f) * endScale - val ey = 0.5f + (gp.endY - 0.5f) * endScale - return Brush.linearGradient( - colorStops = stops, - start = Offset(sx * w, sy * h), - end = Offset(ex * w, ey * h) - ) -} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index cb36d86230..7e43db2e98 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -502,7 +502,7 @@ fun ChatView( groupMembersJob = scope.launch(Dispatchers.Default) { val r = chatModel.controller.apiGroupMemberInfo(chatRh, groupInfo.groupId, member.groupMemberId) val stats = r?.second - val (updatedMember, code) = if (member.memberActive) { + val (updatedMember, code) = if ((member.memberActive || (groupInfo.useRelays && member.memberCurrent)) && member.memberRole != GroupMemberRole.Relay) { val memCode = chatModel.controller.apiGetGroupMemberCode(chatRh, groupInfo.apiId, member.groupMemberId) (memCode?.first ?: r?.first ?: member) to memCode?.second } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt index 7e66c15937..da64862c03 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt @@ -622,7 +622,7 @@ fun stripFormattedTextLink(ft: List?, link: String): List= 0 && result[i].format == null && result[i].text.endsWith("\n")) { result[i] = FormattedText(result[i].text.dropLast(1), null) - if (result[i].text.isEmpty()) result.removeLast() + if (result[i].text.isEmpty()) result.removeAt(result.lastIndex) } return result.ifEmpty { null } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index a013cc97a2..d0748e4a16 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 @@ -64,9 +64,9 @@ sealed class ActiveFilter { data object Unread: ActiveFilter() } -private fun showSupportEndedDismissAlert() { +private fun showBadgeAlertDismissAlert(title: String) { AlertManager.shared.showAlertDialogButtonsColumn( - title = generalGetString(MR.strings.badges_support_ended), + title = title, buttons = { Column { SectionItemView({ @@ -950,6 +950,11 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat val oneHandUICardShown = remember { appPrefs.oneHandUICardShown.state } val addressCreationCardShown = remember { appPrefs.addressCreationCardShown.state } val supporterBannerShown = remember { appPrefs.supporterBannerShown.state } + val supporterBannerTapped = remember { appPrefs.supporterBannerTapped.state } + val getStakeBannerTapped = remember { appPrefs.getStakeBannerTapped.state } + val getStakeBannerDismissed = remember { appPrefs.getStakeBannerDismissed.state } + // read here rather than in the LazyColumn: it launches an effect, so it needs a composable scope + val crowdfunding = crowdfundingAvailable() val activeFilter = remember { chatModel.activeChatTagFilter } LaunchedEffect(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) { @@ -1042,24 +1047,54 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat val alert = BadgeModel.alert.value if (supportEnded() && alert != null) { item { + SideEffect { chatModel.chatListBanner = ChatListBanner.BadgeExpired } 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 + onDismiss = { showBadgeAlertDismissAlert(generalGetString(MR.strings.badges_support_ended)) } ) } } - } else if (!supporterBannerShown.value && !hasShownBadge() && chatModel.chats.value.size > 3) { + } else if (badgeIssueFailed()) { item { + SideEffect { chatModel.chatListBanner = ChatListBanner.BadgeIssueFailed } Box(Modifier.zIndex(1f).padding(16.dp)) { SupportSimpleXBanner( + title = stringResource(MR.strings.badges_renewal_failed), + subtitle = stringResource(MR.strings.badges_tap_for_details), + warning = true, onTap = { ModalManager.start.showCustomModal { close -> BadgesView(close) } }, + onDismiss = { showBadgeAlertDismissAlert(generalGetString(MR.strings.badges_renewal_failed)) } + ) + } + } + } else if (chatModel.bannerSlotFree(ChatListBanner.BadgePitch) && !supporterBannerShown.value && noShownBadge() && chatModel.chats.value.size > 3) { + item { + SideEffect { chatModel.chatListBanner = ChatListBanner.BadgePitch } + Box(Modifier.zIndex(1f).padding(16.dp)) { + SupportSimpleXBanner( + showDismiss = supporterBannerTapped.value, + onTap = { + appPrefs.supporterBannerTapped.set(true) + ModalManager.start.showCustomModal { close -> BadgesView(close) } + }, onDismiss = ::showSupportSimpleXDismissAlert ) } } + } else if (chatModel.bannerSlotFree(ChatListBanner.GetStake) && crowdfunding && !getStakeBannerDismissed.value) { + item { + SideEffect { chatModel.chatListBanner = ChatListBanner.GetStake } + Box(Modifier.zIndex(1f).padding(16.dp)) { + GetStakeBanner( + showDismiss = getStakeBannerTapped.value && chatModel.chats.value.isNotEmpty(), + onTap = { openGetStake(ModalManager.start) }, + onDismiss = { appPrefs.getStakeBannerDismissed.set(true) } + ) + } + } } itemsIndexed(chats, key = { _, chat -> chat.remoteHostId to chat.id }) { index, chat -> val nextChatSelected = remember(chat.id, chats) { derivedStateOf { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/GetStakeBanner.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/GetStakeBanner.kt new file mode 100644 index 0000000000..d383366dd4 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/GetStakeBanner.kt @@ -0,0 +1,133 @@ +package chat.simplex.common.views.chatlist + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.ui.theme.isInDarkTheme +import chat.simplex.common.views.helpers.ModalManager +import chat.simplex.common.views.helpers.fontSizeMultiplier +import chat.simplex.common.views.newchat.darkStops +import chat.simplex.common.views.newchat.gradientPoints +import chat.simplex.common.views.newchat.lightStops +import chat.simplex.common.views.onboarding.GetStakeView +import chat.simplex.res.MR + +// Spec: spec/client/chat-list.md#GetStakeBanner +@Composable +fun GetStakeBanner(showDismiss: Boolean, onTap: () -> Unit, onDismiss: () -> Unit) { + Box(Modifier.fillMaxWidth()) { + Row( + Modifier + .bannerCard(onTap) + .padding(start = 16.dp, end = if (showDismiss) 40.dp else 18.dp, top = 12.dp, bottom = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + stringResource(MR.strings.invest_banner_title), + style = MaterialTheme.typography.body1, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colors.primary, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Text( + stringResource(MR.strings.invest_banner_subtitle), + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.onBackground, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + Image( + painterResource(if (isInDarkTheme()) MR.images.decentralized_light else MR.images.decentralized), + contentDescription = null, + modifier = Modifier.size(37.dp) + ) + } + + if (showDismiss) { + BannerDismissButton(Modifier.align(Alignment.TopEnd), onDismiss) + } + } +} + +fun openGetStake(modalManager: ModalManager) { + appPrefs.getStakeBannerTapped.set(true) + modalManager.showModalCloseable(cardScreen = true) { close -> + GetStakeView(showFirstImage = true, inCenterOfWindow = modalManager === ModalManager.center, close = close) + } +} + +@Composable +fun Modifier.bannerCard(onTap: () -> Unit): Modifier { + // grows linearly with system font but never shrinks below the default, and the card grows further + // when 2-line text wraps at very large fonts + val cardHeight = (72.dp * fontSizeMultiplier).coerceAtLeast(72.dp) + val isDark = isInDarkTheme() + var cardSize by remember { mutableStateOf(IntSize.Zero) } + val brush = remember(isDark, cardSize) { gradientBrush(isDark, cardSize) } + return this + .fillMaxWidth() + .heightIn(min = cardHeight) + .clip(RoundedCornerShape(16.dp)) + .background(brush) + .clickable(onClick = onTap) + .onSizeChanged { cardSize = it } +} + +// Same X pattern as OneHandUICard: circle-clipped clickable region with inner padding for hit area. +@Composable +fun BannerDismissButton(modifier: Modifier, onDismiss: () -> Unit) { + Icon( + painterResource(MR.images.ic_close), + contentDescription = stringResource(MR.strings.icon_descr_close_button), + tint = if (isInDarkTheme()) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary, + modifier = modifier + .padding(end = 4.dp, top = 4.dp) + .clip(CircleShape) + .clickable(onClick = onDismiss) + .padding(8.dp) + .size(16.dp) + ) +} + +// Geometry-aware gradient with asymmetric scale: start (dark) pushed further below the card than +// end (warm) is above, so card-middle lands at the bright/mid-transition stop, not the dark region. +private fun gradientBrush(isDark: Boolean, size: IntSize): Brush { + val stops = if (isDark) darkStops else lightStops + if (size.width == 0 || size.height == 0) return Brush.linearGradient(colorStops = stops) + val w = size.width.toFloat() + val h = size.height.toFloat() + val startScale = if (isDark) 3.0f else 2.5f + val endScale = if (isDark) 2.1f else 1.7f + val gp = gradientPoints(h / w, 1.0f) + val sx = 0.5f + (gp.startX - 0.5f) * startScale + val sy = 0.5f + (gp.startY - 0.5f) * startScale + val ex = 0.5f + (gp.endX - 0.5f) * endScale + val ey = 0.5f + (gp.endY - 0.5f) * endScale + return Brush.linearGradient( + colorStops = stops, + start = Offset(sx * w, sy * h), + end = Offset(ex * w, ey * h) + ) +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasscodeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasscodeView.kt index 9b25e9b5e0..669847c3a9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasscodeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasscodeView.kt @@ -64,7 +64,7 @@ fun PasscodeView( @Composable fun VerticalLayout() { Column( - Modifier.handleKeyboard().focusRequester(focusRequester), + Modifier.systemBarsPadding().handleKeyboard().focusRequester(focusRequester), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.SpaceEvenly ) { @@ -74,7 +74,7 @@ fun PasscodeView( Text(reason, Modifier.padding(top = 5.dp), style = MaterialTheme.typography.subtitle1) } } - PasscodeEntry(passcode, true) + PasscodeEntry(passcode, true, Modifier.weight(1f, fill = false)) Row(Modifier.heightIn(min = 70.dp), verticalAlignment = Alignment.CenterVertically) { SimpleButton(generalGetString(MR.strings.cancel_verb), icon = painterResource(MR.images.ic_close), disabled = !buttonsEnabled.value, click = cancel) Spacer(Modifier.size(20.dp)) @@ -85,9 +85,9 @@ fun PasscodeView( @Composable fun HorizontalLayout() { - Row(Modifier.padding(horizontal = DEFAULT_PADDING).handleKeyboard().focusRequester(focusRequester), horizontalArrangement = Arrangement.Center) { + Row(Modifier.systemBarsPadding().padding(horizontal = DEFAULT_PADDING).handleKeyboard().focusRequester(focusRequester), horizontalArrangement = Arrangement.Center) { Column( - Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, top = DEFAULT_PADDING), + Modifier.weight(1f, fill = false).padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, top = DEFAULT_PADDING), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.SpaceBetween ) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasswordEntry.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasswordEntry.kt index f76b82c31e..d16f0a2a38 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasswordEntry.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasswordEntry.kt @@ -22,8 +22,9 @@ import chat.simplex.res.MR fun PasscodeEntry( password: MutableState, vertical: Boolean, + modifier: Modifier = Modifier, ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { + Column(modifier, horizontalAlignment = Alignment.CenterHorizontally) { PasscodeView(password) BoxWithConstraints { if (vertical) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/OnboardingCards.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/OnboardingCards.kt index bd33bde96a..661e752751 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/OnboardingCards.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/OnboardingCards.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import dev.icerock.moko.resources.compose.painterResource @@ -32,7 +33,10 @@ import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chatlist.GetStakeBanner +import chat.simplex.common.views.chatlist.openGetStake import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.onboarding.crowdfundingAvailable import chat.simplex.common.views.usersettings.UserAddressView import chat.simplex.res.MR import kotlinx.coroutines.launch @@ -48,14 +52,19 @@ private const val GRADIENT_ANGLE_RAD = 80.0 * Math.PI / 180.0 fun shouldShowOnboarding(): Boolean { val addressCreationCardShown = remember { appPrefs.addressCreationCardShown.state } val chats = chatModel.chats.value - return !addressCreationCardShown.value && chats.isNotEmpty() && !hasConversations(chats) && !supportEnded() + return !addressCreationCardShown.value && chats.isNotEmpty() && !hasConversations(chats) && !supportEnded() && !badgeIssueFailed() } 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 badgeIssueFailed(): Boolean = + BadgeModel.alert.value?.kind == BadgeAlertKind.IssueFailed && BadgeModel.isCurrent(chatModel.remoteHostId(), chatModel.currentUser.value?.userId) + +// false until the badge state loads: if the pitch rendered before that, it would lock the slot, and a supporter's badge +// arriving a moment later would hide it, leaving the slot empty for the session +fun noShownBadge(): Boolean = + BadgeModel.badgeState.value?.shown != true && BadgeModel.isCurrent(chatModel.remoteHostId(), chatModel.currentUser.value?.userId) fun hasConversations(chats: List): Boolean = chats.any { chat -> @@ -415,6 +424,28 @@ fun ConnectOnboardingView() { } } + val getStakeBannerDismissed = remember { appPrefs.getStakeBannerDismissed.state } + val showGetStakeBanner = chatModel.bannerSlotFree(ChatListBanner.GetStake) && crowdfundingAvailable() && !getStakeBannerDismissed.value + // on desktop the pages span the window, but the banner keeps the width it has in the chat list + val bannerMaxWidth = if (appPlatform.isDesktop) DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier else Dp.Unspecified + val content = @Composable { + Column(Modifier.fillMaxSize()) { + Box(Modifier.weight(1f).fillMaxWidth()) { + pager() + } + if (showGetStakeBanner) { + SideEffect { chatModel.chatListBanner = ChatListBanner.GetStake } + Box(Modifier.align(Alignment.CenterHorizontally).widthIn(max = bannerMaxWidth).padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = 8.dp)) { + GetStakeBanner( + showDismiss = false, + onTap = cardClickOverride ?: { openGetStake(if (appPlatform.isDesktop) ModalManager.center else ModalManager.start) }, + onDismiss = {} + ) + } + } + } + } + if (appPlatform.isDesktop) { val maxContentWidth = DEFAULT_WINDOW_WIDTH - DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier Box( @@ -422,12 +453,12 @@ fun ConnectOnboardingView() { contentAlignment = Alignment.Center ) { Box(Modifier.widthIn(max = maxContentWidth).fillMaxHeight()) { - pager() + content() } } } else { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - pager() + content() } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt index ca9ee078d6..0fa53ea6de 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt @@ -1088,7 +1088,7 @@ fun isInUs(): Boolean = @Composable private fun InvestInSimpleXChatView(modalManager: ModalManager) { if (!crowdfundingAvailable()) return - val showGetStake = { modalManager.showModalCloseable(cardScreen = true) { close -> GetStakeView(fromSettings = false, inCenterOfWindow = crowdfundingLayout.inCenterOfWindow(modalManager), close = close) } } + val showGetStake = { modalManager.showModalCloseable(cardScreen = true) { close -> GetStakeView(showFirstImage = false, inCenterOfWindow = crowdfundingLayout.inCenterOfWindow(modalManager), close = close) } } Column(modifier = Modifier.padding(bottom = 12.dp)) { Text( generalGetString(MR.strings.v7_0_invest), @@ -1170,7 +1170,7 @@ private val getStakeSlides: List = listOf( ) @Composable -fun GetStakeView(fromSettings: Boolean, inCenterOfWindow: Boolean = false, close: () -> Unit) { +fun GetStakeView(showFirstImage: Boolean, inCenterOfWindow: Boolean = false, close: () -> Unit) { val uriHandler = LocalUriHandler.current val stopped = chatModel.chatRunning.value == false @@ -1200,7 +1200,7 @@ fun GetStakeView(fromSettings: Boolean, inCenterOfWindow: Boolean = false, close val title = "Get a stake in\nSimpleX Chat" AppBarTitle(if (inCenterOfWindow) title.replace("\n", " ") else title, withPadding = false) // What's new already shows the image of the first slide, above the link that opens this page - if (fromSettings) { + if (showFirstImage) { slideImage(getStakeSlides[0]) } Text( @@ -1213,7 +1213,7 @@ fun GetStakeView(fromSettings: Boolean, inCenterOfWindow: Boolean = false, close } } }, - Modifier.padding(top = if (fromSettings) 8.dp else 0.dp), + Modifier.padding(top = if (showFirstImage) 8.dp else 0.dp), lineHeight = 24.sp ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt index cbd2be9ee3..9e1a6eb4ad 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt @@ -132,7 +132,7 @@ fun SettingsLayout( SettingsActionItem( painterResource(MR.images.ic_redeem), stringResource(MR.strings.v7_0_crowdfunding), - { ModalManager.start.showModalCloseable(cardScreen = true) { close -> GetStakeView(fromSettings = true, close = close) } } + { ModalManager.start.showModalCloseable(cardScreen = true) { close -> GetStakeView(showFirstImage = true, close = close) } } ) } } diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml index 5aa9270e4b..4a3f7870fe 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml @@ -14,20 +14,15 @@ اقبل لا يمكن التراجع عن هذا الإجراء - سيتم فقد ملف تعريفك وجهات اتصالك ورسائلك وملفاتك بشكل نهائي. هذه المجموعة لم تعد موجودة. - رمز QR هذا ليس رابطًا! - مستقبل المُراسلة لا يمكن التراجع عن هذا الإجراء - سيتم حذف جميع الملفات والوسائط المستلمة والمرسلة. ستبقى الصور منخفضة الدقة. لا يمكن التراجع عن هذا الإجراء - سيتم حذف الرسائل المرسلة والمستلمة قبل التحديد. قد تأخذ عدة دقائق. ينطبق هذا الإعداد على الرسائل الموجودة في ملف تعريف الدردشة الحالي الخاص بك - منصة الرسائل والتطبيقات تحمي خصوصيتك وأمنك. يتم مشاركة ملف التعريف مع جهات اتصالك فقط. سيتم تغيير الدور إلى "%s". سيتم إبلاغ كل فرد في المجموعة. سيتم تغيير الدور إلى "%s". سيستلم العضو دعوة جديدة. خوادم الاتصالات الجديدة لملف تعريف الدردشة الحالي الخاص بك سيتم تغيير عنوان الاستلام إلى خادم مختلف. سيتم إكمال تغيير العنوان بعد اتصال المرسل بالإنترنت. - هذا الرابط ليس رابط اتصال صالح! اسمح - أضِف خوادم مُعدة مسبقًا أضِف إلى جهاز آخر ستُحذف جميع الدردشات والرسائل - لا يمكن التراجع عن هذا! الوصول إلى الخوادم عبر وسيط SOCKS على المنفذ %d؟ يجب بدء تشغيل الوسيط قبل تفعيل هذا الخيار. @@ -108,8 +103,6 @@ انتهت المكالمة غيِّر لون إضافي ثانوي - " -\nمتوفر في v5.1" مكالمات الصوت/الفيديو ممنوعة. عن طريق ملف تعريف الدردشة (افتراضي) أو عن طريق الاتصال (تجريبي). يمكن تعطيله عبر الإعدادات - سيستمر عرض الإشعارات أثناء تشغيل التطبيق.]]> @@ -167,7 +160,6 @@ خطأ في حذف جهة الاتصال جهة الاتصال مخفية: انسخ - اتصل متصل انضمام إلى المجموعة؟ اتصل عبر رابط لمرة واحدة؟ @@ -305,15 +297,11 @@ متصل سيتم حذف جهة الاتصال وجميع الرسائل - لا يمكن التراجع عن هذا! الحد الأقصى لحجم الملف المدعوم حاليًا هو %1$s. - تواصل عبر الرابط / رمز QR - أنشئ رابط دعوة لمرة واحدة تحقق من عنوان الخادم وحاول مرة أخرى. امحُ التحقُّق - أنشئ عنوانًا للسماح للأشخاص بالتواصل معك. أدخل الخادم يدويًا ملون لدى جهة الاتصال التعمية بين الطرفين - أنشئ أنشئ ملف تعريفك مكالمة جارية فعّل التدمير الذاتي @@ -387,7 +375,6 @@ حذف جهة الاتصال؟ احذف بالنسبة لي وقت مخصّص - لامركزي عبارة مرور قاعدة البيانات عبارة المرور الحالية… سيتم تحديث عبارة مرور تعمية قاعدة البيانات وتخزينها في Keystore. @@ -483,7 +470,6 @@ ساعات السجل سيتم استلام الصورة عندما يكتمل جهة اتصالك من رفعِها. - اعرض رمز QR في مكالمة الفيديو، أو شارك الرابط.]]> ثبّت SimpleX Chat لطرفية إذا أكّدت، فستتمكن خوادم المُراسلة من رؤية عنوان IP الخاص بك ومزود خدمتك - أي الخوادم التي تتصل بها. إخفاء: @@ -505,8 +491,6 @@ معلومات إخفاء شاشة التطبيق في التطبيقات الحديثة. تحسن الخصوصية والأمان - مسح رمز QR في مكالمة الفيديو، أو يمكن لجهة الاتصال مشاركة رابط الدعوة.]]> - محصن ضد الإزعاج (spam) التخفي عبر رابط لمرة واحدة أرسلت صورة صورة @@ -547,7 +531,6 @@ واجهة أستخدام يابانية وبرتغالية يمكن أن يحدث ذلك عندما تستخدم أنت أو اتصالك النُسخة الاحتياطية القديمة لقاعدة البيانات. انضم ك%s - رمز QR غير صالح الواجهة الإيطالية الرابط غير صالح! دعوة الأصدقاء @@ -647,18 +630,12 @@ %d ثانية قُطع الاتصال رسالة تختفي - لا تنشئ عنوانًا خطأ في تحديث تضبيط الشبكة خطأ في استلام الملف خطأ في تبديل ملف التعريف! حافظ على اتصالاتك - تأكد من أن عناوين خادم XFTP بالتنسيق الصحيح، وأن تكون مفصولة بأسطر وليست مكررة. عُلّم محذوف القوائم والتنبيهات - خطأ في حفظ خوادم XFTP - خطأ في تحميل خوادم SMP - خطأ في تحميل خوادم XFTP - تأكد من أن عناوين خادم SMP في التنسيق الصحيح، وأن تكون مفصولة بأسطر وليست مكررة. مساعدة ماركداون عضو ماركداون في الرسائل @@ -690,7 +667,6 @@ إصلاح التعمية بعد استعادة النُسخ الاحتياطية. اجعل رسالة واحدة تختفي خطأ في تفعيل إيصالات التسليم! - خطأ في حفظ خوادم SMP خطأ في إرسال الرسالة خطأ في الانضمام إلى المجموعة خطأ في مزامنة الاتصال @@ -777,7 +753,6 @@ ستكون مضيفات البصل مطلوبة للاتصال. \nيُرجى ملاحظة: أنك لن تتمكن من الاتصال بالخوادم بدون عنوان onion. اسم عرض جديد: عبارة مرور جديدة… - قيد الانتظار عبارة المرور مطلوبة ألصِق الرابط الذي استلمته فقط مالكي المجموعة يمكنهم تفعيل الملفات والوسائط. @@ -808,12 +783,9 @@ افتح وحدة تحكم الدردشة إدخال رمز المرور افتح SimpleX Chat لقبول المكالمة - يمكن لأي شخص استضافة الخوادم. كلمة المرور للإظهار ندّ لِندّ - أنت تقرر مَن يمكنه الاتصال. مكالمة قيد الانتظار - تقوم أجهزة العميل فقط بتخزين ملفات تعريف المستخدمين وجهات الاتصال والمجموعات والرسائل. صفّر الألوان احفظ عنوان الخادم المُعد مسبقًا @@ -836,7 +808,6 @@ قيم التطبيق منفذ احفظ إعدادات عنوان SimpleX - إعادة تعريف الخصوصية يُرجى إبلاغ المطوِّرين بذلك. الخصوصية والأمان أزل @@ -850,7 +821,6 @@ ستتم إزالة خوادم WebRTC ICE المحفوظة. امنع إرسال الملفات والوسائط. استلمت إجابة… - مستودع GitHub.]]> ارفض يحمي خادم المُرحل عنوان IP الخاص بك، ولكن يمكنه مراقبة مُدّة المكالمة. الرجاء إدخال كلمة المرور السابقة بعد استعادة نسخة احتياطية لقاعدة البيانات. لا يمكن التراجع عن هذا الإجراء. @@ -922,7 +892,6 @@ رمز QR صفّر المنفذ %d - خادم مُعد مسبقًا يُستخدم خادم المُرحل فقط إذا لزم الأمر. يمكن لطرف آخر مراقبة عنوان IP الخاص بك. حفظ وإشعار جهة الاتصال إعادة التشغيل @@ -934,7 +903,6 @@ رمز المرور للتدمير الذاتي إرسال الملفات غير مدعوم بعد أُلغيَ المرسل نقل الملف. - (امسح أو ألصق من الحافظة) ثانية حذف المُرسل طلب الاتصال. امسح رمز QR @@ -1007,7 +975,6 @@ قفل SimpleX لم يتحقق من %s قفل SimpleX - خوادم SMP مشاركة الوسائط… رسائل SimpleX Chat قفل SimpleX غير مفعّل! @@ -1024,7 +991,6 @@ إيقاف الدردشة؟ أظهر حدثت بعض الأخطاء غير الفادحة أثناء الاستيراد: - وسيط SOCKS تم تدقيق أمان SimpleX Chat بواسطة Trail of Bits. إيقاف أظهر المعاينة @@ -1077,20 +1043,15 @@ يجلب التطبيق الرسائل الجديدة بشكل دوري - يستخدم نسبة قليلة من البطارية يوميًا. لا يستخدم التطبيق إشعارات الدفع - لا يتم إرسال البيانات من جهازك إلى الخوادم. سيتم إلغاء الاتصال الذي قبلته! لن تتمكن جهة الاتصال التي شاركت هذا الرابط معها من الاتصال! - هذا النص متاح في الإعدادات - لحماية خصوصيتك، يستخدم SimpleX معرّفات منفصلة لكل جهة اتصال لديك. لحماية معلوماتك، فعّل قفل SimpleX \nسيُطلب منك إكمال المصادقة قبل تفعيل هذه الميزة. عزل النقل بفضل المستخدمين - ساهِم عبر Weblate! دعم البلوتوث وتحسينات أخرى. بفضل المستخدمين - ساهِم عبر Weblate! يتم تشغيل SimpleX في الخلفية بدلاً من استخدام إشعارات push.]]> - انقر لبدء محادثة جديدة - (للمشاركة مع جهة اتصالك) للتواصل عبر الرابط للاتصال، يمكن لجهة الاتصال مسح رمز QR أو استخدام الرابط في التطبيق. اختبر الخوادم - لا معرّفات مُستخدم دعم SimpleX Chat بدِّل العنوان الرئيسي @@ -1143,7 +1104,6 @@ ستتصل بجميع أعضاء المجموعة. ملفات تعريف دردشتك عنوان SimpleX الخاص بك - خوادم SMP الخاصة بك عندما يكون التطبيق قيد التشغيل عبر المُرحل لقد انضممت إلى هذه المجموعة @@ -1155,7 +1115,6 @@ سيتم استلام الفيديو عند اكتمال رفع جهة اتصالك. تحقق من رمز الأمان رسائل صوتية - عندما يطلب الأشخاص الاتصال، يمكنك قبوله أو رفضه. ستكون متصلاً بالمجموعة عندما يكون جهاز مضيف المجموعة متصلاً بالإنترنت، يُرجى الانتظار أو التحقق لاحقًا! ستكون متصلاً عندما يتم قبول طلب اتصالك، يُرجى الانتظار أو التحقق لاحقًا! تستخدم خوادم SimpleX Chat. @@ -1164,9 +1123,7 @@ استخدام وسيط SOCKS؟ عندما تكون متاحة ستبقى جهات اتصالك متصلة. - لا نقوم بتخزين أي من جهات اتصالك أو رسائلك (بمجرد تسليمها) على الخوادم. يمكنك استخدام تخفيض السعر لتنسيق الرسائل: - استخدم الدردشة أنت خطأ غير معروف غيّرتَ دور نفسك إلى %s @@ -1182,12 +1139,9 @@ في انتظار الملف عرض رمز الأمان لن تفقد جهات اتصالك إذا حذفت عنوانك لاحقًا. - خوادم XFTP الخاصة بك استخدم خوادم SimpleX Chat - خوادم XFTP خوادم ICE الخاصة بك هل تستخدم اتصالاً مباشرًا بالإنترنت؟ - أنت تتحكم في الدردشة! مكالماتك عبارة مرور خاطئة! تحذير: قد تفقد بعض البيانات! @@ -1211,7 +1165,6 @@ سيتم إرسال ملف تعريف الدردشة الخاص بك إلى أعضاء المجموعة مرحبًا! %1$s يريد الاتصال بك! - خوادم ICE الخاصة بك خصوصيتك حُدثت ملف تعريف المجموعة أنت: %1$s @@ -1233,7 +1186,6 @@ سيُطلب منك المصادقة عند بدء تشغيل التطبيق أو استئنافه بعد 30 ثانية في الخلفية. رسالة صوتية (%1$s) إعداداتك - تحديث وضع عزل النقل؟ يُخزن ملف التعريف وجهات الاتصال والرسائل التي سلُمت على جهازك. سيتم حذف قاعدة بيانات الدردشة الحالية واستبدالها بالقاعدة المستوردة. \nلا يمكن التراجع عن هذا الإجراء - سيتم فقد ملف التعريف وجهات الاتصال والرسائل والملفات الخاصة بك بشكل نهائي. @@ -1245,13 +1197,10 @@ ملف تعريفك الحالي عبر %1$s غير مقروءة - مرحبًا! في انتظار الصورة فيديو في انتظار الفيديو فيديو - يمكنك مشاركة عنوانك كرابط أو رمز QR - يمكن لأي شخص الاتصال بك. - يمكنك إنشاؤه لاحقًا أنت تحاول دعوة جهة اتصال شاركت ملف تعريف متخفي معها إلى المجموعة التي تستخدم فيها ملف تعريفك الرئيسي ألغِ الكتم ألغِ الكتم @@ -1259,7 +1208,6 @@ إلغاء إخفاء ملف تعريف يجب أن تكون جهة الاتصال متصلة بالإنترنت حتى يكتمل الاتصال. \nيمكنك إلغاء هذا الاتصال وإزالة جهة الاتصال (والمحاولة لاحقًا باستخدام رابط جديد). - فتح في تطبيق الجوّال.]]> استخدم للاتصالات الجديدة استخدم الخادم عنوان خادمك @@ -1281,7 +1229,6 @@ رسالة الترحيب عبر رابط عنوان الاتصال أُزيلت جهة اتصالك هذا الرابط، أو أنه كان رابطًا لمرة واحدة وقد استُخدِم بالفعل.\nللتواصل، اطلب من جهة اتصالك إنشاء رابط جديد. - سيتم إرسال ملف تعريف دردشتك\nإلى جهة اتصالك إلغاء الإخفاء ملفك التعريفي العشوائي ستستمر في استلام المكالمات والإشعارات من الملفات التعريفية المكتومة عندما تكون نشطة. @@ -1298,7 +1245,6 @@ تحتاج إلى السماح لجهة اتصالك بإرسال رسائل صوتية لتتمكن من إرسالها. أرسلت جهة اتصالك ملفًا أكبر من الحجم الأقصى المعتمد حاليًا (%1$s). الاتصال بمطوِّري SimpleX Chat لطرح أي أسئلة وتلقي التحديثات.]]> - خادمك يُخزن ملف تعريفك على جهازك ومشاركته فقط مع جهات اتصالك. لا تستطيع خوادم SimpleX رؤية ملف تعريفك. الفيديو مقفل الفيديو مُشغَّل @@ -1323,13 +1269,11 @@ مجموعات صغيرة (الحد الأقصى 20) تواصل مباشرةً؟ سيتم إرسال طلب الاتصال لعضو المجموعة هذا. - اتصال متخفي استخدم ملف التعريف الحالي عطّل الإشعارات افتح إعدادات التطبيق لا يمكن تشغيل SimpleX في الخلفية. ستستلم الإشعارات فقط عندما يكون التطبيق قيد التشغيل. سيتم مشاركة ملف تعريف عشوائي جديد. - ألصِق الرابط المُستلَم للتواصل مع جهة اتصالك… ستتم مشاركة ملفك التعريفي %1$s. قد يغلق التطبيق بعد دقيقة واحدة في الخلفية. اسمح @@ -1498,7 +1442,6 @@ خطأ في إظهار المحتوى خطأ في إظهار الرسالة انتهت المكالمة %1$s - يمكنك جعله مرئيًا لجهات اتصال SimpleX الخاصة بك عبر الإعدادات. لا يتم إرسال التاريخ إلى الأعضاء الجدد. حاول مجددًا الكاميرا غير متوفرة @@ -1845,10 +1788,6 @@ رسالة مُحوّلة لا يوجد اتصال مباشر حتى الآن، الرسالة مُحوّلة بواسطة المُدير. ألصِق رابط / امسح - خوادم SMP المهيأة - خوادم SMP أخرى - خوادم XFTP المهيأة - خوادم XFTP أخرى أظهِر النسبة المئوية مُعطَّل مستقرّ @@ -1894,7 +1833,6 @@ الملفات التي نُزّلت أخطاء التنزيل منتهية الصلاحيّة - افتح إعدادات الخادم أخرى موّكل مؤمن @@ -2178,7 +2116,6 @@ أضف أعضاء فريقك إلى المحادثات. يُمنع إرسال الرسائل المباشرة بين الأعضاء في هذه الدردشة. أجهزة Xiaomi: يُرجى تفعيل التشغيل التلقائي (Autostart) في إعدادات النظام لكي تعمل الإشعارات.]]> - مُعمَّاة بين الطرفين، مع أمان ما بعد الكم في الرسائل المباشرة.]]> تحقق من الرسائل كل 10 دقائق يُمنع إرسال الرسائل المباشرة بين الأعضاء. الدردشة 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 f5fc7f8278..900897fc8a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -12,8 +12,6 @@ Use incognito profile Your profile will be sent to the contact that you received this link from. You will connect to all group members. - Connect - Connect incognito Open chat Join channel %s Connect to %s @@ -128,12 +126,6 @@ Another reason - Error saving SMP servers - Error saving XFTP servers - Make sure SMP server addresses are in correct format, line separated and are not duplicated. - Make sure XFTP server addresses are in correct format, line separated and are not duplicated. - Error loading SMP servers - Error loading XFTP servers Error updating network configuration Failed to load chat Failed to load chats @@ -486,8 +478,6 @@ Welcome %1$s! - Welcome! - This text is available in settings Chats Settings connecting… @@ -497,7 +487,6 @@ Join as %s rejected connecting… - Tap to start a new chat Chat with the developers You have no chats Talk to someone @@ -741,7 +730,6 @@ Connected Disconnected Error - Pending Change receiving address? Receiving address will be changed to a different server. Address change will complete after sender comes online. Abort changing address? @@ -803,12 +791,8 @@ Start new chat - Create one-time invitation link - Connect via link / QR code Scan QR code Create secret group - (to share with your contact) - (scan or paste from clipboard) (only stored by group members) @@ -920,19 +904,12 @@ Show QR code - Invalid QR code - This QR code is not a link! Invalid link! - This link is not a valid connection link! Connection request sent! You will be connected to group when the group host\'s device is online, please wait or check later! You will be connected when your connection request is accepted, please wait or check later! You will be connected when your contact\'s device is online, please wait or check later! - show QR code in the video call, or share the link.]]> - Your chat profile will be sent\nto your contact - scan QR code in the video call, or your contact can share an invitation link.]]> Share 1-time link - Paste the link you received to connect with your contact… Learn more About SimpleX address @@ -946,7 +923,6 @@ Share address publicly Share SimpleX address on social media. - You can share your address as a link or QR code - anybody can connect to you. You won\'t lose your contacts if you later delete your address. Share 1-time link with a friend with one contact only - share in person or via any messenger.]]> @@ -954,7 +930,6 @@ Connection security SimpleX address and 1-time links are safe to share via any messenger. To protect against your link being replaced, you can compare contact security codes. - When people request to connect, you can accept or reject it. User Guide.]]> Address or 1-time link? @@ -963,7 +938,6 @@ Connect Paste This string is not a connection link! - Open in mobile app button.]]> New chat @@ -1044,11 +1018,7 @@ SimpleX Lock Chat console Message servers - SMP servers - Configured SMP servers - Other SMP servers Preset server address - Add preset servers Add server Test server Test servers @@ -1058,8 +1028,6 @@ Scan server QR code Enter server manually New server - Preset server - Your server Your server address Use server Use for new connections @@ -1070,9 +1038,6 @@ The servers for new connections of your current chat profile Save servers? Media & file servers - XFTP servers - Configured XFTP servers - Other XFTP servers Show percentage Install SimpleX Chat for terminal Reset all hints @@ -1080,8 +1045,6 @@ Contribute Rate the app Use SimpleX Chat servers? - Your SMP servers - Your XFTP servers Using SimpleX Chat servers. How to How to use your servers @@ -1131,7 +1094,6 @@ New SOCKS credentials will be used every time you start the app. New SOCKS credentials will be used for each server. for each contact and group member.\nPlease note: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail.]]> - Update transport isolation mode? Use .onion hosts to No if SOCKS proxy does not support them.]]> Please note: message and file relays are connected via SOCKS proxy. Calls use direct connection.]]> Private routing @@ -1207,7 +1169,6 @@ All your contacts will remain connected. Profile update will be sent to your contacts. Share link Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts. - Create an address to let people connect with you. Create SimpleX address Share with SimpleX contacts Share address with SimpleX contacts? @@ -1248,9 +1209,6 @@ Continue - Don\'t create address - You can create it later - You can make it visible to your SimpleX contacts via Settings. Invite @@ -1284,16 +1242,12 @@ Error saving user password - You control your chat! - The messaging and application platform protecting your privacy and security. - We don\'t store any of your contacts or messages (once delivered) on the servers. Create profile Your profile, contacts and delivered messages are stored on your device. The profile is only shared with your contacts. Display name cannot contain whitespace. Enter your name: Your bio: - Create Create profile Create Invalid name! @@ -1349,13 +1303,6 @@ Error initializing WebView. Make sure you have WebView installed and it\'s supported architecture is arm64.\nError: %s - The future of messaging - Privacy redefined - No user identifiers. - Immune to spam - You decide who can connect. - Decentralized - Anybody can host servers. Create your profile Make a private connection Migrate from another device @@ -1381,13 +1328,8 @@ The oldest human freedom — to speak to another person without being watched — built on infrastructure that cannot betray it. Because we destroyed the power to know who you are. So that your power can never be taken. Be free in your network. - To protect your privacy, SimpleX uses separate IDs for each of your contacts. - Only client devices store user profiles, contacts, groups, and messages. - end-to-end encrypted, with post-quantum security in direct messages.]]> - GitHub repository.]]> - Use chat Private notifications How it affects battery When app is running @@ -1457,7 +1399,6 @@ Accept Show Disable - Your ICE servers WebRTC ICE servers Relay server protects your IP address, but it can observe the duration of the call. Relay server is only used if necessary. Another party can observe your IP address. @@ -1616,7 +1557,6 @@ Shutdown Developer tools Experimental features - SOCKS proxy Interface LANGUAGE App icon @@ -2362,7 +2302,6 @@ SimpleX links Visible history Audio/video calls - \nAvailable in v5.1 enabled enabled for you enabled for contact @@ -2739,6 +2678,8 @@ Crowdfunding on Wefunder. Crowdfunding on Wefunder Learn more on Wefunder + Get a stake in SimpleX Chat! + Invest on Wefunder from $100 SimpleX public names (BETA) Public names for your channel or business. Better channels 📢 @@ -3030,7 +2971,6 @@ Downloaded files Download errors Server address - Open server settings You can mention up to %1$s members per message! @@ -3221,19 +3161,20 @@ 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. + Get a badge to send larger files (2-5GB) that stay available longer (7-21 days), and to show it on your profile. Why SimpleX is built. Choose your level Redeem badge code Continue - How 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. + How badges protect your privacy + How badges protect your privacy + A badge is an anonymous credential stored in your profile on your device. This credential is never sent to anyone. + To prove to a contact or a server that you have a badge, the app generates a new proof that reveals only the badge type and the expiry date, rounded to a week. + Nobody can link two different proofs to each other or to the purchase. + our blog.]]> My nickname Support SimpleX - Get badge + files up to 5GB + Get badge + better files You can support SimpleX later in Settings. Purchase successful Purchase pending @@ -3241,32 +3182,64 @@ Purchase error Get your code Redeem code - Paste the code from your receipt. + Paste the code you received. SB-XXXXX-XXXXX-XXXXX-XXXXX Redeem Cannot redeem code This code is not valid. This app version cannot redeem badge codes. This profile already has a badge. Redeem the code on another profile, or once this badge ends. - This code was not recognised. + This code was not recognized. 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 badge service does not recognize this badge. + The badge service reported an internal error. 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 + Your badge expired shown on your profile Ends Prepaid months have no billing date. The badge is reissued each month from the balance you already paid for, and ends when it runs out. Investor - Your support ended on %1$s. + Your badge expired on %1$s. Remind me later Dismiss + Credential + Status + Expires + Months left + Purchase ID + Copy purchase key + Badge ledger + No entries + Date + Balance + Balance start + Anchor + Badge type + Paused since + Entry ID + Invoice ID + Charge ID + From purchase key + To purchase key + Badge renewal failed + Tap for details + The badge service refused the renewal: %1$s + The badge service did not respond. + The badge service could not be reached. + The badge issued by the service cannot be verified. + Unexpected error: %1$s + Since + Last attempt + Contact SimpleX team + Next check Supporter perks Supporter badge ❤️ Help keep the network running — send files up to 2 GB. 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 75de2d72f1..5bf3ab7754 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml @@ -33,7 +33,6 @@ Приеми Приеми инкогнито За SimpleX Chat - Добави предварително зададени сървъри Добави към друго устройство Разширени мрежови настройки За SimpleX @@ -48,7 +47,6 @@ Приеми Допълнителен акцент Админите могат да създадат линкове за присъединяване към групи. - покажете QR кода във видеоразговора или споделете линка.]]> Отворете SimpleX Chat, за да приемете повикването Поддръжка на bluetooth и други подобрения. Relay сървърът защитава вашия IP адрес, но може да наблюдава продължителността на разговора. @@ -103,7 +101,6 @@ Лош хеш на съобщението Деактивиране повикване… - да сканирате QR код във видеоразговора или вашият контакт може да сподели линк за покана.]]> Услугата във фонов режим винаги работи – известията ще се показват веднага щом съобщенията са налични. Може да бъде деактивирано през настройките – известията ще продължат да се показват, докато приложението работи.]]> SimpleX Chat разговори @@ -189,8 +186,6 @@ Позволи на вашите контакти да изпращат изчезващи съобщения. винаги Аудио/видео разговори - " -\nДостъпно в v5.1" Фон Позволи реакции на съобщения. Позволи изпращането на лични съобщения до членовете. @@ -248,7 +243,6 @@ Задайте го вместо системната идентификация. Код за достъп за самоунищожение Още няколко неща - Свързване свързан свързване връзка %1$d @@ -385,12 +379,9 @@ Грешка при декодиране Изтрий контакт Изтрий контакт\? - Свърване чрез линк/QR код Копирано в клипборда - Създай линк за еднократна покана Създай тайна група Персонализирано време - (сканирай или постави от клипборда) Контактът все още не е свързан! Изтрий Изтрий @@ -400,18 +391,15 @@ Продължи Версия на ядрото: v%s Създай адрес - Създайте адрес, за да позволите на хората да се свързват с вас. Създай SimpleX адрес Персонализирай темата Идентификатори в базата данни и опция за изолация на транспорта. Изтрий адрес Изтрий адрес\? Цветове на интерфейса - Създай Създай профил Изтрий изображение Създай профил - Децентрализиран контактът има e2e криптиране контактът няма e2e криптиране Изтрий всички файлове @@ -438,9 +426,7 @@ Сканирай QR кода на сървъра Въведи сървъра ръчно Изтрий сървър - Не създавай адрес Име на профила: - Само потребителските устройства съхраняват потребителски профили, контакти, групи и съобщения. Деактивирай потвърждениeто\? Активиране (запазване на промените) Активирай потвърждениeто\? @@ -594,7 +580,6 @@ Грешка при изтриване на група Грешка при изтриване на чакащата контактна връзка Грешка при запазване на ICE сървърите - Чакаща връзка със сървъра Вашият контакт трябва да бъде онлайн, за да осъществите връзката. \nМожете да откажете тази връзка и да премахнете контакта (и да опитате по -късно с нов линк). Грешка при експортиране на базата данни @@ -603,9 +588,6 @@ Грешка при импортиране на базата данни Грешка при отстраняване на член Грешка при запазване на профила на групата - Грешка при зареждане на SMP сървъри - Грешка при зареждане на XFTP сървъри - Грешка при запазване на SMP сървърите Грешка при присъединяване към група Грешка при зареждане на подробности Грешка при получаване на файл @@ -613,7 +595,6 @@ Грешка при запазване на потребителска парола Грешка при стартиране на чата Грешка при спиране на чата - Грешка при запазване на XFTP сървърите Грешка при изпращане на съобщение Грешка при задаване на адрес Грешка при смяна на профил! @@ -694,7 +675,6 @@ Само вашият контакт може да изпраща изчезващи съобщения. Забрани изпращането на изчезващи съобщения. Членовете могат да изпращат изчезващи съобщения. - Невалиден QR код Невалиден линк! Неправилен код за сигурност! Невалиден адрес на сървъра! @@ -764,7 +744,6 @@ Информация Инсталирай SimpleX Chat за терминал Как работи - Защитен от спам Игнорирай Покани членове Необратимото изтриване на съобщения е забранено в този чат. @@ -801,7 +780,6 @@ Няма се използват Onion хостове. Нека да поговорим в SimpleX Chat Парола за показване - GitHub хранилище.]]> Когато приложението работи Периодично Постави получения линк @@ -842,7 +820,6 @@ Записът е актуализиран на Научете повече Постави - Предварително зададен сървър Предварително зададен адрес на сървъра Поверителени известия Изключено @@ -875,7 +852,6 @@ (съхранено само на устройствата на членовете на групата) Паролата не е намерена в KeyStore, моля, въведете я ръчно. Това може да се е случило, ако възстановите данните на приложението с помощта на инструмент за резервни копия. Ако не е така, моля, свържете се с разработчиците. Моля, свържете се с груповия администартор. - Когато хората искат да се свържат с вас, можете да ги приемете или отхвърлите. Актуализацията на профила ще бъде изпратена до вашите контакти. Забрани необратимото изтриване на съобщения. Забрани изпращането на лични съобщения до членовете. @@ -937,8 +913,6 @@ х Отваряне на база данни… НА ЖИВО - Уверете се, че адресите на SMP сървъра са в правилен формат, разделени на редове и не са дублирани. - Уверете се, че адресите на XFTP сървъра са в правилен формат, разделени на редове и не са дублирани. маркирано като изтрито модерирано модерирано от %s @@ -977,9 +951,6 @@ Разрешение е отказано! профилно изображение запазено място за профилно изображение - Всеки може да оперира сървъри. - Вие решавате кой може да се свърже с вас. - Поверителността преосмислена Добави поверителна връзка Отвори Реле сървър се използва само ако е необходимо. Друга страна може да наблюдава вашия IP адрес. @@ -1036,7 +1007,6 @@ SimpleX Адрес SimpleX Лого SimpleX Екип - SMP сървъри Сподели адреса с контактите? Сподели линк Сподели с контактите @@ -1093,7 +1063,6 @@ Сподели медия… SimpleX адрес Сигурността на SimpleX Chat беше одитирана от Trail of Bits. - SOCKS прокси Рестартиране Изключване Рестартирайте приложението, за да създадете нов чат профил. @@ -1141,7 +1110,6 @@ За свързване чрез линк Връзката, която приехте, ще бъде отказана! Контактът, с когото споделихте този линк, НЯМА да може да се свърже! - Този линк не е валиден линк за връзка! Вашите чат профили Сървърите за нови връзки на текущия ви чат профил За да покажете скрития профил, въведете пълната парола в полето за търсене на страницата "Вашите чат профили". @@ -1150,18 +1118,13 @@ Докосни за активиране на профил. Системен Заглавие - (за споделяне с вашия контакт) Тази група вече не съществува. Подкрепете SimpleX Chat Вашият контакт изпрати файл, който е по-голям от поддържания в момента максимален размер (%1$s). Тази функция все още не се поддържа. Опитайте следващата версия. - Докосни за започване на нов чат - Този текст е достъпен в настройките Твърде много изображения! - Този QR код не е линк! Тествай сървър Транспортна изолация - Платформата за съобщения и приложения, защитаваща вашата поверителност и сигурност. Смени Благодарение на потребителите – допринесете през Weblate! Видео и файлове до 1gb @@ -1179,7 +1142,6 @@ Този текст не е линк за връзка! Твърде много видеоклипове! Тази настройка се прилага за съобщения в текущия ви профил - За да се защити поверителността, SimpleX използва идентификатори за опашки от съобщения, отделни за всеки от вашите контакти. Опит за свързване със сървъра, използван за получаване на съобщения от този контакт. Опит за свързване със сървъра, използван за получаване на съобщения от този контакт (грешка: %1$s). Тестът е неуспешен на стъпка %s. @@ -1188,8 +1150,6 @@ Докосни бутона Благодарим Ви, че инсталирахте SimpleX Chat! Запази и уведоми контактите - Бъдещето на комуникацията - Няма потребителски идентификатори. Системна Неправилно ID на следващото съобщение (по-малко или еднакво с предишното). \nТова може да се случи поради някаква грешка или когато връзката е компрометирана. @@ -1203,11 +1163,9 @@ Настройки SimpleX адрес Използвай за нови връзки - Вашите XFTP сървъри Използвай сървърите на SimpleX Chat\? Използват се сървърите на SimpleX Chat. Вашите ICE сървъри - Вашите SMP сървъри Използвай SOCKS прокси Използвай SOCKS прокси\? Използване на директна интернет връзка\? @@ -1215,7 +1173,6 @@ Когато са налични Вашият профил, контакти и доставени съобщения се съхраняват на вашето устройство. Можете да използвате markdown за форматиране на съобщенията: - Използвай чата Актуализация Трябва да въвеждате парола при всяко стартиране на приложението - тя не се съхранява на устройството. Неизвестна грешка в базата данни: %s @@ -1246,17 +1203,12 @@ Гласово съобщение… Гласовите съобщения са забранени. непрочетено - Добре дошли! Добре дошли %1$s! Нямате чатове вие сте наблюдател - Вашият сървър Използвай сървър Вашият адрес на сървъра - Можете да го създадете по-късно Вашите контакти ще останат свързани. - Ние не съхраняваме вашите контакти или съобщения (веднъж доставени) на сървърите. - Вие контролирате своя чат! Грешна парола! Гласови съобщения Настройки @@ -1269,11 +1221,8 @@ премахнахте %1$s Вашият автоматично генериран профил Уведомявай - Можете да споделите адреса си като линк или QR код - всеки може да се свърже с вас. променихте адреса получаване за %s Вашият чат профил ще бъде изпратен на членовете на групата - Вашият чат профил ще бъде изпратен -\nдо вашия контакт Вашата текуща база данни ще бъде ИЗТРИТА и ЗАМЕНЕНА с импортираната. \nТова действие не може да бъде отменено - вашият профил, контакти, съобщения и файлове ще бъдат безвъзвратно загубени. Актуализирането на настройките ще свърже отново клиента към всички сървъри. актуализиран профил на групата @@ -1309,15 +1258,11 @@ Видео се свържете с разработчиците на SimpleX Chat, за да задавате въпроси и да получавате актуализации;.]]> иска да се свърже с вас! - Отваряне в мобилно приложение.]]> - XFTP сървъри - Актуализиране на режима на изолация на транспорта\? Вашият текущ профил Вашият профил се съхранява на вашето устройство и се споделя само с вашите контакти. SimpleX сървърите не могат да видят вашия профил. Видеото е изключено Видеото е включено WebRTC ICE сървъри - Вашите ICE сървъри Вашата поверителност Трябва да използвате най-новата версия на вашата чат база данни САМО на едно устройство, в противен случай може да спрете да получавате съобщения от някои контакти. вие: %1$s @@ -1327,7 +1272,6 @@ - гласови съобщения до 5 минути. \n- персонализирано време за изчезване. \n- история на редактиране. - Свързване инкогнито Отвори настройките на приложението Позволи Нов автоматично генериран профил ще бъде споделен. @@ -1335,7 +1279,6 @@ Без фонови разговори Свързване директно\? Използвай текущия профил - Поставете линка, който сте получили, за да се свържете с вашия контакт… Вашият профил %1$s ще бъде споделен. SimpleX не може да работи във фонов режим. Ще получавате известията само когато приложението работи. Приложението може да се затвори след 1 минута във фонов режим. @@ -1506,7 +1449,6 @@ Потвърди връзка Няма свързано мобилно устройство блокиран - Можете да го направите видим за вашите контакти в SimpleX чрез Настройки. Историята не се изпраща на нови членове. Опитай отново Камерата е неодстъпна @@ -1856,14 +1798,12 @@ Свържете отново сървъра, за да принудите доставката на съобщенията. Това използва допълнителен трафик. Грешка при нулиране на статистиката Сканирай / Постави линк - Конфигурирани XFTP сървъри Размер на шрифта Статус на съобщението: %s Задай тема по подразбиране Потвърди файлове от неизвестни сървъри. Изпратени съобщения Грешка при стартиране на WebView. Актуализирайте системата си до новата версия. Моля, свържете се с разработчиците.\nГрешка: %s - Други XFTP сървъри Мащабиране Всички цветови режими Светъл режим @@ -1937,8 +1877,6 @@ Получени грешки Моля, проверете дали мобилното и настолното устройство са свързани към една и съща локална мрежа и дали защитната стена на настолното устройство позволява връзката.\nМоля, споделете всички други проблеми с разработчиците. Временна файлова грешка - Конфигурирани SMP сървъри - Други SMP сървъри Подобрена доставка на съобщения опити изтекли @@ -2069,7 +2007,6 @@ Контактът е изтрит! Архивирани контакти Достъпни панели - Отвори настройките на сървъра Продължи Отвори местоположението на файла Инсталиране на актуализация @@ -2164,7 +2101,6 @@ Xiaomi устройства : моля, активирайте Autostart в системните настройки, за да работят известията.]]> Няма съобщение Или сподели лично - криптирани от край до край, с постквантова сигурност в директните съобщения.]]> Без фонова услуга Проверявай за съобщения на всеки 10 минути Приложението винаги работи във фонов режим diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/bn/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/bn/strings.xml index dfcfb1685c..552df8e66e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/bn/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/bn/strings.xml @@ -21,7 +21,6 @@ গ্রহণ করুন সংযোগের অনুরোধটি গ্রহণ করবেন কি\? কলটি গৃহীত হয়েছে - পূর্বনির্ধারিত সার্ভারগুলি যুক্ত করুন অ্যাডমিন আপনি একজন অ্যাডমিন স্বাগত বার্তা যুক্ত করুন diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml index d5b8fb5fa0..9763bbaf88 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml @@ -69,8 +69,6 @@ cancel·la la vista prèvia de l\'enllaç Es compartirà un nou perfil aleatori. només amb un contacte: compartiu-lo en persona o mitjançant qualsevol missatger.]]> - escanejar el codi QR a la videotrucada o el vostre contacte pot compartir un enllaç d\'invitació.]]> - mostra el codi QR a la videotrucada o comparteix l\'enllaç.]]> Contactes arxivats Guia de l\'usuari.]]> Configuració de xarxa avançada @@ -98,12 +96,9 @@ trucada en curs Càmera Càmera i micròfon - Qualsevol pot allotjar servidors. Bluetooth - xifrats d\'extrem a extrem, amb seguretat postquàntica als missatges directes.]]> El millor per a la bateria. Només rebràs notificacions quan l\'aplicació s\'està executant (Sense servei en segon pla).]]> Bo per a la bateria. L\'aplicació revisa els missatges cada 10 minuts. Podeu perdre trucades o missatges urgents.]]> - repositori GitHub.]]> %1$s vol connectar amb tu mitjançant Feu servir sempre el retransmisor App always runs in background @@ -171,7 +166,6 @@ Barres d\'eines d\'aplicació Desenfocar Trucades d\'àudio/vídeo - \nDisponible en v5.1 Permet trucades només si el vostre contacte ho permet. Permet la desaparició de missatges només si el vostre contacte ho permet. "Permet la supressió irreversible del missatge només si el teu contacte t\'ho permet. (24 hores)" @@ -237,7 +231,6 @@ s\'està connectant blocat k - Connecta error vostè EN DIRECTE @@ -291,7 +284,6 @@ Enllaç d\'un sol ús Afegiu un contacte Adreça o enllaç d\'un sol ús? - Afegiu servidors predefinits Afegeix servidor Quant a SimpleX trucada acceptada @@ -374,7 +366,6 @@ Esborrar adreça Suprimeix la imatge El nom mostrat no pot contenir espais en blanc. - Descentralitzada Desactivar Desactivar per a tothom Desactiva per a tots els grups @@ -485,7 +476,6 @@ Suprimides Errors d\'eliminació Mòbil connectat - Connectar en mode incògnit connexió %1$d Comprova si hi ha missatges cada 10 minuts durant un minut com a màxim Canvia el codi d\'accés @@ -501,7 +491,6 @@ Connecta Consola del xat Comproveu l\'adreça del servidor de i torneu a provar. - Servidors SMP configurats Configura els servidors ICE Perfil del xat Connexió @@ -547,7 +536,6 @@ Error en desar el fitxer Canviar l\'adreça de recepció? connectar amb els desenvolupadors de SimpleX Chat per fer qualsevol pregunta i rebre actualitzacions.]]> - Obre a l\'aplicació mòbil.]]> Error en desar els servidors ICE Error en desar el servidor intermediari Base de dades dels xats @@ -605,8 +593,6 @@ Introduïu la contrasenya error en mostrar el contingut error en mostrar el missatge - Error en desar els servidors SMP - Error en desar els servidors XFTP Error en enviar el missatge Errors en la configuració dels servidors. Error en canviar el perfil! @@ -658,8 +644,6 @@ Error en crear el missatge Error en crear el perfil! Error en reenviar els missatges - Error en carregar els servidors SMP - Error en carregar els servidors XFTP Error en afegir membre(s) Error en crear l\'adreça Error en unir-se al grup @@ -700,8 +684,6 @@ Crea un grup tot fent servir un perfil aleatori. Perfil actual Crea un enllaç d\'un sol ús - No crear cap adreça - Crea Crea perfil Cantonada Frase de pas actual… @@ -719,11 +701,9 @@ Crea un fitxer Codi d\'accés actual Ara per ara, la mida màxima per als fitxers és %1$s. - Crea un enllaç d\'invitació d\'un sol ús Crea un grupo secreto Temps personalitzat Crea un perfil de xat - Creeu una adreça perquè la gent pugui connectar amb vós. Crea una adreça SimpleX Crea perfil creador @@ -744,7 +724,6 @@ Missatges de xat de SimpleX enviat per llegir - Benvinguts! Envia Envia Restableix @@ -824,7 +803,6 @@ Conserva la conversa Marcar com ha llegit Enllaç no vàlid - El codi QR no és vàlid Més informació El codi QR no és vàlid Codi de seguretat incorrecte! @@ -911,11 +889,9 @@ Connectar amb %1$s? Descarregar La connexió requereix una renegociació del xifratge. - Connectar mitjançant enllaç/codi QR Habilita l\'accés a la càmera Seguretat de connexió Creant enllaç… - Servidors XFTP configurats No utilitzeu credencials amb servidor intermediari. Versió del nucli: v%s Personalitzar tema @@ -1157,7 +1133,6 @@ Canvia l\'aspecte dels teus xats! Màxim 40 segons, rebut a l\'instant. Només vos podreu suprimir els missatges de manera irreversible (el vostre contacte pot marcar-los per suprimir-los). (24 hores) - Obrir configuració del servidor altres errors - Notificació opcional als contactes suprimits.\n- Noms de perfil amb espais.\n- I més! Pendents @@ -1184,8 +1159,6 @@ Desar la configuració d\'adreça SimpleX Deixar de compartir O per compartir en privat - Podeu crear-la més tard - Podeu fer-lo visible per als vostres contactes de SimpleX mitjançant Configuració. Nom del perfil: Desa i notifica el contacte El teu perfil actual @@ -1196,7 +1169,6 @@ Contrasenya per mostrar Desa la contrasenya del perfil Per revelar el vostre perfil ocult introduïu una contrasenya completa al camp de cerca de la pàgina Els vostres perfils de xat. - Tu controles el teu xat! Pots utilitzar la sintaxi markdown per donar format als teus missatges: Com utilitzar la sintaxis markdown trucada rebutjada @@ -1206,13 +1178,6 @@ esperant confirmació… confirmació rebuda… Un navegador web predeterminat és necessari per a les trucades. Configura\'n un al sistema i comparteix més informació amb els desenvolupadors. - Privadesa redefinida - Tu decideixes qui es pot connectar. - Immune al correu brossa - Sense identificadors d\'usuari. - Per protegir la vostra privadesa SimpleX utilitza identificadors separats per a cadascun dels vostres contactes.. - Obrir SimpleX - Només els dispositius client emmagatzemen perfils d\'usuari, contactes, grups i missatges. Notificacions privades Com afecta la bateria Periòdic @@ -1262,7 +1227,6 @@ Enviar els rebus de lliurament a L\'enviament de rebuts està desactivat per a %d grups Reiniciar - Servidor intermediari SOCKS Imatges de perfil Temes Cua @@ -1579,8 +1543,6 @@ mitjançant enllaç d\'un sol ús has compartit un enllaç d\'un sol ús Nom mostrat no vàlid! - Assegureu-vos que les adreces del servidor SMP estiguin en el format correcte, que estiguin separades per línies i que no estiguin duplicades. - Assegureu-vos que les adreces del servidor XFTP estiguin en el format correcte, que estiguin separades per línies i que no estiguin duplicades. No hi ha servidors multimèdia ni de fitxers. No hi ha servidors de missatges. No hi ha servidors per a l\'encaminament de missatges privats. @@ -1681,11 +1643,9 @@ Encara no hi ha connexió directa, el missatge el reenvia l\'administrador. Revocar fitxer enviament no autoritzat - Aquest text està disponible a la configuració Benvingut %1$s! Entrar com a %s enviar per connectar - Toca per iniciar un xat nou Carregant xats… No hi ha xats filtrats Cerqueu o enganxeu l\'enllaç SimpleX @@ -1745,7 +1705,6 @@ Missatge de veu (%1$s ) Esperant el fitxer Només suprimeix la conversa - Pendente L\'adreça de recepció es canviarà per un servidor diferent. El canvi d\'adreça es completarà quan el remitent estigui en línia. Estableix el nom del contacte… Pots enviar missatges a %1$s des dels contactes arxivats. @@ -1769,7 +1728,6 @@ Envia un missatge que desapareix Envia missatge en directe Toqueu per escanejar - (per compartir amb el teu contacte) Missatges de veu prohibits! Gràcies per instal·lar SimpleX Xat! Per iniciar un xat nou @@ -1791,22 +1749,16 @@ Has convidat un contacte El vostre contacte ha d\'estar en línia perquè la connexió es completi.\nPots cancel·lar aquesta connexió i eliminar el contacte (i provar-ho més tard amb un enllaç nou). Mostrar codi QR - Aquest no és un enllaç de connexió vàlid! - Aquest codi QR no és un enllaç! Et connectaràs al grup quan el dispositiu de l\'amfitrió estigui en línia. Espereu o comproveu més tard! Et connectaràs quan s\'accepti la teva sol·licitud de connexió, si us plau, espera o consulta més tard! Si no pots trobar-te en persona, mostra el codi QR en una videotrucada o comparteix l\'enllaç. - Enganxeu l\'enllaç que heu rebut per connectar amb el vostre contacte… Compartir enllaç d\'un sol ús Compartir enllaç d\'un sol ús amb un amic Compartir adreça públicament Comparteix l\'adreça SimpleX a les xarxes socials. Per connectar-se, el vostre contacte pot escanejar el codi QR o utilitzar l\'enllaç de l\'aplicació. Per protegir-vos de la substitució del vostre enllaç, podeu comparar els codis de seguretat de contacte. - Quan algú sol·liciti la connexió, pots acceptar-la o rebutjar-la. Podeu definir el nom de la connexió per recordar amb qui s\'ha compartit l\'enllaç. - Pots compartir la teva adreça com a enllaç o codi QR; qualsevol es pot connectar amb tu. - S\'enviarà el teu perfil de xat\nal teu contacte El teu perfil %1$s es compartirà. Et connectaràs quan el dispositiu del teu contacte estigui en línia, si us plau, espera o consulta més tard! Si més tard decideixes eliminar la teva adreça els contactes no es perdran. @@ -1835,9 +1787,6 @@ Servidors de fitxers i mitjans Servidors de missatges Nou servidor - Altres servidors SMP - Altres servidors XFTP - Servidor preestablert Adreça predeterminada del servidor Preguntes i idees Contacta via email @@ -1852,7 +1801,6 @@ Utilitzar per a noves connexions Utilitzar servidor Perfils de xat - El teu servidor L\'adreça del teu servidor Configuració La teva adreça SimpleX @@ -1869,8 +1817,6 @@ Utilitzar servidors SimpleX Xat? Usant servidors SimpleX Xat. Servidors ICE - Servidors SMP - Servidors XFTP Xarxa i servidors Autenticació d\'intermediari Servidor intermediari SOCKS @@ -1887,7 +1833,6 @@ Encaminament privat Servidor Aïllament de transport - Actualitzar el mode d\'aïllament de transport? Utilitza credencials de servidors intermediari diferents per a cada connexió. Utilitza credencials de servidor intermediari diferents per a cada perfil. Utilitzar connexió a Internet directa? @@ -1916,12 +1861,9 @@ Mostrar trucades lentes d\'API Per rebre notificacions sobre les noves versions activeu la comprovació periòdica de les versions Estable o Beta. Compartir amb contactes - La plataforma de missatgeria i aplicacions que protegeix la vostra privadesa i seguretat. El perfil només es comparteix amb els teus contactes. - No emmagatzemem cap dels vostres contactes o missatges (un cop lliurats) als servidors. El vostre perfil, contactes i missatges lliurats s\'emmagatzemen al vostre dispositiu. Obrir configuració - El futur de la missatgeria Operadors de xarxa Notificacions i bateria La contrasenya aleatòria s\'emmagatzema a la configuració com a text pla.\nPodeu canviar-ho més tard. @@ -1931,7 +1873,6 @@ Obrir SimpleX Chat per acceptar la trucada El servidor de retransmissió protegeix la vostra adreça IP, però pot veure la durada de la trucada. Servidors WebRTC ICE - Servidors ICE Privacitat i seguretat Protegeix la pantalla de l\'aplicació Protegir l\'adreça IP @@ -2160,7 +2101,6 @@ Funció lenta Estat convidats(des) a un grup cerca - (escaneja o enganxa del porta-retalls) Escaneja un codi QR Comença una conversa nova Logo de SimpleX @@ -2171,8 +2111,6 @@ Codi de seguretat Voleu desar els servidors? Escaneja el codi QR del servidor - Servidors SMP - Servidors XFTP Altaveu Vós %s segon(s) 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 f8a6dd83dc..1d404ef94b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml @@ -5,7 +5,6 @@ Hlasové zprávy povoleny. Správci mohou vytvářet odkazy pro připojení ke skupinám. Přijmout - Přidat přednastavené servery Pokročilá nastavení sítě Přijmout Přidat server @@ -115,7 +114,6 @@ Úplný odkaz Prostřednictvím prohlížeče Otevření odkazu v prohlížeči může snížit soukromí a bezpečnost připojení. Nedůvěryhodné odkazy SimpleX budou červené. - Chyba ukládání serverů SMP Chyba změny konfigurace sítě Nepodařilo se načíst chat Nepodařilo se načíst chaty @@ -145,8 +143,6 @@ Kopírovat nepřečteno Vítejte %1$s! - Vítejte! - Tento text je k dispozici v nastavení odeslání se nezdařilo Sdílet soubor… Připojit @@ -179,7 +175,6 @@ Zkopírováno Nová konverzace Vytvořit tajnou skupinu - (sdílet s kontaktem) (uloženo pouze členy skupiny) Oprávnění zamítnuto! Fotoaparát @@ -210,7 +205,6 @@ Bezpečnostní kód %s ověřen Chat konzole - SMP servery Přednastavená adresa serveru Test serveru se nezdařil! Některé servery neprošly testem: @@ -234,15 +228,12 @@ Vytvořit adresu Uložit a upozornit členy skupiny Ukončit bez uložení - Platforma pro zasílání zpráv a aplikace chránící vaše soukromí a bezpečnost. Vytvořit profil Profil je sdílen pouze s vašimi kontakty. Zobrazované jméno nesmí obsahovat bílé znaky. tučně probíhající hovor - Decentralizovaná Jak to funguje - Pouze klientská zařízení ukládají uživatelské profily, kontakty, skupiny a zprávy. Soukromé oznámení Pravidelné Ignorovat @@ -264,7 +255,6 @@ Zařízení Konverzace Experimentální funkce - SOCKS proxy Ikona aplikace Témata Zprávy a soubory @@ -335,15 +325,12 @@ Instalovat SimpleX Chat terminál Hvězdu na GitHubu Ohodnoťte aplikaci - Vaše servery SMP Pokud potvrdíte, budou servery zasílající zprávy vidět vaši IP adresu a váš poskytovatel - ke kterým serverům se připojujete. barevné tajné volání… připojen ukončen - Budoucnost soukromé komunikace - Rozhodněte, kdo se může připojit. špatný kontrolní součet zprávy Databáze chatu importována Nová přístupová fráze… @@ -361,7 +348,6 @@ Smazat čekající připojení\? Nastavení QR kód - videohovoru QR kód nebo sdílejte odkaz.]]> Skenovat kód Nesprávný bezpečnostní kód! Naskenujte bezpečnostní kód z aplikace vašeho kontaktu. @@ -377,7 +363,6 @@ Servery ICE (jeden na řádek) Pro připojení budou vyžadováni Onion hostitelé. \nVezměte prosím na vědomí: nebudete mít možnost připojení k serverům bez adresy .onion. - Aktualizovat režim dopravní izolace\? Sestavení aplikace: %s Sdílet odkaz Smazat adresu @@ -423,13 +408,10 @@ Hlasové zprávy jsou zakázány! Prosím, požádejte kontaktní osobu, aby umožnila odesílání hlasových zpráv. Poslat živou zprávu - zpráva se bude aktualizovat pro příjemce během psaní. - Vytvořit jednorázovou pozvánku Skenovat QR kód - ( skenovat nebo vložit ze schránky) Upravit obrázek Smazat obrázek chyba volání - Servery může provozovat kdokoli. Vytvořte si svůj profil Vytvořte si soukromé připojení Videohovor šifrovaný e2e @@ -490,7 +472,6 @@ Izolace přenosu Podle profilu chatu (výchozí) nebo podle připojení (BETA). Připojtíte se ke všem členům skupiny. - Připojení Jste připojeni k serveru, který se používá k přijímání zpráv od tohoto kontaktu. Pokoušíte se připojit k serveru používaném pro příjem zpráv od tohoto kontaktu (chyba: %1$s). označeno jako smazáno @@ -501,7 +482,6 @@ neplatný formát zprávy ŽIVĚ inkognito přes skupinový odkaz - Ujistěte se, že adresy serverů SMP jsou ve správném formátu, oddělené na řádcích a nejsou duplicitní. Chyba vytváření profilu! Duplicitní zobrazované jméno! Chyba přepínání profilu! @@ -549,7 +529,6 @@ Jste pozváni do skupiny Připojit jako %s připojuji… - Začněte nový chat Chat s vývojáři Nemáte žádné konverzace Zrušit náhled obrázku @@ -560,13 +539,11 @@ Čekání na soubor Oznámení Smazat kontakt\? - Čeká na vyřízení Ověřit bezpečnostní kód Odeslat zprávu Pouze majitelé skupin mohou povolit zasílání hlasových zpráv. Odeslat živou zprávu Živá zpráva! - Připojit se prostřednictvím odkazu / QR kódu Děkujeme za instalaci SimpleX Chat! připojit k SimpleX Chat vývojářům, položit jim případné dotazy a získat aktualizace.]]> Připojení prostřednictvím odkazu @@ -592,24 +569,17 @@ E-mail Více Zobrazit QR kód - Neplatný QR kód - Tento QR kód není odkaz! Neplatný odkaz! - Tento odkaz není platným odkazem pro připojení! - skenovat QR kód ve videohovoru nebo může váš kontakt sdílet pozvánku.]]> Připojte se prostřednictvím odkazu Připojit Vložit Tento řetězec není odkazem na připojení! - Otevřít v mobilní aplikaci.]]> %s neověřen Návod k použití Nápověda k markdown Uložit servery Markdown ve zprávách Testovací servery - Přednastavený server - Váš server Adresa vašeho serveru Použít server Použít pro nová připojení @@ -640,11 +610,8 @@ Uložit předvolby\? Uložit a upozornit kontakt Uložit a upozornit kontakty - Kontrolujete konverzaci! - Na serverech neukládáme žádné vaše kontakty ani zprávy (po doručení). Váš profil, kontakty a doručené zprávy jsou uloženy ve vašem zařízení. Zadejte vaše jméno: - Vytvořit Jak používat markdown K formátování zpráv můžete použít markdown: kurzíva @@ -659,12 +626,6 @@ obdržel odpověď… obdržel potvrzení… připojování… - Nové vymezení soukromí - Bez uživatelských identifikátorů - Odolná vůči spamu - K ochraně soukromí, SimpleX používá ID pro každý z vašich kontaktů. - úložišti GitHub.]]> - Použijte chat Jak ovlivňuje baterii Když aplikace běží Okamžité @@ -682,7 +643,6 @@ Spojení přes relé Zobrazit Zakázat - Vaše servery ICE WebRTC servery ICE Přenosový server chrání vaši IP adresu, ale může sledovat dobu trvání hovoru. Přenosový server se používá pouze v případě potřeby. Jiná strana může sledovat vaši IP adresu. @@ -921,7 +881,6 @@ Italské rozhraní Díky uživatelům - překládejte prostřednictvím Weblate! Budete připojeni, jakmile bude zařízení vašeho kontaktu online, vyčkejte prosím nebo se podívejte později! - Váš profil chatu bude odeslán \nvašemu kontaktu Konverzace Sdílet jednorázovou pozvánku koncově šifrované @@ -1014,17 +973,12 @@ Video bude přijato, až kontakt dokončí jeho nahrávání. Video obdržíte, až bude váš kontakt online, vyčkejte prosím nebo zkontrolujte později! Čekám na video - Chyba načítání serverů SMP - Chyba načítání serverů XFTP - Chyba ukládání XFTP serverů - Ujistěte se, že adresy XFTP serverů jsou ve správném formátu s oddělenými řádky a nejsou duplicitní. Server vyžaduje autorizaci pro nahrávání, zkontrolujte heslo. Porovnat soubor Vytvořit soubor Smazat soubor Stáhnout soubor Nahrát soubor - XFTP servery Použít SOCKS proxy Host port %d @@ -1047,7 +1001,6 @@ Zámek SimpleX můžete zapnout v Nastavení. Port Použít .onion hostitele na Ne, pokud je SOCKS proxy nepodporuje.]]> - Vaše XFTP servery Povolit zámek Zamknout po Režim zámku @@ -1077,8 +1030,6 @@ Povolte hovory, pouze pokud je váš kontakt povolí. Povolte svým kontaktům vám volat. Audio/video hovory - " -\nDostupné ve verzi 5.1" Volat můžete vy i váš kontakt. Volat můžete pouze vy. Zákaz audio/video hovorů. @@ -1165,7 +1116,6 @@ Otevírání databáze… Chyba nastavení adresy Pro připojení může váš kontakt naskenovat QR kód, nebo použít odkaz v aplikaci. - Když někdo požádá o připojení, můžete žádost přijmout nebo odmítnout. Uživatelské příručce.]]> Adresa SimpleX Barvy motivu @@ -1173,12 +1123,9 @@ Aktualizace profilu bude zaslána vašim kontaktům. Sdílet adresu s kontakty? Přestat sdílet adresu\? - Vytvořit adresu, aby se s vámi lidé mohli spojit. Uložit nastavení SimpleX adresy Přestat sdílet Automaticky přijmout - Můžete vytvořit později - Nevytvářet adresu Ahoj! \nSpojte se se mnou přes SimpleX Chat: %s Promluvme si v SimpleX Chatu @@ -1224,7 +1171,6 @@ Reakce na zprávy může přidávat pouze váš kontakt. Vaše kontakty zůstanou připojeny. Tuto adresu můžete sdílet se svými kontakty, aby se mohli připojit k %s. - Svou adresu můžete sdílet jako odkaz nebo QR kód - kdokoli se k vám může připojit. Pokud později adresu odstraníte, o kontakty nepřijdete. Přístupový kód aplikace je nahrazen sebedestrukčním přístupovým heslem. žádný text @@ -1309,7 +1255,6 @@ Druhé zaškrtnutí jsme přehlédli! ✅ Přijímací adresa bude změněna na jiný server. Změna adresy bude dokončena po připojení odesílatele. Vybrat soubor - Spojit se inkognito Povolit Vypnout upozornění Otevřít nastavení aplikace @@ -1320,7 +1265,6 @@ Nový náhodný profil bude sdílen. Povolit pro všechny skupiny Žádný vybraný chat - Vložte odkaz který jste obdrželi, pro spojení se svým kontaktem… Žádné informace o doručení Odesílání doručenky je zakázáno pro %d skupin Vypnout pro všechny skupiny @@ -1421,7 +1365,6 @@ Obnovit Nový telefon Pouze jedno zařízení může pracovat současně - Můžete ji svým SimpleX kontaktům zviditelnit v Nastavení. %1$s.]]> Propojit mobilní a stolní aplikace! 🔗 To je váš vlastní jednorázový odkaz! @@ -1847,8 +1790,6 @@ Chyba připojení k přeposílajícímu serveru %1$s. Prosím, zkuste to později. Předávacímu serveru %1$s se nepodařilo připojit k cílovému serveru %2$s. Prosím, zkuste to později. Vybrané nastavení chatu zakazuje tuto zprávu. - Jiné SMP servery - Nastavené SMP servery Probíhá Části nahrány %1$d chyba souboru:\n%2$s @@ -1918,7 +1859,6 @@ Potvrzeno duplikáty Smazán - Otevřít nastavení serveru Nový zážitek z chatu 🎉 Nové možnosti médií Nová zpráva @@ -2022,10 +1962,8 @@ Vybrány %d Střední Příjem zpráv - Nastavené XFTP servery Servery médií a souborů Servery zpráv - Jiné FXTP servery Pozvat Pošlete zprávu pro povolení volání. Přijmout podmínky @@ -2190,7 +2128,6 @@ Opravit Opravit připojení? Nový server - koncovým šifrováním, s post-quantovým zabezpečením v přímých zprávách.]]> Žádné služba na pozadí Kontrolovat zprávy každých 10 minut Chyba vytváření hlášení 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 8c7d92086c..9706c1b552 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml @@ -66,7 +66,6 @@ Yderligere sekundær Tilføj liste Tilføj besked - Tilføj forudindstillede servere Tilføj profil Adresse Adresseændringen bliver annulleret. Den gamle modtageradresse bliver brugt. @@ -101,8 +100,6 @@ Brug ny inkognito -profil Din profil sendes til den kontakt, du har modtaget dette link fra. Du opretter forbindelse til alle gruppemedlemmer. - Forbinde - Tilslut inkognito Alle chats og meddelelser slettes - dette kan ikke fortrydes! Alle chats fjernes fra listen %s, og listen slettes Alle farvetilstande @@ -156,7 +153,6 @@ En ny tilfældig profil deles. En anden grund Svaropkald - Alle kan være vært for servere. App App løber altid i baggrunden App Build: %s @@ -241,12 +237,6 @@ Upassende indhold Overtrædelse af retningslinjer for fællesskabet Upassende profil - Fejl under lagring af SMP-servere - Fejl ved lagring af XFTP-servere - Sørg for, at SMP-serveradresserne er i korrekt format, linjeseparerede og ikke duplikerede. - Sørg for, at XFTP-serveradresserne er i korrekt format, linjeseparerede og ikke duplikerede. - Fejl ved indlæsning af SMP-servere - Fejl ved indlæsning af XFTP-servere Fejl ved opdatering af netværkskonfigurationen Chatten kunne ikke indlæses Kunne ikke indlæse chats @@ -520,8 +510,6 @@ afsendelse mislykkedes ulæst Velkommen %1$s! - Velkomst! - Denne tekst er tilgængelig i indstillinger Chats Indstillinger forbinder… @@ -531,7 +519,6 @@ Deltag som %s afvist forbinder… - Tryk for at starte en ny chat Chat med udviklerne Du har ingen chats Indlæser chats… @@ -699,7 +686,6 @@ Forbundet Afbrudt Fejl - Indtil Ændre modtageradresse? Modtageradressen vil blive ændret til en anden server. Adresseændringen vil blive gennemført, når afsenderen er online. Genforhandle kryptering? @@ -750,7 +736,6 @@ Auto-accept Auto-accept-kontaktanmodninger Auto-accept-billeder - \nFås i v5.1 Tilbage Baggrund Dårlig skrivebordsadresse @@ -824,7 +809,6 @@ Kan ikke sende besked til gruppemedlem Katalansk, indonesisk, rumænsk og vietnamesisk - takket være vores brugere! med kun en kontakt - Del personligt eller via enhver messenger.]]> - ende-til-ende krypteret med sikkerhed efter kvantet i direkte meddelelser.]]> for hver chatprofil, du har i appen.]]> til hvert kontakt- og gruppemedlem.\n Bemærk : Hvis du har mange forbindelser, kan dit batteri og trafikforbrug være væsentligt højere, og nogle forbindelser kan mislykkes.]]> Tilføj kontakt: Sådan opretter du et nyt invitationslink eller opretter forbindelse via et link, du har modtaget.]]> 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 f8fa2dbd66..6ea5d7686d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml @@ -8,7 +8,6 @@ Der Gruppe beitreten? Ihr Profil wird an den Kontakt gesendet, von dem Sie diesen Link erhalten haben. Sie werden mit allen Gruppenmitgliedern verbunden. - Verbinden Verbunden Fehler @@ -48,8 +47,6 @@ Über den Browser Das Öffnen des Links über den Browser kann die Privatsphäre und Sicherheit der Verbindung reduzieren. SimpleX-Links, denen nicht vertraut wird, werden rot markiert. - Fehler beim Speichern der SMP-Server - Stellen Sie sicher, dass die SMP-Server-Adressen das richtige Format haben, zeilenweise getrennt und nicht doppelt vorhanden sind. Fehler bei der Aktualisierung der Netzwerkkonfiguration. Verbindungszeitüberschreitung @@ -168,14 +165,11 @@ Ungelesen Willkommen %1$s! - Willkommen! - Dieser Text ist in den Einstellungen verfügbar. Chats Verbinde… Sie sind zu der Gruppe eingeladen Beitreten als %s Verbinde… - Tippen, um einen neuen Chat zu starten Chatten Sie mit den Entwicklern Sie haben keine Chats @@ -223,7 +217,6 @@ Verbunden Getrennt Fehler - Ausstehend Empfängeradresse wechseln\? Die Empfängeradresse wird auf einen anderen Server geändert. Der Adresswechsel wird abgeschlossen, wenn der Absender wieder online ist. @@ -245,12 +238,8 @@ In die Zwischenablage kopiert Neuen Chat starten - Einmal-Einladungslink teilen - Per Link / QR-Code verbinden QR-Code scannen Geheime Gruppe erstellen - (zum Teilen mit Ihrem Kontakt) - (Scannen oder Einfügen aus der Zwischenablage) (Wird nur von Gruppenmitgliedern gespeichert) Berechtigung verweigert! @@ -319,25 +308,17 @@ QR-Code anzeigen - Ungültiger QR-Code - Dieser QR-Code beschreibt keinen Link! Ungültiger Link! - Dieser Link ist kein gültiger Verbindungslink! Verbindungsanfrage gesendet! Sie werden mit der Gruppe verbunden, sobald das Endgerät des Gruppen-Hosts online ist. Bitte warten oder schauen Sie später nochmal nach! Sie werden verbunden, sobald Ihre Verbindungsanfrage angenommen wird. Bitte warten oder schauen Sie später nochmal nach! Sie werden verbunden, sobald das Endgerät Ihres Kontakts online ist. Bitte warten oder schauen Sie später nochmal nach! - den QR-Code während eines Videoanrufs anzeigen oder einen Einladungslink über einen anderen Kanal mit Ihrem Kontakt teilen.]]> - Ihr Chat-Profil wird -\nan Ihren Kontakt gesendet - den QR-Code während eines Videoanrufs scannen oder Ihr Kontakt kann einen Einladungslink über einen anderen Kanal mit Ihnen teilen.]]> Einmal-Einladungslink teilen Über einen Link verbinden Verbinden Einfügen Diese Zeichenfolge entspricht keinem gültigen Verbindungslink! - In mobiler App öffnen“.]]> Einmal-Einladungslink @@ -352,9 +333,7 @@ Senden Sie uns eine E-Mail SimpleX-Sperre Chat-Konsole - SMP-Server Voreingestellte Serveradresse - Voreingestellte Server hinzufügen Server hinzufügen Teste Server Teste alle Server @@ -363,8 +342,6 @@ Einige Server haben den Test nicht bestanden: Scannen Sie den QR-Code des Servers Geben Sie den Server manuell ein - Voreingestellter Server - Ihr Server Ihre Serveradresse Server nutzen Für neue Verbindungen nutzen @@ -377,7 +354,6 @@ Unterstützen Sie uns Bewerten Sie die App Verwenden Sie SimpleX-Chat-Server\? - Ihre SMP-Server Verwendung von SimpleX-Chat-Servern. Anleitung Wie Sie Ihre Server nutzen @@ -423,15 +399,11 @@ Speichern und Gruppenmitglieder benachrichtigen Beenden ohne Speichern - Sie haben volle Kontrolle über Ihren Chat! - Die Messaging- und Anwendungsplattform zum Schutz Ihrer Privatsphäre und Sicherheit. - Wir speichern auf den Servern keinen Ihrer Kontakte und keine Ihrer Nachrichten (sobald einmal zugestellt). Profil erstellen Ihr Profil, Ihre Kontakte und zugestellten Nachrichten werden auf Ihrem Gerät gespeichert. Das Profil wird nur mit Ihren Kontakten geteilt. Der angezeigte Name darf keine Leerzeichen enthalten. Geben Sie Ihren Namen ein: - Erstellen Über SimpleX Wie Sie Markdowns anwenden @@ -461,20 +433,10 @@ Verbunden Beendet - Die Zukunft des Messagings - Datenschutz neu definiert - Keine Benutzerkennungen. - Immun gegen Spam - Sie entscheiden, wer sich mit Ihnen verbinden kann. - Dezentral - Jeder kann seine eigenen Server aufsetzen. Ihr Profil erstellen Stellen Sie eine private Verbindung her Wie es funktioniert - SimpleX nutzt individuelle Kennungen für jeden Ihrer Kontakte, um Ihre Privatsphäre zu schützen. - Nur die Endgeräte speichern Benutzerprofile, Kontakte, Gruppen und Nachrichten. - GitHub-Repository mehr dazu.]]> Fügen Sie den erhaltenen Link ein @@ -499,7 +461,6 @@ Akzeptieren Anzeigen Deaktivieren - Ihre ICE-Server WebRTC-ICE-Server Relais-Server schützen Ihre IP-Adresse, können aber die Anrufdauer erfassen. Relais-Server werden nur genutzt, wenn sie benötigt werden. Ihre IP-Adresse kann von Anderen erfasst werden. @@ -556,7 +517,6 @@ Chats Entwicklertools Experimentelle Funktionen - SOCKS-Proxy App-Icon Design Nachrichten und Dateien @@ -933,7 +893,6 @@ %s wurde noch nicht überprüft Um die Ende-zu-Ende-Verschlüsselung mit Ihrem Kontakt zu überprüfen, müssen Sie den Sicherheitscode in Ihren Apps vergleichen oder scannen. Private Benachrichtigungen - Chat verwenden Ihr Kontakt und Sie können beide verschwindende Nachrichten versenden. %dh Gruppen-Links @@ -974,7 +933,6 @@ Es werden alle Chats und Nachrichten gelöscht. Dies kann nicht rückgängig gemacht werden! Chat-Profil löschen für PING-Zähler - Transport-Isolations-Modus aktualisieren\? Nachrichten-Server für neue Verbindungen über Ihr aktuelles Chat-Profil Dateien und Medien Transport-Isolation @@ -1098,10 +1056,7 @@ Auf das Video warten Auf das Video warten Das Video wird heruntergeladen, wenn Ihr Kontakt online ist. Bitte warten oder überprüfen Sie es später! - Ihre XFTP-Server Host - Fehler beim Speichern der XFTP-Server - Fehler beim Laden der SMP-Server Der Server erfordert zum Hochladen eine Autorisierung. Bitte überprüfen Sie das Passwort. Datei herunterladen Datei vergleichen @@ -1119,7 +1074,6 @@ Sperrmodus ändern Zugangscode wurde nicht geändert! Entschlüsselungsfehler - Fehler beim Laden der XFTP-Server Eingabe des Zugangscodes Zugangscode eingeben Kein App-Zugangscode @@ -1138,7 +1092,6 @@ Ungültiger Nachrichten-Hash Authentifizierung fehlgeschlagen Falsche Nachrichten-ID - Stellen Sie sicher, dass die XFTP-Server-Adressen das richtige Format haben, zeilenweise getrennt und nicht doppelt vorhanden sind. SOCKS-Proxy nutzen SimpleX-Sperrmodus SimpleX-Sperre ist nicht aktiviert! @@ -1146,7 +1099,6 @@ Bestätigen System Sie können nicht überprüft werden – bitte versuchen Sie es nochmal. - XFTP-Server Die ID der nächsten Nachricht ist falsch (kleiner oder gleich der vorherigen). \nDies kann passieren, wenn es einen Fehler gegeben hat oder die Verbindung kompromittiert wurde. %1$d Nachrichten konnten nicht entschlüsselt werden. @@ -1159,8 +1111,6 @@ Erlauben Sie Anrufe nur dann, wenn es Ihr Kontakt ebenfalls erlaubt. Erlaubt es Ihren Kontakten Sie anzurufen. Audio-/Video-Anrufe - " -\nVerfügbar in v5.1" Sowohl Sie als auch Ihr Kontakt können Anrufe tätigen. Nur Sie können Anrufe tätigen. Audio-/Video-Anrufe nicht erlauben. @@ -1192,13 +1142,11 @@ Mehr erfahren Um eine Verbindung herzustellen, kann Ihr Kontakt den QR-Code scannen oder den Link in der App verwenden. SimpleX-Adresse - Wenn Personen eine Verbindung anfordern, können Sie diese annehmen oder ablehnen. Sie werden Ihre damit verbundenen Kontakte nicht verlieren, wenn Sie diese Adresse später löschen. Design anpassen Interface-Farben Fügen Sie die Adresse Ihrem Profil hinzu, damit Ihre SimpleX-Kontakte sie mit anderen Personen teilen können. Es wird eine Profilaktualisierung an Ihre SimpleX-Kontakte gesendet. Alle Ihre Kontakte bleiben verbunden. Es wird eine Profilaktualisierung an Ihre Kontakte gesendet. - Erstellen Sie eine Adresse, damit sich Personen mit Ihnen verbinden können. SimpleX-Adresse erstellen Mit SimpleX-Kontakten teilen Ihre Kontakte bleiben weiterhin verbunden. @@ -1212,10 +1160,8 @@ Die Adresse mit SimpleX-Kontakten teilen? Teilen beenden Das Teilen der Adresse beenden\? - Keine Adresse erstellt Hallo! \nVerbinden Sie sich per SimpleX Chat mit mir: %s - Sie können dies später erstellen Geben Sie eine Begrüßungsmeldung ein … Vorschau Sie können diese Adresse mit Ihren Kontakten teilen, um sie mit %s verbinden zu lassen. @@ -1236,7 +1182,6 @@ Benutzeranleitung.]]> Stellen Sie sicher, dass die Datei die korrekte YAML-Syntax hat. Exportieren Sie das Design, um ein Beispiel für die Dateistruktur des Designs zu erhalten. Chat-Profile wechseln - Sie können Ihre Adresse als Link oder QR-Code teilen – jede Person kann sich mit Ihnen verbinden. Werden die App-Daten komplett gelöscht. Es wurde ein leeres Chat-Profil mit dem eingegebenen Namen erstellt und die App öffnet wie gewohnt. Wenn Sie Ihren Selbstzerstörungs-Zugangscode während des Öffnens der App eingeben: @@ -1412,11 +1357,9 @@ Es werden keine Empfangsbestätigungen gesendet, da diese Gruppe über %1$d Mitglieder hat. An dieses Gruppenmitglied wird eine Verbindungsanfrage gesendet. Direkt verbinden\? - Inkognito verbinden Aktuelles Chat-Profil nutzen Neues Inkognito-Profil nutzen App-Akkuverbrauch / Unbeschränkt , um Anrufe im Hintergrund zu führen.]]> - Fügen Sie den erhaltenen Link ein, um sich mit Ihrem Kontakt zu verbinden… Es wird ein neues Zufallsprofil geteilt. Ihr Profil %1$s wird geteilt. Erlauben @@ -1586,7 +1529,6 @@ Ansicht abgestürzt Fehler beim Anzeigen des Inhalts Fehler beim Anzeigen der Nachricht - Sie können sie über Einstellungen für Ihre SimpleX-Kontakte sichtbar machen. Der Nachrichtenverlauf wird nicht an neue Gruppenmitglieder gesendet. Wiederholen Kamera nicht verfügbar @@ -1933,10 +1875,6 @@ Fehler beim privaten Routing Die Nachricht kann später zugestellt werden, wenn das Mitglied aktiv wird. Bisher keine direkte Verbindung. Nachricht wird von einem Admin weitergeleitet. - Konfigurierte SMP-Server - Konfigurierte XFTP-Server - Andere SMP-Server - Andere XFTP-Server Abgeschlossen Inaktiv Verbunden @@ -1969,7 +1907,6 @@ Fehler beim Herunterladen Duplikate Abgelaufen - Server-Einstellungen öffnen Andere Fehler Proxy-vermittelt Fehler beim Empfang @@ -2269,7 +2206,6 @@ Der Text der aktuellen Nutzungsbedingungen konnte nicht geladen werden. Sie können die Nutzungsbedingungen unter diesem Link einsehen: Oder importieren Sie eine Archiv-Datei Hinweis für Geräte von Xiaomi: Bitte aktivieren Sie in den System-Einstellungen die Option "Autostart", damit Benachrichtigungen funktionieren.]]> - Ende-zu-Ende-verschlüsselt versendet. In Direktnachrichten sogar mit Post-Quantum-Security.]]> Team-Mitglieder aufnehmen Freunde aufnehmen Einladung angenommen 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 66cd9cf03b..30b5dad519 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml @@ -17,7 +17,6 @@ Επέτρεψε Αποδοχή Αποδοχή ανώνυμης περιήγησης - Προσθήκη προκαθορισμένου διακομιστή Προσθήκη σε άλλη συσκευή Όλες οι επαφές σου θα παραμείνουν ενεργές. Αποδοχή @@ -61,11 +60,9 @@ Αντίγραφο δεδομένων εφαρμογής 5 λεπτά Θα συνδεθείς όταν η συσκευή της επαφής σου είναι συνδεδεμένη, παρακαλώ περίμενε ή έλεγξε αργότερα! - Ο ICE διακομιστής σου Έκδοση εφαρμογής Έστειλες πρόσκληση ομάδας 1 λεπτό - Ο διακομιστής σου Διεύθυνση Ακύρωση Πίσω @@ -73,10 +70,8 @@ Θα συνδεθείς με όλα τα μέλη της ομάδας. %1$s θέλει να συνδεθεί μαζί σου μέσω Επέτρεψε τις αντιδράσεις σε μηνύματα. - Ο διακομιστής XFTP σου Η διεύθυνση του διακομιστή σου Το προφίλ, επαφές και παραδομένα μηνύματα σου είναι αποθηκευμένα στην συσκευή σου. - Ο διακομιστής SMP σου Επιτρέπεται να σταλούν αρχεία και μέσα. Το τυχαίο προφίλ σου %1$s ΜΕΛΗ @@ -103,7 +98,6 @@ Αλλαγή διεύθυνσης λήψης Πιστοποίηση μη διαθέσιμη Άλλαξε - \nΔιαθέσιμο στην έκδοση 5.1 Τέλος κλήσης Κλήσεις Αυτόματη αποδοχή @@ -146,7 +140,6 @@ Οι επαφές μπορούν να επισημάνουν μηνύματα προς διαγραφή, τα οποία θα μπορείς να τα δεις. Σύνδεση μέσω μιας εφάπαξ σύνδεσης; Δημιουργία σύνδεσμου - Σύνδεση μέσω σύνδεσμο/κωδικό γρήγορης ανταπόκρισης Σφάλμα σύνδεσης (πιστοποίηση) συνδέεται συνδέεται… @@ -169,7 +162,6 @@ Σύνδεση με %1$s? Δημιουργία του προφίλ σου συνδέεται… - Δημιουργία Προτιμήσεις επαφής Συνδεδεμένο κινητό Σύνδεση @@ -177,7 +169,6 @@ συνδέεται… Σύνδεση τερματίστηκε Συνδε - Σύνδεση ανώνυμης περιήγησης σύνδεση επετεύχθη η επαφή δεν έχει κρυπτογράφηση από άκρη-σε-άκρη Η επαφή επιτρέπει @@ -196,13 +187,11 @@ αιτούμενη σύνδεση Η ιδιωτικότητά σου Η επαφή σου έστειλε ένα αρχείο το οποίο είναι μεγαλύτερο από το παρόν υποστηριζόμενο μέγεθος (%1$s). - Το προφίλ της συνομιλίας σου θα σταλεί\nστην επαφή σου Χρήση νέου ανώνυμου προφίλ Ήδη συνδέεται Απέρριψες την πρόσκληση της ομάδας Σύνδεση μέσω διεύθυνση επαφής Χρήση του τρέχοντος προφίλ - Συνδέσου Το τρέχον προφίλ σου αφαίρεσες %1$s Συμμετοχή ομάδας; @@ -276,7 +265,6 @@ Πάντα Η ενημέρωση της εφαρμογής κατέβηκε Έλεγχος για ενημερώσεις - Οποιοσδήποτε μπορεί να φιλοξενήσει διακομιστές. κλήση ήχου (χωρίς κρυπτογράφηση e2e) Κλήσεις στην οθόνη κλειδώματος: Κλήση ήχου @@ -461,7 +449,6 @@ δεν μπορείς να στείλεις μηνύματα Καταλανικά, Ινδονησιακά, Ρουμανικά και Βιετναμέζικα – ευχαριστούμε τους χρήστες μας! με μία μόνο επαφή - προσωπικό διαμοιρασμό ή μέσω οποιασδήποτε εφαρμογής μηνυμάτων.]]> - με κρυπτογράφηση από άκρη-σε-άκρη και με μετα-κβαντική ασφάλεια σε άμεσα μηνύματα.]]> Επέτρεψε το στο επόμενο παράθυρο διαλόγου για να λαμβάνεις ειδοποιήσεις άμεσα.]]> Συσκευές Xiaomi: ενεργοποίησε το Αυτόματο Ξεκίνημα στις ρυθμίσεις συστήματος για να λειτουργούν οι ειδοποιήσεις.]]> %s.]]> @@ -471,8 +458,6 @@ %s βρίσκεται σε κακή κατάσταση]]> Σάρωση QR κωδικού.]]> %s με τον λόγο: %s]]> - σαρώσεις τον QR κωδικό στη βιντεοκλήση, ή η επαφή σου να διαμοιραστεί ένα σύνδεσμο πρόσκλησης.]]> - δείξε τον QR κωδικό στη βιντεοκλήση, ή μοιράσου το σύνδεσμο.]]> (νέο)]]> (αυτή η συσκευή v%s)]]> κρυπτογράφηση από άκρη-σε-άκρη.]]> @@ -487,7 +472,6 @@ Άνοιγμα στην εφαρμογή κινητού, μετά πάτα Σύνδεση μέσα στην εφαρμογή.]]> Χρήση από τον υπολογιστή στην εφαρμογή του κινητού και σκάναρε τον QR κωδικό.]]> Οδηγό Χρήσης.]]> - αποθετήριό μας στο GitHub.]]> Χρήση .onion hosts σε Όχι, αν ο διακομιστής μεσολάβησης SOCKS δεν τα υποστηρίζει.]]> %s.]]> %s.]]> @@ -504,7 +488,6 @@ %1$s.]]> %1$s.]]> πρέπει να χρησιμοποιείς την ίδια βάση δεδομένων σε δύο συσκευές.]]> - Άνοιγμα στην εφαρμογή κινητού κουμπί.]]> συνδεθείς με τους δημιουργούς του SimpleX Chat για να κάνεις ερωτήσεις και να λαμβάνεις ενημερώσεις.]]> μόνο αφού γίνει αποδεκτό το αίτημά σου.]]> Να αλλάξεις την αυτόματη διαγραφή μηνυμάτων; @@ -545,8 +528,6 @@ Όροι χρήσης Οι όροι θα γίνουν αποδεκτοί στις: %s. Οι όροι θα γίνουν αυτόματα αποδεκτοί για τους ενεργούς χειριστές στις: %s. - Διαμορφωμένοι SMP διακομιστές - Διαμορφωμένοι XFTP διακομιστές Διαμορφωμένοι ICE διακομιστές Επιβεβαίωσε Επιβεβαίωση διαγραφής επαφής; @@ -615,13 +596,11 @@ Γωνία Δημιουργία Δημιουργία συνδέσμου 1-χρήσης - Δημιούργησε μια διεύθυνση για να μπορούν οι άλλοι να συνδεθούν μαζί σου. Δημιουργήθηκε Δημιουργήθηκε στις Δημιουργήθηκε στις: %s Δημιουργία λίστας Δημιουργία νέου προφίλ στην εφαρμογή υπολογιστή. 💻 - Δημιουργία συνδέσμου 1-χρήσης Δημιουργία ουράς Δημιούργησε τη διεύθυνσή σου Δημιουργία συνδέσμου αρχειοθέτησης @@ -673,7 +652,6 @@ %d ημέρα %d ημέρες Αποστολή για αποσφαλμάτωση - Αποκεντρωμένο Σφάλμα αποκωδικοποίησης Σφάλμα αποκρυπτογράφησης σφάλματα αποκρυπτογράφησης @@ -817,7 +795,6 @@ ΜΗΝ στέλνεις μηνύματα απευθείας, ακόμα κι αν ο δικός σου διακομιστής ή ο διακομιστής προορισμού δεν υποστηρίζει ιδιωτική δρομολόγηση. Μην χρησιμοποιείς διαπιστευτήρια με το διακομιστή μεσολάβησης (proxy). ΜΗΝ χρησιμοποιείς ιδιωτική δρομολόγηση. - Μην δημιουργήσεις διεύθυνση Μην ενεργοποιήσεις Μην χάσεις σημαντικά μηνύματα. Να μην εμφανιστεί ξανά @@ -1023,7 +1000,6 @@ Η εικόνα θα ληφθεί όταν η επαφή σου ολοκληρώσει τη μεταφόρτωσή της. Η εικόνα θα ληφθεί όταν η επαφή σου είναι συνδεδεμένη, περίμενε ή έλεγξε αργότερα! Άμεσα - Ανοσοποιημένο στο spam Εισαγωγή Εισαγωγή βάσης δεδομένων συνομιλίας; Εισαγωγή βάσης δεδομένων @@ -1078,7 +1054,6 @@ μη έγκυρη διαμόρφωση μηνύματος Μη έγκυρη επιβεβαίωση μετεγκατάστασης Μη έγκυρο όνομα! - Μη έγκυρος QR κωδικός Μη έγκυρος QR κωδικός Μη έγκυρη διεύθυνση διακομιστή! Η πρόσκληση έληξε! @@ -1158,10 +1133,8 @@ Εξαφάνισε ένα μήνυμα Κάνε το προφίλ ιδιωτικό! Βεβαιώσου ότι έχεις σωστή διαμόρφωση του διακομιστή μεσολάβησης. - Βεβαιώσου ότι οι διευθύνσεις του διακομιστή SMP έχουν σωστή μορφή, διαχωρίζονται με νέα γραμμή και δεν είναι διπλότυπες. Βεβαιώσου ότι το αρχείο έχει σωστή σύνταξη YAML. Κάνε εξαγωγή ενός θέματος για να έχεις παράδειγμα της δομής αρχείου των θεμάτων. "Βεβαιώσου ότι οι διευθύνσεις των διακομιστών WebRTC ICE έχουν σωστή μορφή, διαχωρίζονται με νέα γραμμή και δεν είναι διπλότυπες." - Βεβαιώσου ότι οι διευθύνσεις των διακομιστών XFTP έχουν σωστή μορφή, διαχωρίζονται με νέα γραμμή και δεν είναι διπλότυπες. Κάνε τις συνομιλίες σου να ξεχωρίζουν! Βοήθεια στη Markdown σύνταξη Σύνταξη Markdown στα μηνύματα @@ -1291,7 +1264,6 @@ Πάτα για ανώνυμη συμμετοχή Πάτα για επικόλληση συνδέσμου Πάτα για σάρωση - Πάτα για να ξεκινήσεις μία νέα συνομιλία Σύνδεση TCP Χρόνος λήξης σύνδεσης TCP στο παρασκήνιο Χρόνος λήξης σύνδεσης TCP @@ -1320,7 +1292,6 @@ Για τις κλήσεις απαιτείται ο προεπιλεγμένος περιηγητής. Ρύθμισε τον προεπιλεγμένο περιηγητή στο σύστημα σου και μοιράσου περισσότερες πληροφορίες με τους προγραμματιστές. Το όνομα της συσκευής θα κοινοποιηθεί στην εφαρμογή του συνδεδεμένου κινητού. Η κρυπτογράφηση λειτουργεί και η νέα κρυπτογράφηση δεν είναι απαραίτητη. Μπορεί να προκαλέσει σφάλματα σύνδεσης! - Το μέλλον στην ανταλλαγή μηνυμάτων Ο κωδικός ελέγχου του προηγούμενου μηνύματος είναι διαφορετικός. Ο αναγνωριστικός κωδικός του επόμενου μηνύματος είναι λανθασμένος (μικρότερος ή ίσος με τον προηγούμενο).\nΑυτό μπορεί να συμβεί λόγω κάποιου σφάλματος ή όταν η σύνδεση έχει παραβιαστεί. Η εικόνα δεν μπορεί να αποκωδικοποιηθεί. Δοκίμασε μια άλλη εικόνα ή επικοινώνησε με τους προγραμματιστές. @@ -1331,7 +1302,6 @@ Τα μηνύματα θα επισημαίνονται ως ελεγχόμενα για όλα τα μέλη. Το μήνυμα θα διαγραφεί για όλα τα μέλη. Το μήνυμα θα επισημανθεί ως υπό έλεγχο για όλα τα μέλη. - Η πλατφόρμα μηνυμάτων και εφαρμογών που προστατεύει το απόρρητο και την ασφάλειά σου. Η φράση πρόσβασης αποθηκεύεται στις ρυθμίσεις ως απλό κείμενο. Η φράση πρόσβασης θα αποθηκευτεί στις ρυθμίσεις ως απλό κείμενο μετά την αλλαγή της ή την επανεκκίνηση της εφαρμογής. Το προφίλ κοινοποιείται μόνο στις επαφές σου. @@ -1362,14 +1332,11 @@ Αυτή η ομάδα δεν υπάρχει πλέον. Αυτός είναι ο δικός σου σύνδεσμος 1-χρήσης! Αυτή είναι η διεύθυνση σου SimpeX! - Αυτός ο σύνδεσμος δεν είναι έγκυρος! Αυτός ο σύνδεσμος απαιτεί νεότερη έκδοση της εφαρμογής. Αναβάθμισε την εφαρμογή ή ζήτησε από την επαφή σου να σου στείλει ένα συμβατό σύνδεσμο. Αυτός ο σύνδεσμος χρησιμοποιήθηκε με άλλη κινητή συσκευή. Δημιούργησε ένα νέο σύνδεσμο στον υπολογιστή σου. Αυτό το μήνυμα διαγράφηκε ή δεν έχει ληφθεί ακόμα. - Αυτός ο κωδικός QR δεν είναι σύνδεσμος! Αυτή η ρύθμιση ισχύει για τα μηνύματα στο τρέχον προφίλ συνομιλίας σου. Αυτό το κείμενο δεν είναι σύνδεσμος! - Αυτό το κείμενο είναι διαθέσιμο στις ρυθμίσεις Εξαντλήθηκε ο χρόνος αναμονής κατά τη σύνδεση με τον υπολογιστή Ο χρόνος εξαφάνισης ορίζεται μόνο για τις νέες επαφές. Τίτλος @@ -1386,13 +1353,11 @@ Για την προστασία της ζώνης ώρας, τα αρχεία εικόνας/φωνής χρησιμοποιούν UTC ώρα. Για να προστατεύσεις τις πληροφορίες σου, ενεργοποίησε το SimpleX Lock.\nΘα σου ζητηθεί να ολοκληρώσεις την επαλήθευση ταυτότητας πριν ενεργοποιηθεί αυτή η λειτουργία. Για την προστασία της IP διεύθυνσής σου, η ιδιωτική δρομολόγηση χρησιμοποιεί τους διακομιστές SMP για την παράδοση μηνυμάτων. - Για την προστασία της ιδιωτικότητάς σου, το SimpleX χρησιμοποιεί ξεχωριστά αναγνωριστικά για κάθε μία από τις επαφές σου. Για λήψη Για να λαμβάνεις ειδοποιήσεις, παρακαλώ εισήγαγε τη φράση πρόσβασης της βάσης δεδομένων. Για να αποκαλύψεις το κρυφό προφίλ σου, εισήγαγε έναν πλήρη κωδικό στο πεδίο αναζήτησης στη σελίδα Τα προφίλ συνομιλίας σου. Για αποστολή Για αποστολή εντολών, θα πρέπει να είσαι συνδεδεμένος. - (για διαμοιρασμό με την επαφή σου) Για να ξεκινήσεις μία νέα συνομιλία Συνολικά Για να χρησιμοποιήσεις άλλο προφίλ μετά την προσπάθεια σύνδεσης, διέγραψε τη συνομιλία και χρησιμοποίησε ξανά τον σύνδεσμο. @@ -1440,7 +1405,6 @@ Η λήψη της ενημέρωσης ακυρώθηκε ενημερωμένο προφίλ Ενημέρωση ρυθμίσεων δικτύου; - Ενημέρωση της λειτουργίας απομόνωσης μεταφοράς; Ενημέρωσε τη διεύθυνσή σου Η ενημέρωση των ρυθμίσεων θα επανασυνδέσει την εφαρμογή με όλους τους διακομιστές. Αναβάθμιση @@ -1457,7 +1421,6 @@ Ανέβασμα αρχείου Ανεβαίνει το αρχείο αρχειοθέτησης Τα τελευταία 100 μηνύματα αποστέλλονται στα νέα μέλη. - Χρήση συνομιλίας Χρησιμοποίησε διαφορετικά διαπιστευτήρια διακομιστή μεσολάβησης για κάθε σύνδεση. Χρησιμοποίησε διαφορετικά διαπιστευτήρια διακομιστή μεσολάβησης για κάθε προφίλ. Χρήση απευθείας σύνδεσης στο Διαδίκτυο; @@ -1522,8 +1485,6 @@ Σφάλμα κατά τη συμμετοχή στην ομάδα Σφάλμα κατά τη φόρτωση των λιστών συνομιλιών Σφάλμα κατά τη φόρτωση των λεπτομερειών - Σφάλμα κατά τη φόρτωση των διακομιστών SMP - Σφάλμα κατά τη φόρτωση των διακομιστών XFTP Σφάλμα επισήμανσης ως αναγνωσμένου Σφάλμα κατά το άνοιγμα του προγράμματος περιήγησης Σφάλμα κατά το άνοιγμα της συνομιλίας @@ -1545,9 +1506,7 @@ Σφάλμα κατά την αποθήκευση διακομιστών Σφάλμα κατά την αποθήκευση των ρυθμίσεων Σφάλμα κατά την αποθήκευση των ρυθμίσεων - Σφάλμα κατά την αποθήκευση των διακομιστών SMP Σφάλμα κατά την αποθήκευση του κωδικού πρόσβασης χρήστη - Σφάλμα κατά την αποθήκευση διακομιστών XFTP Σφάλμα κατά την αποστολή της πρόσκλησης Σφάλμα κατά την αποστολή του μηνύματος Σφάλμα κατά τη ρύθμιση της διεύθυνσης @@ -1685,7 +1644,6 @@ Οι ειδοποιήσεις θα σταματήσουν να λειτουργούν μέχρι να επανεκκινήσεις την εφαρμογή. μη συγχρονισμένο Δεν υπάρχουν μη αναγνωσμένες συνομιλίες - Χωρίς αναγνωριστικά χρήστη. Τώρα οι διαχειριστές μπορούν:\n- να διαγράφουν τα μηνύματα των μελών.\n- να απενεργοποιούν μέλη (ρόλος παρατηρητή) παρατηρητής κλειστό` @@ -1706,7 +1664,6 @@ Μπορούν να σταλούν μόνο 10 εικόνες ταυτόχρονα Μπορούν να σταλούν μόνο 10 βίντεο ταυτόχρονα Μόνο οι ιδιοκτήτες του chat μπορούν να αλλάξουν τις προτιμήσεις. - Μόνο οι συσκευές αποθηκεύουν προφίλ χρηστών, επαφές, ομάδες και μηνύματα. Διαγραφή μόνο της συνομιλίας Μόνο οι ιδιοκτήτες ομάδων μπορούν να αλλάξουν τις προτιμήσεις της ομάδας. Μόνο οι ιδιοκτήτες ομάδων μπορούν να ενεργοποιήσουν αρχεία και πολυμέσα. @@ -1749,7 +1706,6 @@ Άνοιξε νέα ομάδα Άνοιγμα θύρας στο τείχος προστασίας Άνοιξε τις Ρυθμίσεις Safari / Ιστοσελίδες / Μικρόφωνο και στη συνέχεια επέλεξε Να επιτρέπεται για το localhost. - Άνοιγμα ρυθμίσεων διακομιστή Άνοιγμα ρυθμίσεων Άνοιξε το SimpleX Chat για να αποδεχθείς την κλήση Άνοιξε για να αποδεχθείς @@ -1771,8 +1727,6 @@ άλλο Άλλο άλλα σφάλματα - Άλλοι διακομιστές SMP - Άλλοι διακομιστές XFTP ιδιοκτήτης ιδιοκτήτες Κωδικός πρόσβασης @@ -1792,10 +1746,8 @@ Επικόλληση συνδέσμου Επικόλλησε το σύνδεσμο για να συνδεθείς! Επικόλλησε το σύνδεσμο που έλαβες - Επικόλλησε το σύνδεσμο που έλαβες για να συνδεθείς με την επαφή σου… από άκρη-σε-άκρη εκκρεμής - Εκκρεμής Εκκρεμής σε αναμονή έγκρισης Εκκρεμής κλήση @@ -1838,7 +1790,6 @@ Προετοιμασία λήψης Προετοιμασία μεταφόρτωσης Διατήρηση του τελευταίου πρόχειρου μηνύματος, με τα συνημμένα. - Προκαθορισμένος διακομιστής Διεύθυνση προκαθορισμένου διακομιστή Προκαθορισμένοι διακομιστές Προκαθορισμένοι διακομιστές @@ -1846,7 +1797,6 @@ Προηγούμενοι συνδεδεμένοι διακομιστές Προστασία της ιδιωτικότητας των πελατών σου. Πολιτική απορρήτου και όροι χρήσης. - Επαναπροσδιορισμός της ιδιωτικότητας Απόρρητο & ασφάλεια Οι ιδιωτικές συνομιλίες, οι ομάδες και οι επαφές σου δεν είναι προσβάσιμες στους χειριστές του διακομιστή. Ιδιωτικά ονόματα αρχείων @@ -2057,7 +2007,6 @@ Κλιμάκωση στην οθόνη Σάρωση κωδικού Σάρωση από κινητό - (σάρωσε ή επικόλλησε από το πρόχειρο) Σάρωση / Επικόλληση συνδέσμου Σάρωσε τον κωδικό QR από τον υπολογιστή %s συνδέθηκε @@ -2242,9 +2191,7 @@ Αργή λειτουργία Μικρές ομάδες (μέγιστο 20 άτομα) Διακομιστής SMP - Διακομιστές SMP Διακομιστής μεσολάβησης SOCKS - Διακομιστής μεσολάβησης SOCKS Ρυθμίσεις διακομιστή μεσολάβησης SOCKS Απαλό Κάποιο/α αρχείο/α δεν εξήχθησαν @@ -2361,9 +2308,7 @@ Προειδοποίηση: η έναρξη συνομιλίας σε πολλαπλές συσκευές δεν υποστηρίζεται και θα προκαλέσει σφάλματα στην παράδοση των μηνυμάτων. Προειδοποίηση: ενδέχεται να χάσεις ορισμένα δεδομένα! Διακομιστές WebRTC ICE - Δεν αποθηκεύουμε καμία από τις επαφές ή τα μηνύματά σου (αφού παραδοθούν) στους διακομιστές. εβδομάδες - Καλωσόρισες! Καλωσόρισες %1$s! Μήνυμα καλωσορίσματος Μήνυμα καλωσορίσματος @@ -2376,7 +2321,6 @@ Κατά τη σύνδεση κλήσεων ήχου και βίντεο. Όταν η IP είναι κρυφή Όταν είναι ενεργοποιημένοι περισσότεροι από ένας χειριστές, κανένας από αυτούς δεν διαθέτει μεταδεδομένα για να μάθει ποιος επικοινωνεί με ποιον. - Όταν κάποιος ζητήσει να συνδεθεί, μπορείς να αποδεχτείς ή να απορρίψεις το αίτημα. Όταν μοιράζεσε ένα ανώνυμο προφίλ με κάποιον, αυτό το προφίλ θα χρησιμοποιείται για τις ομάδες στις οποίες σε προσκαλούν. WiFi Θα ενεργοποιηθεί στις άμεσες συνομιλίες! @@ -2392,7 +2336,6 @@ Λανθασμένο κλειδί ή άγνωστη διεύθυνση τμήματος αρχείου - πιθανότατα το αρχείο έχει διαγραφεί. Λανθασμένη φράση πρόσβασης! Διακομιστής XFTP - Διακομιστές XFTP ναι Ναι Ναι @@ -2422,19 +2365,16 @@ Μπορείς να το αλλάξεις στις ρυθμίσεις Εμφάνισης. Μπορείς να διαμορφώσεις τους διακομιστές μέσω των ρυθμίσεων. Μπορείς να αντιγράψεις και να μειώσεις το μέγεθος του μηνύματος για να το στείλεις. - Μπορείς να το δημιουργήσεις αργότερα Μπορείς να το ενεργοποιήσεις αργότερα μέσω των Ρυθμίσεων. Μπορείς να τις ενεργοποιήσεις αργότερα μέσω των ρυθμίσεων απορρήτου και ασφάλειας της εφαρμογής. Μπορείς να δοκιμάσεις ξανά. Μπορείς να δοκιμάσεις ξανά. Μπορείς να αποκρύψεις ή να σιγάσεις ένα προφίλ χρήστη - κράτησέ το πατημένο για να εμφανιστεί το μενού. - Μπορείς να το κάνεις ορατό στις επαφές σου στο SimpleX μέσω των Ρυθμίσεων. Μπορείς να αναφέρεις εώς και %1$s μέλη ανά μήνυμα! Μπορείς να στείλεις μηνύματα στην επαφή %1$s από τις αρχειοθετημένες επαφές. Μπορείς να ορίσεις το όνομα της σύνδεσης για να θυμάσε με ποιον μοιράστηκες το σύνδεσμο. Μπορείς να μοιραστείς ένα σύνδεσμο ή έναν κωδικό QR - οποιοσδήποτε θα μπορεί να συμμετάσχει στην ομάδα. Δεν θα χάσεις μέλη της ομάδας αν τον διαγράψεις αργότερα. Μπορείς να μοιραστείς αυτήν τη διεύθυνση με τις επαφές σου για να τους επιτρέψεις να συνδεθούν με την επαφή %s. - Μπορείς να διαμοιραστείς τη διεύθυνσή σου ως σύνδεσμο ή κωδικό QR - οποιοσδήποτε θα μπορεί να συνδεθεί μαζί σου. Μπορείς να ξεκινήσεις τη συνομιλία μέσω της εφαρμογής Ρυθμίσεις / Βάση δεδομένων ή επανεκκινώντας την εφαρμογή. Μπορείς ακόμα να δεις τη συνομιλία με την επαφή %1$s, στη λίστα των συνομιλιών. Δεν μπορείς να στείλεις μηνύματα! @@ -2446,9 +2386,7 @@ άλλαξες διεύθυνση για %s άλλαξες ρόλο για τον εαυτό σου σε %s άλλαξες το ρόλο του μέλους %s σε %s - Έχεις τον έλεγχο της συνομιλίας σου! Δεν ήταν δυνατή η επαλήθευση. Παρακαλώ, δοκίμασε ξανά. - Εσύ αποφασίζεις ποιος μπορεί να συνδεθεί. Έχεις ήδη ζητήσει σύνδεση μέσω αυτής της διεύθυνσης! Δεν έχεις συνομιλίες Πρέπει να εισάγεις τη φράση πρόσβασης κάθε φορά που ξεκινά η εφαρμογή - δεν αποθηκεύεται στη συσκευή. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml index 5ba89e4df5..59b99534cd 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml @@ -12,7 +12,6 @@ 1 semana Se permiten los mensajes temporales pero sólo si tu contacto también los permite. Añadir servidores mediante el escaneo de códigos QR. - Añadir servidores predefinidos Todos los miembros del grupo permanecerán conectados. Se permite la eliminación irreversible de mensajes pero sólo si tu contacto también lo permite. (24 horas) Android Keystore se usará para almacenar la frase de contraseña de forma segura después de cambiarla o reiniciar la aplicación - permitirá recibir notificaciones. @@ -77,7 +76,6 @@ ¡Consume más energía! La aplicación está siempre en segundo plano y las notificaciones se muestran de inmediato.]]> Tanto tú como tu contacto podéis eliminar los mensajes enviados de forma irreversible. (24 horas) Tanto tú como tu contacto podéis enviar mensajes temporales. - Crear Crea grupo secreto La contraseña de cifrado de la base de datos será actualizada. ID base de datos @@ -135,7 +133,6 @@ conectando (aceptado) conectando (anunciado) conexión %1$d - Conecta vía enlace / Código QR El contacto y todos los mensajes serán eliminados. ¡No puede deshacerse! Contacto verificado el contacto dispone de cifrado de extremo a extremo @@ -157,7 +154,6 @@ Desconectado Conectado Copiado en portapapeles - Crea enlace de invitación de un uso. Escanear código QR ]]> Eliminar Eliminar @@ -188,7 +184,6 @@ ¿Eliminar la dirección\? Nombre del perfil: conectando… - Descentralizada La base de datos será cifrada. Crear enlace de grupo Eliminar enlace @@ -225,7 +220,6 @@ ¿Conectar mediante dirección de contacto? ¿Te unes al grupo? ¿Conectar mediante enlace de invitación? - Conectar conectado conectando conectando… @@ -315,14 +309,12 @@ Cómo usar la sintaxis markdown en modo incógnito mediante enlace de un solo uso Dirección de contacto SimpleX - Error al guardar servidores SMP Abrir el enlace en el navegador puede reducir la privacidad y seguridad de la conexión. Los enlaces de SimpleX que no son de confianza aparecerán en rojo. Error al actualizar la configuración de red Error al crear dirección Error al eliminar perfil Activar Bloqueo SimpleX Enlace de invitación de un solo uso - Servidores SMP Características experimentales Error al importar base de datos Error al cambiar configuración @@ -348,8 +340,6 @@ Si has recibido un enlace de invitación a SimpleX Chat puedes abrirlo en tu navegador: Si seleccionas rechazar, el remitente NO será notificado. ¡Enlace no válido! - escanear el código QR por videollamada, o tu contacto puede compartir un enlace de invitación.]]> - muestra el código QR por videollamada, o comparte el enlace.]]> Cómo Ignorar Error al eliminar base de datos @@ -380,7 +370,6 @@ Error al guardar servidores ICE Servidores ICE (uno por línea) Nombre completo: - Tu decides quién se conecta. Colgar Archivos y multimedia ¡Grupo no encontrado! @@ -436,10 +425,8 @@ Invitar al grupo Para verificar el cifrado de extremo a extremo con tu contacto, compara (o escanea) el código en ambos dispositivos. La base de datos no está cifrada. Escribe una contraseña para protegerla. - Asegúrate de que las direcciones del servidor SMP tienen el formato correcto, están separadas por líneas y no están duplicadas. Notificaciones instantáneas Configuración avanzada - Sólo los dispositivos cliente almacenan perfiles de usuario, contactos, grupos y mensajes. Cómo afecta a la batería Instantánea Unirme @@ -469,7 +456,6 @@ Abrir en aplicación móvil, después pulsa Conectar en la aplicación.]]> Marcar como leído Marcar como no leído - Código QR no válido ¡Código de seguridad incorrecto! Sintaxis Markdown No @@ -501,7 +487,6 @@ Asegúrate de que las direcciones del servidor WebRTC ICE tienen el formato correcto, están separadas por líneas y no duplicadas. Se requieren hosts .onion para la conexión \nRecuerda: no podrás conectarte a servidores que no tengan dirección .onion. - Inmune al spam Videollamada entrante has salido has cambiado de servidor @@ -595,7 +580,6 @@ secreto Abrir SimpleX Chat para aceptar llamada Reiniciar a valores predetarminados - Pendiente Notificaciones periódicas Guarda la contraseña de forma segura, NO podrás cambiarla si la pierdes. Reinicia la aplicación para crear un perfil nuevo. @@ -604,7 +588,6 @@ imagen del perfil No se permiten mensajes de voz. Proteger pantalla de la aplicación - repositorio GitHub .]]> Grabar mensaje de voz ha expulsado a %1$s Enviar previsualizacion de enlaces @@ -622,7 +605,6 @@ Rechazar Obligatorio Guardar y notificar contactos - Cualquiera puede alojar servidores. Rol Intervalo PING Contador PING @@ -635,7 +617,6 @@ Mensaje en vivo Escanear código QR Enviar - (escanear o pegar desde el portapapeles) Espacio reservado para la imagen del perfil Código QR Consultas y sugerencias @@ -646,7 +627,6 @@ respuesta recibida… confirmación recibida… Periódico - Privacidad redefinida Rechazar Abrir Llamada pendiente @@ -688,7 +668,6 @@ Escanea el código de seguridad desde la aplicación de tu contacto. Guardar servidores Escanear código QR - Servidor predefinido Guardar y notificar contacto ¿Guardar preferencias\? Guardar y notificar grupo @@ -696,14 +675,11 @@ La aplicación recoge nuevos mensajes periódicamente lo que consume un pequeño porcentaje de batería al día. La aplicación no usa notificaciones push por tanto los datos de tu dispositivo no se envían a los servidores push. Bloqueo SimpleX Desbloquear - Este texto está disponible en Configuración La dirección de recepción pasará a otro servidor. El cambio se completará cuando el remitente esté en línea. Bloqueo SimpleX Usando servidores SimpleX Chat. Aislamiento de transporte tachado - Abrir SimpleX - Proxy SOCKS Temas Parar Esta acción es irreversible. Tu perfil, contactos, mensajes y archivos se perderán. @@ -724,15 +700,11 @@ Cámara ¡El contacto con el que has compartido este enlace NO podrá conectarse! Mostrar código QR - ¡El enlace no es un enlace de conexión válido! - ¡El código QR no es un enlace! Compartir enlace de un uso - ¿Actualizar el modo de aislamiento de transporte\? Altavoz activado ¡La conexión que has aceptado se cancelará! La base de datos no funciona correctamente. Pulsa para conocer más El mensaje será marcado como moderado para todos los miembros. - El futuro de la mensajería Esta acción es irreversible. Se eliminarán todos los archivos y multimedia recibidos y enviados. Las imágenes de baja resolución permanecerán. Esta acción es irreversible. Los mensajes enviados y recibidos anteriores a la selección serán eliminados. Podría tardar varios minutos. Esta configuración se aplica a los mensajes del perfil actual @@ -743,7 +715,6 @@ Inciar chat nuevo Para exportar, importar o eliminar la base de datos debes parar SimpleX. Mientra tanto no podrás enviar ni recibir mensajes. ¡Gracias por instalar SimpleX Chat! - Para proteger tu privacidad, SimpleX usa identificadores distintos para cada uno de tus contactos. Para proteger tu información, activa el Bloqueo SimpleX. \nSe te pedirá que completes la autenticación antes de activar esta función. Para actualizar la configuración el cliente se reconectará a todos los servidores. @@ -766,15 +737,12 @@ ¿Usa proxy SOCKS\? Usar hosts .onion simplexmq: v%s (%2s) - La plataforma de mensajería y aplicaciones que protege tu privacidad y seguridad. - Sin identificadores de usuario. Este grupo ya no existe. Establecer 1 día ¡Agradecimiento a los colaboradores! Puedes contribuir a través de Weblate. ¡Agradecimiento a los colaboradores! Puedes contribuir a través de Weblate. Para proteger la zona horaria, los archivos de imagen/voz usan la hora UTC. Aislamiento de transporte - (para compartir con tu contacto) Activar sonido %s está verificado %s no está verificado @@ -807,7 +775,6 @@ formato de mensaje desconocido Error al conectar con el servidor usado para recibir mensajes de esta conexión: (error: %1$s). Prueba no superada en el paso %s. - Pulsa para iniciar chat nuevo Compartir mensaje… Compartir medios… Mostrar @@ -839,9 +806,6 @@ Mensajes de voz no permitidos. Comprobar la seguridad de la conexión ¡Ya estás conectado con %1$s. - ¡Bienvenido! - Tu perfil será enviado -\na tu contacto Servidores ICE Has rechazado la invitación del grupo. has cambiado el rol de %s a %s @@ -852,7 +816,6 @@ Servidores WebRTC ICE %1$s quiere conectarse contigo mediante Ya tienes un perfil con este nombre mostrado. Por favor, selecciona otro nombre. - Abrir en aplicación móvil.]]> ponerte en contacto con los desarrolladores de SimpleX Chat para consultas y para recibir actualizaciones.]]> eres observador Puedes usar la sintaxis markdown para dar formato a tus mensajes: @@ -875,7 +838,6 @@ Esperando imagen Mensaje de voz (%1$s ) Si disponibles - No almacenamos ninguno de tus contactos o mensajes (una vez entregados) en los servidores. Has sido invitado a un grupo. Únete para conectar con sus miembros. has expulsado a %1$s Tú: %1$s @@ -889,15 +851,12 @@ Has invitado a tu contacto Te conectarás al grupo cuando el dispositivo anfitrión esté en línea, por favor espera o revisa más tarde. Configuración - Servidores SMP - ¡Tú controlas tu chat! Tu perfil, contactos y mensajes se almacenan en tu dispositivo. esperando respuesta… esperando confirmación… Cuando la aplicación se está ejecutando Videollamada Llamadas - Servidores ICE Privacidad Mis datos Base de datos @@ -933,7 +892,6 @@ Equipo SimpleX Mis perfiles Mi dirección SimpleX - Tu servidor Dirección de tu servidor Tu perfil actual Tu perfil se almacena en tu dispositivo y se comparte sólo con tus contactos. Los servidores SimpleX no pueden ver tu perfil. @@ -1011,17 +969,11 @@ El vídeo se recibirá cuando el contacto termine de subirlo. Sólo se pueden enviar 10 vídeos de forma simultánea Esperando el vídeo - Error al guardar servidores SMP - Error al cargar servidores XFTP - Error al cargar servidores SMP - Asegúrate de que las direcciones del servidor XFTP tienen el formato correcto, están separadas por líneas y no están duplicadas. El servidor requiere autorización para subir, comprueba la contraseña. Comparar archivo Crear archivo Eliminar archivo Subir archivo - Servidores XFTP - Servidores XFTP Puerto puerto %d Usar hosts .onion debe estar a No si el proxy SOCKS no los admite.]]> @@ -1083,8 +1035,6 @@ Permites que tus contactos te llamen. Llamadas y videollamadas Las llamadas y videollamadas no están permitidas. - " -\nDisponible en v5.1" Tanto tú como tu contacto podéis realizar llamadas. Sólo tú puedes realizar llamadas. Sólo tu contacto puede realizar llamadas. @@ -1107,7 +1057,6 @@ Guía de Usuario.]]> Enlace de un solo uso Dirección SimpleX - Cuando alguien solicite conectarse podrás aceptar o rechazar su solicitud. Compartir dirección… Deja un mensaje de bienvenida… SimpleX @@ -1124,8 +1073,6 @@ Tema oscuro Personalizar tema Deja un mensaje de bienvenida… (opcional) - Crea una dirección para que otras personas puedan conectar contigo. - No crear dirección SimpleX ¡Hola! \nConecta conmigo a través de SimpleX Chat: %s Importar tema @@ -1136,14 +1083,12 @@ La actualización del perfil se enviará a tus contactos SimpleX. Mensaje recibido Guardar configuración de dirección SimpleX - Puedes compartir tu dirección como enlace o código QR para que cualquiera pueda conectarse contigo. ¿Guardar configuración\? Secundario Mensaje enviado Dejar de compartir ¿Dejar de compartir la dirección\? Colores de la interfaz - Puedes crearla más tarde ¿Compartir la dirección con los contactos SimpleX? Compartir con contactos SimpleX Título @@ -1329,11 +1274,9 @@ Este grupo tiene más de %1$d miembros, no se enviarán confirmaciones de entrega. ¿Conectar directamente\? La solicitud se enviará a este miembro del grupo. - Conectar en incógnito Permitir Abrir configuración Compartirás un perfil nuevo aleatorio. - Pega el enlace recibido para conectar con tu contacto… El perfil %1$s será compartido. Desactivar notificaciones Sin llamadas en segundo plano. @@ -1507,7 +1450,6 @@ Error aplicación error al mostrar el contenido error al mostrar mensaje - Puedes hacerlo visible para tus contactos de SimpleX en Configuración. El historial no se envía a miembros nuevos. Reintentar Cámara no disponible @@ -1858,8 +1800,6 @@ Suscripciones ignoradas Para ser notificado sobre versiones nuevas, activa el chequeo periódico para las versiones Estable o Beta. Beta - Servidores SMP configurados - Servidores XFTP configurados Servidores conectados Conectando Perfil actual @@ -1903,8 +1843,6 @@ Descargado Servidor SMP Aún no hay conexión directa, los mensajes son reenviados por el administrador. - Otros servidores SMP - Otros servidores XFTP Pegar enlace / Escanear Mostrar porcentaje Desactivar @@ -1946,7 +1884,6 @@ Errores de descarga duplicados caducados - Abrir configuración del servidor otros otros errores Como proxy @@ -2226,7 +2163,6 @@ ¡El chat ya existe! Acerca de los operadores La aplicación siempre funciona en segundo plano - cifrados de extremo a extremo y con seguridad postcuántica en mensajes directos.]]> ¡Mensaje demasiado largo! Simplex Chat y Flux han acordado incluir en la aplicación servidores operados por Flux. Activar registros 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 0051298873..65f1769787 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml @@ -16,7 +16,6 @@ استفاده از پروفایل ناشناس جدید در حال باز کردن پایگاه داده… پروفایل شما به مخاطبی که این لینک را از او دریافت کردید، فرستاده خواهد شد. - اتصال ناشناس شما یک مسیر نامعتبر فایل به اشتراک گذاشتید. موضوع را به توسعه‌دهندگان برنامه گزارش دهید. هنوز از دریافت فایل پشتیبانی نمی‌شود قالب پیام نامعتبر @@ -32,7 +31,6 @@ به گروه می‌پیوندید؟ استفاده از پروفایل کنونی به تمام اعضای گروه متصل خواهید شد. - متصل شدن مسیر نامعتبر فایل برنامه از کار افتاد در حال تلاش برای اتصال به سرور مورد استفاده برای دریافت پیام‌ها از این مخاطب (خطا: %1$s). @@ -66,8 +64,6 @@ توصیف لینک کامل لینک‌های SimpleX - خطا در ذخیره کردن سرورهای SMP - خطا در ذخیره کردن سرورهای XFTP خطای تجدید مذاکره رمزنگاری اتصال %1$d اتصال برقرار شد @@ -82,7 +78,6 @@ دعوت یک بار مصرف SimpleX در حال اتصال… باز کردن لینک در مرورگر ممکن است حریم خصوصی و امنیت اتصال را کاهش دهد. لینک‌های SimpleX غیر قابل اعتماد به رنگ قرمز خواهند بود. - خطا در بارگیری سرورهای XFTP خطا در ایجاد پروفایل! خطا در تعویض پروفایل! توقف اتصال @@ -94,7 +89,6 @@ فرستنده انتقال فایل را لغو کرد. شما از قبل به %1$s متصل هستید. خطا در اتصال (تصدیق) - خطا در بارگیری سرورهای SMP عدم موفقیت در بارگیری چت‌ها لطفا برنامه را به‌روزرسانی کنید و با توسعه‌دهندگان تماس بگیرید. نام نمایشی همسان! @@ -106,13 +100,11 @@ مخاطب از قبل وجود دارد لینک اتصال نامعتبر لطفا بررسی کنید که از لینک صحیح استفاده کردید یا از مخاطبتان بخواهید لینک دیگری برایتان بفرستد. - مطمئن شوید قالب نشانی‌های سرور SMP صحیح است، در خط‌های جدا نوشته شده و تکرار نشده‌اند. خطا در به‌روزرسانی پیکربندی شبکه عدم موفقیت در بارگیری چت نام نمایشی نامعتبر! شما یک پروفایل چت با نام نمایشی یکسان دارید، لطفا نام دیگری انتخاب کنید. خطا در ایجاد نشانی - مطمئن شوید قالب نشانی‌های سرور XFTP صحیح است، در خط‌های جدا نوشته شده و تکرار نشده‌اند. خطا در پذیرش درخواست مخاطب فرستنده ممکن است درخواست اتصال را حذف کرده باشد. خطا در حذف یادداشت‌های خصوصی @@ -227,7 +219,6 @@ پیام برای تمام اعضا به عنوان حذف شده علامت‌گذاری خواهد شد. توقف دریافت فایل متوقف خواهد شد. - خوش آمدید! خطا در کدبرداری ارسال پیام مستقیم برای اتصال لطفا تا زمانی که فایل در حال بارگیری از موبایل متصل است، منتظر باشید. @@ -237,7 +228,6 @@ ارسال پیام ناپدید شونده لینک دعوت یک‌بارمصرف ایجاد گروه محرمانه - (برای اشتراک‌گذاری با مخاطبتان) اگر لینک دعوت SimpleX Chat دریافت کردید، می‌توانید آن را در مرورگر خود باز کنید: ورود کد عبور %d ثانیه @@ -297,7 +287,6 @@ ارسال غیرمجاز خوانده نشده خوش آمدید، %1$s! - این متن در تنظیمات دردسترس است چت با توسعه‌دهندگان چت فیلتر شده‌ای نیست به %1$s متصل شوید؟ @@ -370,13 +359,11 @@ شما به گروه دعوت شده‌اید پیوستن به عنوان %s جستجو یا الصاق لینک SimpleX - برای شروع چت جدید ضربه بزنید اشتراک‌گذاری پیام… در انتظار تصویر پیام صوتی… تعیین نام مخاطب… قطع شد - در حال انتظار نشانی‌ دریافت تغییر کند؟ تغییر نشانی‌ لغو خواهد شد. نشانی‌ دریافت پیشین استفاده خواهد شد. مذاکره مجدد @@ -384,7 +371,6 @@ تایید بازنشاندن اسکن کد QR - (اسکن یا الصاق از کلیپ بورد) (تنها ذخیره شده توسط اعضای گروه) فعال کردن دسترسی دوربین اجازه داده نشد! @@ -403,10 +389,8 @@ ضبط پیام صوتی ارسال پیام بدون جزئیات - اتصال به وسیله لینک / کد QR دوربین موجود نیست تصویر - ایجاد لینک دعوت یک‌بارمصرف برای پرسش هر سوال و دریافت اطلاعات به توسعه‌دهندگان SimpleX Chat متصل شوید.]]> افزودن مخاطب: برای ایجاد لینک دعوت جدید، یا اتصال از طریق لینکی که دریافت کردید.]]> اتصال از طریق لینک @@ -477,13 +461,11 @@ یا کد QR را اسکن کنید تلاش مجدد عبارت عبور و اکسپورت پایگاه داده - افزودن سرورهای از پیش تنظیم شده افزودن به دستگاه دیگر حذف سرور چگونه از سرورهای خود استفاده کنید تصویر پیش‌نمایش لینک پروفایل‌های چت شما - سرورهای SMP SimpleX Chat را برای ترمینال نصب کنید در GitHub ستاره بدهید همکاری کنید @@ -505,23 +487,17 @@ کمک لوگوی SimpleX ایمیل - این لینک، یک لینک اتصال معتبر نیست! درخواست اتصال ارسال شد! وقتی دستگاه میزبان گروه آنلاین شد، به گروه متصل خواهید شد، لطفا صبر کنید یا بعدا بررسی کنید! - لینکی که دریافت کردید را الصاق کنید تا به مخاطبتان متصل شوید… پروفایل شما %1$s به اشتراک گذاشته خواهد شد. برای اتصال، مخاطبتان می‌تواند کد QR را اسکن یا از لینک در برنامه استفاده کند. اگر نمی‌توانید ملاقات حضوری داشته باشید، کد QR را در یک تماس تصویری نمایش دهید، یا لینک را به اشتراک بگذارید. - می‌توانید نشانی خود را به صورت لینک یا کد QR به اشتراک بگذارید - هر کسی می‌تواند به شما متصل شود. - وقتی اشخاص درخواست اتصال کنند، شما می‌توانید آن را بپذیرید یا رد کنید. یا این کد را نشان دهید می‌توانید دوباره لینک دعوت را در جزئیات اتصال مشاهده کنید. نگه‌داشتن در حال ایجاد لینک… لینکی که دریافت کردید را الصاق کنید متن الصاقی شما یک لینک SimpleX نیست. - سرور شما - سرور از پیش تنظیم شده نشانی‌ سرور نامعتبر! به‌کارگیری برای اتصال‌های جدید کد QR نامعتبر @@ -530,13 +506,11 @@ علامت‌گذاری به عنوان تایید شده %s تایید نشده است برای تایید رمزنگاری انتها به انتها، روی دستگاه‌های خود، کد را با مخاطبتان مقایسه(یا اسکن) کنید. - سرورهای XFTP شما روش استفاده در حال استفاده از سرورهای SimpleX Chat. تنظیم سرورهای ICE می‌خواهد به شما متصل شود! وقتی دستگاه مخاطبتان آنلاین شد، متصل خواهید شد، لطفا صبر کنید یا بعدا بررسی کنید! - کد QR را در تماس تصویری اسکن کنید، یا مخاطبتان می‌تواند یک لینک دعوت به اشتراک بگذارد.]]> دعوت استعمال نشده نگه داشته شود؟ نشانی‌ SimpleX شما مارکداون در پیام‌ها @@ -549,14 +523,11 @@ عدم موفقیت آزمایش سرور! برخی از سرورها در تست ناموفق بودند: اسکن کد QR سرور - سرورهای SMP شما - سرورهای XFTP از سرورهای SimpleX Chat استفاده شود؟ سرورهای WebRTC ICE ذخیره شده حذف خواهند شد. سرورهای ICE شما اتصال الصاق - باز کردن در برنامه موبایل کلیک کنید.]]> افزودن مخاطب نشانی‌ SimpleX پاک‌سازی تایید @@ -571,7 +542,6 @@ لازم است مخاطبتان آنلاین باشد تا اتصال کامل شود. \nمی‌توانید این اتصال را لغو و مخاطب را حذف کنید (و بعدا با یک لینک جدید امتحان کنید). نشانی‌ SimpleX - این کد QR یک لینک نیست! راهنمای کاربر.]]> این رشته متن، یک لینک اتصال نیست! کدی که اسکن کردید یک کد QR لینک SimpleX نیست. @@ -579,10 +549,7 @@ تصویر پروفایل بیشتر نمایش کد QR - کد QR نامعتبر وقتی درخواست اتصال شما پذیرفته شد، متصل خواهید شد، لطفا صبر کنید یا بعدا بررسی کنید! - کد QR را در تماس تصویری نمایش دهید، یا لینک را به اشتراک بگذارید.]]> - پروفایل چت شما ارسال خواهد شد \nبه مخاطبتان اطلاعات بیشتر اگر بعدا نشانی‌ خود را حذف کنید، مخاطبان خود را از دست نخواهید داد. این لینک دعوت یک‌بارمصرف را به اشتراک بگذارید @@ -640,7 +607,6 @@ اگر تایید کنید، سرورهای پیام‌رسانی خواهند توانست نشانی‌ IP، و فراهم‌کننده شما را ببینند - و این که به چه سرورهایی متصل می‌شوید. مطمئن شوید قالب نشانی‌های سرور WebRTC ICE صحیح است، در خط‌های جدا نوشته شده و تکرار نشده‌اند. برای هر مخاطب و عضو گروه استفاده خواهد شد. \nلطفا توجه داشته باشید: اگر اتصال‌های زیادی داشته باشید، مصرف باتری و ترافیک شما می‌تواند به شکل قابل توجه بالاتر باشد و بعضی اتصال‌ها ممکن است با موفقیت انجام نشوند.]]> - حالت انزوای ترابری به روز شود؟ ویرایش تصویر ایجاد نشانی‌ SimpleX اشتراک‌گذاری با مخاطبان @@ -652,7 +618,6 @@ حذف نشانی سلام! \nبه وسیله SimpleX Chat به من متصل شوید: %s - نشانی ایجاد نشود ادامه پروفایل فعلی شما نام کامل: @@ -664,8 +629,6 @@ ذخیره کلمه عبور پروفایل اشتراک‌گذاری نشانی متوقف شود؟ توقف اشتراک‌گذاری - می‌توانید بعدا آن را ایجاد کنید - می‌توانید آن را از طریق تنظیمات برای مخاطبان SimpleX خود قابل رویت کنید. نام پروفایل: حذف تصویر ذخیره کردن و اطلاع به اعضای گروه @@ -677,7 +640,6 @@ کلمه عبور پروفایل پنهان به پروفایل خود نشانی اضافه کنید، تا مخاطبانتان بتوانند آن را با اشخاص دیگر به اشتراک بگذارند. به‌روزرسانی پروفایل به مخاطبانتان ارسال خواهد شد. نشانی با مخاطبان به اشتراک گذاشته شود؟ - یک نشانی ایجاد کنید تا اشخاص بتوانند به شما متصل شوند. پروفایل شما روی دستگاهتان ذخیره شده و فقط با مخاطبانتان به اشتراک گذاشته می‌شود. سرورهای SimpleX قادر به دیدن پروفایل شما نیستند. خطا در ذخیره کردن کلمه عبور کاربر تماس‌های صوتی و تصویری @@ -727,15 +689,11 @@ اکسپورت پایگاه داده پروفایل چت حذف شود؟ نام خود را وارد کنید: - ایجاد روش استفاده از مارکداون می‌توانید از مارکداون برای آرایش پیام‌ها استفاده کنید: تماس رد شده تماس پذیرفته - نامتمرکز پروفایل خود را ایجاد کنید - مخزن GitHub ما.]]> - استفاده از چت بهترین گزینه برای باتری. شما اعلان‌ها را فقط وقتی دریافت می‌کنید که برنامه در حال اجراست (بدون سرویس پس‌زمینه).]]> تماس‌ها روی صفحه قفل: پذیرفتن @@ -771,11 +729,9 @@ حذف پایگاه داده پایگاه داده چت ایمپورت شد %d فایل با اندازه کل %s - فقط دستگاه‌های کلاینت پروفایل‌های کاربری، مخاطبان، گروه‌ها و پیام‌ها را ذخیره می‌کنند. نادیده گرفتن بلوتوث وارد کردن پایگاه داده - شما تصمیم می‌گیرید که چه کسی می‌تواند متصل شود. تماس از پیش پایان یافته! هش پیام ناصحیح پذیرفتن خودکار تصاویر @@ -786,18 +742,15 @@ ارسال رسید برای %d گروه فعال است ارسال رسید برای %d گروه غیرفعال است حمایت از SimpleX Chat - پروکسی SOCKS استفاده از کامپیوتر آرشیو پایگاه داده جدید آرشیو پایگاه داده قدیمی خطا در شروع چت - مصونیت در برابر هرزنامه چگونه کار می‌کند تماس تصویری گوشی بلندگو هدفون‌ها - آینده پیام‌رسانی پایان یافت خطا در باز کردن مرورگر عبارت عبور برای اکسپورت را تعیین کنید @@ -808,7 +761,6 @@ چت متوقف شده است خطا در حذف پایگاه داده چت خطا در ایمپورت کردن پایگاه داده چت - شما چت خود را کنترل می‌کنید! پروفایل، مخاطبان و پیام‌های تحویل داده شده شما روی دستگاهتان ذخیره می‌شوند. پروفایل فقط با مخاطبانتان به اشتراک گذاشته می‌شود. نام نمایشی نمی‌تواند شامل نویسه‌های فاصله باشد. @@ -823,7 +775,6 @@ تعیین عبارت عبور پایگاه داده استفاده از عبارت عبور تصادفی تماس صوتی - سرورهای ICE شما تماس در جریان است شناسه پیام ناصحیح حریم خصوصی و امنیت @@ -839,7 +790,6 @@ آن‌ها در تنظیمات مخاطب و گروه قابل جایگزینی هستند. گروه‌های کوچک (حداکثر ۲۰) تنظیمات - هیچ شناسه کاربری وجود ندارد اعلان‌های خصوصی عبارت عبور تصادفی در تنظیمات به صورت متن آشکار ذخیره می‌شود. \nمی‌توانید بعدا آن را تغییر دهید. @@ -858,7 +808,6 @@ کد عبور تغییر نکرد! ایجاد پروفایل مورب - ما هیچکدام از مخاطبان و پیام‌های(وقتی تحویل داده شدند) شما را روی سرورها ذخیره نمی‌کنیم. رنگی محرمانه در حال تماس… @@ -882,7 +831,6 @@ اتصال شبکه آیکون برنامه پایگاه داده چت - بن‌سازه پیام‌رسانی و کاربردی که از حریم خصوصی و امنیت شما محافظت می‌کند. گزینه خوب برای باتری. سرویس پس‌زمینه هر ۱۰ دقیقه پیام‌ها را بررسی می‌کند. ممکن است تماس‌ها یا پیام‌های ضروری را از دست دهید.]]> پیام‌های نادیده گرفته شده مرورگر وب پیش‌فرض برای تماس‌ها لازم است. لطفا مرورگر پیش‌فرض را در سیستم تنظیم کنید، و اطلاعات بیشتر را با توسعه‌دهندگان به اشتراک بگذارید. @@ -919,7 +867,6 @@ اعطای اجازه‌ها در تنظیمات این مجوز را در تنظیمات اندروید پیدا و به صورت دستی آن را اعطا کنید. باز کردن تنظیمات - هر کسی می‌تواند سرویس‌دهنده میزبانی کند. رسیدها غیرفعال شوند؟ فعال کردن (نگه‌داشتن مقدارهای جایگزین شده) اعطای اجازه‌ها @@ -927,8 +874,6 @@ دوربین دوربین و میکروفون اعطای اجازه‌ها برای برقراری تماس‌ها - تعریف مجدد حریم خصوصی - برای حفظ حریم خصوصی شما، SimpleX از شناسه‌های جداگانه برای هر یک از مخاطبان شما استفاده می‌کند. از باتری بیشتر استفاده می‌کند! سرویس پس‌زمینه همیشه در حال اجراست - اعلان‌ها به محض موجود شدن، نمایش داده می‌شوند.]]> وقتی می‌تواند اتفاق بیفتد که: \n۱. پیام‌ها در کلاینت فرستنده بعد از ۲ روز یا روی سرور بعد از ۳۰ روز منقضی شده باشند. @@ -1339,8 +1284,6 @@ تنظیمات گفت‌و‌گو فایل‌ها و رسانه تماس‌های صوتی/تصویری - " -\nموجود در نسخه 5.1" به مخاطبان خود اجازه ارسال پیام‌های صوتی می‌دهید. فقط زمانی اجازه حذف پیام‌ها به صورت غیرقابل برگشت را می‌دهید که مخاطب شما این اجازه را به شما بدهد. (۲۴ ساعت) پیام‌های ناپدید شونده ممنوع است. @@ -1982,7 +1925,6 @@ باز کردن چت جدید باز کردن گروه جدید به منوی تنظیمات سافاری / وب‌سایت‌ها / و سپس میکروفن رفته و گزینه \"اجازه دادن برای localhost\" را انتخاب کنید. - باز کردن تنظیمات سرور باز کردن لینک باز کردن لینک‌ها از لیست چت‌ها باز کردن برای پذیرش @@ -1997,8 +1939,6 @@ یا به‌طور خصوصی به اشتراک بگذارید سایر سایر خطاها - سایر سرورهای SMP - سایر سرورهای XFTP عبارت عبور در کی‌استور قابل خواندن نیست، لطفاً آن را به‌صورت دستی وارد کنید. این ممکن است پس از یک به‌روزرسانی سیستم که با برنامه سازگار نیست، اتفاق افتاده باشد. اگر این مورد نیست، لطفاً با توسعه‌دهندگان تماس بگیرید. عبارت عبور در مخزن کلید قابل خواندن نیست. این ممکن است پس از به‌روزرسانی سیستم که با برنامه سازگار نیست، اتفاق افتاده باشد. اگر این طور نیست، لطفا با توسعه دهندگان تماس بگیرید. کلمه عبور @@ -2152,7 +2092,6 @@ با استفاده از SimpleX Chat شما موافقت می‌کنید که:\n- فقط محتوای قانونی را در گروه‌های عمومی ارسال کنید.\n- به سایر کاربران احترام بگذارید - از ارسال هرزنامه خودداری کنید. تماس با یک مخاطب استفاده شود - آن را به صورت حضوری یا از طریق هر پیام‌رسانی به اشتراک بگذارید.]]> - به صورت رمزنگاری انتها به انتهاو با امنیت پساکوانتومی در پیام‌های مستقیم ارسال می‌شوند.]]> %s.]]> %s.]]> رمزنگاری انتها به انتها محافظت می‌شوند.]]> @@ -2175,8 +2114,6 @@ شرایط استفاده شرایط در: %s پذیرفته خواهد شد. شرایط به‌طور خودکار برای اپراتورهای فعال در: %s پذیرفته خواهد شد. - سرورهای SMP پیکربندی‌شده - سرورهای XFTP پیکربندی‌شده سرورهای متصل شده اتصال نیاز به تجدید مذاکره رمزنگاری دارد. اتصالات diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml index e5e7c525a5..5036d874d7 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml @@ -50,7 +50,6 @@ Yhteyden aikakatkaisu Kontaktin asetukset Jatka - Luo osoite, jolla ihmiset voivat ottaa sinuun yhteyttä. Tietokannan salauksen tunnuslause päivitetään ja tallennetaan Keystoreen. Poista keskusteluprofiili käyttäjälle poistettu @@ -96,7 +95,6 @@ Yhdistetäänkö kontaktilinkin kautta\? Liitytäänkö ryhmään? Yhdistetäänkö kutsulinkin kautta\? - Yhdistä yhdistää Kuvaus Yhdistä @@ -106,7 +104,6 @@ Dekoodauksen virhe peruuta linkin esikatselu Tietoja SimpleX:stä - Hajautettu Ääni pois päältä Vaihda rooli Poista kaikilta @@ -166,7 +163,6 @@ Poista ryhmä\? Tumma Tunnistautuminen epäonnistui - Lisää esiasetettuja palvelimia poistettu ryhmä yhdistää yhdistäminen (hyväksytty) @@ -215,8 +211,6 @@ Chat on pysähtynyt Vahvista uusi tunnuslause… Poista keskusteluprofiili\? - " -\nSaatavilla v5.1:ssä" Yksityisviestit Yhdistetty Vahvista @@ -235,7 +229,6 @@ oletus (%s) Katoavat viestit Kopioitu leikepöydälle - Luo kertaluonteinen kutsulinkki tietokantaversio on uudempi kuin sovellus, mutta ei alaspäin siirtymistä: %s erilainen siirto sovelluksessa/tietokannassa: %s / %s Hyväksy yhteydenottopyynnöt automaattisesti @@ -263,7 +256,6 @@ Kontakti ja kaikki viestit poistetaan - tätä ei voi perua! Katkaistu Takaisin - Yhdistä linkillä / QR-koodilla Tyhjennä Skannaa QR-koodi.]]> Poistetaanko odottava yhteys\? @@ -272,7 +264,6 @@ Tietoja SimpleX Chatistä Tietokannan tunnuslause ja vienti Luo profiili - Luo Poista käytöstä Ääni päällä Vastaa puheluun @@ -346,11 +337,8 @@ Poista jälkeen %d tunti päivää - Virhe XFTP-palvelimien tallentamisessa Päällekkäinen näyttönimi! Virhe profiilin luomisessa! - Virhe SMP-palvelimia ladattaessa - Virhe XFTP-palvelimia ladattaessa Virhe profiilin vaihdossa! Virhe ryhmään liittymisessä Virhe käyttäjäprofiilin poistamisessa @@ -370,14 +358,12 @@ Ryhmälinkki Otetaanko automaattinen viestien poisto käyttöön\? Tiedosto vastaanotetaan, kun kontakti on online-tilassa, odota tai tarkista myöhemmin! - skannata QR-koodin videopuhelussa , tai kontaktisi voi jakaa kutsulinkin.]]> Jos päätät hylätä, lähettäjälle EI ilmoiteta. Jos syötät tämän pääsykoodin sovellusta avatessasi, kaikki sovelluksen tiedot poistetaan peruuttamattomasti! Ryhmäprofiili tallennetaan jäsenten laitteille, ei palvelimille. Tuo teema t virhe - Virhe SMP-palvelimien tallentamisessa Koko linkki Virhe verkkoasetuksia päivitettäessä Keskustelun lataaminen epäonnistui @@ -494,7 +480,6 @@ Kutsu ystäviä Hei! \nOta minuun yhteyttä SimpleX Chatin kautta: %s - Älä luo osoitetta Muokkaa kuvaa Poistu tallentamatta Virhe käyttäjän salasanan tallentamisessa @@ -548,8 +533,6 @@ Kuva Galleriasta Virheellinen linkki! - Virheellinen QR-koodi - näytä QR-koodi videopuhelussa tai jaa linkki.]]> Virheellinen palvelimen osoite! Kirjoita nimesi: Käännä kamera @@ -573,7 +556,6 @@ Kuva Sähköposti Näyttönimi ei saa sisältää välilyöntejä. - Immuuni roskapostille ja väärinkäytöksille Virhe vietäessä keskustelujen tietokantaa Piilota Konsoliin @@ -629,7 +611,6 @@ Aseta pääsykoodi SimpleX-linkit Linkin avaaminen selaimessa voi heikentää yhteyden yksityisyyttä ja turvallisuutta. Epäluotetut SimpleX-linkit näkyvät punaisina. - Varmista, että SMP-palvelinosoitteet ovat oikeassa muodossa, rivieroteltuina ja että ne eivät ole päällekkäisiä. Lähettäjä peruutti tiedoston siirron. Säännölliset ilmoitukset Viestejä vastaanotetaan… @@ -697,11 +678,9 @@ Paljasta Lähetetty viesti Vain ryhmän omistajat voivat ottaa ääniviestit käyttöön. - Vireillä Tallenna ääniviesti Lähetä katoava viesti Lähetä live-viesti - Aloita uusi keskustelu napauttamalla Lähetä Verkkoasetukset profiilikuvan paikka @@ -715,7 +694,6 @@ Itsetuho Itsetuhoutuva pääsykoodi vaihdettu! Itsetuhoutuva pääsykoodi käytössä! - SOCKS välityspalvelin Uusi tietokanta-arkisto Ei vastaanotettuja tai lähetettyjä tiedostoja Poistetaanko tunnuslause Keystoresta\? @@ -776,7 +754,6 @@ Skannaa QR-koodi Lue lisää %s on vahvistettu - SMP-palvelimet Esikatselu Tallenna sek @@ -817,7 +794,6 @@ Tallenna palvelimet Palvelintesti epäonnistui! Jotkut palvelimet epäonnistuivat testissä: - Esiasetettu palvelin Arvioi sovellus Skannaa palvelimen QR-koodi Profiilipäivitys lähetetään kontakteillesi. @@ -828,9 +804,7 @@ Tallenna asetukset\? Jutellaan SimpleX Chatissa vahvistus saatu… - GitHub-arkistostamme.]]> Säännölliset - 2-kerroksisella päästä päähän -salauksella.]]> Liitä vastaanotettu linkki Välityspalvelin suojaa IP-osoitteesi, mutta se voi tarkkailla puhelun kestoa. Avaa SimpleX Chat hyväksyäksesi puhelun @@ -845,7 +819,6 @@ Kiitos käyttäjille – osallistu Weblaten kautta! Lähetä Lupa evätty! - (skannaa tai liitä leikepöydältä) Napauta -painike Näytä QR-koodi Kertakutsulinkki @@ -888,7 +861,6 @@ Edellisen viestin tarkiste on erilainen. Live-viestit Suojaa keskusteluprofiilisi salasanalla! - Varmista, että XFTP-palvelinosoitteet ovat oikeassa muodossa, rivieroteltuina ja että ne eivät ole päällekkäisiä. SimpleX Chat -viestit SimpleX Chat -puhelut Näytä kontakti ja viesti @@ -946,10 +918,6 @@ QR-koodi Skannaa koodi alkaa… - Yksityisyys uudelleen määritettynä - Ensimmäinen alusta ilman käyttäjätunnisteita – suunniteltu yksityiseksi. - Avoimen lähdekoodin protokolla ja koodi - kuka tahansa voi käyttää palvelimia. - Ihmiset voivat ottaa sinuun yhteyttä vain jakamiesi linkkien kautta. Luo yksityinen yhteys Ilmoita siitä kehittäjille. Suojaa sovellusnäyttö @@ -1017,8 +985,6 @@ Ääniviestit kielletty! Palvelimet nykyisen keskusteluprofiilisi uusille yhteyksille Käytä palvelinta - XFTP-palvelimet - Palvelimesi Palvelimesi osoite Tätä toimintoa ei voi kumota - valittua aikaisemmin lähetetyt ja vastaanotetut viestit poistetaan. Tämä voi kestää useita minuutteja. Uusimmat @@ -1057,7 +1023,6 @@ Video vastaanotetaan, kun kontaktisi on ladannut sen. Keskustelusi Käytä SOCKS-välityspalvelinta\? - Hallitset keskustelujasi! Nykyinen profiilisi Profiilisi tallennetaan laitteeseesi ja jaetaan vain kontaktiesi kanssa. SimpleX -palvelimet eivät näe profiiliasi. Teemat @@ -1089,10 +1054,8 @@ Näytä turvakoodi Ääniviesti (%1$s) Video - Voit jakaa osoitteesi linkkinä tai QR-koodina - kuka tahansa voi muodostaa yhteyden sinuun. Voit tarkistaa päästä päähän -salauksen kontaktisi kanssa vertaamalla (tai skannaamalla) laitteidenne koodia. Kontaktisi pysyvät yhdistettyinä. - Viestintä- ja sovellusalusta, joka suojaa yksityisyyttäsi ja tietoturvaasi. videopuhelu %1$s JÄSENET Rooli muuttuu muotoon "%s". Kaikille ryhmän jäsenille ilmoitetaan asiasta. @@ -1110,17 +1073,10 @@ Kontaktin tulee olla online-tilassa, jotta yhteys voidaan muodostaa. \nVoit peruuttaa tämän yhteyden ja poistaa kontaktin (ja yrittää myöhemmin uudella linkillä). Kontaktisi voi muodostaa yhteyden skannaamalla QR-koodin tai käyttämällä sovelluksessa olevaa linkkiä. - Kun ihmiset pyytävät yhteyden muodostamista, voit hyväksyä tai hylätä sen. Asetuksesi - SMP-palvelimesi - XFTP-palvelimesi Käytä SimpleX Chat palvelimia\? Käyttöliittymän värit - Päivitä kuljetuksen eristystila\? - Voit luoda sen myöhemmin Voit paljastaa piilotetun profiilisi kirjoittamalla koko salasanan Keskusteluprofiilit-sivun hakukenttään. - Emme tallenna mitään kontaktejasi tai viestejäsi (kun ne on toimitettu) palvelimille. - Yksityisyyden suojaamiseksi kaikkien muiden alustojen käyttämien käyttäjätunnusten sijaan SimpleX käyttää viestijonojen tunnisteita, jotka ovat kaikille kontakteille erillisiä. %1$s haluaa olla yhteydessä sinuun kautta WebRTC ICE -palvelimet Keskustelut-tietokantasi @@ -1134,9 +1090,6 @@ Valinnaisella tervetuloviestillä. Sinulla ei ole keskusteluja Liikaa kuvia! - (jaa kontaktisi kanssa) - Keskusteluprofiilisi lähetetään -\nkontakteillesi ICE-palvelimesi Kun saatavilla Käytä .onion-isäntiä @@ -1158,12 +1111,9 @@ \n- muokattava kesto katoaville. \n- muokkaushistoria. Voit ottaa SimpleX Lockin käyttöön Asetusten kautta. - Tämä teksti on saatavilla asetuksista - Tervetuloa! lukematon SimpleX Logo SimpleX Tiimi - Tämä linkki ei ole kelvollinen yhteyslinkki! Keskusteluprofiilisi Lataa tiedosto SimpleX-taustapalvelu – se kuluttaa muutaman prosentin akusta päivässä.]]> @@ -1179,19 +1129,15 @@ Vastaanotto-osoite vaihdetaan toiseen palvelimeen. Osoitteenmuutos tehdään sen jälkeen, kun lähettäjä tulee verkkoon. Sinun on sallittava kontaktiesi lähettää ääniviestejä, jotta voit lähettää niitä. olla yhteydessä SimpleX Chatin -kehittäjiin kysyäksesi kysymyksiä ja saadaksesi päivityksiä.]]> - Tämä QR-koodi ei ole linkki! Sinut yhdistetään ryhmään, kun ryhmän isännän laite on online-tilassa, odota tai tarkista myöhemmin! Sinut yhdistetään, kun yhteyspyyntösi on hyväksytty, odota tai tarkista myöhemmin! Sinut yhdistetään, kun kontaktisi laite on online-tilassa, odota tai tarkista myöhemmin! - Avaa mobiilisovelluksessa -painiketta.]]> SimpleX-osoitteesi Käytä uusiin yhteyksiin Käytä SOCKS-välityspalvelinta Kuljetuksen eristäminen Profiilisi, kontaktisi ja toimitetut viestit tallennetaan laitteellesi. Profiili jaetaan vain kontaktiesi kanssa. - Käytä chattia - ICE-palvelimesi Video pois päältä Video päällä Et enää saa viestejä tästä ryhmästä. Keskusteluhistoria säilytetään. @@ -1209,7 +1155,6 @@ Varoitus: saatat menettää joitain tietoja! SimpleX Osoite odottaa vastaamista… - Seuraavan sukupolven yksityisviestit odottaa vahvistusta… %1$d viestit ohitettu. Seuraavan viestin tunnus on väärä (pienempi tai yhtä suuri kuin edellisen). @@ -1247,7 +1192,6 @@ Protokollan aikakatkaisu per KB Tiedostot ja media ovat tässä ryhmässä kiellettyjä. Ryhmän jäsenet voivat lähettää tiedostoja ja mediaa. - Yhdistä Incognito Käytä nykyistä profiilia Käytä uutta incognito-profiilia Salli @@ -1263,7 +1207,6 @@ Suosikki Epäsuosikki Uusi satunnainen profiili jaetaan. - Liitä linkki, jonka sait yhteydenottoon kontaktisi kanssa… Profiilisi %1$s jaetaan. Kuittaukset pois käytöstä ryhmiltä\? Tulossa pian! diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml index 572b10c988..51cfa90ff1 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml @@ -5,7 +5,6 @@ SimpleX Votre profil va être envoyé au contact qui vous a envoyé ce lien. Vous vous connecterez à tous les membres du groupe. - Se connecter Rejoindre le groupe ? Se connecter via un lien unique ? erreur @@ -45,7 +44,6 @@ vous avez partagé un lien unique via un lien unique mode incognito via un lien unique - Assurez-vous que les adresses des serveurs SMP sont au bon format, séparées par des lignes et ne sont pas dupliquées. Erreur lors de la mise à jour de la configuration réseau Erreur lors de la création de l\'adresse Contact déjà existant @@ -55,7 +53,6 @@ Tentative de connexion au serveur utilisé pour recevoir les messages de ce contact (erreur : %1$s). format de message invalide Lien entier - Erreur lors de la sauvegarde des serveurs SMP Impossible de recevoir le fichier Lien de connection invalide Délai de connexion @@ -137,11 +134,9 @@ Échec d’initialisation de la base de données Pour recevoir des notifications, veuillez entrer la phrase secrète de la base de données service SimpleX Chat - Ce texte est disponible dans les paramètres Rejoindre en tant que %s Vous êtes invité·e au groupe Discuter avec les développeurs - Appuyez ici pour démarrer une nouvelle conversation Vous n\'avez aucune conversation Trop d’images ! Partager le fichier… @@ -150,7 +145,6 @@ Annuler l’aperçu du fichier échec d’envoi non lu - Bienvenue ! connexion… connexion… Partager le message… @@ -192,11 +186,8 @@ aucun détail Lien d\'invitation unique Copié dans le presse-papiers - Créer un lien d\'invitation unique Commencer une nouvelle conversation - Se connecter via un lien / code QR Scanner un QR code - (à partager avec votre contact) Créer un groupe secret Depuis la Phototèque Fichier @@ -233,19 +224,14 @@ Équipe SimpleX Plus Afficher le code QR - Code QR invalide - Ce code QR n\'est pas un lien ! Vous serez connecté·e lorsque votre demande de connexion sera acceptée, veuillez attendre ou vérifier plus tard ! Vous serez connecté·e lorsque l\'appareil de votre contact sera en ligne, veuillez attendre ou vérifier plus tard ! - Votre profil de messagerie sera envoyé\nà votre contact Partager un lien unique Coller Cette chaîne n\'est pas un lien de connexion ! - Ouvrir dans l\'app mobile.]]> Définir le nom du contact… Déconnecté Erreur - En attente Accepter la demande de connexion \? Effacer Effacer la conversation @@ -261,7 +247,6 @@ Confirmer Réinitialisation OK - (scanner ou coller depuis le presse-papiers) (uniquement stocké par les membres du groupe) Votre contact a besoin d\'être en ligne pour completer la connexion. \nVous pouvez annuler la connexion et supprimer le contact (et réessayer plus tard avec un autre lien). @@ -297,7 +282,6 @@ Réactiver le son Le contact avec lequel vous avez partagé ce lien NE pourra PAS se connecter ! Lien invalide ! - Ce lien n\'est pas un lien de connexion valide ! Demande de connexion envoyée ! Le fichier sera reçu lorsque votre contact sera en ligne, veuillez patienter ou vérifier plus tard ! Message vocal… @@ -306,13 +290,10 @@ Notifications L\'adresse de réception sera changée pour un autre serveur. Le changement d\'adresse sera terminé lorsque l\'expéditeur sera en ligne. Vous serez connecté·e au groupe lorsque l\'appareil de l\'hôte sera en ligne, veuillez attendre ou vérifier plus tard ! - montrez le code QR lors d\'un appel vidéo, ou partagez le lien.]]> Scannez le code de sécurité depuis l\'application de votre contact. Pour vérifier le chiffrement de bout en bout avec votre contact, comparez (ou scannez) le code sur vos appareils. - scanner un QR code lors d\'un appel vidéo, ou votre contact peut partager un lien d\'invitation.]]> Ajouter un serveur Markdown dans les messages - Ajouter des serveurs prédéfinis Utiliser les serveurs SimpleX Chat \? Supprimer le serveur Assurez-vous que les adresses des serveurs WebRTC ICE sont au bon format et ne sont pas dupliquées, un par ligne. @@ -321,7 +302,6 @@ Vos paramètres Verrouillage SimpleX Console de la messagerie - Serveurs SMP Tester les serveurs Enregistrer les serveurs Scanner un QR code de serveur @@ -332,7 +312,6 @@ Star sur GitHub Contribuer Évaluer l\'app - Vos serveurs SMP Comment utiliser vos serveurs Les serveurs WebRTC ICE sauvegardés seront supprimés. Vos serveurs ICE @@ -354,7 +333,6 @@ Votre profil, vos contacts et les messages reçus sont stockés sur votre appareil. Le profil n\'est partagé qu\'avec vos contacts. Le nom d\'affichage ne peut pas contenir d\'espace. - Créer À propos de SimpleX Vous pouvez utiliser le format markdown pour mettre en forme les messages : gras @@ -366,10 +344,7 @@ réponse reçu… confimation reçu… connexion… - N\'importe qui peut heberger un serveur. - Pour protéger votre vie privée, SimpleX utilise des identifiants distincts pour chacun de vos contacts. Collez le lien que vous avez reçu - Utiliser la messagerie Notifications privées Comment il affecte la batterie Quand l\'application fonctionne @@ -398,8 +373,6 @@ Échec du test du serveur ! Certains serveurs n\'ont pas réussi le test : Entrer un serveur manuellement - Serveur prédéfini - Votre serveur Votre adresse de serveur Adresse de serveur invalide ! Vérifiez l\'adresse du serveur et réessayez. @@ -420,9 +393,6 @@ Votre profil est stocké sur votre appareil et partagé uniquement avec vos contacts. Les serveurs SimpleX ne peuvent pas voir votre profil. Supprimer l\'image Enregistrer les préférences ? - Vous contrôlez votre messagerie ! - La plateforme de messagerie et d\'applications qui protège votre vie privée et votre sécurité. - Nous ne stockons aucun de vos contacts ou messages (une fois délivrés) sur les serveurs. Créer le profil Saisissez votre nom : Comment utiliser markdown @@ -435,17 +405,9 @@ en attente de confirmation… connecté terminé - L\'avenir de la messagerie - La vie privée redéfinie - Aucun identifiant d\'utilisateur. - Protégé du spam - Vous choisissez qui peut se connecter. - Décentralisé Créez votre profil Établir une connexion privée Comment ça fonctionne - Seuls les appareils clients stockent les profils des utilisateurs, les contacts, les groupes et les messages. - GitHub repository.]]> Batterie peu utilisée. L\'app vérifie les messages toutes les 10 minutes. Vous risquez de manquer des appels ou des messages urgents.]]> Consomme davantage de batterie ! L’application fonctionne toujours en arrière-plan — les notifications sont affichées instantanément.]]> %1$d message(s) manqué(s) @@ -516,7 +478,6 @@ Appels audio et vidéo chiffré de bout en bout Fonctionnalités expérimentales - SOCKS proxy Thèmes Messages et fichiers Appels @@ -570,7 +531,6 @@ Appels sur l\'écran de verrouillage : Afficher Désactiver - Vos serveurs ICE Serveurs WebRTC ICE Le serveur relais protège votre adresse IP, mais il peut observer la durée de l\'appel. Le serveur relais n\'est utilisé que si nécessaire. Un tiers peut observer votre adresse IP. @@ -896,7 +856,6 @@ Effacer les fichiers de tous les profils de messagerie Profil et connexions au serveur Transport isolé - Mettre à jour le mode d\'isolement du transport \? pour chaque contact et membre de groupe. \nVeuillez noter : si vous avez de nombreuses connexions, votre consommation de batterie et de réseau peut être nettement plus élevée et certaines liaisons peuvent échouer.]]> Profil de messagerie Ajouter un profil @@ -1014,16 +973,10 @@ Seulement 10 vidéos peuvent être envoyées en même temps Créer un fichier Supprimer le fichier - Erreur lors de la sauvegarde des serveurs XFTP - Assurez-vous que les adresses des serveurs XFTP sont au bon format, séparées par des lignes et qu\'elles ne sont pas dupliquées. Le serveur requiert une autorisation pour téléverser, vérifiez le mot de passe. Téléverser le fichier - Serveurs XFTP - Vos serveurs XFTP Comparer le fichier Télécharger le fichier - Erreur lors du chargement des serveurs SMP - Erreur lors du chargement des serveurs XFTP Héberger Port port %d @@ -1064,7 +1017,6 @@ Autoriser les appels que si votre contact les autorise. Autorise vos contacts à vous appeler. Appels audio/vidéo - \nDisponible dans la v5.1 Interdire les appels audio/vidéo. Le fichier sera supprimé des serveurs. Révoquer @@ -1098,10 +1050,8 @@ Ouverture de la base de données… À propos de l\'adresse SimpleX En savoir plus - Vous pouvez partager votre adresse sous la forme d\'un lien ou d\'un code QR - tout le monde peut l\'utiliser pour vous contacter. Vous ne perdrez pas vos contacts si vous supprimez votre adresse ultérieurement. Adresse SimpleX - Vous pouvez accepter ou refuser les demandes de contacts. Couleurs de l\'interface Vos contacts resteront connectés. Partager l\'adresse avec les contacts SimpleX ? @@ -1112,7 +1062,6 @@ Inviter des amis Discutons sur SimpleX Chat Enregistrer les paramètres ? - Ne pas créer d\'adresse Adresse Partager l\'adresse… Vous pouvez partager cette adresse avec vos contacts pour leur permettre de se connecter avec %s. @@ -1136,9 +1085,7 @@ Personnaliser le thème Continuer Erreur lors du réglage de l\'adresse - Vous pouvez créer une adresse pour permettre aux autres utilisateurs de vous contacter. Entrez un message de bienvenue… - Vous pouvez la créer plus tard Salut !\nConnectez-vous avec moi via SimpleX Chat : %s Si vous ne pouvez pas vous rencontrer en personne, montrez le code QR lors d\'un appel vidéo ou partagez le lien. Modifier les profils de messagerie @@ -1333,10 +1280,8 @@ Désactiver les notifications Utilisation de la batterie de l\'app / Sans restriction dans les paramètres de l\'app.]]> Votre profil %1$s sera partagé. - Se connecter incognito Pas d\'appels en arrière-plan Ouvrir les paramètres de l\'app - Collez le lien que vous avez reçu pour vous connecter à votre contact… SimpleX ne peut pas fonctionner en arrière-plan. Vous ne recevrez les notifications que lorsque l\'application sera en cours d\'exécution. L\'application peut se fermer après 1 minute en arrière-plan. Utilisation de la batterie de l\'app / Sans restriction dans les paramètres de l\'app.]]> @@ -1504,7 +1449,6 @@ Ouvrir le port de votre pare-feu erreur d\'affichage de contenu erreur d\'affichage de message - Vous pouvez le rendre visible à vos contacts SimpleX via les Paramètres. L\'historique n\'est pas envoyé aux nouveaux membres. Réessayer Caméra non disponible @@ -1897,14 +1841,10 @@ Bêta Vérifier les mises à jour Vérifier les mises à jour - Serveurs SMP configurés - Serveurs XFTP configurés Désactivé Installé avec succès Installer la mise à jour Ouvrir l\'emplacement du fichier - Autres serveurs SMP - Autres serveurs XFTP Veuillez redémarrer l\'application. Rappeler plus tard Afficher le pourcentage @@ -1954,7 +1894,6 @@ Erreurs de suppression doublons expiré - Ouvrir les paramètres du serveur autre autres erreurs Sécurisées @@ -2134,7 +2073,6 @@ %s.]]> Les conditions seront acceptées le : %s. %s, acceptez les conditions d\'utilisation.]]> - chiffrés de bout en bout, avec une sécurité post-quantique dans les messages directs.]]> Réception des messages toutes les 10 minutes %s.]]> %s.]]> diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/hi/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/hi/strings.xml index 3f17a6d8a7..ed301675fd 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hi/strings.xml @@ -34,7 +34,6 @@ संबंध अनुरोध स्वीकार करें\? स्वीकृत कॉल गुप्त स्वीकार करें - पूर्वनिर्धारित सर्वर जोड़ें प्रोफ़ाइल जोड़ें सर्वर जोड़े हमेशा बने रहें @@ -51,7 +50,6 @@ रंगीन जुड़े हुए जुडिये - जुडिये जुड़े हुए स्वामी जुड़े हुए @@ -63,7 +61,6 @@ आप चले गए अज्ञात त्रुटि आप आज्ञा दें - स्वागत! चालू करो अज्ञात संदेश प्रारूप स्वागत %1$s! @@ -166,7 +163,6 @@ संपर्क नाम सेट करें ईमेल जवाब मिला… - चैट का प्रयोग करें गलती: %s संदेशों को बाद में हटाएं छवि @@ -212,7 +208,6 @@ विवरण ऐप केवल तभी सूचनाएं प्राप्त कर सकता है जब वह चल रहा हो, कोई पृष्ठभूमि सेवा प्रारंभ नहीं की जाएगी संपादन करना - विकेन्द्रीकृत कॉल समाप्त कॉल चल रहा है छवियों को स्वत: स्वीकार करें @@ -227,7 +222,6 @@ चैट कंसोल आपके सभी संपर्क जुड़े रहेंगे। चैट प्रोफ़ाइल - बनाएं कॉल त्रुटि कॉल चल रहा है हमेशा रिले का प्रयोग करें @@ -278,7 +272,6 @@ चालू प्रणाली प्रमाणीकरण प्रणाली - आपका एसएमपी सर्वर संदेशों सदस्य को समूह से निकाल दिया जाएगा - इसे पूर्ववत नहीं किया जा सकता! सदस्य @@ -296,7 +289,6 @@ गुप्त प्रोफ़ाइल का उपयोग करें आपकी प्रोफ़ाइल उस संपर्क को भेजी जाएगी जिससे आपको यह लिंक प्राप्त हुआ है। आप सभी ग्रुप मेंबर्स से कनेक्ट होंगे। - इनकॉग्निटो कनेक्ट करें चैट खोलें नया ग्रुप खोलें यह लिंक मान्य नहीं है diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml index 7be983c71a..2f010d8aa9 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml @@ -125,7 +125,6 @@ Razumeo Odstranjeno odstranjeno - Napraviti Poruke i datoteke Poruka Serveri @@ -170,7 +169,6 @@ Pristup na servere pomoću SOCKS proxy na portu %d? Proxy mora da bude uključen pre omogućavanja ove opcije. Prihvaćeni uslovi Dodaj server - Dodaj unapred postavljene servere Odstraniti adresu? odstranjena grupa Dodaj nalog @@ -222,9 +220,7 @@ blokirano administratorom Vi nepoznat status - Poveži se anonimno greška u pozivu - Poveži nepoznat format poruke Povezano %ds @@ -442,8 +438,6 @@ Video poziv Koristi server Adresa vašeg servera - Vaši SMP serveri - Vaši XFTP serveri Obaveštenja i baterija Prikazati e2e šifrovano @@ -460,7 +454,6 @@ Započeti novi razgovor Očistiti Očistiti razgovor - Tvoj server šifrovanje prihvaćeno za %s Glasovne poruke su zabranjene. veza %1$d @@ -654,8 +647,6 @@ Sistem Poslati direktnu poruku za povezivanje Slika - Na čekanju - SMP serveri Upišite Vaše ime: Otvoriti razgovor pozvan %1$s @@ -683,7 +674,6 @@ Otvoriti pomoću %s Ukloniti arhivu? Izabrano %d - Dobrodošli! Previše video snimaka! Poslati Obnoviti @@ -691,7 +681,6 @@ Izabrati profil razgovora Skenirati QR kod servera Napredna mrežna podešavanja - SOCKS proxy Anonimni režim broj PING Obnoviti statistiku? @@ -759,7 +748,6 @@ vlasnik Kopirati Predati - Ostali XFTP serveri Napraviti SimpleX adresu Šifrovati Kreirano u @@ -781,7 +769,6 @@ Dovršiti migraciju Ništa nije izabrano Konverzacija odstranjena! - Ostali SMP serveri Verzija jezgra: v%s Šifrovati bazu podataka Odstraniti vezu? @@ -794,7 +781,6 @@ Za primanje drugo Napraviti profil - Vi odlučujete ko se može povezati. Odstraniti grupu? Obnoviti Prebaciti @@ -904,7 +890,6 @@ Glasovna poruka (%1$s) Naučiti više Označiti da je verifikovano - Koristiti razgovor Označiti da nije pročitano Verifikovati vezu zahtev za povezivanje @@ -944,7 +929,6 @@ Odstraniti server blokirali ste %s Kvantno otporno šifrovanje - Ne praviti adresu Omogućiti svim grupama Ne prikazivati ponovo minuti @@ -1011,7 +995,6 @@ Operator servera blokira vezu:\n%1$s. Promeniti ulogu Ime ovog uređaja - Dodirnuti za početak novog razgovora pozvani ste u grupu Slika sačuvana u Galeriji vaša uloga je promenjena u %s @@ -1047,7 +1030,6 @@ Pozvani ste u grupu promenili ste vašu ulogu na %s Potvrda je onemogućena - Imunitet na spam duplikat poruke Da Greška u bazi podataka @@ -1078,7 +1060,6 @@ Proslediti poruku… Adresa ili jednokratna veza? Omogućiti pristup kameri - Nevažeći QR kod Jednokratna pozivnica Uneti poruku dobrodošlice… Čuvanje %1$s poruka @@ -1207,9 +1188,7 @@ Koristiti za poruke Greška pri kreiranju profila! Greška pri učitavanju detalja - Greška pri čuvanju XFTP servera Otpremljene datoteke - Otvoriti podešavanja servera Greška pri odstranjivanju privatnih beleški Greška pri prosleđivanju poruka Poruke su odstranjene nakon što ste ih odabrali. @@ -1224,7 +1203,6 @@ Onemogućiti potvrde? povezivanje (uvedeno) Uneti šifru u pretragu - Greška pri učitavanju XFTP servera Greška pri odstranjivanju grupe Nije moguće poslati poruku Poslati poruku koja nestaje @@ -1294,8 +1272,6 @@ Poslano putem proxy pozvan za povezivanje pomoću %1$s - Greška pri učitavanju SMP servera - Greška pri čuvanju SMP servera Greška pri promeni adrese Pristupnu frazu je potrebna Spomenuti članove 👋 @@ -1308,7 +1284,6 @@ Bez Informacija, pokušajte ponovo da učitate Komadi su odstranjeni SimpleX Chat pozivi - XFTP serveri Mreža i serveri Podesite ICE servere Koristiti direktnu internet vezu? @@ -1325,7 +1300,6 @@ Vremensko ograničenje protokola Greška pri slanju poruke Greška pri odstranjivanju kontakta - Napraviti jednokratnu pozivnicu Markdown pomoć Dozvoliti slanje datoteka i medijskog sadržaji. Pin kod promenjen! @@ -1380,7 +1354,6 @@ Zapis ažuriran u: %s Član će biti uklonjen iz grupe – ovo se ne može poništiti! Pozivi zabranjeni! - \nDostupno u v5.1 SimpleX veze Odstraniti za sve Dozvoliti slanje poruka koje nestaju. @@ -1396,13 +1369,11 @@ Dozvoliti vašim kontaktima da vas zovu. Prijem poruke Greška pri izvoženju baze podataka razgovora - Možete učiniti vidljivim vašim SimpleX kontaktima putem Podešavanja. Greška pri slanju pozivnice Preskočiti pozivanje članova Za privatno usmeravanje Dodatni sekundarni Pokretanje iz %s. - Vaši ICE serveri Koristiti nasumičnu pristupnu frazu Onemogućiti SimpleX Zaključavanje Ponovo povezati server? @@ -1415,15 +1386,12 @@ Neispravan hash poruke Ponovo povezati server kako biste prisilili dostavu poruke. To koristi dodatni saobraćaj. neispravan ID poruke - Konfigurisani XFTP serveri Upozorenje pri isporuci poruke - Povezati se pomoću linka / QR koda Već imate chat profil sa istim prikaznim imenom. Molimo vas da odaberete drugo ime. Greška u autentifikaciji Unapređena konfiguracija servera %1$d ostale greška datoteke(a). Autentifikacija nije dostupna - Konfigurisani SMP serveri Automatsko prihvatanje Sačuvati preference? Za pozive je potreban podrazumevani veb pretraživač. Molimo vas da konfigurišete podrazumevani pretraživač u sistemu i podelite više informacija sa programerima. @@ -1445,7 +1413,6 @@ Otvoriti fasciklu baze podataka Sačuvati i ažurirati grupni profil Uz smanjenu potrošnju baterije. - (skenirati ili nalepiti iz memorije) Greška u vezi Ažurirajte aplikaciju i kontaktirajte programere. Tokom uvoza došlo je do nekih nefatalnih grešaka: @@ -1458,11 +1425,9 @@ Uz smanjenu potrošnju baterije. Greška pri preuzimanju arhive Svi razgovori biće uklonjeni sa liste %s, a lista odstranjena - Ovaj QR kod nije link! Već se povezujete! Migriraj na drugi uređaj Otvoriti promene - Decentralizovano Nema informacija o prijem Povezati se pomoću linka Zadržati Vaše konekcije diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml index 82518712da..8bc886865d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml @@ -36,7 +36,6 @@ Hitelesítés Egy üres csevegési profil lesz létrehozva a megadott névvel, és az alkalmazás a szokásos módon megnyílik. %s visszavonva - Előre beállított kiszolgálók hozzáadása A hívások kezdeményezése le van tiltva. Az összes partneréhez és csoporttaghoz külön TCP-kapcsolat (és SOCKS-hitelesítési adat) lesz használva.\nMegjegyzés: ha sok kapcsolata van, akkor az akkumulátor-használat és az adatforgalom jelentősen megnövekedhet, és néhány kapcsolódási kísérlet sikertelen lehet.]]> hivatkozáselőnézet visszavonása @@ -66,7 +65,6 @@ Alkalmazás verziója Üdvözlőüzenet hozzáadása titkosítás elfogadása %s számára… - \nElérhető az v5.1-es kiadásban Mindkét fél véglegesen törölheti az elküldött üzeneteket. (24 óra) Továbbfejlesztett csoportok Az összes üzenet törölve lesz – ez a művelet nem vonható vissza! Az üzenetek CSAK az Ön számára törlődnek. @@ -179,7 +177,6 @@ Zárolási mód módosítása Kapcsolódott kapcsolódott - Kapcsolódás kapcsolódott Társított hordozható eszköz kapcsolódott @@ -200,7 +197,6 @@ A partner és az összes üzenet törölve lesz – ez a művelet nem vonható vissza! A partnerei törlésre jelölhetnek üzeneteket; Ön majd meg tudja nézni azokat. Kapcsolódik az egyszer használható meghívón keresztül? - Kapcsolódás egy hivatkozáson vagy QR-kódon keresztül A kapcsolódási hivatkozás el lett távolítva Csak név Kapcsolódik a kapcsolattartási címen keresztül? @@ -219,7 +215,6 @@ Helyesbíti a nevet a következőre: %s? Időtúllépés kapcsolódáskor Kapcsolódik vele: %1$s? - Létrehozás Partnerbeállítások Kapcsolat Kapcsolat megszakítva @@ -240,7 +235,6 @@ törölt partner Törli a tag üzenetét? A csevegés fut - Egyszer használható meghívó létrehozása Törlés Új üzenetek ellenőrzése 10 percenként, legfeljebb 1 percen keresztül Adatbázis törlése @@ -312,7 +306,6 @@ Törli a hivatkozást? kapcsolódás Egyéni időköz - Kapcsolódás inkognitóban Csevegések Új profil létrehozása a számítógépes alkalmazásban. 💻 kapcsolódás (bejelentve) @@ -334,7 +327,6 @@ Elvetés Törli a partnert? Ürítés - Cím létrehozása, hogy az emberek kapcsolatba léphessenek Önnel. Biztonsági kódok összehasonlítása a partnerekével. Fájl-összehasonlítás Csevegések @@ -389,7 +381,6 @@ Csoportok felfedezése és csatlakozás %2$s %1$d üzenetet moderált Eltűnő üzenet - Ne hozzon létre címet Ne jelenjen meg újra SimpleX-zár kikapcsolása végpontok között titkosított @@ -437,7 +428,6 @@ Csoportprofil szerkesztése végpontok között titkosított hanghívás %d mp - Decentralizált Dekódolási hiba Kép szerkesztése Értesítések letiltása @@ -469,8 +459,6 @@ Hiba történt az ICE-kiszolgálók mentésekor Hiba Hiba - Hiba történt az XFTP-kiszolgálók betöltésekor - Hiba történt az SMP-kiszolgálók betöltésekor Hiba történt a hálózat konfigurációjának frissítésekor TCP életben tartása Kamera váltás @@ -497,7 +485,6 @@ Hiba történt a felhasználói adatvédelem frissítésekor Titkosítás Csoport nem található! - Hiba történt az SMP-kiszolgálók mentésekor Visszafejlesztés és a csevegés megnyitása A csoport inaktív Gyors és nem kell várni, amíg az üzenetküldő online lesz! @@ -563,7 +550,6 @@ Hiba történt a függőben lévő kapcsolat törlésekor Hiba történt a csevegési adatbázis importálásakor Hiba történt a kézbesítési jelentések engedélyezésekor! - Hiba történt az XFTP-kiszolgálók mentésekor A tagok küldhetnek egymásnak közvetlen üzeneteket. Hiba történt a tag eltávolításakor hívás vége @@ -660,10 +646,8 @@ Elrejtve: Hiba történt a partnerrel történő kapcsolat létrehozásában ICE-kiszolgálók (soronként egy) - beolvashatja a QR-kódot a videohívásban, vagy a partnere megoszthat egy meghívási hivatkozást.]]> Ha az alkalmazás megnyitásakor megadja ezt a jelkódot, az összes alkalmazásadat véglegesen el lesz távolítva! Ha nem tud személyesen találkozni, mutassa meg a QR-kódot egy videohívás közben, vagy ossza meg a hivatkozást. - mutassa meg a QR-kódot a videohívásban, vagy ossza meg a hivatkozást.]]> Megerősítés esetén az üzenetváltó-kiszolgálók látni fogják az IP-címét és a szolgáltatóját – azt, hogy mely kiszolgálókhoz kapcsolódik. A kép akkor érkezik meg, amikor a küldője befejezte annak feltöltését. QR-kód beolvasásával.]]> @@ -673,7 +657,6 @@ Számítógépek A markdown használata Csevegési profil létrehozása - Védett a kéretlen tartalmakkal szemben Hordozható eszközök leválasztása Különböző nevek, profilképek és átvitelelkülönítés. Elutasítás esetén a kérés küldője NEM kap értesítést. @@ -693,7 +676,6 @@ Nincsenek kézbesítési adatok moderált A tag el lesz távolítva a csoportból – ez a művelet nem vonható vissza! - Győződjön meg arról, hogy a megadott XFTP-kiszolgálók címei megfelelő formátumúak, soronként elkülönítettek, és nincsenek duplikálva. Nincs partner kiválasztva Nincsenek fogadott vagy küldött fájlok Megnyitás hordozható eszköz-alkalmazásban, majd koppintson a Kapcsolódás gombra az alkalmazásban.]]> @@ -732,7 +714,6 @@ k soha (új)]]> - Győződjön meg arról, hogy a megadott SMP-kiszolgálók címei megfelelő formátumúak, soronként elkülönítettek, és nincsenek duplikálva. Az onion kiszolgálók nem lesznek használva. perc Tudjon meg többet @@ -791,11 +772,9 @@ Hordozható eszköz társítása Értesítési szolgáltatás Csak a csoport tulajdonosai engedélyezhetik a hangüzenetek küldését. - A felhasználói profilok, partnerek, csoportok és üzenetek csak az eszközön vannak tárolva a kliensen belül. Érvénytelen átköltöztetési visszaigazolás Csak a csoport tulajdonosai módosíthatják a csoportbeállításokat. Nincsenek előzmények - Érvénytelen QR-kód Megjelölés olvasottként ÉLŐ Megjelölés olvasatlanként @@ -875,9 +854,7 @@ Szerepkör SimpleX kapcsolattartási cím Megállítás - Előre beállított kiszolgáló Új csevegés indítása - Bárki üzemeltethet kiszolgálókat. Megnyitás Protokoll időtúllépése titok @@ -892,7 +869,6 @@ Adatvédelem Profil SimpleX-címe Jelentse a fejlesztőknek. - Ön dönti el, hogy kivel beszélget. Az eltűnő üzenetek küldése le van tiltva. Csak Ön küldhet hangüzeneteket. Frissítés @@ -912,7 +888,6 @@ Csökkentett akkumulátor-használat Mentés és a partnerek értesítése Előnézet - SimpleX Chat használata Megosztás Fogadott üzenet Üdvözlőüzenet @@ -920,9 +895,7 @@ Csak a partnere kezdeményezhet hívásokat. Témák Túl sok videó! - Üdvözöljük! Önmegsemmisítő jelkód - (beolvasás vagy beillesztés a vágólapról) Várakozás a videóra Válasz Ez a saját egyszer használható meghívója! @@ -959,7 +932,6 @@ Kérje meg a partnerét, hogy engedélyezze a hangüzenetek küldését. Ön egy egyszer használható meghívót osztott meg A hivatkozás megnyitása a böngészőben gyengítheti az adatvédelmet és a biztonságot. A megbízhatatlan SimpleX-hivatkozások pirossal vannak kiemelve. - Saját ICE-kiszolgálók Ön elfogadta a kapcsolatot Elutasítás Partner nevének és az üzenet tartalmának megjelenítése @@ -976,7 +948,6 @@ Amikor elérhető Hangüzenet (%1$s) %s (jelenlegi) - Saját SMP-kiszolgáló Véletlen Megosztás a SimpleX partnerekkel Ön @@ -1075,15 +1046,12 @@ Kezelőfelület színei Adja meg a korábbi jelszót az adatbázis biztonsági mentésének visszaállítása után. Ez a művelet nem vonható vissza. Másodlagos szín - SOCKS proxy Mentés Újraindítás - SMP-kiszolgálók Videó SimpleX-címbeállítások mentése Újraegyeztetés Várakozás a videóra - Saját XFTP-kiszolgálók Videó kikapcsolva Privát fájlnevek Menti a beállításokat? @@ -1097,7 +1065,6 @@ Feloldja a tag letiltását? A kérés küldője törölte a kapcsolódási kérést. Érvénytelen adatbázis-jelmondat - Saját SMP-kiszolgálók A kézbesítési jelentések le vannak tiltva Adatbázismappa megnyitása egy egyszer használható meghívón keresztül @@ -1145,7 +1112,6 @@ Időszakos értesítések letiltva! A jelkód módosult! Akkor fut, amikor az alkalmazás meg van nyitva - Ez a QR-kód nem egy hivatkozás! Várakozás a fájlra simplexmq: v%s (%2s) Leválasztás @@ -1154,7 +1120,6 @@ A reakciók hozzáadása az üzenetekhez le van tiltva. Rendszer olvasatlan - Függőben Üdvözöljük %1$s! Eltávolítja a jelmondatot a Keystrore-ból? Feloldás @@ -1173,7 +1138,6 @@ Színek visszaállítása Mentés Váltás - A kapott hivatkozás beillesztése a partnerhez való kapcsolódáshoz… Beolvasás Port nyitása a tűzfalban hívás indítása… @@ -1181,7 +1145,6 @@ elküldve SOCKS proxy használata Élő üzenet küldése - Újraértelmezett adatvédelem Hangüzenet… Alkalmazás képernyőjének védelme QR-kód megjelenítése @@ -1200,7 +1163,6 @@ Felfedés Zárolási mód Fájl visszavonása - XFTP-kiszolgálók A fájlok és a médiatartalmak küldése le van tiltva. Fájl megosztása… Mentés @@ -1209,7 +1171,6 @@ Ön eltávolította őt: %1$s Jelmondat mentése és a csevegés megnyitása Menti a beállításokat? - Nincsenek felhasználói azonosítók. A közvetlen üzenetek küldése a tagok között le van tiltva. SOCKS proxy használata? Hangszóró kikapcsolva @@ -1274,9 +1235,7 @@ A jelenlegi csevegési adatbázis TÖRÖLVE és CSERÉLVE lesz az importáltra!\nEz a művelet nem vonható vissza – profiljai, partnerei, csevegési üzenetei és fájljai véglegesen törölve lesznek. Ötletek és javaslatok Figyelmeztetés: néhány adat elveszhet! - Koppintson ide az új csevegés indításához Várakozás a számítógép-alkalmazásra… - Az üzenetváltás jövője Módosítja a hálózati beállításokat? Várakozás a hordozható eszköz társítására: Biztonságos kapcsolat ellenőrzése @@ -1306,7 +1265,6 @@ Az alkalmazás elindításához vagy 30 másodpercnyi háttérben töltött idő után, az alkalmazáshoz való visszatéréshez hitelesítésre lesz szükség. Az üzenet az összes tag számára törölve lesz. A videó nem dekódolható. Próbálja ki egy másik videóval, vagy lépjen kapcsolatba a fejlesztőkkel. - Ez a szöveg a beállításokban érhető el A profilja el lesz küldve a partnere számára, akitől ezt a hivatkozást kapta. Az alkalmazás 1 perc után bezárható a háttérben. Ön meghívást kapott a csoportba @@ -1334,7 +1292,6 @@ különböző átköltöztetés az alkalmazásban/adatbázisban: %s / %s %1$s.]]> Profil felfedése - Ez nem egy érvényes kapcsolattartási hivatkozás! A végpontok közötti titkosítás ellenőrzéséhez hasonlítsa össze (vagy olvassa be a QR-kódot) a partnere eszközén lévő kóddal. A csevegési adatbázis legfrissebb verzióját CSAK egy eszközön kell használnia, ellenkező esetben előfordulhat, hogy az üzeneteket nem fogja megkapni valamennyi partnerétől. Ez a beállítás csak az Ön jelenlegi csevegési profiljában lévő üzenetekre vonatkozik @@ -1343,10 +1300,7 @@ A csatlakozás már folyamatban van a csoporthoz ezen a hivatkozáson keresztül. Ön meghívást kapott a csoportba A partnere a jelenleg támogatott legnagyobb (%1$s) fájlméretnél nagyobbat küldött. - A partnerei és az üzenetek (kézbesítés után) nem a SimpleX kiszolgálókon vannak tárolva. Üzenetek formázása a szövegbe szúrt speciális karakterekkel: - Megnyitás az alkalmazásban gombra.]]> - A csevegési profilja el lesz küldve\na partnere számára Egy olyan partnerét próbálja meghívni, akivel inkognitóprofilt osztott meg abban a csoportban, amelyben a fő profilja van használatban %1$s nevű csoporthoz.]]> Amikor az alkalmazás fut @@ -1362,15 +1316,10 @@ Akkor lesz kapcsolódva, amikor a partnerének az eszköze online lesz, várjon, vagy ellenőrizze később! Kéretlen üzenetek elrejtése. Onion kiszolgálók használata beállítást „Nemre”, ha a SOCKS proxy nem támogatja őket.]]> - Megoszthatja a címét egy hivatkozásként vagy egy QR-kódként – így bárki kapcsolódhat Önhöz. - Létrehozás később A profilja az eszközén van tárolva és csak a partnereivel van megosztva. A SimpleX kiszolgálók nem láthatják a profilját. Ön a következőre módosította %s szerepkörét: „%s” Csoportmeghívó elutasítva - Adatainak védelme érdekében a SimpleX külön azonosítókat használ minden egyes kapcsolatához. - (a megosztáshoz a partnerével) Csoportmeghívó elküldve - Frissíti az átvitelelkülönítési módot? Átvitelelkülönítés Nem fog több üzenetet kapni ebből a csoportból, de a csevegés előzményei megmaradnak. A csevegési adatbázis nem titkosított – állítson be egy jelmondatot annak védelméhez. @@ -1407,14 +1356,12 @@ Ön csatlakozott ehhez a csoporthoz %1$s nevű csoporthoz!]]> A hangüzenetek küldése le van tiltva ebben a csevegésben. - Ön irányítja csevegését! Kód ellenőrzése a számítógépen Az időzóna védelmének érdekében a kép-/hangfájlok UTC-t használnak. A csatlakozási kérése el lesz küldve ennek a csoporttagnak. Ha egy inkognitóprofilt oszt meg valamelyik partnerével, a rendszer ezt az inkognitóprofilt fogja használni azokban a csoportokban, ahová az adott partnere meghívja Önt. Már kért egy kapcsolódási kérést ezen a címen keresztül! Megoszthatja ezt a SimpleX-címet a partnereivel, hogy kapcsolatba léphessenek vele: %s. - Amikor az emberek kapcsolatot kérnek, Ön elfogadhatja vagy elutasíthatja azokat. Megjelenítendő üzenet beállítása az új tagok számára! Köszönet a felhasználóknak a Weblate-en való közreműködésért! A kézbesítési jelentések küldése az összes partnere számára engedélyezve lesz. @@ -1456,7 +1403,6 @@ A kézbesítési jelentések engedélyezve vannak %d csoportban A tag szerepköre a következőre fog módosulni: „%s”. A csoport összes tagja értesítést fog kapni. Profil és kiszolgálókapcsolatok - Egy üzenetváltó- és alkalmazásplatform, amely védi az adatait és biztonságát. Koppintson ide a profil aktiválásához. A kézbesítési jelentések le vannak tiltva %d partner számára Munkamenet kódja @@ -1484,10 +1430,8 @@ Az előző üzenet kivonata különbözik. Ezek a beállítások csak a jelenlegi csevegési profiljára vonatkoznak Várjon, amíg a fájl betöltődik a társított hordozható eszközről - GitHub-tárolónkban talál.]]> Hiba történt a tartalom megjelenítésekor Hiba történt az üzenet megjelenítésekor - Láthatóvá teheti a SimpleXbeli partnerei számára a beállításokban. Legfeljebb az utolsó 100 üzenet lesz elküldve az új tagok számára. A beolvasott QR-kód nem egy SimpleX-hivatkozás. A beillesztett szöveg nem egy SimpleX-hivatkozás. @@ -1827,9 +1771,6 @@ Az üzenet később is kézbesíthető, ha a tag aktívvá válik. Még nincs közvetlen kapcsolat, az üzenetet az adminisztrátor továbbítja. Hivatkozás megadása vagy QR-kód beolvasása - Konfigurált SMP-kiszolgálók - Egyéb SMP-kiszolgálók - Egyéb XFTP-kiszolgálók letiltva inaktív Nagyítás @@ -1875,7 +1816,6 @@ Feltöltött fájlok Letöltött töredékek Letöltött fájlok - Kiszolgáló-beállítások megnyitása Kiszolgáló címe Feltöltési hibák Visszaigazolva @@ -1886,7 +1826,6 @@ Feltöltött töredékek Elkészült Kapcsolódott kiszolgálók - Konfigurált XFTP-kiszolgálók Kapcsolódott Jelenlegi profil További részletek @@ -2163,7 +2102,6 @@ Másolhatja és csökkentheti az üzenet méretét a küldéshez. Adja hozzá a munkatársait a beszélgetésekhez. Üzleti cím - végpontok közötti titkosítással, a közvetlen üzenetek továbbá kvantumbiztos titkosítással is rendelkeznek.]]> Hogyan segíti az adatvédelmet Nincs háttérszolgáltatás Értesítések és akkumulátor 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 ea18b5e428..392c935575 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml @@ -75,8 +75,6 @@ Gunakan profil penyamaran baru Profil Anda akan dikirim ke kontak yang menerima tautan ini. Anda akan terhubung ke semua anggota grup. - Hubungkan - Hubungkan penyamaran Membuka basis data… k Hubungkan melalui alamat kontak? @@ -137,7 +135,6 @@ Izinkan panggilan? Pesan baru Hanya kamu yang dapat mengirim pesan menghilang. - Tidak ada pengidentifikasi pengguna. Hanya pemilik grup yang dapat mengubah preferensi grup. dan %d peristiwa lainnya Peran baru anggota @@ -216,7 +213,6 @@ TIdak pernah Mati Hanya 10 video dapat dikirim pada saat bersamaan - Hanya perangkat klien yang menyimpan profil pengguna, kontak, grup, dan pesan. Hanya pemilik grup yang dapat mengaktifkan pesan suara. Seluruh pesan akan dihapus - ini tidak bisa dibatalkan! Izinkan turun versi @@ -322,7 +318,6 @@ Unduh diedit Obrolan - Selamat Datang! Chat dengan pengembang Setel nama kontak… Pengaturan @@ -373,15 +368,11 @@ Bersihkan verifikasi Konsol obrolan Server pesan - Server SMP Beri nilai aplikasi Bintang di GitHub Gunakan server SimpleX Chat? - Server SMP Anda - Server XFTP Kredensial Anda mungkin dikirim tidak terenkripsi. Username - Server XFTP Anda Gunakan server SimpleX Chat. Gunakan kredensial proxy yang berbeda untuk setiap koneksi. Kata sandi @@ -404,7 +395,6 @@ Tampilkan status pesan Terima otomatis Undang teman - Buat Nama tidak valid! Buat profil Masukkan nama Anda: @@ -417,8 +407,6 @@ berakhir Headphone Kesalahan saat menginisialisasi WebView. Perbarui sistem Anda ke versi baru. Mohon hubungi pengembang.\nKesalahan: %s - Anda pilih siapa yang dapat terhubung. - Kebal terhadap spam Buat profil Anda Buat koneksi pribadi Lewati @@ -534,9 +522,6 @@ Via peramban Gagal mengganti profil Buka tautan di peramban mengurangi privasi dan keamanan koneksi. Tautan SimpleX tidak tepercaya akan berwarna merah. - Gagal simpan server SMP - Pastikan alamat server SMP dalam format yang benar, pisahkan baris dan tidak terduplikasi. - Pastikan alamat server XFTP dalam format yang benar, pisahkan baris dan tidak terduplikasi. gagal terkirim belum dibaca Pesan dapat disampaikan kemudian jika anggota menjadi aktif. @@ -613,10 +598,7 @@ Statistik server akan direset - ini tidak dapat dibatalkan! Perbesar ukuran font. Sumber pesan tetap pribadi. - Siapa pun dapat menjadi pemegang server. - Terdesentralisasi Kesalahan saat menginisialisasi WebView. Pastikan Anda telah menginstal WebView dan arsitektur yang didukung adalah arm64.\nKesalahan: %s - Gunakan obrolan Bagaimana caranya Berkala Panggilan suara masuk @@ -658,7 +640,6 @@ Preferensi kontak diaktifkan diaktifkan untuk anda - \nTersedia di v5.1 Kirim pesan suara tidak diizinkan. Hingga 100 pesan terakhir dikirim ke anggota baru. Pesan suara @@ -696,7 +677,6 @@ Kunci SimpleX Bantuan Markdown Server media & berkas - Server Anda Alamat server ditetapkan Gunakan di koneksi baru Simpan server? @@ -712,7 +692,6 @@ Simpan kata sandi profil Kata sandi profil tersembunyi Mikrofon - Privasi didefinisikan ulang Hal yang mempengaruhi baterai Saat aplikasi sedang berjalan Notifikasi pribadi @@ -780,7 +759,6 @@ Tautan SimpleX Undangan 1-kali SimpleX via %1$s - Gagal simpan server XFTP Nama tampilan tidak valid! Gagal membuat profil! Kesalahan koneksi @@ -788,8 +766,6 @@ Gagal membuat pesan Waktu koneksi habis pengiriman tidak sah - Teks ini tersedia di pengaturan - Ketuk untuk memulai obrolan baru Anda diundang ke grup Gabung sebagai %s menghubungkan… @@ -820,12 +796,10 @@ Markdown dalam pesan Simpan server Tambah server - Tambah server prasetel Uji server gagal! Server uji Server uji Masukkan server manual - Server Prasetel Pindai kode QR server Beberapa server gagal dalam pengujian: Alamat server Anda @@ -870,7 +844,6 @@ Diaktifkan untuk %dh Alamat server tidak valid! - Server XFTP lainnya Gunakan kredensial proksi yang berbeda untuk setiap profil. Server penerusan %1$s gagal terhubung ke server tujuan %2$s. Coba lagi nanti. Cari atau tempel tautan SimpleX @@ -896,10 +869,7 @@ Pengaturan Anda Alamat SimpleX Anda Profil obrolan Anda - Server SMP dikonfigurasi - Server SMP lainnya Gunakan server - Server XFTP dikonfigurasi Periksa alamat server dan coba lagi. Tampilkan persentase Kontribusi @@ -979,7 +949,6 @@ Buat alamat ID Basis Data dan Opsi Isolasi Transport. panggilan tak terjawab - Anda dapat membuatnya terlihat oleh kontak SimpleX Anda melalui Pengaturan. Undang Hai!\nHubungi saya melalui SimpleX Chat: %s Konfirmasi kata sandi @@ -993,11 +962,9 @@ Panggilan pada layar terkunci: Speaker Izin dalam pengaturan - Perpesanan masa depan Temukan izin ini di pengaturan Android dan ubah secara manual. Earpiece Matikan - Server ICE Anda Server ICE WebRTC Jika Anda memasukkan kode sandi hapus otomatis saat membuka aplikasi: Ikon aplikasi @@ -1185,7 +1152,6 @@ Matikan (tetap ditimpa) Gagal memuat obrolan Gagal hapus kontak - Gagal memuat server SMP Gagal memuat obrolan Nama tampilan ini tidak valid. Silakan pilih nama lain. Pengirim mungkin telah hapus permintaan koneksi. @@ -1206,7 +1172,6 @@ Pengaturan ini untuk profil Anda saat ini Kirim tanda terima diaktifkan untuk %d kontak Kirim tanda terima dimatikan untuk %d kontak - Gagal memuat server XFTP Gagal memperbarui konfigurasi jaringan Mohon perbarui aplikasi dan hubungi pengembang. Anda sudah memiliki nama tampilan profil obrolan yang sama. Silakan pilih nama lain. @@ -1476,7 +1441,6 @@ Gagal hapus Perangkat Xiaomi: harap aktifkan Autostart di pengaturan sistem agar notifikasi berfungsi.]]> Hapus obrolan - Hubungkan via tautan / kode QR Profil acak baru akan dibagikan. Permintaan koneksi terkirim! Keamanan koneksi @@ -1540,7 +1504,6 @@ batal pratinjau tautan Tombol tutup Email - pindai kode QR dalam panggilan video, atau kontak Anda dapat bagikan tautan undangan.]]> Panduan Pengguna.]]> Aktifkan Ketika lebih dari satu operator diaktifkan, tidak satupun dari mereka memiliki metadata untuk mengetahui siapa yang berkomunikasi. @@ -1556,8 +1519,6 @@ Frasa sandi enkripsi basis data akan diperbarui dan disimpan di pengaturan. Frasa sandi enkripsi basis data akan diperbarui dan disimpan di Keystore. migrasi berbeda di aplikasi/basis data: %s / %s - tunjukkan kode QR dalam panggilan video, atau bagikan tautan.]]> - Jangan buat alamat Rincian Koneksi Terhubung ke ponsel @@ -1570,13 +1531,10 @@ Kapasitas terlampaui - penerima tidak menerima pesan yang dikirim sebelumnya. hanya dengan satu kontak - bagikan secara langsung atau melalui messenger apa pun.]]> Disalin ke papan klip - Buat tautan undangan satu-kali Saat ini maksimal ukuran berkas adalah %1$s. - Buka di ponsel.]]> Pengaturan alamat Tambahkan anggota tim Anda ke percakapan. Alamat bisnis - dengan enkripsi end-to-end, dengan keamanan post-quantum dalam pesan pribadi.]]> Buat tautan 1-kali Notifikasi dan baterai Tiada layanan latar belakang @@ -1586,14 +1544,12 @@ Bagaimana ini membantu privasi Lanjutkan Anda dapat konfigurasi server di pengaturan. - repositori GitHub kami.]]> Rincian tautan unduhan untuk setiap profil obrolan yang Anda miliki di aplikasi.]]> untuk setiap kontak dan anggota grup.\nHarap diperhatikan: jika Anda memiliki banyak koneksi, konsumsi baterai dan lalu lintas dapat jauh lebih tinggi dan beberapa koneksi mungkin gagal.]]> Mengunduh pembaruan aplikasi, jangan tutup aplikasi Mati Buat alamat SimpleX - Buat alamat agar orang dapat terhubung dengan Anda. Lanjutkan Hapus gambar Edit gambar @@ -1732,7 +1688,6 @@ Gagal memulai obrolan Mode samaran Gagal hapus basis data - Kode QR tidak valid Gagal enkripsi basis data Galat Impor @@ -1883,17 +1838,14 @@ Silakan hubungi admin grup. Memuat berkas Harap tunggu sementara berkas sedang dimuat dari ponsel yang terhubung - Tertunda Negosiasi ulang enkripsi? Negosiasi ulang Kirim pesan sementara Kirim Pindai kode QR - (pindai atau tempel dari papan klip) Izin Ditolak! Tandai dibaca tautan pratinjau gambar - Anda dapat bagikan alamat sebagai tautan atau kode QR - siapa pun dapat terhubung dengan Anda. Simpan Jadikan profil pribadi! Jalankan obrolan @@ -1905,14 +1857,12 @@ Hapus frasa sandi dari pengaturan? Simpan frasa sandi di pengaturan Diproxy - Tempel tautan yang Anda terima untuk terhubung dengan kontak… lainnya Kirim pesan langsung Pesan langsung! Kode QR Memindah Alamat server - Buka pengaturan server alamat kontak dihapus Putar dari daftar obrolan. Ponsel terhubung @@ -1939,7 +1889,6 @@ Mari bicara di SimpleX Chat Simpan dan beritahu kontak Simpan preferensi? - Anda dapat buat nanti Gabung ke grup? Bergabung dengan grup Minta kontak Anda untuk aktifkan panggilan. @@ -2008,18 +1957,12 @@ Untuk memulai obrolan baru Video Koneksi yang Anda terima akan dibatalkan! - Kode QR ini bukan tautan! Bunyikan Isolasi transport - Perbarui mode isolasi transport? Untuk melindungi alamat IP, routing pribadi menggunakan server SMP untuk mengirim pesan. Perlihat kesalahan internal Lihat panggilan API lambat - Kami tidak menyimpan kontak atau pesan Anda (setelah terkirim) di server. Profil, kontak, dan pesan terkirim Anda disimpan di perangkat Anda. - Platform perpesanan dan aplikasi yang melindungi privasi dan keamanan Anda. - Untuk melindungi privasi Anda, SimpleX gunakan ID terpisah untuk setiap kontak. - Proxy SOCKS Tingkatkan dan buka obrolan Ketuk untuk gabung ke samaran Anda memblokir %s @@ -2140,7 +2083,6 @@ Pesan suara (%1$s) Lihat kode keamanan Anda perlu mengizinkan kontak mengirim pesan suara agar dapat mengirimkannya. - (untuk dibagikan dengan kontak Anda) Ketuk untuk pindai Terima kasih telah memasang SimpleX Chat! Setel nama kontak @@ -2150,7 +2092,6 @@ Anda akan terhubung saat perangkat kontak Anda online, harap tunggu atau periksa nanti! Anda akan terhubung saat permintaan koneksi Anda diterima. Harap tunggu atau periksa nanti! Bagikan tautan 1-kali - Saat orang meminta untuk terhubung, Anda dapat terima atau menolaknya. Anda tidak akan kehilangan kontak jika menghapus alamat Anda nanti. Matikan? Hentikan obrolan? @@ -2165,7 +2106,6 @@ Perlihat profil Anda harus masukkan frasa sandi setiap aplikasi dibuka - frasa sandi tidak disimpan di perangkat. Server SMP - Profil obrolan Anda akan dikirim\nke kontak Anda Bagikan alamat Peran akan diubah menjadi %s. Anggota akan menerima undangan baru. Perbarui pengaturan jaringan? @@ -2192,14 +2132,12 @@ Profil Anda saat ini Anda menggunakan profil samaran untuk grup ini - untuk mencegah berbagi profil utama Anda, undang kontak tidak diizinkan Anda mengirim undangan grup - Tautan ini bukan tautan koneksi yang valid! Anda akan berhenti menerima pesan dari grup ini. Riwayat obrolan akan disimpan. Anda bergabung ke grup ini. Menghubungkan untuk undang anggota grup. Anda buka blokir %s Menunggu desktop… Ganti profil obrolan untuk undangan 1-kali. Untuk perlihat profil tersembunyi Anda, masukkan kata sandi lengkap di kolom pencarian di halaman Profil obrolan Anda. - Anda mengendalikan obrolan Anda! Untuk terima Perlihat Perbarui pengaturan akan menghubungkan ulang klien ke semua server. 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 2f19777c2b..afaac45347 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml @@ -14,7 +14,6 @@ Connettere via link una tantum? Entrare nel gruppo? Il tuo profilo verrà inviato al contatto da cui hai ricevuto questo link. - Connetti connesso errore in connessione @@ -46,8 +45,6 @@ Link gruppo SimpleX Link completo Via browser - Errore di salvataggio server SMP - Assicurati che gli indirizzi dei server SMP siano nel formato giusto, uno per riga e non doppi. Errore di aggiornamento della configurazione di rete Caricamento conversazione fallito Caricamento delle chat fallito @@ -143,13 +140,10 @@ invio fallito non letto Benvenuto/a %1$s! - Benvenuto/a! - Questo testo è disponibile nelle impostazioni Сhat Sei stato/a invitato/a in un gruppo Entra come %s in connessione… - Tocca per iniziare una conversazione Scrivi agli sviluppatori Non hai chat Condividi multimediale… @@ -182,7 +176,6 @@ Connesso Disconnesso Errore - In attesa Cambiare l\'indirizzo di ricezione\? Vedi codice di sicurezza Verifica codice di sicurezza @@ -247,7 +240,6 @@ Accettare la richiesta di connessione\? Accetta in incognito Tutti i messaggi verranno eliminati, non è reversibile! I messaggi verranno eliminati SOLO per te. - Aggiungi server preimpostati Aggiungi server Impostazioni di rete avanzate Riguardo SimpleX @@ -263,7 +255,6 @@ 1 mese Errore nell\'importazione del database della chat Nome completo del gruppo: - scansionare il codice QR nella videochiamata, oppure il tuo contatto può condividere un link di invito.]]> Backup dei dati dell\'app L\'archivio chiavi di Android è usato per memorizzare in modo sicuro la password; permette il funzionamento del servizio di notifica. Permetti ai tuoi contatti di inviare messaggi vocali. @@ -294,9 +285,7 @@ Svuota chat Svuotare la chat\? Svuota - Connetti via link / codice QR Copiato negli appunti - Crea link di invito una tantum Crea gruppo segreto Scansiona un codice QR.]]> Dalla Galleria @@ -310,7 +299,6 @@ Eliminare la connessione in attesa\? Email aiuto - mostra il codice QR nella videochiamata, oppure condividi il link.]]> Console della chat Annulla la verifica Connetti @@ -338,7 +326,6 @@ connesso connessione… connessione chiamata… - Crea Crea profilo Elimina immagine Nome del profilo: @@ -352,7 +339,6 @@ Buono per la batteria. L\'app cerca messaggi ogni 10 minuti. Potresti perdere chiamate o messaggi urgenti.]]> Chiamata già terminata! Crea il tuo profilo - Decentralizzato Chiamata crittografata e2e Videochiamata crittografata e2e terminata @@ -522,18 +508,15 @@ (memorizzato solo dai membri del gruppo) Autorizzazione negata! Rifiuta - (scansiona o incolla dagli appunti) Scansiona un codice QR Inizia una nuova conversazione Tocca il pulsante Grazie per aver installato SimpleX Chat! Per connettersi via link - (da condividere con il tuo contatto) Per iniziare una nuova chat Fotocamera connetterti con gli sviluppatori di SimpleX Chat per porre domande e ricevere aggiornamenti.]]> Link non valido! - Codice QR non valido immagine di anteprima link Segna come già letto Segna come non letta @@ -547,8 +530,6 @@ Mostra codice QR La connessione che hai accettato verrà annullata! Il contatto con cui hai condiviso questo link NON sarà in grado di connettersi! - Questo non è un link di connessione valido! - Questo codice QR non è un link! Riattiva notifiche vuole connettersi con te! Logo di SimpleX @@ -556,8 +537,6 @@ Squadra di SimpleX Hai accettato la connessione Hai invitato il contatto - Il tuo profilo di chat verrà inviato -\nal tuo contatto Il tuo contatto deve essere in linea per completare la connessione. \nPuoi annullare questa connessione e rimuovere il contatto (e riprovare più tardi con un link nuovo). Verrai connesso/a quando la tua richiesta di connessione verrà accettata, attendi o controlla più tardi! @@ -569,7 +548,6 @@ Segna come verificato/a Link di invito una tantum Incolla - Server preimpostato Indirizzo server preimpostato Salva i server Scansiona codice @@ -583,7 +561,6 @@ SimpleX Lock %s non è verificato/a %s è verificato/a - Server SMP Alcuni server hanno fallito il test: Prova server Prova i server @@ -591,8 +568,6 @@ Per verificare la crittografia end-to-end con il tuo contatto, confrontate (o scansionate) il codice sui vostri dispositivi. Usa per connessioni nuove Usa il server - Apri nell\'app mobile.]]> - Il tuo server L\'indirizzo del tuo server Il tuo indirizzo SimpleX Se confermi, i server di messaggistica saranno in grado di vedere il tuo indirizzo IP e il tuo fornitore, a quali server ti stai connettendo. @@ -618,7 +593,6 @@ Stai usando i server di SimpleX Chat. Quando disponibili I tuoi server ICE - I tuoi server SMP corsivo chiamata persa risposta ricevuta… @@ -631,36 +605,23 @@ segreto avvio… barrato - La piattaforma di messaggistica che protegge la tua privacy e sicurezza. Il profilo è condiviso solo con i tuoi contatti. in attesa di risposta… in attesa di conferma… - Non memorizziamo nessuno dei tuoi contatti o messaggi (una volta recapitati) sui server. Puoi usare il markdown per formattare i messaggi: - Sei tu a controllare la tua chat! Il tuo profilo attuale Il tuo profilo, i contatti e i messaggi recapitati sono memorizzati sul tuo dispositivo. Il tuo profilo è memorizzato sul tuo dispositivo e condiviso solo con i tuoi contatti. I server di SimpleX non possono vedere il tuo profilo. Ignora - Immune allo spam Chiamata in arrivo Videochiamata in arrivo Istantaneo Come influisce sulla batteria Crea una connessione privata - Solo i dispositivi client memorizzano i profili utente, i contatti, i gruppi e i messaggi. - Chiunque può installare i server. Incolla il link che hai ricevuto - Sei tu a decidere chi può connettersi. Periodico - Privacy ridefinita Notifiche private - repository GitHub.]]> Rifiuta - Nessun identificatore utente. - Il futuro dei messaggi - Per proteggere la tua privacy, SimpleX usa ID separati per ciascuno dei tuoi contatti. - Usa la chat videochiamata videochiamata (non crittografata e2e) Quando l\'app è in esecuzione @@ -690,7 +651,6 @@ Il server relay viene usato solo se necessario. Un altro utente può osservare il tuo indirizzo IP. %1$d messaggio/i saltato/i Le tue chiamate - I tuoi server ICE La tua privacy Importa Importare il database della chat\? @@ -705,7 +665,6 @@ Invia le anteprime dei link Imposta la password per esportare Impostazioni - Proxy SOCKS Ferma Fermare la chat\? Ferma la chat per esportare, importare o eliminare il database della chat. Non potrai ricevere e inviare messaggi mentre la chat è ferma. @@ -897,7 +856,6 @@ Solo dati del profilo locale Messaggi File e multimediali - Aggiornare la modalità di isolamento del trasporto\? Tutte le chat e i messaggi verranno eliminati. Non è reversibile! Profilo di chat per ogni contatto e membro del gruppo .\n Nota: : se hai molte connessioni, il consumo di batteria e traffico può essere notevolmente superiore e alcune connessioni potrebbero fallire.]]> @@ -1018,15 +976,11 @@ Il video verrà ricevuto quando il tuo contatto sarà in linea, attendi o controlla più tardi! In attesa del video In attesa del video - Errore nel caricamento dei server XFTP - Errore nel salvataggio dei server XFTP Il server richiede l\'autorizzazione per l\'invio, controlla la password. Confronta file Crea file Scarica file Invia file - Server XFTP - I tuoi server XFTP Impostazioni proxy SOCKS Usa proxy SOCKS Host @@ -1034,8 +988,6 @@ porta %d Usa gli host .onion su No se il proxy SOCKS non li supporta.]]> Elimina file - Errore nel caricamento dei server SMP - Assicurati che gli indirizzi del server XFTP siano nel formato corretto, uno per riga e non doppi. Autenticazione fallita Inserimento del codice di accesso Modalità di SimpleX Lock @@ -1093,8 +1045,6 @@ Solo il tuo contatto può effettuare chiamate. Proibisci le chiamate audio/video. Chiamate audio/video - " -\nDisponibile nella v5.1" Consenti ai tuoi contatti di chiamarti. Codice di accesso dell\'app Veloce e senza aspettare che il mittente sia in linea! @@ -1106,12 +1056,10 @@ Cambia i profili di chat Maggiori informazioni Per connettervi, il tuo contatto può scansionare il codice QR o usare il link nell\'app. - Quando le persone chiedono di connettersi, puoi accettare o rifiutare. Indirizzo SimpleX Colori dell\'interfaccia I tuoi contatti resteranno connessi. Aggiungi l\'indirizzo al tuo profilo, in modo che i tuoi contatti di SimpleX possano condividerlo con altre persone. L\'aggiornamento del profilo verrà inviato ai tuoi contatti di SimpleX. - Crea un indirizzo per consentire alle persone di connettersi con te. Crea indirizzo SimpleX Condividi con i contatti di SimpleX Condividere l\'indirizzo con i contatti di SimpleX? @@ -1121,7 +1069,6 @@ \nConnettiti a me tramite SimpleX Chat: %s Invita amici Salva le impostazioni dell\'indirizzo SimpleX - Puoi crearlo più tardi Condividi indirizzo… Inserisci il messaggio di benvenuto… Anteprima @@ -1144,7 +1091,6 @@ Sfondo Continua Errore di impostazione dell\'indirizzo - Non creare un indirizzo Personalizza il tema Tema scuro Se non potete incontrarvi di persona, mostra il codice QR in una videochiamata o condividi il link. @@ -1155,7 +1101,6 @@ Smettere di condividere l\'indirizzo\? Salvare le impostazioni\? Non perderai i contatti se in seguito elimini il tuo indirizzo. - Puoi condividere il tuo indirizzo come link o codice QR: chiunque può connettersi a te. Apertura del database… Cambia modalità di autodistruzione Attiva il codice di autodistruzione @@ -1336,13 +1281,11 @@ Questo gruppo ha più di %1$d membri, le ricevute di consegna non vengono inviate. Connettersi direttamente\? La richiesta di connessione verrà inviata a questo membro del gruppo. - Connetti in incognito Usa il profilo attuale Usa nuovo profilo in incognito Consenti Apri impostazioni app L\'app potrebbe venire chiusa dopo 1 minuto in secondo piano. - Incolla il link che hai ricevuto per connetterti con il contatto… Verrà condiviso il tuo profilo %1$s. Verrà condiviso un nuovo profilo casuale. Disattiva le notifiche @@ -1514,7 +1457,6 @@ Apri porta nel firewall errore di visualizzazione del contenuto errore di visualizzazione del messaggio - Puoi renderlo visibile ai tuoi contatti SimpleX nelle impostazioni. La cronologia non viene inviata ai nuovi membri. Riprova Fotocamera non disponibile @@ -1903,7 +1845,6 @@ Eliminato Errori di eliminazione File scaricati - Apri impostazioni server Protetto Indirizzo server Dimensione @@ -1916,8 +1857,6 @@ La versione del server non è compatibile con la tua app: %1$s. Membro inattivo Il messaggio può essere consegnato più tardi se il membro diventa attivo. - Server SMP configurati - Altri server SMP Mostra percentuale inattivo Zoom @@ -1935,7 +1874,6 @@ Sessioni di trasporto Tutti i profili tentativi - Server XFTP configurati Completato Server connessi disattivato @@ -1952,7 +1890,6 @@ Partendo da %s. \nTutti i dati sono privati, nel tuo dispositivo. Ancora nessuna connessione diretta, il messaggio viene inoltrato dall\'amministratore. Non sei connesso/a a questi server. L\'instradamento privato è usato per consegnare loro i messaggi. - Altri server XFTP Server precedentemente connessi Riprova più tardi. Beta @@ -2203,7 +2140,6 @@ Dispositivi Xiaomi: attiva l\'avvio automatico nelle impostazioni di sistema per fare funzionare le notifiche.]]> Aggiungi i membri del tuo team alle conversazioni. Indirizzo di lavoro - cifrati end-to-end, con sicurezza quantistica nei messaggi diretti.]]> Controlla i messaggi ogni 10 minuti Come aiuta la privacy Invita in chat 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 a2113482d9..e42232f93a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml @@ -17,7 +17,6 @@ אפשר כל ההודעות יימחקו – לא ניתן לבטל זאת! ההודעות יימחקו רק עבורך. אשר זהות נסתרת - הוסף שרתים מוגדרים מראש הוסף שרת לגשת לשרתים דרך פרוקסי SOCKS בפורט %d\? הפרוקסי חייב לפעול לפני הפעלת אפשרות זו. הגדרות רשת מתקדמות @@ -85,8 +84,6 @@ מזהה הודעה שגוי קבל אוטומטית תמונות שימו לב: לא ניתן יהיה לשחזר או לשנות את הסיסמה אם תאבדו אותה.]]> - " -\nזמין מ־v5.1" גם אתם וגם איש הקשר יכולים לשלוח הודעות קוליות. גם אתם וגם איש הקשר יכולים לבצע שיחות. אשר אוטומטית בקשות ליצירת קשר. @@ -117,7 +114,6 @@ השיחה הסתיימה %1$s בטל תצוגה מקדימה של קבצים להתחבר באמצעות קישור ליצירת קשר? - התחבר להצטרף לקבוצה? מחובר מתחבר @@ -197,7 +193,6 @@ להתחבר דרך קישור חד-פעמי? איש הקשר כבר קיים איש הקשר וכל ההודעות יימחקו – לא ניתן לבטל זאת! - התחברות באמצעות קישור / קוד QR התחברות באמצעות קישור איש הקשר מאפשר איש קשר מוסתר: @@ -211,12 +206,10 @@ העתק סמל מידע נוסף הועתק ללוח - צור קישור הזמנה חד־פעמי צור קבוצה סודית תרומה גרסת ליבה: v%s צור כתובת - צור צור פרופיל יצירת הפרופיל שלך צור קישור קבוצה @@ -246,8 +239,6 @@ מחק מחק מחק שרת - צרו כתובת כדי לאפשר לאנשים להתחבר אליכם. - מבוזר מסד הנתונים מוצפן באמצעות סיסמה אקראית. אנא שנו אותה לפני הייצוא. מחק פרופיל צ׳אט ברירת מחדל (%s) @@ -317,7 +308,6 @@ נעילת המכשיר אינה מופעלת. ניתן להפעיל את נעילת SimpleX, לאחר שתפעילו את נעילת המכשיר. %d קבצים בגודל כולל של %s שגיאה - שגיאה בטעינת שרתי SMP שגיאה ביצירת פרופיל! שם תצוגה כבר קיים! שגיאה ביצירת כתובת @@ -353,7 +343,6 @@ שגיאה בשינוי כתובת שגיאה במחיקת פרופיל משתמש השבתת נעילת SimpleX - לא ליצור כתובת מושבת הודעה כפולה שגיאה במחיקת מסד הנתונים של הצ׳אט @@ -399,7 +388,6 @@ הזינו הודעת פתיחה… שגיאה במחיקת איש קשר שגיאה במחיקת חיבור איש קשר ממתין - שגיאה בטעינת שרתי XFTP שגיאה בשליחת הודעה שגיאה בקבלת קובץ שגיאה בשמירת שרתי ICE @@ -410,8 +398,6 @@ שגיאה בשמירת קובץ יציאה ללא שמירה שגיאה בעצירת צ׳אט - שגיאה בשמירת שרתי SMP - שגיאה בשמירת שרתי XFTP שגיאה בהחלפת פרופיל! שגיאה בעדכון תצורת הרשת טעינת הצ׳אט נכשלה @@ -453,7 +439,6 @@ הקובץ יימחק מהשרתים. הפוך מצלמה עזרה - הציגו את קוד ה־QR בשיחת וידאו, או שתפו את הקישור.]]> איך איך להשתמש בשרתים שלך שרתי ICE (אחד בכל שורה) @@ -473,7 +458,6 @@ מוסתר הסתר איש קשר והודעה הסתר - לסרוק את קוד ה־QR בשיחת וידאו, או שאיש הקשר שלך ישתף קישור הזמנה.]]> אם לא ניתן להיפגש פנים אל פנים, הציגו את קוד ה־QR בשיחת וידאו, או שתפו את הקישור. איך להשתמש בזה היי! @@ -504,7 +488,6 @@ התעלם מיד ייבא מסד נתונים - חסין מפני ספאם לייבא מסד נתונים של צ׳אט\? תמונה נשלחה התמונה תתקבל כאשר איש הקשר יסיים להעלות אותה. @@ -541,7 +524,6 @@ התראות מיידיות התראות מיידיות! קישור לא תקין! - קוד QR לא תקין קוד אבטחה שגוי! מיידית שיחת שמע נכנסת @@ -576,8 +558,6 @@ עזוב הפוך את הפרופיל לפרטי! הודעה חיה - ודאו שכתובות שרתי ה־SMP הן בפורמט הנכון, מופרדות בשורה ולא משוכפלות. - ודאו שכתובות שרתי ה־XFTP הן בפורמט הנכון, מופרדות בשורה ולא משוכפלות. התחבר באמצעות האישור שלך למדו עוד עזרה במרקדאון @@ -662,7 +642,6 @@ \nשימו לב: לא תוכלו להתחבר לשרתים ללא כתובת .onion. לא ייעשה שימוש במארחי Onion. שיחה שלא נענתה - הצפנה מקצה־לקצה דו־שכבתית.]]> ללא הצפנה מקצה־לקצה שיחה שלא נענתה קוד גישה חדש @@ -730,10 +709,6 @@ אנא צרו קשר עם מנהל הקבוצה. אנא בקשו מאיש הקשר שלכם לאפשר שליחת הודעות קוליות. שומר מקום לתמונת פרופיל - שרת מוגדר מראש - פרטיות מוגדרת מחדש - אנשים יכולים להתחבר אליכם רק דרך הקישורים שאתם משתפים. - כל אחד יכול לארח שרתים. תקופתי נא להזין את הסיסמה הקודמת לאחר שחזור גיבוי מסד הנתונים, לא ניתן לבטל פעולה זו. לאסור מחיקה בלתי הפיכה של הודעות. @@ -743,7 +718,6 @@ ייתכן שטביעת האצבע של התעודה בכתובת השרת שגויה פתיחת מסוף צ׳אט שנה פרופילי צ׳אט - ממתין כתובת שרת מוגדר מראש סיסמה להצגה התראות פרטיות @@ -786,7 +760,6 @@ דחיה מדריך למשתמש.]]> דרגו את האפליקציה - GitHub repository שלנו.]]> יבוצע שימוש בשרת ממסר רק במידת הצורך. גורם אחר יכול לצפות בכתובת ה־IP שלך. שרת ממסר מגן על כתובת ה־IP שלך, אך הוא יכול לראות את משך השיחה. שיחה נדחתה @@ -853,7 +826,6 @@ הצג בטל קובץ שליחה נכשלה - (סירקו או הדביקו מהלוח) שלח סירקו קוד אבטחה מהאפליקציה של איש הקשר שלך. השמדה עצמית @@ -900,7 +872,6 @@ נשלח שלחו שאלות ורעיונות בדיקת השרתים נכשלה! - פרוקסי SOCKS נשלח ב: %s הגדרת שם איש קשר… שלח הודעה @@ -967,7 +938,6 @@ לעצור קבלת קובץ\? לשנות כתובת קבלה\? כתובת SimpleX - שרתי SMP שתף עם אנשי קשר הצג רמקול כבוי @@ -989,7 +959,6 @@ בזכות המשתמשים – תרמו באמצעות Weblate! בזכות המשתמשים – תרמו באמצעות Weblate! בזכות המשתמשים – תרמו באמצעות Weblate! - הקישו כדי להתחיל צ׳אט חדש בדוק שרת בדוק שרתים הקישו על הכפתור @@ -1003,20 +972,15 @@ האפליקציה בודקת הודעות חדשות מעת לעת - היא משתמשת בכמה אחוזים מהסוללה ביום. האפליקציה לא משתמשת בהתראות דחיפה - נתונים מהמכשיר שלך לא נשלחים לשרתים. התפקיד ישתנה ל־"%s". כל חברי הקבוצה יקבלו הודעה על כך. כדי להתחבר באמצעות קישור - פלטפורמת ההודעות והיישומים המגנה על הפרטיות והאבטחה שלך. המזהה של ההודעה הבאה שגוי (קטן או שווה להודעה הקודמת). \nזה יכול לקרות בגלל באג כלשהו או כאשר החיבור נפגע. ערכת נושא מסד הנתונים אינו פועל כראוי. הקישו למידע נוסף - טקסט זה זמין בהגדרות יותר מדי תמונות! תודה שהתקנתם את SimpleX Chat! - קישור זה אינו קישור חיבור תקין! צבעי ממשק התפקיד ישתנה ל־"%s". החבר יקבל הזמנה חדשה. השרתים לחיבורים חדשים של פרופיל הצ׳אט הנוכחי שלך - הפלטפורמה הראשונה ללא כל מזהי משתמש - פרטית בעיצובה. - הדור הבא של תקשורת פרטית הגיבוב של ההודעה הקודמת שונה. לא ניתן לבטל פעולה זו - ההודעות שנשלחו והתקבלו לפני הזמן שנבחר יימחקו. זה עשוי להימשך מספר דקות. הקבוצה הזו כבר לא קיימת. @@ -1025,7 +989,6 @@ כתובת הקבלה תשתנה לשרת אחר. שינוי הכתובת יושלם לאחר שהשולח יתחבר לאינטרנט. החיבור שאישרת יבוטל! איש הקשר שאיתו שיתפת את הקישור הזה לא יוכל להתחבר! - קוד QR זה אינו קישור! כדי להתחבר, איש הקשר שלך יכול לסרוק קוד QR או להשתמש בקישור באפליקציה. מחרוזת טקסט זו אינה קישור חיבור! הפרופיל משותף רק עם אנשי הקשר שלך. @@ -1036,7 +999,6 @@ הניסיון לשנות את סיסמת מסד הנתונים לא הושלם. כותרת הקבוצה מבוזרת לחלוטין - היא גלויה רק לחברי הקבוצה. - כדי לשמור על הפרטיות, במקום מזהי משתמש הקיימים בכל הפלטפורמות האחרות, ל־SimpleX יש מזהים לתורי הודעות, נפרדים עבור כל אחד מאנשי הקשר שלך. משתמש בשרתי SimpleX Chatז מנסה להתחבר לשרת המשמש לקבלת הודעות מאיש קשר זה. אלא אם איש הקשר שלכם מחק את החיבור או שהקישור הזה כבר היה בשימוש, זה עשוי להיות באג - אנא דווחו על כך. \nכדי להתחבר, אנא בקשו מאיש הקשר שלכם ליצור קישור חיבור נוסף ובידקו שיש לכם חיבור יציב לרשת. @@ -1055,12 +1017,10 @@ שליחה לא מורשית לא נקרא אימות קוד אבטחה - (כדי לשתף עם איש הקשר שלך) כדי להתחיל צ׳אט חדש השתמש עבור חיבורים חדשים כדי לאמת הצפנה מקצה־לקצה עם איש הקשר שלכם, יש להשוות (או לסרוק) את הקוד במכשירים שלכם. פרופילי צ׳אט - לעדכן מצב בידוד תעבורה\? מנסה להתחבר לשרת המשמש לקבלת הודעות מאיש קשר זה (שגיאה: %1$s). פורמט הודעה לא ידוע דרך הדפדפן @@ -1069,7 +1029,6 @@ להשתמש בשרתי SimpleX Chat\? להשתמש בחיבור ישיר לאינטרנט\? כדי לחשוף את הפרופיל המוסתר שלכם, הזינו סיסמה מלאה בשדה חיפוש בדף פרופילי צ׳אט. - שימוש בצ׳אט עדכן שגיאת מסד נתונים לא ידועה: %s עידכן את פרופיל הקבוצה @@ -1083,7 +1042,6 @@ בטל השתקה ממתין לאישור… ממתין למענה… - איננו מאחסנים את אנשי הקשר או ההודעות שלך (לאחר המסירה) בשרתים. SimpleX אתם באמצעות קישור לכתובת איש קשר @@ -1110,7 +1068,6 @@ אתם כבר מחוברים ל־%1$s. שירות SimpleX Chat לא ניתן היה לאמת אתכם; אנא נסו שוב. - ברוכים הבאים! ברוכ/ה הבא/ה %1$s! הוזמנת לקבוצה ממתין לתמונה @@ -1137,7 +1094,6 @@ הודעה קולית אישרת את החיבור סמל SimpleX - שרתי XFTP דילג על %1$d הודעות. סיסמת מסד נתונים שגויה הוזמנת לקבוצה @@ -1153,12 +1109,7 @@ ממתין לקובץ הודעות קוליות אסורות! הזמנת את איש הקשר שלך - באפשרותכם לשתף את הכתובת שלכם כקישור או כקוד QR – כל אחד יכול להתחבר אליכם. - כאשר אנשים מבקשים להתחבר, באפשרותך לקבל או לדחות זאת. - פתח באפליקציה.]]> כשזמין - תוכלו ליצור אותה מאוחר יותר - אתם שולטים בצ׳אט שלכם! באפשרותך להשתמש במרקדאון כדי לעצב הודעות: כאשר האפליקציה פועלת %1$s רוצה שתתחברו דרך @@ -1189,15 +1140,12 @@ הצ׳אטים איש הקשר שלך שלח קובץ גדול יותר מהגודל המרבי הנתמך כעת (%1$s). איש הקשר שלך צריך להיות מקוון כדי שהחיבור יושלם.\nניתן לבטל חיבור זה ולהסיר את איש הקשר (ולנסות מאוחר יותר עם קישור חדש). - פרופיל הצ׳אט שלך יישלח -\nלאיש הקשר שלך מסד הנתונים שלך דחית את ההזמנה לקבוצה מסד הנתונים הנוכחי שלך יימחק ויוחלף במסד הנתונים המיובא. \nלא ניתן לבטל פעולה זו – הפרופיל, אנשי הקשר, ההודעות והקבצים שלך ייאבדו באופן בלתי הפיך. הפרופיל הנוכחי שלך אנשי הקשר שלך יישארו מחוברים. - שרתי ה־ICE שלך אתם תהיו מחוברים כאשר בקשת החיבור תאושר, אנא חכו או בידקו מאוחר יותר! הפרופיל שלך יישלח לאיש הקשר ממנו קיבלת קישור זה. תתחבר לכל חברי הקבוצה. @@ -1210,16 +1158,13 @@ הפרטיות שלך תידרשו לבצע אימות כאשר תפעילו או תחזרו לאפליקציה לאחר 30 שניות ברקע. ההגדרות שלך - השרת שלך כתובת השרת שלך - שרתי XFTP שלך שלחת הזמנה לקבוצה שיתפת קישור חד־פעמי אתם תהיו מחוברים לקבוצה כאשר המכשיר של מארח הקבוצה יהיה מקוון, אנא חכו או בידקו מאוחר יותר! אתם תהיו מחוברים כאשר המכשיר של איש הקשר שלך יהיה מקוון, אנא חכו או בידקו מאוחר יותר! לא תאבדו את אנשי הקשר שלכם אם תמחקו מאוחר יותר את הכתובת שלכם. כתובת SimpleX שלך - שרתי SMP שלך הפרופיל שלך מאוחסן במכשירך ומשותף רק עם אנשי הקשר שלך.\nשרתי SimpleX אינם יכולים לראות את הפרופיל שלך. הפרופיל, אנשי הקשר וההודעות שנמסרו מאוחסנים במכשיר שלך. אתם תפסיקו לקבל הודעות מקבוצה זו. היסטוריית הצ׳אט תישמר. @@ -1309,14 +1254,12 @@ גם אם הוא מושבת בשיחה. שימרו על הקשרים שלכם בחר קובץ - התחבר בזהות נסתרת השתמש בפרופיל הנוכחי השתמש בפרופיל זהות נסתרת חדש השבת התראות פתח את הגדרות האפליקציה ללא שיחות ברקע ישותף פרופיל אקראי חדש. - הדבק את הקישור שקיבלת כדי להתחבר לאיש הקשר שלך… הפרופיל שלך %1$s ישותף. קבלות מושבתות %s :%s @@ -1396,7 +1339,6 @@ פתח מסך העברה הרחב הקוד שסרקת אינו קוד QR של קישור SimpleX. - תוכל להפוך אותו לגלוי לאנשי הקשר שלך ב-SimpleX דרך ההגדרות. שם לא חוקי! הגדר סיסמא לא ידוע @@ -1822,7 +1764,6 @@ שגיאה בהתחברות לשרת %1$s, אנא נסה מאוחר יותר אין עדיין חיבור ישיר, ההודעה תעובר ע"י מנהל. חבר לא פעיל - שרתי XFTP אחרים הצג אחוזים מושבת יציבה @@ -1872,7 +1813,6 @@ נשלח דרך פרוקסי קבצים שהורדו שגיאות בהורדה - פתח הגדרות שרת כתובת שרת חריגה מהקיבולת - הנמען לא קיבל הודעות שנשלחו בעבר. בדוק עבור עדכונים @@ -1889,7 +1829,6 @@ קישורי SimpleX לא מאופשרים בקבוצה הזו. סרוק/ הדבק קישור אנא נסה מאוחר יותר - שרתי SMP אחרים אפס הועלה סטטיסטיקה מפורטת @@ -1957,7 +1896,6 @@ %s.]]> מכשירי שיואמי: אנא תאפשר הפעלה אוטומטית בהגדרות הטלפון שלך כדי שההתראות על הודעות חדשות יפעלו.]]> %1$s ההודעות לא הועברו. - מוצפנים מקצה לקצה, עם אבטחה פוסט-קוונטית בהודעות ישירות.]]> טישטוש עסקי %s, קבל את תנאי השימוש.]]> diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml index c28136d201..e5c425e7d7 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml @@ -3,7 +3,6 @@ 1日 1週間 受けた通話 - 既存サーバを追加 管理者 管理者はグループの参加リンクを発行できます。 ネットワーク詳細設定 @@ -98,7 +97,6 @@ %d分 グループのリンク k - 接続 接続中 エラー 接続待ち @@ -121,7 +119,6 @@ 削除する ファイルを保存しました 連絡先を削除しますか? - リンク・QRコード経由で繋がる 検証済みにマーク 使い方 必須 @@ -137,7 +134,6 @@ グループリンク発行にエラー発生 役割変更にエラー発生 チャットデータベースの書き出しでエラー発生 - SMPサーバ保存でエラー発生 ICEサーバ保存でエラー発生 ネットワーク設定の更新にエラー発生 ファイル @@ -151,14 +147,12 @@ グループのプロフィールが更新されました。 連絡先とメッセージ内容をプライベートにする。 通知の常時受信 - SMPサーバのアドレスを正しく1行ずつに分けて、重複しないように、形式もご確認ください。 WebRTC ICEサーバのアドレスを正しく1行ずつに分けて、重複しないように、形式もご確認ください。 通話中 電池消費がより高い!非アクティブ時でもバックグラウンドのサービスが常に稼働します(着信してすぐに通知が出ます)。]]> 発信中 通話終了 %1$s 通話が終了しました。 - ビデオ通話中にQRコードを見せてもらうか、招待リンクを送ってもらえば相手に繋がります。]]> メンバーをグループから除名する (※元に戻せません※)! 通話をロック画面に表示 リンクのプレビューを中止 @@ -167,7 +161,6 @@ ファイル受信ができません。 連絡先を招待できません! 変更 - ビデオ通話にてQRコードを見せるか、招待リンクを送って、相手に繋がります。]]> あなたのアドレスを変えました。 アドレスを変更しています… チャットのデータベース @@ -217,7 +210,6 @@ オニオンのホストが利用可能時に使われます。 オニオンのホストが使われません。 画像を1回で最大10枚を送信できます。 - 2層エンドツーエンド暗号化で送信されたプロフィール、連絡先、グループ、メッセージは、クライント端末にしか保存されません。]]> グループ設定を変えられるのはグループのオーナーだけです。 音声メッセージを利用可能に設定できるのはグループのオーナーだけです。 プロフィールを作成する @@ -256,13 +248,11 @@ TCPキープアライブを有効にする メール 設定メニューでロック画面からの通話を有効にできます。 - あなたと繋がることができるのは、あなたからリンクを頂いた方のみです。 仮のプロフィール画像 QRコード リンクが正しいかどうかご確認ください。または、連絡相手にもう一度リンクをお求めください。 %1$s との接続を確認し、もう一度お試しください。 現在の正しいパスフレーズを入力してください。 - プレセットサーバ プレセットサーバのアドレス 接続待ち… 連絡先リンク経由でシークレットモード @@ -290,7 +280,6 @@ 全員用 ファイル保存でエラー発生 接続中 - 確認待ち キャンセル 使い捨ての招待リンク 音声メッセージを録音 @@ -298,7 +287,6 @@ 削除 ヘルプ 無効なリンク! - 無効なQRコード つづき 誤ったセキュリティコード! 貼り付け @@ -319,12 +307,9 @@ 接続中 発信中… 終了 - 誰でもサーバーをホストできます。 - プライバシーを再定義 技術の説明 プライベートな接続をする プライベートな通知 - GitHubリポジトリで詳細をご確認ください。]]> エンドツーエンド暗号化済みビデオ通話 無効にする エンドツーエンド暗号化がありません @@ -497,7 +482,6 @@ 詳細がありません 戻す クリップボードにコピー完了 - 使い捨てリンクを発行する シークレットグループを作成する (グループのメンバーのみに保存されてます) 「QRコードを読み取る」経由でアプリに表示されるQRコードを読み取る]]> @@ -522,12 +506,9 @@ アドレスを削除? プロフィール名: フルネーム: - 作成 あなたの名前を入力: 色付き 応答 - 分散型 - スパム耐性 常時受信 定期的に受信 通話は既に終了してます! @@ -651,9 +632,7 @@ リンク経由で接続 セキュリティコード %s は認証済み - SMPサーバ 保存 - トランスポート隔離モードを更新しますか? この設定でよろしいですか? ビデオオフ リレー経由 @@ -678,9 +657,7 @@ 起動時、または非アクティブ状態で30秒が経った後に戻ると、認証する必要となります。 共有する 非認証の送信 - このテキストは設定にあります。 画像数の上限を超えてます! - ようこそ! グループ招待が届きました チャット 連絡先を設定… @@ -688,8 +665,6 @@ 受信アドレスを変えますか? 送信する ライブメッセージを送信 (入力しながら宛先の画面で更新される) - (連絡先に共有) - (クリップボードから読み込むか、貼り付ける) SimpleX Chatをご利用いただきありがとうございます! カメラ 接続を承認しました @@ -698,12 +673,8 @@ あなたからリンクを受けた連絡先が接続できなくなります! SimpleXアドレス QRコードを表示 - このリンクは有効な接続リンクではありません! - このQRコードはリンクではありません! グループのホスト端末がオンラインになったら、接続されます。後でチェックするか、しばらくお待ちください。 連絡先がオンラインになったら、接続されます。後でチェックするか、しばらくお待ちください。 - あなたのチャットプロフィールが -\n連絡先に公開されます。 ワンタイムリンクを送る コードを読み込む 連絡相手のアプリからセキュリティコードを読み込む @@ -714,7 +685,6 @@ サーバを保存 サーバテスト失敗! サーバを使う - あなたのサーバ あなたのサーバアドレス 現在のチャットプロフィールの新しい接続のサーバ 新しい接続に使う @@ -728,20 +698,15 @@ 保存して連絡先に公開 現在のプロフィール 保存して連絡先に公開 - あなたのプライバシーとセキュリティを守るメッセージとアプリのプラットフォーム - 連絡先情報と届けたメッセージをサーバに保存することは一切ありません。 - あなたのチャットはあなたが決めます! あなたのプロフィール、連絡先、送信したメッセージがご自分の端末に保存されます。 プロフィールは連絡先にしか共有されません。 メッセージの書式をマークダウンで編集できます。 シークレット 取り消し線 接続中… - 次世代のプライベートメッセンジャー ビデオ通話 アプリがアクティブ時のみ WebRTC ICEサーバ - あなたのICEサーバ 設定 飛ばしたメッセージ あなた @@ -834,7 +799,6 @@ ロック解除 保存 開示する - タップして新しいチャットを始める 画像が解読できません。別のイメージで試すか、開発者に伝えてください。 画像を待機中 画像を待機中 @@ -847,7 +811,6 @@ ミュート解除 連絡先を設定 あなたと接続を希望しています! - Open in mobile app (アプリで開く)ボタンをクリックしてください。]]> チャットプロフィール サーバのQRコードを読み込む テストに失敗したサーバがあります: @@ -857,8 +820,6 @@ simplexmq: バージョン%s (%2s) 応答を待機中… 確認を待機中… - 世界初のユーザーIDのないプラットフォーム - プライバシーに配慮した設計 - チャット %1$sは次の方法であなたと繋がりたいです: ビデオ通話 (非エンドツーエンド暗号化) 表示 @@ -883,7 +844,6 @@ SimpleX Chatを支援 テストサーバ 受信アドレスは別のサーバーに変更されます。アドレス変更は送信者がオンラインになった後に完了します。 - あなたのプライバシーを守るために、他のアプリと違って、ユーザーIDの変わりに SimpleX メッセージ束毎にIDを配布し、各連絡先が別々と扱います。 あなたのチャットプロフィールが他のグループメンバーに公開されます。 エンドツーエンド暗号化を確認するには、ご自分の端末と連絡先の端末のコードを比べます (スキャンします)。 このコンタクトから受信するメッセージのサーバに接続しようとしてます。(エラー: %1$s)。 @@ -899,13 +859,11 @@ シークレットモードのプロフィールでこのグループに参加しています。メインのプロフィールを守るために、招待することができません。 使い捨てリンクを送りました リンクを送ってくれた連絡先にあなたのプロフィールを送ります。 - あなたのSMPサーバ あなたのSimpleXアドレス 連絡先が繋がりリクエストを承認したら、接続されます。後でチェックするか、しばらくお待ちください。 受信アドレスを変える あなたのランダム・プロフィール 音声メッセージ - SOCKSプロキシ データベースのエクスポート、読み込み、削除するにはチャット機能を停止する必要があります。チャット機能を停止すると送受信ができなくなります。 あなたのプロフィール、連絡先、メッセージ、ファイルが完全削除されます (※元に戻せません※)。 データベースパスフレーズを更新 @@ -915,7 +873,6 @@ 連絡先を選択 今はメンバーを招待しません %1$sメンバー - XFTPサーバ ウェルカムメッセージを保存しますか? 次から表示しない パスコードを変更 @@ -963,7 +920,6 @@ 音声/ビデオ通話 音声/ビデオ通話は禁止されています。 受信拒絶 - XFTPサーバーのアドレスが正しい形式で、行で区切られており、重複していないことを確認してください。 ファイルの削除 ビデオ待機中 ビデオを待機中 @@ -975,7 +931,6 @@ ビデオ ← から: SOCKSプロキシー設定 - あなたのXFTPサーバ プロフィールにアドレスを追加し、連絡先があなたのアドレスを他の人と共有できるようにします。プロフィールの更新は連絡先に送信されます。 自動承認 ロックモード @@ -1006,12 +961,9 @@ 音声/ビデオ通話を禁止する 。 連絡先からの通話を許可する。 連絡先が通話を許可している場合のみ通話を許可する。 - " -\nv5.1 で利用可能" 連絡先からのみ通話ができます。 あなたからも連絡先からも通話ができます。 システム - XFTP サーバーの保存中にエラーが発生しました ファイルを作成 メッセージはすべてのメンバーに対して削除されます。 確認できませんでした。 もう一度お試しください。 @@ -1025,7 +977,6 @@ インターフェースカラー 共有を停止 友人を招待する - 後からでも作成できます ここにパスワードを入力してください。 認証がキャンセルされました アドレスを共有する @@ -1047,9 +998,7 @@ 1GBまでのビデオとファイル %1$d メッセージの復号化に失敗しました。 %1$d メッセージをスキップしました - SMPサーバーのロード中にエラーが発生しました ユーザーパスワードの保存中にエラー発生 - XFTPサーバーのロード中にエラーが発生しました テーマのエクスポート ファイルはサーバーから削除されます。 直接会えない場合は、ビデオ通話で QR コードを表示するか、リンクを共有してください。 @@ -1078,7 +1027,6 @@ 次のメッセージの ID が正しくありません (前のメッセージより小さいか等しい)。 \n何らかのバグが原因で、または接続に問題があった場合に発生する可能性があります。 ユーザーに感謝します – Weblate 経由で貢献してください! - 接続が要求されたら、それを受け入れるか拒否するかを選択できます。 ビデオが送信されました 管理者は次のことができます。 \n- メンバーのメッセージを削除します。 @@ -1139,16 +1087,13 @@ 表示にする 非表示プロフィールのパスワード 表示する: - 人々があなたとつながるためのアドレスを作成します。 アドレス設定エラー - アドレスをリンクまたは QR コードとして共有すると、誰でもあなたに接続できます。 後でアドレスを削除しても、連絡先が失われることはありません。 連絡先との接続は維持されます。 連絡先との接続は維持されます。 プロフィールの更新が連絡先に共有されます。 SimpleX のアドレスを作成 連絡先に公開する プロフィールの更新は連絡先に送信されます。 - アドレスを作成しない SimpleXチャットで会話しよう 自動受け入れ設定を保存する プロフィールのパスワードを保存する @@ -1300,7 +1245,6 @@ ファイルやメディアを有効にできるのは、グループオーナーだけです。 再接続 再起動 - シークレットモードで接続 許可 直接接続しますか\? 新しいランダムなプロファイルが共有されます。 @@ -1321,7 +1265,6 @@ 配信通知の送信が%d件のグループで無効になっています 無効 バックグラウンド通話なし - 受信したリンクを貼り付け、連絡先に接続する。 %s と %s は接続中 SimpleXはバックグラウンドでは動作できません。アプリが起動している時のみ通知を受け取ることができます。 送信情報なし @@ -1454,7 +1397,6 @@ %sと%s 新規モバイルデバイス 同時に動作するのはひとつのデバイスだけです。 - 設定でSimpleXの連絡先に表示させることができます。 %1$sに参加しています。]]> これはあなた自身のワンタイムリンクです! チャットが停止しています。このデータベースを他のデバイスで既に使用している場合は、チャットを開始する前に転送し直してください。 @@ -1826,9 +1768,7 @@ アップデートを確認 アップデートを確認 完了 - SMPサーバーの構成 接続中 - XFTPサーバーの構成 連絡先 メッセージサーバ メディア&ファイルサーバ @@ -2290,9 +2230,7 @@ あなたのプロフィール プロフィールを変更できません 接続試行後に別のプロフィールを使用するには、チャットを削除してリンクをもう一度使用してください。 - その他のSMPサーバ 新しいサーバ - その他のXFTPサーバ プロキシ認証 ランダムな認証情報を使用 プロフィールごとに異なるプロキシ認証情報を使用します。 @@ -2368,7 +2306,6 @@ 監視されることなく他者と話すという、人類最古の自由を — それを裏切ることのできないインフラの上に築く。 私たちは、あなたが誰であるかを知る力を破壊したからだ。あなたの力が決して奪われないように。 あなたのネットワークで自由に。 - エンドツーエンドで暗号化されて送信され、ダイレクトメッセージではポスト量子暗号で保護されます。]]> 通知とバッテリー ネットワーク運営者 アプリは会話ごとに異なる運営者を使用することで、あなたのプライバシーを保護します。 @@ -2677,7 +2614,6 @@ サイズ アップロードエラー サーバアドレス - サーバ設定を開く 1つのメッセージにつき最大 %1$s 人のメンバーをメンションできます! 購読者 オーナーと貢献者 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 a4fd13d137..880d328df6 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml @@ -31,7 +31,6 @@ 라이브 메시지 취소 파일 확인 - 링크 / QR 코드로 연결 클립보드로 복사됨 비밀 그룹 생성 수락 @@ -42,7 +41,6 @@ 채팅을 지우겠습니까\? 연결 요청이 전송되었습니다! 링크로 연결 - 프리셋 서버 추가 서버 추가 채팅 콘솔 서버 주소를 확인 후 다시 시도해 주세요. @@ -62,7 +60,6 @@ 색깔 암호 확인 전화 연결 중… - 생성 프로필 생성 통화 오류 연결됨 @@ -128,7 +125,6 @@ 중국어 및 스페인어 인터페이스 SimpleX에 대하여 수락 - 일회용 초대 링크 생성 주소 생성 1일 SimpleX Chat에 대하여 @@ -184,7 +180,6 @@ 채팅이 멈춤 채팅 설정 개발자와 채팅 - 연결 연결 중… 닫기 버튼 연결 @@ -221,7 +216,6 @@ 주소 삭제 주소를 삭제할까요\? 이미지 삭제 - 탈중앙화 개발자 도구 기기 데이터베이스 암호 @@ -357,7 +351,6 @@ 부재 중 전화 사용 안 함 질문이나 아이디어 보내기 - (스캔하거나 클립보드에서 붙여넣기) 대화 상대가 현재 지원되는 최대 크기(%1$s)보다 큰 파일을 보냈습니다. 초대를 받았어요. 프로필 생성 오류! @@ -369,8 +362,6 @@ 상대의 연락처 링크로 익명 연결 상대의 연락처 링크로 연결 일회용 링크로 익명 연결 - SMP 서버 주소가 올바른 형식이고 줄로 구분되어 있고 중복이 없는지 확인해 주세요. - SMP 서버 저장 오류 네트워크 설정 업데이트 오류 프로필 변경 오류! 동일한 표시 이름을 가진 채팅 프로필이 있어요. 다른 이름을 선택해 주세요. @@ -516,7 +507,6 @@ 보내기 실패 비인증 전송 도움말 - 개인을 식별할 수 있는 어떠한 정보(임의의 숫자 포함)도 없는 첫 번째 플랫폼. 단순히 약속이 아니라 프로그램 설계상 완전한 익명성을 제공해요. 거절된 전화 대기 중인 전화 부재중 전화 @@ -526,7 +516,6 @@ 오류 사용법 이메일 - 대기 중 설정 이미지 기다리는 중 이미지를 디코딩할 수 없어요. 다른 이미지를 시도하거나 개발자에게 문의해 주세요. @@ -535,7 +524,6 @@ 동시에 최대 10개까지만 이미지를 보낼 수 있어요. 이미지 수가 너무 많아요! 거절해도 상대에게 알림이 전송되지 않아요. - 영상 통화에서 QR 코드를 보여주거나 링크를 공유해 주세요.]]> 영상 통화 영상 끄기 스피커 켜기 @@ -547,13 +535,11 @@ 대화 상대가 업로드를 완료하면 이미지가 수신될 거예요. 프로필 이미지 하나의 프로필로 여러 사람과 연락할 필요 없이 무수히 많은 익명 프로필로 연락할 수 있어요. - 스팸 방지 무시하기 SimpleX Chat 초대 링크를 받으면 브라우저에서 참여할 수 있어요 : 링크 미리보기 이미지 QR 코드 SimpleX 팀 - 영상 통화에서 QR 코드를 스캔하거나 상대에게 초대 링크를 공유할 수 있어요.]]> 동영상 보내짐 동영상 수신 요청됨 동영상 기다리는 중 @@ -588,7 +574,6 @@ 영상 통화 옴 잘못된 이전 확인 익명 모드로 참여 - 잘못된 QR 코드 잘못된 보안 코드! 잘못된 링크! 삭제됨으로 표시됨 @@ -657,7 +642,6 @@ 사용 가능한 경우 Onion 호스트가 사용될 거예요. Onion 호스트가 사용되지 않을 거예요. 전송 격리 - 차세대 사생활 보호 메시징 새 암호… TCP 연결 유지 활성화 %s의 새로운 기능 @@ -683,7 +667,6 @@ OK 거절 비밀번호 표시 - 2계층 종단 간 암호화 로 전송된 사용자 프로필, 연락처, 그룹 및 메시지를 저장되어요.]]> 개인 정보 보호 및 보안 알림은 앱이 중지되기 전까지만 전달될 거예요! 당신만 사라지는 메시지를 보낼 수 있습니다. @@ -708,10 +691,7 @@ 일회성 초대 링크 붙여넣기 프로필은 대화 상대들하고만 공유됩니다. - 프라이버시의 재정의 - 누구나 서버를 호스팅할 수 있습니다. 앱이 실행 중일 때 - GitHub 에서 확인해 주세요.]]> 릴레이 서버는 IP 주소를 숨겨주지만, 통화 시간을 관찰 할 수 있어요. 그룹 링크로 초대 설정을 통해 나중에 변경할 수 있어요. @@ -728,7 +708,6 @@ 받은 링크 붙여넣기 릴레이 서버는 필요한 경우에만 사용되어요. 릴레이 서버가 사용되지 않으면 제3자가 내 IP 주소를 관찰할 수 있어요. 채팅 열기 - 공유한 링크를 통해서만 나에게 연결할 수 있어요. 그룹 프로필 업데이트됨 프로필 비밀번호 보이기 @@ -824,7 +803,6 @@ 도움말 설정 링크 미리보기 보내기 - SOCKS 프록시 SimpleX Chat 도와주기 나 실험적 기능 @@ -832,7 +810,6 @@ 연락처 이름 설정 개발자에게 이메일 보내기 서버를 수동으로 입력 - 미리 설정된 서버 서버 저장하기 서버 테스트하기 현재 채팅 프로필의 새로운 연결을 위한 서버 @@ -843,9 +820,7 @@ 익명 모드 실험적 내보낼 암호 설정 - SMP 서버 미리 설정된 서버 주소 - 내 서버 내 서버 주소 %1$s을(를) 강퇴했어요. 채팅 데이터베이스를 내보내기, 가져오기 또는 삭제 하려면 채팅 기능을 중지해 주세요. 채팅 기능이 중지된 동안에는 메시지를 주고받을 수 없어요. @@ -862,8 +837,6 @@ 제공된 이름을 가진 빈 채팅 프로필이 생성되고, 앱이 정상적으로 열립니다. 데이터베이스 ID: %d 음성/영상 통화 - " -\nv5.1에서 사용 가능" 대화 상대가 허용하는 경우에만 통화를 허용합니다. 입력하면 모든 데이터가 삭제됩니다. 더 나은 메시지 @@ -881,7 +854,6 @@ 사용자 설정 기간 사라지는 메시지 사라지는 메시지 전송 - 다른 사용자와 연결할 수 있도록 주소를 만듭니다. 커스텀 테마 자동 수락 자체 소멸 모드 변경 @@ -1012,7 +984,6 @@ - 디렉터리 서비스(베타)에 연결하세요!\n- 전송 알림(최대 20명).\n- 더 빠르고 안정적입니다. 이전 취소 이전하려는 데이터베이스의 암호를 기억하고 있는지 확인합니다. - 익명 모드로 연결 베타 앱 패스코드 계속 @@ -1050,12 +1021,10 @@ 그룹 생성: 새로운 그룹을 생성합니다.]]> 연락처 추가 : 새 초대 링크를 만들거나 받은 링크를 통해 연결합니다.]]> 개인 메모를 지우시겠습니까? - XFTP 서버 구성 나중에 채팅할 수 있도록 연락처를 보관합니다. 연결된 데스크톱 일괄 업로드됨 보관된 연락처 - SMP 서버 구성 채팅 프로필 생성 그룹 멤버에 전화를 걸 수 없음 자동 연결 @@ -1085,7 +1054,6 @@ 암호화 OK 데스크톱 기능 처리 시간이: %1$d 초 이상: %2$s - 주소를 만들지 않음 기기 파일 및 미디어 금지됨! 링크 생성 중… @@ -1191,7 +1159,6 @@ 상대가 온라인 상태가 될 때까지 기다릴 필요가 없습니다! 그룹 멤버 차단 현재 프로필 - SMP 서버를 로드하는 중 오류 개인 메모를 삭제하는 동안 오류 데이터베이스를 삭제하는 동안 오류 다음과 같은 이유로 끊어졌습니다: %s @@ -1199,7 +1166,6 @@ 알림을 표시하는 동안 오류가 발생하였으니, 개발자에게 문의하십시오. %d 분 %d 개의 메시지가 삭제됨 - XFTP 서버를 저장하는 중 오류 알림 비활성화 패스코드 입력 주소를 설정하는 중 오류 @@ -1227,7 +1193,6 @@ 자체 소멸 환영 메시지 입력…(선택사항) WebView를 초기화하는 중 오류가 발생했습니다. WebView가 설치되어 있고 지원되는 아키텍처가 arm64인지 확인합니다.\n오류: %s - XFTP 서버를 로드하는 중 오류가 발생했습니다. 세부 정보를 로드하는 중 오류 통계를 재설정하는 중 오류 대화에서 비활성화 된 경우에도 마찬가지입니다. @@ -1423,7 +1388,6 @@ %1$s!]]> 사용해서는 안 됩니다.]]> 사용자 가이드에서 확인하세요.]]> - 모바일 앱에서 열기 버튼을 클릭합니다.]]> 운영자 %s.]]> 약관 수락 날짜: %s. @@ -1466,7 +1430,6 @@ 현재 약관 텍스트를 로드할 수 없습니다, 다음 링크를 통해 약관을 검토할 수 있습니다: 사용 약관 Flux 활성화 - 종단 간 암호화로 전송됩니다.]]> 앱이 항상 백그라운드에서 실행 팀 멤버 추가하기 친구 추가 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 c429a8341a..f942077e4c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ku/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ku/strings.xml @@ -50,8 +50,6 @@ şandin bi ser neket nexwendî Bi xêr hatî %1$s! - Bi xêr hatî! - Ev nivîs di eyaran de heye Eyar Bi navê %s bikeviyê redkirî @@ -318,7 +316,6 @@ Funksiyona hêdî Komên piçûk (herî zêde 20) Servera SMPyê - Serverên SMPyê Nerm Bêdeng Spam @@ -437,8 +434,6 @@ Şert di %s de hatin qebûlkirin. Şertên şuxulandinê Şert wê di %s de bên qebûlkirin. - Serverên SMPyê ên eyarkirî - Serverên XFTPyê ên eyarkirî Serverên ICEyê eyar bike Dosyayên ji serverên nenas qebûl bike. Eyarên torê tesdîq bike. @@ -446,7 +441,6 @@ Bi xwe re bikeve danûstandinê? Bi riya lînkê bikeve danûstandinê Bi riya lînkê bikeve danûstandinê? - Bi riya lînkê / koda QRyê bikeve danûstandinê Bi riya lînka yek carê bikeve danûstandinê? Bi %1$s re bikeve danûstandinê? Muhtewa ne li gora şertên şuxulandinê ye @@ -455,10 +449,8 @@ Beşdar bibe Tora xwe kontrol bike Xeletiyê kopî bike - Çêke Çêke Adres çêke - Adresekê çêke ji bo ko xelk karibin bi te re bikevin danûstandinê. Hat çêkirin Wextê çêkirinê Wextê çêkirinê: %s @@ -466,7 +458,6 @@ Kom çêke Lînka komê çêke Lînk çêke - Lînkeke dewetiyê ya yek carî çêke Profîl çêke Profîl çêke Dor çêke @@ -526,7 +517,6 @@ %d heyv %d heyv %d heyv - Adres çêneke Dîsa nîşan nede Daxe Daxistî @@ -564,21 +554,16 @@ Xeletî di guhertina profîlê de Xeletî di guhertina rolê de Xeletî di çêkirina adresê de - Serverên te yên XFTPyê Te dewetîke komê şand - Serverên te yên SMPyê Serverên te Adresa servera te - Servera te Profîla te yî %1$s wê bê parvekirin. Tercihên te - Serverên te yên ICEyê Serverên te yên ICEyê Koma te te %1$s derxist Te dewetiya komê red kir Profîla te yî niha - Tu karî dûvre wê çêkî Tu dikarî wê di Eyarên xuyakirinê de biguherî. te %s blok kir Tu hatiye dewetkirinî komê @@ -590,7 +575,6 @@ tu Erê erê - Serverên XFTPyê Servera XFTPyê Şîfra xelet! Şîfra xelet ya databasê @@ -634,7 +618,6 @@ Aplîkasyon Dosya Ji nû ve veke - Proksiya SOCKSê Sûretên profîlan Girêdana torê Ji kompîterê bişuxulîne @@ -780,8 +763,6 @@ Profîla siḧbetê çêke Ber serverên SimpleX Chatê werin şuxulandin? Profîla siḧbetê - Tu siḧbeta xwe qontrol dikî! - Siḧbetê bişuxulîne Siḧbet Rengên siḧbetê Siḧbet sekinandî ye diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml index aa1da521bb..87144b710d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml @@ -45,7 +45,6 @@ Turėkite omenyje: jeigu prarasite slaptafrazę, NEBEGALĖSITE jos atkurti ar pakeisti.]]> Atsisakyti jungiamasi… - Prisijungti prisijungta jungiamasi ryšys užmegztas @@ -86,7 +85,6 @@ Grupių nuorodos klaida Aprašas - Klaida įrašant SMP serverius Klaida atnaujinant tinklo konfigūraciją Klaida kuriant profilį! Nepavyko įkelti pokalbio @@ -155,7 +153,6 @@ Sukurti adresą Ištrinti adresą Klaida įrašant naudotojo slaptažodį - Sukurti Kaip tai veikia Gaunamas garso skambutis Gaunamas vaizdo skambutis @@ -227,7 +224,6 @@ Patobulinta serverio konfigūracija Ištrinti pokalbio profilį Ištrinti profilį - Sukurti vienkartinio pakvietimo nuorodą Ištrinti Įrašyti ir pranešti kontaktui Įjungti automatinį žinučių ištrynimą\? @@ -237,7 +233,6 @@ Importuoti Įrašyti ir pranešti kontaktams pradedama… - Naudoti pokalbį Išjungti garsiakalbį Įjungti garsiakalbį Praleistos žinutės @@ -251,12 +246,10 @@ Rodyti peržiūrą Atrakinti Bendrinti - Bakstelėkite, norėdami pradėti naują pokalbį Bendrinti failą… Nustatymai Rodyti QR kodą Bendrinti pakvietimo nuorodą - SMP serveriai Naudoti SimpleX Chat serverius\? Naudoti tiesioginį interneto ryšį\? Atnaujinti @@ -306,16 +299,13 @@ Balso žinutė… Rodyti saugumo kodą SimpleX adresas - Jūsų SMP serveriai Jūsų ICE serveriai Pridėti profilį Visi pokalbiai ir žinutės bus ištrinti – to neįmanoma bus atšaukti! - Sveiki! Pridėti Jūs negalite siųsti žinučių! SimpleX logotipas Jūsų nustatymai - Jūsų serveris Jūsų serverio adresas Pridėti į kitą įrenginį Naudojami SimpleX Chat serveriai. @@ -325,7 +315,6 @@ vaizdo skambutis Jūsų skambučiai WebRTC ICE serveriai - Jūsų ICE serveriai išėjote administratorius Jūsų atsitiktinis profilis @@ -372,9 +361,6 @@ Didelis failas! Balso žinutės uždraustos! Siųsti - Klaida įkeliant SMP serverius - Klaida įrašant XFTP serverius - Klaida įkeliant XFTP serverius Nepavyksta gauti failo Sukurti eilę Gali būti, kad liudijimo kontrolinis kodas serverio adrese yra neteisingas @@ -382,8 +368,6 @@ Dekodavimo klaida Išvalyti pokalbį Įjungti pranešimus - Neteisingas QR kodas - Šis QR kodas nėra nuoroda! Prisijungti per nuorodą Saugumo kodas Žymėti kaip patvirtintą @@ -419,8 +403,6 @@ Neteisingas serverio adresas! Pokalbio profilis Profilis yra bendrinamas tik su jūsų kontaktais. - „GitHub“ saugykloje.]]> - SOCKS įgaliotasis serveris Įrašyti slaptafrazę ir atverti pokalbį Atkurti atsarginę duomenų bazės kopiją Atkurti atsarginę duomenų bazės kopiją\? @@ -463,7 +445,6 @@ Išvalyti Nebeslėpti profilio Per daug vaizdo įrašų! - Prisijungti per nuorodą / QR kodą Įrašyti profilio slaptažodį Iššifravimo klaida inkognito per grupės nuorodą @@ -494,7 +475,6 @@ Stabdyti failą Stabdyti failo gavimą\? Stabdyti failo siuntimą\? - Šis tekstas yra prieinamas nustatymuose Garso/vaizdo skambučiai yra uždrausti. Tiek jūs, tiek ir jūsų kontaktas gali skambinti. Žinutės juodraštis @@ -508,8 +488,6 @@ Profilis ir ryšiai su serveriu Tiesioginės žinutės tarp narių šioje grupėje yra uždraustos. Garso/vaizdo skambučiai - " -\nPrieinama versijoje v5.1" Vaizdo įrašas Vaizdo įrašai ir failai iki 1GB Ieškoti @@ -533,7 +511,6 @@ Programėlė galės gauti sistemos pranešimus tik tada, kai veiks. Jokia foninė tarnyba nebus paleidžiama Priimti Priimti inkognito režimu - Pridėti iš anksto parinktus serverius Administratoriai gali kurti prisijungimo prie grupių nuorodas. Foninė tarnyba visada veikia – pranešimai bus rodomi iš karto, kai tik bus prieinamos žinutės. Patvirtinti tapatybę @@ -765,7 +742,6 @@ Atkreipkite dėmesį: žinučių ir failų perdavimas yra prijungtas per SOCKS tarpinį serverį. Skambučiams ir nuorodų peržiūrų siuntimui naudojamas tiesioginis ryšys.]]> Netinkamas vardas! Sukurti savo profilį - Asmenys gali prisijungti prie jūsų tik per nuorodas kuriomis dalinatės. Tai gali būti pakeista vėliau nustatymuose. Tai gali įvykti kai: \n1. Žinutės galiojimo laikas baigėsi siuntėjo programoje po 2 dienų arba serveryje po 30 dienų. @@ -816,12 +792,10 @@ Prisijungti prie savęs? Kontaktas dar nėra susijungęs! Pataisyti pavadinimą į %s? - Laukiama Duomenų bazės šifravimo slaptafrazė bus atnaujinta ir saugoma raktų saugykloje. %s yra blogos būsenos]]> užblokuota administratoriaus Pilna nuoroda - Atsparu šlamštui ir piktnaudžiavimui Prašome pranešti tai kūrėjams. Prašome saugoti slaptafrazę saugiai, jūs NEGALĖSITE pasiekti pokalbių, jei ją prarasite. Duomenų bazės slaptafrazė yra reikalinga pokalbių atidarymui. @@ -852,7 +826,6 @@ Įrenginio autentifikacija išjungta. Išjungiamas SimpleX užraktas. %d failas (-ai), kurių bendras dydis yra %s %d valandos - Prisijungti inkognito režimu Reikalinga slaptafrazė Uždrausti siųsti balso žinutes. Eksperimentinis @@ -865,14 +838,12 @@ Paprašyta gauti vaizdo įrašą Labas! \nPrisijunk prie manęs per SimpleX Chat: %s - Privatumas apibrėžtas iš naujo Sukurta: %s jungiamasi (supažindinimo pakvietimas) Kaip naudoti markdown Savaiminio susinaikinimo prieigos kodas pakeistas! Pasilikti nepanaudotą pakvietimą? Pasilikti - Atviro kodo protokolas ir kodas - bet kas gali paleisti savo serverius. - pasirinktinai praneškite ištrintiems kontaktams. \n- profilių vardai su tarpais. \n- ir daugiau! @@ -890,7 +861,6 @@ Nuotrauka Įvertinti programėlę jungiamas skambutis… - Sukurkite adresą, kad leistumėte žmonėms prisijungti prie jūsų. Duomenų bazės ID ir transporto izoliacijos parinktis. kursyvinis Atsitiktinė slaptafrazė yra saugoma nustatymuose kaip paprastas tekstas. @@ -930,8 +900,6 @@ Nuotrauka išsaugota į galeriją Failas bus gautas kai jūsų kontaktas yra prisijungęs, palaukite arba patikrinkite vėliau! Iš anksto nustatyto serverio adresas - Iš anksto nustatytas serveris - Decentralizuota Pateikti Prisijungti inkognito režimu Pradinė rolė @@ -948,7 +916,6 @@ Nuotrauka išsiųsta Nuotrauka bus gauta kai jūsų kontaktas užbaigs jos įkėlimą. Pakeisti gavimo adresą? - nuskanuoti QR kodą vaizdo skambutyje, arba jūsų kontaktas gali pasidalinti pakvietimo nuoroda.]]> Arba parodykite šį kodą ICE serveriai (vienas per liniją) Įdiegti SimpleX Chat terminalui @@ -986,10 +953,8 @@ Momentiniai pranešimai! Momentiniai pranešimai yra išjungti! profilio nuotraukos vietos ženklas - parodykite QR kodą vaizdo skambutyje, arba pasidalinkite nuoroda.]]> naudotojo vadove.]]> Jei negalite susitikti asmeniškai, parodykite QR kodą vaizdo skambutyje, arba pasidalinkite nuoroda. - Įklijuokite nuorodą, kurią gavote prisijungimui prie savo kontakto… gautas atsakas… Jungiamas skambutis Tai gali įvykti kai jūs ar jūsų prisijungimas naudojo seną duomenų bazės atsarginę kopiją. @@ -1109,8 +1074,6 @@ Temos spalvos Rodyti lėtus API iškvietimus Nustoti bendrinti adresą? - Žinučių siuntimo ir programų platforma, apsauganti jūsų privatumą ir saugumą. - Pirma platforma neturinti jokių naudotojų identifikatorių - privati pagal sumanymą. Nustatyti duomenų bazės slaptafrazę Sistema Nustatyti slaptafrazę eksportui @@ -1128,7 +1091,6 @@ SimpleX Chat tarnyba Nustoti bendrinti Bendrinti su kontaktais - Naujos kartos privatus susirašinėjimas %s sekundė(s) Kodas kurį nuskanavote, nėra SimpleX nuorodos QR kodas. Kontaktas su kurio pasidalinote šia nuoroda NEGALĖS prisijungti! @@ -1197,7 +1159,6 @@ Ši galimybė dar nėra palaikoma. Išbandykite sekantį leidimą. %1$s!]]> Kad prisijungti su nuoroda - Ši nuoroda nėra tinkama prisijungimo nuoroda! Šie nustatymai yra jūsų dabartiniam profiliui Slaptafrazė saugoma nustatymuose kaip paprastas tekstas. Bakstelėkite, kad prisijungti kaip inkognito @@ -1233,8 +1194,6 @@ Sustabdomi pokalbiai %s įkeltas Jūsų pokalbių profiliai - Jūsų pokalbių profilis bus nusiųstas -\njūsų kontaktui Jūsų duomenų bazė Jūsų duomenų bazė nėra užšifruota - nustatykite slaptafrazę, kad ją apsaugoti. Jūsų pokalbių profilis bus nusiųstas grupės nariams @@ -1266,7 +1225,6 @@ Jūsų profilis, kontaktai ir gautos žinutės yra saugomi jūsų įrenginyje. Išnyks Rodomas vardas negali turėti tarpų. - Nekurti adreso Atsiuntimas nepavyko Atsiunčiamas archyvas visapusiškai užšifruotas balso skambutis @@ -1359,13 +1317,10 @@ neteisėtas siuntimas Ieškoti ar įklijuoti SimpleX nuorodą Jūs turite leisti savo kontaktui siųsti balso žinutes, kad galėtumėte siųsti jas. - (nuskanuokite ar įklijuokite iš iškarpinės) Priėmėte prisijungimą Jūs pakvietėte kontaktą Onion serveriai bus naudojami, kai tik bus. Išeiti neišsaugant - Jūs kontroliuojate savo pokalbį! - Mes nesaugome jokių jūsų kontaktu ar žinučių (po pristatymo) serveriuose. Nėra gautų ar išsiųstų failų Išsaugoti slaptafrazę raktų saugykloje Atnaujinti duomenų bazės slaptafrazę @@ -1428,8 +1383,6 @@ Nuskanuoti QR kodą iš darbastalio %s buvo atjungtas]]> %1$s.]]> - Galite bendrinti savo adresą kaip nuorodą ar QR kodą - bet kas galės prisijungti prie jūsų. - Atnaujinti transporto izoliacijos režimą? Išsaugoti automatinio priėmimo nustatymus Antrinis Žinučių reakcijos yra draudžiamos šiame pokalbyje. @@ -1439,7 +1392,6 @@ siuntimas nepavyko Per daug nuotraukų! Siųsti išnykstančią žinutę - (kad bendrinti su savo kontaktu) Kad pradėti naują pokalbį Pakartoti Naudoti .onion serverius @@ -1451,8 +1403,6 @@ Gavimo adresas bus pakeistas į kitą serverį. Adreso pakeitimas bus užbaigtas kai siuntėjas prisijungs. Įvyko klaida įkeliant archyvą Prisijunkite su savo kredencialais - Įsitikinkite, kad SMP serverių adresai yra tinkamu formatu, atskirose eilutėse ir nesikartojantys. - Įsitikinkite, kad XFTP serverių adresai yra tinkamu formatu, atskirose eilutėse ir nesikartojantys. Markdown pagalba Maks. 40 sekundžių, gaunama iš karto. Žinučių reakcijos @@ -1464,7 +1414,6 @@ Jūsų kontaktas turi būti prisijungęs, kad užbaigti prisijungimą. \nJūs galite atšaukti šį prisijungimą ir pašalinti kontaktą (ir vėliau bandyti su nauja nuoroda). Jūsų kontaktai išliks prisijungę. - Jūsų XFTP serveriai Prisijungsite kai jūsų prisijungimo užklausa bus patvirtinta, palaukite arba patikrinkite vėliau! %s yra užsiėmęs]]> %s yra neaktyvus]]> @@ -1484,7 +1433,6 @@ Su sumažintu akumuliatoriaus naudojimu. Esate kviečiami į grupę prisijungti prie SimpleX Chat kūrėjų, kad klausti klausimus ir gauti atnaujinimus.]]> - Galite padaryti tai matomą savo SimpleX kontaktams per nustatymus. Susieto darbastalio parinktys %1$s.]]> Pašalinti nutildymą @@ -1511,7 +1459,6 @@ Prisijungsite kai jūsų konktakto įrenginys bus prisijungęs, palaukite arba patikrinkite vėliau! Atidaryti mobilioje programėlėje, tada bakstelėkite Prisijungti programėlėje.]]> Naudoti naujiems prisijungimams - Atidaryti mobilioje programėlėje mygtuko.]]> Įvyko klaida atveriant naršyklę paslaptis Pranešimai nustos veikti iki tol kol paleisite programėlę iš naujo @@ -1581,11 +1528,9 @@ Laukiama nuotraukos Siųsti tiesioginę žinutę, kad prisijungti Laukiama nuotraukos - Kai asmenys išsiunčia užklausą prisijungti, galite ją priimti arba atmesti. Galite peržiūrėti pakvietimo nuorodą vėl, prisijungimo detalėse. Markdown žinutėse Norėdami patvirtinti visapusį šifravimą su savo kontaktu, palyginkite (ar nuskanuokite) kodą ant savo įrenginių. - XFTP serveriai Onion serveriai bus reikalingi ryšiui. \nAtkreipkite dėmesį: negalėsite prisijungti prie serverių be .onion adreso. Išsaugoti nustatymus? @@ -1598,8 +1543,6 @@ pakeitėte adresą %s Išplėsti rolių pasirinkimą %1$s.]]> - dviejų sluoksnių visapusiu šifravimu.]]> - Kad apsaugoti privatumą, vietoj naudotojų ID naudojamų visose kitose platformose, SimpleX turi identifikatorius žinučių eilėms, skirtingus kiekvienam jūsų kontaktui. Žinutės juodraštis Paleisti iš naujo narys @@ -1641,7 +1584,6 @@ %s ir %s Atnaujinti Įsitikinkite, kad WebRTC ICE serverių adresai yra tinkamu formatu, atskirose eilutėse ir nesikartojantys. - Galite sukurti tai vėliau Nėra pasirinkto pokalbio nežinoma būsena pašalintas diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/lv/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/lv/strings.xml index e09aef2162..b045044925 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/lv/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/lv/strings.xml @@ -10,7 +10,6 @@ Izmantot inkognito profilu Jūsu profils tiks nosūtīts kontaktpersonai, no kuras saņēmāt šo saiti. Jūs pievienosities visiem grupas dalībniekiem. - Pievienoties Atvērt tērzēšanu Atvērt jaunu tērzēšanu Atvērt grupu @@ -71,7 +70,6 @@ Saites atvēršana pārlūkprogrammā var samazināt savienojuma privātumu un drošību. Neuzticamas SimpleX saites būs sarkanas. uzaicināts pievienoties Savienoties, izmantojot kontaktpersonas saiti - Savienoties, izmantojot saiti inkognito režīmā Ziņot par vienuma redzamību moderatoriem Ziņot par vienuma arhivēšanu Ziņot par vienuma arhivēšanu, ko veica @@ -105,12 +103,6 @@ Ziņošanas iemesls: Kopienas noteikumu pārkāpums Ziņošanas iemesls: Profils Ziņošanas iemesls: Cits - Kļūda, saglabājot Smp serverus - Kļūda, saglabājot Xftp serverus - Pārliecinieties, vai Smp serveru adreses ir pareizā formātā un unikālas - Pārliecinieties, vai Xftp serveru adreses ir pareizā formātā un unikālas - Kļūda ielādējot Smp serverus - Kļūda ielādējot Xftp serverus Kļūda iestatot tīkla konfigurāciju Neizdevās parsēt čata nosaukumu Neizdevās parsēt čatu nosaukumus @@ -351,11 +343,7 @@ Čata slēdzene Čata konsole Ziņojumu serveri - Smp serveri - Konfigurēti Smp serveri - Citi Smp serveri Smp serveru iepriekš iestatīta adrese - Pievienot iepriekš iestatītu Smp serveri Pievienot Smp serveri Smp serveru testa serveris Smp serveru testēšana @@ -365,8 +353,6 @@ Smp serveru QR koda skenēšana Smp serveru ievadīšana manuāli Smp serveru jauns serveris - Smp serveru iepriekš iestatīts serveris - Smp serveru jūsu serveris Smp serveru jūsu servera adrese Smp serveru izmantot serveri Smp serveru izmantot serveri jaunam savienojumam @@ -377,9 +363,6 @@ Smp Serveri katram lietotājam Vai saglabāt Smp Serverus? Multivides un failu serveri - Xftp Serveri - Konfigurēti Xftp Serveri - Citi Xftp Serveri Abonēšanas Procentuālais Daudzums Instalēt SimpleX Chat Terminālim Atiestatīt Visus Padomus @@ -387,8 +370,6 @@ Ziedot Novērtēt Aplikāciju Izmantot SimpleX Chat Serverus? - Jūsu SMP Serveri - Jūsu XFTP Serveri Izmantojot SimpleX Chat Serverus Kā to darīt Kā lietot savus serverus @@ -451,7 +432,6 @@ Pieņemt zvanu bloķēšanas ekrānā Rādīt zvanu bloķēšanas ekrānā Zvans nav atļauts bloķēšanas ekrānā - Jūsu Ice Serveri Webrtc Ice Serveri Relay Serveris Aizsargā Ip Relay Serveris Ja Nepieciešams @@ -733,7 +713,6 @@ SimpleX Saites Nesenā Vēsture Audio Video Zvani - \nPieejams V51 versijā Funkcija Ieslēgta Funkcija Ieslēgta Jums Funkcija Ieslēgta Kontaktam @@ -1105,8 +1084,6 @@ Ziņas nosūtīšana neizdevās Saņemtā ziņa nav izlasīta Laipni lūdzam - Laipni lūdzam - Šis teksts ir pieejams iestatījumos Jūsu sarunas Rīkjoslas iestatījumi Kontakta savienojums gaida apstiprinājumu @@ -1116,7 +1093,6 @@ Grupas priekšskatījums, pievienojieties kā Grupas priekšskatījums noraidīts Grupas savienojums gaida apstiprinājumu - Noklikšķiniet, lai uzsāktu jaunu sarunu Sarunājieties ar izstrādātājiem Jums nav nevienas sarunas Ielādēju sarunas… @@ -1186,7 +1162,6 @@ Serveris ir savienots Serveris nav savienots Servera kļūda - Serveris gaida Vai vēlaties mainīt saņemšanas adresi? Mainīt saņemšanas adresi Pārtraukt saņemšanas adreses maiņu @@ -1224,12 +1199,8 @@ Pievienot kontaktu Kopēts Pievienot kontaktu vai izveidot grupu - Kopīgot vienreizēju saiti - Savienot, izmantojot saiti vai QR Nolasīt QR kodu Izveidot grupu - Lai kopīgotu ar savu kontaktu - Savienot, izmantojot saiti vai QR no starpliktuves vai klātienē Tikai saglabāts dalībnieku ierīcēs Iespējot kameras piekļuvi Noklikšķiniet, lai nolasītu @@ -1301,19 +1272,12 @@ E-pasts Vairāk Rādīt QR kodu - Nederīgs QR kods - Šis QR kods nav saite Nederīga kontaktu saite - Šī saite nav derīga savienojuma saite Savienojuma pieprasījums nosūtīts Jūs tiksiet savienots, kad grupas saimnieka ierīce būs tiešsaistē Jūs tiksiet savienots, kad jūsu savienojuma pieprasījums tiks pieņemts Jūs tiksiet savienots, kad jūsu kontaktu ierīce būs tiešsaistē - - Jūsu čata profils tiks nosūtīts jūsu kontaktam - Kopīgot ielūguma saiti - Ielīmējiet saiti, ko saņēmāt, lai savienotos ar savu kontaktu Uzzināt vairāk Uzzināt vairāk par adresi Savienojiet, tiks kopīgots jauns nejaušs profils @@ -1322,7 +1286,6 @@ Ja jūs nevarat tikties klātienē Kopīgot adresi publiski Kopīgot simplex adresi sociālajos tīklos - Jūs varat kopīgot savu adresi Jūs nezaudēsiet savus kontaktus, ja izdzēsīsiet adresi Kopīgot vienreizēju saiti ar draugu @@ -1330,14 +1293,12 @@ Savienojuma drošība Simplex adrese un vienreizējās saites ir drošas kopīgošanai Lai pasargātu no jūsu saites aizvietošanas, salīdziniet kodus - Jūs varat pieņemt vai noraidīt savienojumu Adrese vai vienreizēja saite Savienoties caur saiti Savienoties Ielīmēt Šī virkne nav savienojuma saite - Jauna saruna Jauns Pievienot kontaktu cilni @@ -1372,7 +1333,6 @@ Tīkla sesijas režīms sesija. Tīkla sesijas režīms serveris. - Atjaunināt tīkla sesijas režīmu? Tīkla smp proxy režīms privātā maršrutēšana @@ -1445,7 +1405,6 @@ Visi jūsu kontakti paliks savienoti, atjauninājums nosūtīts. Kopīgot saiti Pievienot adresi savai profilam - Izveidot adresi un ļaut cilvēkiem savienoties Izveidot simplex adresi Kopīgot ar kontaktiem Kopīgot adresi ar kontaktiem? @@ -1478,9 +1437,6 @@ Dalīties ar veco adresi Dalīties ar veco saiti Turpināt uz nākamo soli - Nekādā gadījumā neveidot adresi - Jūs varat to izveidot vēlāk - Jūs varat padarīt adresi redzamu caur iestatījumiem Aicināt draugus (īsi) Rādāmais vārds Pilns vārds @@ -1503,16 +1459,12 @@ Apstiprināt paroli Lai atklātu profilu, ievadiet paroli Kļūda, saglabājot lietotāja paroli - Jūs kontrolējat savu sarunu - Ziņojumu un lietotņu platforma, kas aizsargā jūsu privātumu un drošību - Mēs nesaglabājam kontaktus vai ziņas serveros Izveidot profilu Jūsu profils tiek saglabāts jūsu ierīcē Profils tiek koplietots tikai ar jūsu kontaktiem Parādāmā vārda laukā nedrīkst būt atstarpes Parādāmais vārds Īss - Izveidot profilu Izveidot citu profilu Izveidot adresi Nederīgs vārds @@ -1547,22 +1499,10 @@ Audio ierīce - Bluetooth Kļūda, inicializējot tīmekļa skatu WebView nav atbalstīts šajā ierīces arhitektūrā. - Nākamā paaudze privātajai ziņošanai - Privātums pārdefinēts - Pirmā platforma bez lietotāju ID - Imūna pret surogātpastu un ļaunprātīgu izmantošanu - Cilvēki var savienoties tikai caur saites, ko jūs kopīgojat - Decentralizēts - Atvērtā koda protokols un kods, ko ikviens var palaist serveros Izveidot savu profilu Izveidot privātu savienojumu Migrēt no citas ierīces Kā tas darbojas - Lai aizsargātu privātumu, SimpleX izmanto ID rindām - Tikai klientu ierīces glabā kontaktu grupas un e2e šifrētas ziņas - - - Izmantot čatu Ievada paziņojumu režīms Ievada paziņojumu režīma apakšvirsraksts Ievada paziņojumu režīms izslēgts @@ -1642,7 +1582,6 @@ Iestatījumi izslēgt Iestatījumi izstrādātāja rīki Iestatījumi eksperimentālās funkcijas - Iestatījumi zeķes Iestatījumi Tēmas Profila attēli @@ -2478,6 +2417,5 @@ Lejupielādētie faili Lejupielādes kļūdas Servera adrese - Atvērt servera iestatījumus Sasniegts maksimālais grupas pieminējumu skaits ziņā. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ml/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ml/strings.xml index 008ad2bb81..02e93696b3 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ml/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ml/strings.xml @@ -59,14 +59,11 @@ ഉപയോക്താക്കൾക്ക് നന്ദി - Weblate വഴി സംഭാവന ചെയ്യുക! സ്വകാര്യതയും സുരക്ഷയും ബന്ധിപ്പിച്ചിരിക്കുന്നു - സ്വകാര്യത പുനർ നിർവചിച്ചു ബന്ധിപ്പിക്കുന്നു (പ്രഖ്യാപിച്ചു) വിലാസം സൃഷ്ടിക്കുക അജ്ഞാത പിശക് - നിങ്ങളുമായി ബന്ധപ്പെടാൻ ആളുകളെ അനുവദിക്കുന്നതിന് ഒരു വിലാസം സൃഷ്‌ടിക്കുക. ബന്ധിപ്പിക്കുന്നു പുനഃസ്ഥാപിക്കുക - ബന്ധിപ്പിക്കുക ബന്ധിപ്പിക്കുക ബന്ധിപ്പിച്ചിരിക്കുന്നു ബന്ധിപ്പിക്കുക @@ -130,8 +127,6 @@ പശ്ചാത്തലം ഇല്ല നേരിട്ടുള്ള സന്ദേശങ്ങൾ - " -\nപ5.1-ൽ ലഭ്യമാണ്" എപ്പോഴും നിങ്ങൾക്ക് മാത്രമേ ശബ്ദ സന്ദേശങ്ങൾ അയയ്ക്കാൻ കഴിയൂ. അയച്ച സന്ദേശങ്ങൾ മാറ്റാനാവാത്തവിധം ഇല്ലാതാക്കാൻ അനുവദിക്കുക. @@ -154,7 +149,6 @@ സന്ദേശം പങ്കിടുക… മറയ്ക്കുക: കാണിക്കുക: - വികേന്ദ്രീകൃതം ശബ്ദ സന്ദേശം ശബ്ദ സന്ദേശങ്ങൾ ശബ്ദ സന്ദേശം… @@ -214,12 +208,10 @@ കൂടുതലറിയുക ഉപയോക്തൃ മാര്‍ഗ്ഗദര്‍ശിയിൽ കൂടുതൽ വായിക്കുക.]]> സെർവർ വിലാസം പരിശോധിച്ച് വീണ്ടും ശ്രമിക്കുക. - നിങ്ങളുടെ SMP സെർവറുകൾ ബന്ധം വിലാസം പങ്കിടുന്നത് നിർത്തണോ\? തുടരുക SimpleX വിലാസം സൃഷ്ടിക്കുക - സൃഷ്ടിക്കുക നിറമുള്ള അവസാനിച്ചു നിങ്ങളുടെ വിളികൾ @@ -261,18 +253,15 @@ %1$s നീക്കം ചെയ്തു അപ്രത്യക്ഷമാകുന്ന സന്ദേശം അയയ്ക്കുക ഓരോ 10 നിമിഷവും ഒരു നിമിഷം വരെ പുതിയ സന്ദേശങ്ങൾ പരിശോധിക്കുന്നു - നിങ്ങൾക്ക് ഇത് പിന്നീട് സൃഷ്ടിക്കാൻ കഴിയും നിങ്ങൾ സ്വയം %s എന്ന കര്‍ത്തവ്യം മാറ്റി വീഡിയോ സ്വീകരിക്കാൻ ആവശ്യപ്പെട്ടു തത്സമയ സന്ദേശം റദ്ദാക്കുക വിളിക്കുന്നു… തത്സമയ സന്ദേശം! ഞങ്ങൾക്ക് ഇമെയിൽ അയയ്ക്കുക - നിങ്ങളുടെ സെർവർ അയച്ചു നിങ്ങളുടെ സെർവർ വിലാസം എന്ന വിലാസത്തിൽ അയച്ചു - നിങ്ങളുടെ XFTP സെർവറുകൾ കുറിച്ച് SimpleX Chat എല്ലാ സംഭാഷണങ്ങളും സന്ദേശങ്ങളും ഇല്ലാതാക്കപ്പെടും - ഇത് പഴയപടിയാക്കാനാകില്ല! സംഘത്തിലെ എല്ലാ അംഗങ്ങളുടെ ബന്ധം നിലനിർത്തും. @@ -292,13 +281,10 @@ എല്ലാ അംഗങ്ങൾക്കും സന്ദേശം ഇല്ലാതാക്കപ്പെടും. വെളിപ്പെടുത്തുക നിർത്തുക - സ്വാഗതം! - ഈ വാചകം ക്രമീകരണങ്ങളിൽ ലഭ്യമാണ് നിങ്ങൾ നിരീക്ഷകനാണ് നിങ്ങൾ നിരീക്ഷകനാണ് നിങ്ങൾ അംഗമാണ് നിങ്ങൾ ഉടമയാണ് - തീർപ്പാക്കാത്തത് സന്ദേശം അയയ്ക്കുക തത്സമയ സന്ദേശം അയയ്ക്കുക %s സ്ഥിരീകരിച്ചിട്ടില്ല @@ -311,7 +297,6 @@ മുൻഗണനകൾ സംരക്ഷിക്കണോ\? ആരംഭിക്കുന്നു ഉത്തരം ലഭിച്ചു… - സ്വകാര്യ സന്ദേശമയയ്ക്കലിന്റെ അടുത്ത തലമുറ ഒഴിവാക്കിയ സന്ദേശങ്ങൾ സംവിധാനം സ്വയം നശിപ്പിക്കുക @@ -380,7 +365,6 @@ %d മണിക്കൂർ %d മാസങ്ങള്‍ മണിക്കൂറുകൾ - വിലാസം സൃഷ്ടിക്കരുത് ചിത്രം തിരുത്തുക പരീക്ഷണാത്മക സവിശേഷതകൾ വിലാസം മാറ്റുന്നത് നിർത്തുക diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/nb-rNO/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/nb-rNO/strings.xml index eeddfd1b38..65614f25aa 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/nb-rNO/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/nb-rNO/strings.xml @@ -47,7 +47,6 @@ Legg til venner Legg til en liste Legg til en melding - Legg til forhåndsvalgte servere Legg til en profil Adresse Adresseendringen vil bli avbrutt. Den gamle mottakeradressen vil bli brukt. @@ -124,7 +123,6 @@ En ny tilfeldig profil vil bli delt. En annen grunn Svar anrop - Hvem som helst kan være vert for servere. App Appen kjører alltid i bakgrunnen App build: %s @@ -179,7 +177,6 @@ Godta automatisk Godta kontaktforespørsler automatisk Godta bilder automatisk - \nTilgjengelig i v5.1 Tilbake Bakgrunn Bakgrunnstjenesten kjører alltid - varsler vises så snart meldingene er tilgjengelige. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml index c2cc76fa50..988d0e73cc 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml @@ -36,7 +36,6 @@ Verbindingsverzoek accepteren\? geaccepteerde oproep Accepteer incognito - Vooraf ingestelde servers toevoegen Profiel toevoegen Server toevoegen Toevoegen aan een ander apparaat @@ -135,7 +134,6 @@ verbinding gemaakt Verbindingsverzoek verzonden! Timeout verbinding - Maak een eenmalige uitnodiging link Adres aanmaken Groep link maken Maak een geheime groep aan @@ -147,7 +145,6 @@ %dd Verwijderen Verwijderen na - Verbind verbonden Verbinden Verbinden via contact link? @@ -160,7 +157,6 @@ Contact en alle berichten worden verwijderd, dit kan niet ongedaan worden gemaakt! Verbonden Bevestigen - Maak verbinding via link / QR-code Gekopieerd naar het klembord Bijdragen ICE servers configureren @@ -168,7 +164,6 @@ Core versie: v%s verbonden Verbinden… - Gedecentraliseerd Maak je profiel aan %d dag %d dagen @@ -200,7 +195,6 @@ Maak verbinding via link gekleurd Oproep verbinden… - Maak Maak een profiel aan Adres verwijderen Altijd relay gebruiken @@ -383,7 +377,6 @@ Fout bij aanmaken van adres help Draai camera - Fout bij opslaan van SMP servers Fout bij updaten van netwerk configuratie Kan het gesprek niet laden Kan de chats niet laden @@ -410,9 +403,6 @@ Markeer gelezen Markeer als ongelezen Dempen - de QR-code scannen in het video gesprek , of uw contact kan een uitnodiging link delen.]]> - toon je de QR-code in het video gesprek of deel je de link.]]> - Ongeldige QR-code Meer Onjuiste beveiligingscode! Markeer geverifieerd @@ -505,7 +495,6 @@ Zorg ervoor dat WebRTC ICE server adressen de juiste indeling hebben, regel gescheiden zijn en niet gedupliceerd zijn. Als u bevestigt, kunnen de berichten servers en uw provider uw IP-adres zien en met welke servers u verbinding maakt. Nee - Immuun voor spam Maak een privéverbinding Het definitief verwijderen van berichten is niet toegestaan in dit gesprek. Nieuw in %s @@ -523,11 +512,9 @@ ongeldig berichtformaat uitgenodigd om te verbinden LIVE - Zorg ervoor dat SMP server adressen de juiste indeling hebben, regel gescheiden zijn en niet gedupliceerd zijn. gemarkeerd als verwijderd Controleer of u de juiste link heeft gebruikt of vraag uw contact om u een andere te sturen. profielfoto - Privacy opnieuw gedefinieerd Privacy en beveiliging Controleer uw netwerkverbinding met %1$s en probeer het opnieuw. Mogelijk is de certificaat vingerafdruk in het server adres onjuist @@ -566,17 +553,13 @@ Eenmalige uitnodiging link Alleen groep eigenaren kunnen groep voorkeuren wijzigen. (alleen opgeslagen door groepsleden) - Vooraf ingestelde server Periodieke meldingen zijn uitgeschakeld! - In behandeling Alleen groep eigenaren kunnen spraak berichten inschakelen. Vraag uw contact om het verzenden van spraak berichten in te schakelen. OK Onion hosts zijn vereist voor verbinding. Onion hosts worden gebruikt indien beschikbaar. Onion hosts worden niet gebruikt. - Iedereen kan servers hosten. - Jij bepaalt wie er verbinding mag maken. Alleen uw contact kan berichten onherroepelijk verwijderen (u kunt ze markeren voor verwijdering). (24 uur) Alleen jij kunt spraak berichten verzenden. Alleen uw contact kan spraak berichten verzenden. @@ -589,7 +572,6 @@ Oproep in behandeling Het openen van de link in de browser kan de privacy en beveiliging van de verbinding verminderen. Niet vertrouwde SimpleX links worden rood weergegeven. Werk de app bij en neem contact op met de ontwikkelaars. - Alleen clientapparaten slaan gebruikersprofielen, contacten, groepen en berichten op. De afzender heeft mogelijk het verbindingsverzoek verwijderd. Schakel SimpleX Vergrendelen in om uw informatie te beschermen. \nU wordt gevraagd de authenticatie te voltooien voordat deze functie wordt ingeschakeld. @@ -600,13 +582,10 @@ Om een nieuw gesprek te starten Dempen opheffen SimpleX-Team - Deze QR-code is geen link! - Deze link is geen geldige link! Eenmalige link delen Code scannen Uw instellingen Deel link - Jij beheert je gesprek! Uw profiel, contacten en afgeleverde berichten worden op uw apparaat opgeslagen. beginnen… Video aan @@ -636,9 +615,6 @@ verstuurd ongeoorloofd verzenden Ongelezen - Tik hier om een nieuw gesprek te starten - Deze tekst is beschikbaar in instellingen - Welkom! je bent uitgenodigd voor de groep Je hebt geen chats Chats @@ -654,7 +630,6 @@ Controleer de beveiligingscode Beveiligingscode bekijken U moet uw contact toestemming geven om spraak berichten te verzenden om ze te kunnen verzenden. - (scannen of plakken vanaf klembord) Om verbinding te maken via een link Afwijzen Naam contact instellen @@ -670,7 +645,6 @@ SimpleX Adres Toon QR-code SimpleX-Logo - Je chatprofiel wordt verzonden naar uw contact Je wordt verbonden met de groep wanneer het apparaat van de groep host online is, even geduld a.u.b. of controleer het later! U wordt verbonden wanneer uw verbindingsverzoek wordt geaccepteerd, even geduld a.u.b. of controleer later! Je wordt verbonden wanneer het apparaat van je contact online is, even geduld a.u.b. of controleer het later! @@ -679,13 +653,11 @@ %s is niet geverifieerd %s is geverifieerd Vergelijk (of scan) de code op uw apparaten om end-to-end codering met uw contact te verifiëren. - Openen in mobiele app .]]> Uw chat profielen Uw SimpleX adres Stuur vragen en ideeën Stuur ons een e-mail SimpleX Vergrendelen - SMP servers Servers opslaan Servertest mislukt! Sommige servers hebben de test niet doorstaan: @@ -695,7 +667,6 @@ Gebruik server Gebruik SimpleX Chat servers. Uw server adres - Uw server Transport isolation Voorkeuren opslaan\? Opslaan en Contact melden @@ -703,16 +674,11 @@ Opslaan en Contacten melden Opslaan en groepsleden melden staking - Het berichten- en applicatieplatform dat uw privacy en veiligheid beschermt. Het profiel wordt alleen gedeeld met uw contacten. - We slaan geen van uw contacten of berichten (eenmaal afgeleverd) op de servers op. U kunt markdown gebruiken voor opmaak in berichten: geweigerde oproep geheim - De toekomst van berichtenuitwisseling wachten op antwoord… - Om uw privacy te beschermen, gebruikt SimpleX voor elk van uw contacten afzonderlijke ID\'s. - Gebruik chat Wanneer de app actief is video gesprek (niet e2e versleuteld) %1$s wil met je in contact komen via @@ -723,7 +689,6 @@ WebRTC ICE servers Relay server beschermt uw IP-adres, maar kan de duur van het gesprek observeren. Relay server wordt alleen gebruikt als dat nodig is. Een andere partij kan uw IP-adres zien. - Uw ICE servers Overgeslagen berichten via relay Video uit @@ -825,13 +790,11 @@ Resetten Verstuur Stuur een live bericht, het wordt bijgewerkt voor de ontvanger(s) terwijl u het typt - (om te delen met uw contact) Bedankt voor het installeren van SimpleX Chat! Camera Gebruik voor nieuwe verbindingen Star on GitHub De servers voor nieuwe verbindingen van je huidige chatprofiel - Uw SMP servers Opgeslagen WebRTC ICE servers worden verwijderd. Uw ICE servers Opslaan @@ -841,10 +804,8 @@ Wanneer beschikbaar Vereist simplexmq: v%s (%2s) - Transportisolatiemodus updaten\? bevestiging ontvangen… Wachten op bevestiging… - Geen gebruikers-ID\'s. Verbied het sturen van directe berichten naar leden. Verbieden het verzenden van spraak berichten. De beveiliging van SimpleX Chat is gecontroleerd door Trail of Bits. @@ -874,7 +835,6 @@ Bericht delen… SimpleX Vergrendelen Sla het wachtwoord op in Keychain - SOCKS proxy Dank aan de gebruikers – draag bij via Weblate! De app haalt regelmatig nieuwe berichten op - het gebruikt een paar procent van de batterij per dag. De app maakt geen gebruik van push meldingen, gegevens van uw apparaat worden niet naar de servers verzonden. De afbeelding kan niet worden gedecodeerd. Probeer een andere afbeelding of neem contact op met de ontwikkelaars. @@ -922,7 +882,6 @@ je hebt een eenmalige link gedeeld je hebt een eenmalige link incognito gedeeld Tik op de knop - GitHub repository.]]> %1$d bericht(en) overgeslagen gemodereerd gemodereerd door %s @@ -1013,23 +972,17 @@ Wachten op video Video De video wordt gedownload wanneer uw contact het uploaden heeft voltooid. - Fout bij opslaan van XFTP-servers - Fout bij het laden van XFTP servers Server vereist autorisatie om te uploaden, wachtwoord controleren Bestand vergelijken Bestand verwijderen Bestand downloaden Upload bestand - XFTP servers - Uw XFTP servers Host Poort SOCKS proxy instellingen Gebruik SOCKS proxy Use .onion hosts in op Nee als de SOCKS-proxy deze niet ondersteunt.]]> Bestand maken - Fout bij het laden van SMP servers - Zorg ervoor dat XFTP server adressen de juiste indeling hebben, regel gescheiden zijn en niet gedupliceerd zijn. poort %d Vergrendelen na Verificatie mislukt @@ -1087,8 +1040,6 @@ Sta oproepen alleen toe als uw contact dit toestaat. Sta toe dat uw contacten u bellen. Audio/video oproepen - " -\nBeschikbaar in v5.1" Audio/video gesprekken verbieden. Audio/video gesprekken zijn niet toegestaan. Snel en niet wachten tot de afzender online is! @@ -1106,10 +1057,8 @@ U kunt dit adres delen met uw contacten om ze verbinding te laten maken met %s. Exporteer thema Titel - Wanneer mensen vragen om verbinding te maken, kunt u dit accepteren of weigeren. U raakt uw contacten niet kwijt als u later uw adres verwijdert. SimpleX adres - Maak een adres aan zodat mensen contact met je kunnen opnemen. Maak een SimpleX adres aan Delen met contacten Uw contacten blijven verbonden. @@ -1122,10 +1071,8 @@ Sla instellingen voor automatisch accepteren op Instellingen opslaan\? Voer welkom bericht in... (optioneel) - Maak geen adres aan Hoi! \nMaak verbinding met mij via SimpleX Chat: %s - U kan het later maken Adres delen Welkom bericht invoeren… Thema importeren @@ -1151,7 +1098,6 @@ Database openen… Gebruikershandleiding.]]> Interface kleuren - U kunt uw adres delen als een link of QR-code - iedereen kan verbinding met u maken. Alle app-gegevens worden verwijderd. Er wordt een leeg chatprofiel met de opgegeven naam gemaakt en de app wordt zoals gewoonlijk geopend. Zelfvernietigings wachtwoord inschakelen @@ -1331,7 +1277,6 @@ Ontvangstbevestigingen zijn uitgeschakeld Verbind direct\? Toestaan - Verbind incognito Meldingen uitzetten Open app instellingen Een nieuw willekeurig profiel zal gedeeld worden. @@ -1342,7 +1287,6 @@ app -batterijgebruik / onbeperkt in de app -instellingen om met gesloten app te bellen.]]> Uw profiel %1$s wordt gedeeld. Verzoek voor het verbinden wordt naar dit groepslid verzonden. - Plak de link die je hebt ontvangen om verbinding te maken met je contact… App-batterijgebruik / Onbeperkt in de app-instellingen.]]> Gebruik een nieuw incognito profiel Concept bericht @@ -1509,7 +1453,6 @@ Open poort in firewall Fout bij het tonen van inhoud fout bij weergeven bericht - Je kunt het via Instellingen zichtbaar maken voor je SimpleX contacten. Geschiedenis wordt niet naar nieuwe leden gestuurd. Opnieuw proberen Camera niet beschikbaar @@ -1858,7 +1801,6 @@ Serverversie is niet compatibel met uw app: %1$s. Bericht doorgestuurd Nog geen directe verbinding, bericht wordt doorgestuurd door beheerder. - Overige XFTP servers Link scannen/plakken Zoom Huidig profiel @@ -1919,7 +1861,6 @@ Geüploade bestanden Upload fouten Downloadfouten - Server instellingen openen Server adres Alle profielen pogingen @@ -1938,15 +1879,12 @@ Het bericht kan later worden bezorgd als het lid actief wordt. Berichten verzonden Geen info, probeer opnieuw te laden - Overige SMP servers Totaal ontvangen Probeer het later. Maak opnieuw verbinding met alle verbonden servers om de bezorging van berichten te forceren. Er wordt gebruik gemaakt van extra data. Het serveradres is niet compatibel met de netwerkinstellingen: %1$s. Serverstatistieken worden gereset - dit kan niet ongedaan worden gemaakt! Beginnend vanaf %s. - Geconfigureerde SMP-servers - Geconfigureerde XFTP-servers Percentage weergeven Uitgeschakeld App update downloaden. Sluit de app niet @@ -2201,7 +2139,6 @@ U kunt het bericht kopiëren en verkleinen om het te verzenden. Voeg uw teamleden toe aan de gesprekken. Zakelijk adres - end-to-end-versleuteld verzonden, met post-kwantumbeveiliging in directe berichten.]]> App draait altijd op de achtergrond Controleer berichten elke 10 minuten Meldingen en batterij 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 6b6c3ab3eb..b725bfab32 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml @@ -3,7 +3,6 @@ Uwierzytelnianie urządzenia jest wyłączone. Wyłączanie blokady SimpleX. Uwierzytelnianie urządzenia nie jest włączone. Możesz włączyć blokadę SimpleX w Ustawieniach po włączeniu uwierzytelniania urządzenia. Bezpośrednie wiadomości - Połącz połączony łączenie łączenie… @@ -146,13 +145,10 @@ wyślij Udostępnij plik… Udostępnij media… - Dotknij, aby rozpocząć nowy czat Wiadomość zostanie usunięta dla wszystkich członków. - Ten tekst jest dostępny w ustawieniach Zbyt wiele obrazów! nieautoryzowane wysyłanie oznacz jako nieprzeczytane - Witaj! Witaj %1$s! Jesteś zaproszony do grupy Nie masz czatów @@ -198,7 +194,6 @@ Plik zostanie odebrany, gdy Twój kontakt będzie online, proszę czekać lub sprawdzić później! Duży plik! Powiadomienia - Oczekujące Nagraj wiadomość głosową Wyślij wiadomość Ustaw nazwę kontaktu… @@ -221,13 +216,11 @@ Wysyłaj wiadomości na żywo - będą one aktualizowane dla odbiorcy(ów) w trakcie ich wpisywania Wyślij wiadomość na żywo Skopiowano do schowka - Utwórz jednorazowy link do zaproszenia brak szczegółów Jednorazowy link zaproszenia (przechowywane tylko przez członków grupy) Zeskanuj kod QR Rozpocznij nowy czat - (aby udostępnić Twojemu kontaktowi) Plik Odmowa Uprawnienia! Aparatu @@ -269,10 +262,8 @@ Połącz się przez link Email pomoc - pokaż kod QR w rozmowie wideo, lub udostępnij link.]]> Nieprawidłowy kod bezpieczeństwa! Nieprawidłowy link! - Nieprawidłowy kod QR Oznacz jako zweryfikowane Więcej Jednorazowy link zaproszenia @@ -285,16 +276,11 @@ Udostępnij link jednorazowy Pokaż kod QR %s jest zweryfikowany - Ten kod QR nie jest linkiem! Ten ciąg nie jest linkiem połączenia! Adres SimpleX Logo SimpleX - Otwórz w aplikacji mobilnej.]]> - Twój profil czatu zostanie wysłany -\ndo Twojego kontaktu Zostaniesz połączony do grupy, gdy urządzenie gospodarza grupy będzie online, proszę czekać lub sprawdzić później! Zostaniesz połączony, gdy urządzenie Twojego kontaktu będzie online, proszę czekać lub sprawdzić później! - Dodaj gotowe serwery Dodaj serwer Dodaj do innego urządzenia Konsola czatu @@ -308,7 +294,6 @@ Nieprawidłowy adres serwera! Pomoc markdown Markdown w wiadomościach - Predefiniowany serwer Adres predefiniowanego serwera Oceń aplikację Zapisane serwery WebRTC ICE zostaną usunięte. @@ -320,7 +305,6 @@ Test serwera nie powiódł się! Blokada SimpleX %s nie jest zweryfikowany - Serwery SMP Daj gwiazdkę na GitHub Przetestuj serwer Przetestuj serwery @@ -329,7 +313,6 @@ Używanie serwerów SimpleX Chat. Twoje profile czatu Twoje serwery ICE - Twój serwer Twój adres serwera Twój adres SimpleX Przyczyń się @@ -357,7 +340,6 @@ Wymagane Zapisz Izolacja transportu - Zaktualizować tryb izolacji transportu\? Użyć bezpośredniego połączenia z Internetem\? Użyj hostów .onion Użyć proxy SOCKS\? @@ -390,11 +372,8 @@ Hasło ukrytego profilu Hasło do wyświetlenia Zapisz hasło profilu - Platforma komunikacyjna i aplikacyjna chroniąca Twoją prywatność i bezpieczeństwo. Profil jest udostępniany tylko Twoim kontaktom. Aby ujawnić Twój ukryty profil, wprowadź pełne hasło w pole wyszukiwania na stronie Twoich profili czatu. - Nie przechowujemy na serwerach żadnych Twoich kontaktów ani wiadomości (po ich dostarczeniu). - Ty kontrolujesz swój czat! Twój profil, kontakty i dostarczone wiadomości są przechowywane na Twoim urządzeniu. a + b O SimpleX @@ -408,24 +387,16 @@ połączony łączenie… łączenie połączenia… - Utwórz - Zdecentralizowane zakończona Jak korzystać z markdown - Odporność na spam kursywa nieodebrane połączenie - Każdy może hostować serwery. - Ty decydujesz, kto może się połączyć. - Redefinicja prywatności otrzymano odpowiedź… otrzymano potwierdzenie… odrzucone połączenie sekret uruchamianie… przekreślenie - Brak identyfikatorów użytkownika. - Następna generacja \nprywatnych wiadomości oczekiwanie na odpowiedź… oczekiwanie na potwierdzenie… Możesz używać markdown do formatowania wiadomości: @@ -434,11 +405,8 @@ Natychmiastowy Jak wpływa na baterię Nawiąż prywatne połączenie - Tylko urządzenia klienckie przechowują profile użytkowników, kontakty, grupy i wiadomości. Okresowo Prywatne powiadomienia - repozytorium GitHub.]]> - Użyj czatu Gdy aplikacja jest uruchomiona Zużywa więcej baterii! Aplikacja zawsze działa w tle - powiadomienia są wyświetlane natychmiastowo.]]> Przychodzące połączenie audio @@ -462,7 +430,6 @@ Serwer przekaźnikowy chroni Twój adres IP, ale może obserwować czas trwania połączenia Pokaż Serwery WebRTC ICE - Twoje serwery ICE Odbierz połączenie Dźwięk wyłączony Dźwięk włączony @@ -525,7 +492,6 @@ Uruchom czat Wyślij podgląd linku Ustawienia - Proxy SOCKS Zatrzymać czat\? Wspieraj SimpleX Chat Motywy @@ -918,7 +884,6 @@ łączenie… Kontakt i wszystkie wiadomości zostaną usunięte - nie można tego cofnąć! Błąd połączenia (UWIERZYTELNIANIE) - Połącz się przez link / kod QR Utwórz tajną grupę Utwórz tajną grupę Baza danych jest zaszyfrowana przy użyciu losowego hasła. Proszę zmienić je przed eksportem. @@ -928,7 +893,6 @@ Znikające wiadomości są zabronione. Błąd usuwania prośby o kontakt Nie znaleziono pliku - Błąd zapisu serwerów SMP Błąd aktualizacji linku grupy Błąd importu bazy danych czatu Błąd dołączenia do grupy @@ -948,7 +912,6 @@ Członkowie mogą wysyłać wiadomości głosowe. Grupa zostanie usunięta dla wszystkich członków - nie można tego cofnąć! Jak korzystać z Twoich serwerów - zeskanować kod QR w rozmowie wideo, lub Twój rozmówca może udostępnić link z zaproszeniem.]]> Jeśli odrzucisz nadawca NIE zostanie powiadomiony. incognito poprzez link adresu kontaktowego Jeśli otrzymałeś link do zaproszenia SimpleX Chat, możesz go otworzyć w swojej przeglądarce: @@ -961,7 +924,6 @@ zaproszenie do grupy %1$s Nieodwracalne usuwanie wiadomości Upewnij się, że adresy serwerów WebRTC ICE są w poprawnym formacie, rozdzielone liniami i nie są zduplikowane. - Upewnij się, że adresy serwerów SMP są w poprawnym formacie, rozdzielone liniami i nie są zduplikowane. Poproś Twój kontakt o włączenie wysyłania wiadomości głosowych. Sprawdź, czy użyłeś prawidłowego linku lub poproś Twój kontakt o przesłanie innego. Wiadomość zostanie usunięta - nie można tego cofnąć! @@ -976,7 +938,6 @@ Odbiorcy widzą aktualizacje podczas ich wpisywania. Uruchom ponownie aplikację, aby użyć zaimportowanej bazy danych czatu. Zapisz profil grupy - (zeskanuj lub wklej ze schowka) Ustaw preferencje grupy Udostępnij wiadomość… Bezpieczeństwo SimpleX Chat zostało zaudytowane przez Trail of Bits. @@ -988,9 +949,7 @@ Rola zostanie zmieniona na "%s". Wszyscy w grupie zostaną powiadomieni. Tego działania nie można cofnąć - wszystkie odebrane i wysłane pliki oraz media zostaną usunięte. Obrazy o niskiej rozdzielczości pozostaną. Adres odbiorczy zostanie zmieniony na inny serwer. Zmiana adresu zostanie zakończona gdy nadawca będzie online. - Ten link nie jest prawidłowym linkiem połączenia! SimpleX działa w tle zamiast korzystać z powiadomień push.]]> - Aby chronić Twoją prywatność, SimpleX używa oddzielnych identyfikatorów dla każdego z Twoich kontaktów. Aby zweryfikować szyfrowanie end-to-end z Twoim kontaktem porównaj (lub zeskanuj) kod na waszych urządzeniach. Użyj dla nowych połączeń O ile Twój kontakt nie usunął połączenia lub ten link był już użyty, może to być błąd - zgłoś go. \nAby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połączenia i sprawdź, czy masz stabilne połączenie z siecią. @@ -1014,26 +973,19 @@ Twój profil jest przechowywany na Twoim urządzeniu i udostępniany tylko Twoim kontaktom. Serwery SimpleX nie widzą Twojego profilu. udostępniłeś jednorazowy link incognito Zostaniesz połączony ze wszystkimi członkami grupy. - Twoje serwery SMP Zostaniesz połączony, gdy Twoje żądanie połączenia zostanie zaakceptowane, proszę czekać lub sprawdzić później! - Błąd ładowania serwerów SMP - Błąd ładowania serwerów XFTP - Błąd zapisu serwerów XFTP Utwórz plik Pobierz plik Serwer wymaga autoryzacji do przesłania, sprawdź hasło Prześlij plik Porównaj plik Usuń plik - Serwery XFTP - Twoje serwery XFTP Host Port port %d Użyj hostów .onion na Nie jeśli proxy SOCKS ich nie obsługuje.]]> Ustawienia PROXY SOCKS Użyj proxy SOCKS - Upewnij się, że adresy serwerów XFTP są w poprawnym formacie, rozdzielone liniami i nie są zduplikowane. Uwierzytelnianie nie powiodło się Wpis pinu Tryb blokady SimpleX @@ -1081,8 +1033,6 @@ Zezwalaj na połączenia tylko wtedy, gdy Twój kontakt na to pozwala. Zezwól swoim kontaktom na połączenia do Ciebie. Połączenia audio/wideo - " -\nDostępny w v5.1" Zarówno Ty, jak i Twój kontakt możecie nawiązywać połączenia. Zabroń połączeń audio/wideo. Tylko Twój kontakt może wykonywać połączenia. @@ -1124,7 +1074,6 @@ Zapisać ustawienia\? Przestań udostępniać Kontynuuj - Nie twórz adresu Cześć! \nPołącz się ze mną za pomocą SimpleX Chat: %s Zaproś znajomych @@ -1137,17 +1086,14 @@ link jednorazowy Podręczniku Użytkownika.]]> Adres SimpleX - Kiedy ludzie proszą o połączenie, możesz je zaakceptować lub odrzucić. Nie stracisz kontaktów, jeśli później usuniesz swój adres. Dostosuj motyw Kolory interfejsu Twoje kontakty pozostaną połączone. Dodaj adres do swojego profilu, aby Twoje kontakty mogły go udostępnić innym osobom. Aktualizacja profilu zostanie wysłana do Twoich kontaktów. - Utwórz adres, aby ludzie mogli się z Tobą połączyć. Utwórz adres SimpleX Zapisz ustawienia adresów SimpleX Udostępnij kontaktom - Możesz go utworzyć później Adres SimpleX Drugorzędny @@ -1166,7 +1112,6 @@ Wszystkie Twoje kontakty pozostaną połączone. Aktualizacja profilu zostanie wysłana do Twoich kontaktów. Wpisz wiadomość powitalną… Jeśli nie możesz spotkać się osobiście, pokaż kod QR w rozmowie wideo lub udostępnij link. - Możesz udostępnić swój adres jako link lub kod QR - każdy może się z Tobą połączyć. Pin samozniszczenia Pin samozniszczenia włączony! Aby się połączyć, Twój kontakt może zeskanować kod QR lub skorzystać z linku w aplikacji. @@ -1341,9 +1286,7 @@ Brak rozmów w tle SimpleX nie może działać w tle. Otrzymasz powiadomienia gdy aplikacja jest uruchomiona. Nowy losowy profil zostanie udostępniony. - Wklej otrzymany link w pole poniżej, aby połączyć się z kontaktem… Twój profil %1$s zostanie udostępniony. - Połącz incognito Aplikacja może zostać zamknięta po 1 minucie w tle. Optymalizacja baterii / Nieograniczony w ustawieniach aplikacji.]]> Optymalizacja baterii / Nieograniczony w ustawieniach aplikacji.]]> @@ -1501,7 +1444,6 @@ Proszę poczekać na załadowanie pliku z połączonego telefonu Zweryfikuj połączenie Odśwież - Możesz ustawić go jako widoczny dla swoich kontaktów SimpleX w Ustawieniach. Losowy błąd wyświetlania zawartości błąd wyświetlania wiadomości @@ -1888,13 +1830,11 @@ Fragmenty pobrane Fragmenty przesłane Pobrane pliki - Skonfigurowane serwery SMP Potwierdzono Błędy potwierdzenia Usunięto Aktywne połączenia Wszystkie profile - Skonfigurowane serwery XFTP błąd odszyfrowywania Szczegółowe statystyki Szczegóły @@ -1912,8 +1852,6 @@ wygasły Fragmenty usunięte Brak bezpośredniego połączenia, wiadomość została przekazana przez administratora. - Inne serwery SMP - Inne serwery XFTP Pokaż procent Stabilny Aktualizacja dostępna: %s @@ -1946,7 +1884,6 @@ Zabezpieczone Zasubskrybowano Przesłane pliki - Otwórz ustawienia serwera Adres serwera Wysłane wiadomości Ponownie połącz ze wszystkimi połączonymi serwerami w celu wymuszenia dostarczenia wiadomości. Wykorzystuje to dodatkowy ruch. @@ -2138,7 +2075,6 @@ Dodaj listę Dodaj do listy "Wszystkie konwersacje zostaną usunięte z list %s, oraz listy." - zaszyfrowanej, z post-kwantowym bezpieczeństwem w bezpośrednich wiadomościach.]]> Dodaj znajomych Dodane serwery plików i mediów Paski narzędziowe aplikacji 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 5b54d0c452..01239d6049 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml @@ -104,9 +104,7 @@ Permitir Permitir enviar mensagens temporárias. Permitir o envio de mensagens diretas para os membros. - Conectar via link/QR Code Todas as mensagens serão apagadas. Isso não pode ser desfeito! As mensagens serão apagadas APENAS para você. - Adicionar servidores predefinidos Adicionar servidor Crie seu perfil Ícone de contexto @@ -146,7 +144,6 @@ Contato verificado Contato ainda não está conectado! Contribuir - Criar Acessar os servidores por meio do proxy SOCKS na porta %d? O proxy deve ser iniciado antes de ativar esta opção. Permitir que seus contatos apaguem permanentemente as mensagens enviadas em até 24h. Adicionar a outro dispositivo @@ -156,7 +153,6 @@ Conexão Excluir perfil de chat\? Apagar para todos - Conectar conectado conectando apagado @@ -171,7 +167,6 @@ Apagar contato Configurar servidores ICE Excluir endereço\? - Descentralizado Excluir banco de dados O banco de dados é criptografado usando uma senha aleatória. Altere-a antes de exportar. Confirmar nova senha… @@ -281,7 +276,6 @@ Editar Ativar exclusão automática de mensagens\? %d seg - Erro ao salvar servidores SMP Erro ao aceitar solicitação de contato Erro ao excluir solicitação de contato Erro ao trocar de perfil! @@ -382,8 +376,6 @@ Ocultar contato e mensagem Como usar Como usar markdown - exiba o QR Code na videochamada ou compartilhe o link.]]> - escanear o QR Code em uma videochamada, ou pedir que o contato compartilhe um link de convite.]]> Se você confirmar, os servidores de mensagens poderão ver seu endereço IP e seu provedor - e quais servidores você está se conectando. Imagem Se você recebeu o link de convite SimpleX Chat, você pode abri-lo em seu navegador: @@ -391,13 +383,11 @@ anonimamente pelo link do grupo Chamada de vídeo recebida Permita na próxima caixa de diálogo para receber notificações instantaneamente.]]> - Gerar link de uso único Arquivo não encontrado Se você optar por rejeitar o remetente NÃO será notificado. Código de segurança incorreto! Instale o SimpleX para terminal Como funciona - Imune a spam Vire a câmera Desligar Modo anônimo @@ -439,7 +429,6 @@ Atualize o aplicativo e entre em contato com os desenvolvedores. O servidor exige autorização para criar filas. Verifique a senha. Revelar - Pendente Proibir o envio de mensagens diretas para os membros. Escanear QR Code Recusar @@ -449,7 +438,6 @@ Novo em %s Envie-nos um email Escanear QR Code do servidor - Seus servidores SMP nunca Restaurar backup do banco de dados? moderado @@ -459,7 +447,6 @@ Arquivo grande! Marcar como lida Você convidou seu contato - QR Code inválido Mais Você será conectado ao grupo quando o dispositivo que hospeda o grupo estiver online. Aguarde ou verifique mais tarde. Essa string não é um link de conexão! @@ -480,7 +467,6 @@ Notificações periódicas Câmera Seus servidores ICE - Seus servidores ICE Privacidade Entrar no grupo? Sair @@ -546,7 +532,6 @@ conversa inválida Abrir o link no navegador pode reduzir a privacidade e a segurança da conexão. Links SimpleX não confiáveis ficarão vermelhos. Link de conexão inválido - Certifique-se de que os endereços do servidor SMP estejam no formato correto, separados por linhas e não estejam duplicados. O remetente excluiu a solicitação de conexão. Notificações periódicas estão desativadas! As notificações instantâneas estão desativadas! @@ -582,7 +567,6 @@ observador Por favor, entre em contato com o administrador do grupo. Markdown em mensagens - Servidores SMP Endereço do servidor predefinido Rejeitar %1$d mensagem(ens) ignorada(s) @@ -621,22 +605,17 @@ Redefinir Mensagem ao vivo! Envie uma mensagem ao vivo: ela será atualizada para os destinatários enquanto você digita - (para compartilhar com seu contato) - (escanear ou colar da área de transferência) Você aceitou a conexão Marcar como não lida Silenciar Definir nome do contato Link inválido! - Esse QR Code não é um link! - Seu perfil de chat será enviado\nao seu contato Você será conectado quando o dispositivo do seu contato estiver online. Aguarde ou verifique mais tarde. Como Teste do servidor falhou! Os hosts .onion não serão usados. Os hosts .onion serão obrigatórios para a conexão.\nAtenção: você não conseguirá se conectar aos servidores sem um endereço .onion. Versão principal: v%s - repositório do GitHub.]]> Como isso afeta a bateria %1$s quer se conectar com você via sem criptografia de ponta a ponta @@ -745,14 +724,11 @@ Novidades Chamadas de áudio e vídeo Proteja seus perfis de chat com uma senha! - Este texto está disponível nas configurações Escanear código Os hosts .onion serão usados quando estiverem disponíveis. Seu perfil atual - Privacidade redefinida Notificações privadas Fazer uma conexão privada - Você decide quem pode se conectar. Isso pode acontecer quando:\n1. As mensagens expiraram no remetente após 2 dias ou no servidor após 30 dias.\n2. A descriptografia da mensagem falhou porque você ou seu contato usou um backup antigo do banco de dados.\n3. A conexão foi comprometida. Você deverá inserir a senha sempre que o aplicativo for iniciado – ela não fica armazenada no dispositivo. Salvar mensagem de boas-vindas\? @@ -781,7 +757,6 @@ Usar servidor Você precisa permitir que seu contato envie mensagens de voz para poder enviá-las também. Seus perfis de chat - Servidor predefinido Escaneie o código de segurança do aplicativo do seu contato. Enviar mensagem Requerido @@ -833,17 +808,12 @@ O banco de dados não está funcionando corretamente. Toque para saber mais Aguardando a imagem Mostrar QR Code - Abrir no aplicativo móvel.]]> Testar servidores - Atualizar o modo de isolamento de transporte\? Vídeo ativado Aguardando o arquivo Toque no botão Para começar uma nova conversa Ligar - Bem-vindo(a)! - O futuro da transmissão de mensagens - Proxy SOCKS Não foi possível concluir a alteração da senha do banco de dados. Pare o chat para exportar, importar ou excluir o banco de dados. Não será possível enviar ou receber mensagens enquanto o chat estiver parado. %s segundo(s) @@ -865,7 +835,6 @@ O teste falhou na etapa %s. Iniciar periodicamente envio não autorizado - Toque para iniciar uma nova conversa Você não tem conversas aguardando resposta… Seu banco de dados atual será EXCLUÍDO e SUBSTITUÍDO pelo importado.\nEsta ação não pode ser desfeita - seu perfil, contatos, mensagens e arquivos serão completamente destruídos. @@ -883,7 +852,6 @@ Você é um colaborador Mensagem de voz (%1$s) Compartilhar link - Para proteger sua privacidade, o SimpleX usa IDs separados para cada um dos seus contatos. chamada de vídeo Mostrar Servidores ICE WebRTC @@ -909,12 +877,9 @@ Alterar endereço de recebimento\? Ativar som Logo SimpleX - Esse link não é válido! Servidor de teste simplexmq: v%s (%2s) Isolar transporte - Você controla seu chat! - Sem identificadores de usuário. Alto-falante ligado Vídeo desativado Alto-falante desligado @@ -948,7 +913,6 @@ Compartilhar mensagem… Bem-vindo(a) %1$s! Você está convidado para o grupo - Usar chat Mensagens de voz são proibidas neste chat. Vídeo Vídeo enviado @@ -964,7 +928,6 @@ Mostrar: iniciando… aguardando confirmação… - Não armazenamos nenhum dos seus contatos nem suas mensagens (uma vez entregues) nos servidores. Mensagens ignoradas Toque para ativar o perfil. Mostrar perfil de chat @@ -972,7 +935,6 @@ Não foi possível conectar ao servidor que recebe mensagens desta conexão: %1$s. Tentando se conectar ao servidor utilizado para receber mensagens desta conexão. Você está conectado ao servidor usado para receber mensagens desta conexão. - Seu servidor Bloqueio SimpleX A impressão digital do endereço do servidor não corresponde à do certificado. Você enviou um convite de grupo @@ -983,7 +945,6 @@ imagem de pré-visualização do link Para proteger suas informações, ative o bloqueio SimpleX. \nVocê será solicitado a completar a autenticação antes que este recurso seja ativado. - Qualquer um pode hospedar os servidores. Claro O contato permite ativado @@ -1004,23 +965,16 @@ Apoie SimpleX Chat Esta ação não pode ser desfeita - as mensagens enviadas e recebidas antes do período selecionado serão excluídas. Isso pode levar vários minutos. Confirme as atualizações do banco de dados - Somente os dispositivos do usuário armazenam perfis, contatos, grupos e mensagens. Obrigado por instalar o SimpleX Chat! - A plataforma de mensagens que protege sua privacidade e segurança. Você está tentando convidar um contato com quem compartilhou um perfil anônimo para o grupo no qual está usando seu perfil principal. Fila segura imagem de perfil temporária - Erro ao carregar servidores SMP - Erro ao carregar servidores XFTP - Erro ao salvar servidores XFTP - Certifique-se de que os endereços dos servidores XFTP estejam no formato correto, separados por linha e não estão duplicados. Criar arquivo Baixar arquivo O servidor requer autorização para fazer upload, verifique a senha. Enviar arquivo Comparar arquivo Excluir arquivo - Seus servidores XFTP Autenticação falhou Mudar senha Senha atual @@ -1036,7 +990,6 @@ Você não pôde ser verificado; por favor, tente novamente. Autenticar Bloqueio SimpleX não ativado! - Servidores XFTP porta %d Configurações do proxy SOCKS Usar proxy SOCKS @@ -1077,7 +1030,6 @@ %1$d mensagens ignoradas. Chamadas de áudio/vídeo Chamadas de áudio/vídeo são proibidas. - \nDisponível em v5.1 Você e seu contato podem fazer chamadas. Somente você pode fazer chamadas. Somente seu contato pode fazer chamadas. @@ -1098,10 +1050,8 @@ Destaque adicional link de uso único Adicione o endereço ao seu perfil para que seus contatos possam compartilhá-lo com outras pessoas. A atualização do perfil será enviada aos seus contatos. - Crie um endereço para permitir que as pessoas se conectem com você. Criar endereço SimpleX Continuar - Não criar endereço Endereço Tema escuro Fundo @@ -1121,10 +1071,8 @@ Título Se não puderem se encontrar pessoalmente, exiba o QR Code na videochamada ou compartilhe o link. Para se conectar, seu contato pode ler o QR Code ou usar o link no aplicativo. - Você pode compartilhar seu endereço como link ou QR Code para que qualquer pessoa possa se conectar com você. Você não perderá seus contatos se, posteriormente, excluir seu endereço. Endereço SimpleX - Quando alguém solicitar conexão, você pode aceitar ou recusar. Cores da interface Compartilhar com contatos do SimpleX A atualização do perfil será enviada aos seus contatos. @@ -1135,7 +1083,6 @@ \nConecte-se comigo via SimpleX Chat: %s Convide amigos Vamos conversar no SimpleX - Você pode criá-lo mais tarde Compartilhar endereço… Você pode compartilhar este endereço com seus contatos para que eles se conectem com %s. Prévia @@ -1299,13 +1246,11 @@ As confirmações de entrega serão ativadas para todos os contatos. Ocorreu um erro ao ativar as confirmações de entrega! Escolher arquivo - Conexão anônima Permitir Desativar notificações Sem chamadas de fundo Abrir configurações do aplicativo Um novo perfil aleatório será compartilhado. - Cole o link que você recebeu para se conectar com seu contato… Desativar confirmações para grupos? Ativar confirmações para grupos? O envio de confirmações está desativado para %d grupos @@ -1401,7 +1346,6 @@ Você pode visualizar o código de convite novamente nos detalhes de conexão. Opções de desenvolvedor Mostrar chamadas de API lentas - Você pode torná-lo visível para seus contatos nas Configurações. Criptografar arquivos locais %s conectou Erro ao bloquear membro para todos @@ -1633,8 +1577,6 @@ Configurações avançadas Verificar atualizações Completas - Servidores SMP configurados - Servidores XFTP configurados Verifique sua conexão de internet e tente novamente Verificar atualizações Modo de cor @@ -1725,7 +1667,6 @@ \nErro: %2$s Destinatário(s) não podem ver de onde essa mensagem veio. A mensagem poderá ser entregue mais tarde se o membro se tornar ativo. - Outros servidores SMP Para proteger seu endereço IP, o roteamento privado usa seus servidores SMP para entregar mensagens. Pular essa versão Baixar %s (%s) @@ -1911,7 +1852,6 @@ Configurações Manter conversa Apenas apagar conversa - Outros servidores XFTP Use roteamento privado em servidores desconhecidos quando o endereço de IP não está protegido. Sim Instalar atualização @@ -2001,7 +1941,6 @@ Endereço do servidor Tamanho Erros de envio - Abrir configurações de servidor Selecione As mensagens serão apagadas para todos os membros. As mensagens serão marcadas como moderadas para todos os membros. @@ -2123,7 +2062,6 @@ %s.]]> Mensagens diretas entre membros são proibidas neste chat. Melhor desempenho de grupos - com criptografia de ponta a ponta, com segurança pós-quântica em mensagens diretas.]]> A conversa será apagada para você. Isso não pode ser desfeito! Condições aceitas em: %s. Mensagens diretas entre membros são proibidas. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml index 6ab12f76f6..8b43d5295d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml @@ -43,8 +43,6 @@ não Definir preferências de grupo Mensagens de voz - " -\nDisponível na v5.1" Aceitar Permitir que seus contatos enviem mensagens que desaparecem. Definir 1 dia @@ -71,10 +69,8 @@ As mensagens enviadas serão eliminadas após o tempo definido. Mensagem de rascunho Defina-o em vez de autenticação do sistema. - Conectar Você está conectado ao servidor usado para receber mensagens deste contacto. você - Certifique-se de que os endereços do servidor XFTP estão no formato correto, separados por linhas e não estão duplicados. Desconectar Possivelmente, a impressão digital do certificado no endereço do servidor está incorreta A senha é necessária @@ -128,7 +124,6 @@ Aceitar Aceitar pedido de ligação\? Aceitar modo anónimo - Adicionar servidores pré-definidos Aceder aos servidores via proxy SOCKS no porto %d\? O proxy tem de iniciar antes de ativar esta opção. Adicionar a outro dispositivo administrador @@ -157,14 +152,12 @@ Nome para Exibição: Mostrar código QR Mensagens temporárias são proibidas neste grupo. - Conectar via link / código QR Mensagens temporárias são proibidas nesta conversa. Enviar AO VIVO Enviar uma mensagem ao vivo - ela será atualizada para o(s) destinatário(s) à medida que você a digita Nome local Modo de bloqueio - Certifique-se de que os endereços do servidor SMP estão no formato correto, separados por linhas e não estão duplicados. Fazer uma conexão privada Tornar o perfil privado! Certifique-se de que os endereços do servidor WebRTC ICE estão no formato correto, separados por linhas e não estão duplicados. @@ -204,7 +197,6 @@ Você e o seu contato podem eliminar irreversivelmente as mensagens enviadas. Você já tem um perfil de chat com o mesmo nome para exibição. Por favor, escolha outro nome. Você está convidado para o grupo - Abrir na aplicação móvel.]]> Você está convidado para o grupo. Junte-se para se conectar com os membros do grupo. Grupo Áudio ligado @@ -325,7 +317,6 @@ Definir senha para exportar Pedido de conexão enviado! Criar endereço - Criar colorido conectando… Conectando chamada @@ -374,7 +365,6 @@ Se você recebeu convite de ligação do SimpleX Chat, você pode abri-lo no seu navegador: Toque para entrar no modo anônimo Salvar senha na Keystore - mostre o código QR na chamada de vídeo ou partilhe a ligação.]]> Salvar definições de aceitação automática Aceitar automaticamente Salvar e atualizar o perfil do grupo @@ -400,7 +390,6 @@ Ligação inválida! Guia de Utilizador.]]> Conexão - Crie um endereço para permitir que as pessoas se conectem consigo. Salvar e notificar contato Salvar e notificar contatos Salvar e notificar membros do grupo @@ -411,7 +400,6 @@ Todos os seus contatos permanecerão conectados. A atualização do perfil será enviada aos seus contatos. Adicione endereço ao seu perfil, para que os seus contatos possam partilhá-lo com outras pessoas. A atualização do perfil será enviada aos seus contatos. Os contactos podem marcar mensagens para eliminar; você será capaz de as ver. - Criar convite de ligação de utilização única anónimo via ligação de utilização única anónimo via ligação de grupo via ligação de utilização única @@ -419,15 +407,11 @@ Ligação para 1 utilização Criar ligação Apagar ligação\? - ler o código QR na chamada de vídeo , ou o seu contato pode partilhar um convite de ligação.]]> Se não se poderem encontrar pessoalmente, mostre o código QR numa chamada de vídeo ou partilhe a ligação. - As pessoas podem se conectar a si apenas por meio das ligações que você partilhe. Verifique se você usou a ligação correta ou peça ao seu contato para enviar outra. Partilhar ligação Enviar pré-visualizações de ligações - (ler ou colar da área de transferência) Partilhar ligação de utilização única - GitHub.]]> Criar perfil Criar o seu perfil %d contacto(s) selecionado(s) @@ -467,7 +451,6 @@ Eliminar servidor Personalizar tema Eliminar imagem - Não criar endereço Desativar Palavra-passe da base de dados Criar endereço SimpleX @@ -476,7 +459,6 @@ Eliminar contacto? Dispositivo Mensagens diretas - Descentralizado mensagem duplicada Ficheiro: %s você: %1$s @@ -498,7 +480,6 @@ Você não irá perder os seus contatos se eliminar o seu endereço mais tarde. Você pode esconder ou silenciar um perfil de utilizador - pressione-o para o menu. Vídeo ligado - Servidores XFTP %1$s quer conectar-se consigo via Junte-se Sair @@ -535,8 +516,6 @@ Endereço SimpleX Equipa SimpleX Saiba mais - Quando as pessoas solicitam conexão, você pode aceitá-la ou rejeitá-la. - Você pode criá-lo mais tarde Vamos conversar no SimpleX Chat Vídeo desligado %1$d mensagem(s) ignoradas @@ -573,7 +552,6 @@ Mais melhorias chegam brevemente! Pré-visualização de notificação Muito provavelmente este contato eliminou a conexão consigo. - Este texto está disponível nas definições Pode ser alterado mais tarde através das definições. Ajuda Suporte SimpleX Chat @@ -637,7 +615,6 @@ Para verificar a encriptação de ponta a ponta com o seu contato, compare (ou leia) o código nos seus dispositivos. Ler o código de segurança a partir da aplicação do seu contacto. Ler o código QR do servidor - Apenas dispositivos pessoais armazenam perfis de utilizador, contatos, grupos e mensagens. o contacto tem encriptação ponta a ponta sem encriptação ponta a ponta criador @@ -725,7 +702,6 @@ Pesquisar Desativado O teste falhou na etapa %s. - Servidor SMP Servidor de teste %s segundo(s) Parar conversa? @@ -741,7 +717,6 @@ %s, %s e %d outros membros conectados Iniciar conversa? Começa periodicamente - Toque para iniciar uma nova conversa Sistema de autenticação Parar de receber o arquivo? Servidores de teste @@ -798,9 +773,6 @@ A transferir detalhes de ligação A criar ligação de arquivo duplicados(as) - Abrir definições do servidor - O seu perfil de conversa será enviado -\npara o seu contacto Conectar com %1$s? Auricular Dispositivos @@ -818,7 +790,6 @@ Ativar Computador O seu contacto enviou um ficheiro que é maior que o tamanho máximo suportado atualmente (%1$s). - O seu servidor O endereço do seu servidor As tuas chamadas Não enviar histórico a novos membros. @@ -836,7 +807,6 @@ Os seus contactos podem permitir eliminação total de mensagens. Desconectado com a razão: %s Você já solicitou ligação através deste endereço! - Código e protocolo open-source - qualquer indivíduo pode ser anfitrião dos servidores. %d mensagens bloqueadas %d mensagens marcadas como eliminadas Você não pode ser verificado; por favor tente novamente. @@ -885,7 +855,6 @@ Transferir %s (%s) Os seus servidores ICE O seu perfil, contactos e mensagens entregues são armazenados no seu dispositivo. - Os seus servidores ICE Desativar (manter as sobreposições de grupo) Desativar (manter sobreposições) Desativar recibos? @@ -926,10 +895,7 @@ Você será conectado quando o dispositivo do seu contacto estiver online, por favor aguarde ou volte mais tarde! Desconectar Desativado - Os seus servidores XFTP - Você controla a conversa! Você poderá ver a conversa com %1$s na lista de conversas. - Os seus servidores SMP Você pode usar markdown para formatar mensagens: Você poderá enviar mensagens para %1$s através das conversas Eliminadas. O seu contacto precisa de estar online para a ligação ser completada. @@ -943,7 +909,6 @@ A tua base de dados de conversas Detalhes Você pode partilhar o seu endereço com os seus contactos para permitir que se conectem com %s. - Você pode partilhar o seu endereço como uma ligação ou código QR - qualquer pessoa pode conectar-se a si. Permitir Todas as novas mensagens de %s serão ocultadas! Somente você pode fazer ligações. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml index 3af5fbee19..8c7651fecd 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml @@ -33,7 +33,6 @@ Accepți cererea de conectare? Adaugă contact apel acceptat - Adaugă servere presetate Adaugă profil Adresă Adaugă servere prin scanarea codurilor QR. @@ -104,7 +103,6 @@ Arhivează și încarcă Bază de date de arhivare Se creează un link de arhivare - Creează link de invitație de unică folosință Acceptă automat imagini Apel audio apel audio @@ -128,7 +126,6 @@ Autentificare anulată Cod de acces aplicație este înlocuit cu cod de acces de autodistrugere. Apeluri audio/video - \nDisponibil în v5.1 Cod de acces aplicație Migrare date aplicație Eroare critică @@ -285,7 +282,6 @@ Setează numele contactului… Trimite mesaj Trimiteți un mesaj care dispare - (scanează sau lipește din clipboard) Afișează codul QR Testul serverului a eșuat! Afișează: @@ -341,7 +337,6 @@ Pătrat, cerc, sau orice între. %s nu este verificat %s este verificat - Servere SMP %s, %s și %d alți membri s-au conectat %s, %s și %d membri Difuzor @@ -386,7 +381,6 @@ Migrează de pe alt dispozitiv pe noul dispozitiv și scanați codul QR.]]> Te conectezi prin adresa de contact? Vrei să te conectezi printr-un link unic? - Conectare incognito Contactul deja există Schimbă codul de acces Puteți activa SimpleX Lock din Setări. @@ -423,7 +417,6 @@ Profilurile tale de conversație Creează un profil de conversație apel încheiat %1$s - Creează Bluetooth Camera Apeluri pe ecranul blocat: @@ -457,13 +450,11 @@ Nu ați putut fi verificat; vă rugăm să încercați din nou. Copiază Anulează mesajul live - Conectare prin link / cod QR Contactele tale vor rămâne conectate. Parola pentru criptarea bazei de date va fi actualizată. Contactele tale pot permite ștergerea totală a mesajelor. Trebuie să îi permiți contactului tău să trimită mesaje vocale pentru a le putea trimite. Versiunea de bază: v%s - Creează o adresă pentru a permite oamenilor să se conecteze cu tine. %s blocat ați schimbat adresa ați schimbat adresa pentru %s @@ -478,7 +469,6 @@ Copiat Camera Ați invitat un contact - Profilul tău de conversație va fi trimis contactului tău. Contribuie Profil conversație aldin @@ -511,7 +501,6 @@ Contactul tău a trimis un fișier care este mai mare decât dimensiunea maximă acceptată în prezent (%1$s). anulează previzualizarea linkului Verifică adresa serverului și încearcă din nou. - Tu îți controlezi chatul! Apelul s-a încheiat deja! Apel încheiat Blochează pentru toți @@ -530,7 +519,6 @@ Confirmați fișiere de la servere necunoscute. am schimbat adresa pentru tine Confirmă noua parolă… - Conectare conexiune %1$d conexiune stabilită Eroare de conexiune @@ -617,7 +605,6 @@ Ștergeți mesajul membrului? Șterge grupul Șterge și notifică contactele - Descentralizat grup șters %d contact(e) selectat(e) Șters la: %s @@ -661,7 +648,6 @@ Șterge baza de date de pe acest dispozitiv Șterge profilul de conversație Șterge fișierele pentru toate profilurile de conversație - Oricine poate găzdui servere. Contacte arhivate Beta Apeluri interzise! @@ -673,7 +659,6 @@ Controlează-ți rețeaua Parola pentru criptarea bazei de date va fi actualizată și stocată în Keystore. Parola bazei de date - Servere XFTP configurate Baza de date este criptată folosind o parolă aleatorie. Trebuie schimbată înainte de exportare. apel Contact șters! @@ -702,7 +687,6 @@ Profil actual Confirmi ștergerea contactului? Contactul va fi șters - acest lucru nu poate fi anulat! - Servere SMP configurate Conectat Corectează numele la %s? Continuă @@ -747,9 +731,6 @@ Încălcarea liniilor directoare ale comunității Profil inadecvat Alt motiv - Eroare la salvarea serverelor SMP - Asigurați-vă că adresele serverelor XFTP sunt în format corect, separate pe linii și nu sunt duplicate. - Eroare la încărcarea serverlor XFTP Eroare la actualizarea configurației de rețea Nu s-au putut încărca conversațiile Vă rugăm să actualizați aplicația și contactați dezvoltatorii. @@ -771,12 +752,9 @@ incognito printr-un link de unică folosință a + b prin %1$s - Eroare la încărcarea serverlor SMP %1$d fișier(e) au fost șterse. raport arhivat de %s Invitație acceptată - Asigurați-vă că adresele serverelor SMP sunt în format corect, separate pe linii și nu sunt duplicate. - Eroare la salvarea serverelor XFTP %1$d fișier(e) sunt încă în curs de descărcare. %1$d fișier(e) nu au putut fi descărcate. %1$d fișier(e) nu au fost descărcate. @@ -850,7 +828,6 @@ Sau scanați codul QR Gazdă Cum se folosește marcarea - Imun la spam Baza de date va fi criptată. Grup inactiv Bună dimineaţa! @@ -984,7 +961,6 @@ Nume nevalid! Eroare la deschiderea browserului Cum funcționează - Doar dispozitivele client stochează profiluri de utilizator, contacte, grupuri și mesaje. Instalat cu succes Dezactivează Opțiuni dezvoltator @@ -1016,7 +992,6 @@ Erori la ștergere Dezactivați ștergerea mesajelor Păstrează conversația - Cod QR nevalid Cod de securitate incorect! Dispare la: %s Conectează-te cu datele tale de autentificare @@ -1302,7 +1277,6 @@ Încă nu există conexiune directă, mesajul a fost redirecționat de administrator. - Deschide conversația la primul mesaj necitit.\n- Mergi la mesajele citate. Oprit - Deschide setările serverului criptare ok pentru %s profilul grupului a fost actualizat proprietar @@ -1373,7 +1347,6 @@ Eroare la schimbarea profilului Nicio persoană de contact filtrată Servere media și fișiere - Alte servere XFTP NU utilizați rutare privată. Activează apelurile de pe ecranul de blocare prin Setări. Dezactivează (păstrează suprascrierile) @@ -1411,7 +1384,6 @@ expirat Descărcați %s (%s) Instalează actualizarea - Nu crea adresă De exemplu, dacă un contact al tău primește mesaje printr-un server SimpleX Chat, aplicația ta le va trimite printr-un server Flux. Deschide Setările Safari / Site-uri web / Microfon, apoi selectează Permite pentru localhost. Apel ratat @@ -1488,7 +1460,6 @@ Conversație nouă Link de invitație unic Servere mesaje - Alte servere SMP Introduceți serverul manual Servere ICE (unul pe linie) Rețea și servere @@ -1499,7 +1470,6 @@ Ieșire fără salvare Parolă profil ascuns italic - Fără identificatori de utilizator. Acest lucru se poate întâmpla atunci când:\n1. Mesajele au expirat în aplicația de trimitere după 2 zile sau pe server după 30 de zile.\n2. Decriptarea mesajului a eșuat, deoarece tu sau contactul tău ați folosit un backup vechi al bazei de date.\n3. Conexiunea a fost compromisă. Eroare la exportarea bazei de date a conversației Eroare la oprirea conversației @@ -1593,7 +1563,6 @@ Lipește Vă rugăm să rugați persoana de contact să activeze apelurile. Rutare privată - Confidențialitatea redefinită Vă rugăm să verificați conexiunea la rețea cu %1$s și să încercați din nou. Rutare mesaje private 🚀 Bara de instrumente pentru conversație, accesibilă @@ -1605,14 +1574,12 @@ Se pregătește încărcarea Acces refuzat! Vă rugăm să reduceți dimensiunea mesajului și să îl trimiteți din nou. - În așteptare peer-to-peer Vă rugăm să reporniți aplicația. Mesaje primite Lipește linkul pentru conectare! Interziceți reacțiile la mesaje. Interzicerea trimiterii de linkuri SimpleX - Lipește linkul primit pentru a te conecta cu contactul tău… Te rugăm să-i ceri persoanei tale de contact să activeze trimiterea de mesaje vocale. Cod QR portul %d @@ -1698,7 +1665,6 @@ substituent pentru imaginea de profil Lipește linkul Adresa prestabilită a serverului - Server presetat Evaluează aplicația Port Conversațiile private, grupurile și contactele tale nu sunt accesibile operatorilor serverului. @@ -1888,10 +1854,8 @@ Opriți fișierul trimitere neautorizată Bun venit, %1$s! - Atinge pentru a începe o conversație nouă Folosește acreditări proxy diferite pentru fiecare conexiune. Adresă SimpleX sau link de unică folosință? - Pentru a-ți proteja confidențialitatea, SimpleX folosește ID-uri separate pentru fiecare persoană de contact. Ai fost invitat în grup Pentru a primi Utilizare pentru fișiere @@ -1929,7 +1893,6 @@ Mod Blocare SimpleX Mesajele vor fi marcate ca moderate pentru toți membrii. necitit - Bun venit! Atingeți pentru conectare Mesajele vocale sunt interzise! Ați acceptat conexiunea @@ -1963,7 +1926,6 @@ Sistem Acestea pot fi ignorate în setările de contact și de grup. Suport SimpleX Chat - Proxy SOCKS Teme În timpul importului au apărut câteva erori non-fatale: Atingeți pentru a vă alătura @@ -1984,7 +1946,6 @@ Actualizează parola bazei de date Opriți trimiterea fișierului? tăiere - Viitorul mesageriei Pentru a efectua apeluri, permiteți utilizarea microfonului. Încheiați apelul și încercați să sunați din nou. Când sunt activați mai mulți operatori, niciunul dintre ei nu are metadate pentru a afla cine comunică cu cine. Video pornit @@ -2072,7 +2033,6 @@ Când partajezi un profil incognito cu cineva, acest profil va fi folosit pentru grupurile la care te invită. Mesaj vocal La conectarea apelurilor audio și video. - Când oamenii solicită conectarea, poți accepta sau respinge solicitarea. Acest grup are peste %1$d membri, confirmările de livrare nu sunt trimise. Comutați între audio și video în timpul apelului. Deconectați desktopul? @@ -2081,19 +2041,13 @@ Încarcă fișier Atinge butonul Atingeți pentru a scana - (pentru a partaja cu persoana de contact) Pentru a începe o nouă conversație Video Partajează link de unică folosință - Acest link nu este un link de conectare valid! - Acest cod QR nu este un link! Link scurt - Server XFTP - Actualizați modul de izolare a transportului? Folosește rutare privată cu servere necunoscute. Folosește rutare privată cu servere necunoscute atunci când adresa IP nu este protejată. Folosiți portul TCP %1$s atunci când nu este specificat niciun port. - Nu stocăm niciunul dintre contactele sau mesajele tale (odată livrate) pe servere. prin releu Soft Puternic @@ -2116,9 +2070,6 @@ Încărcat Folosiți portul TCP 443 doar pentru serverele presetate. Omiteți această versiune - O poți crea mai târziu - O poți face vizibilă contactelor tale SimpleX din Setări. - Platforma de mesagerie și aplicații care vă protejează confidențialitatea și securitatea. Profilul este partajat doar cu contactele tale. Pentru a-ți dezvălui profilul ascuns, introdu parola completă în câmpul de căutare de pe pagina Profilurile tale de conversație. Puteți configura serverele prin intermediul setărilor. @@ -2143,7 +2094,6 @@ Blocarea SimpleX nu este activă! Autentificare sistem Baza de date nu funcționează corect. Atingeți pentru a afla mai multe. - Acest text este disponibil în setări Ești invitat în grup Se așteaptă imaginea Se așteaptă imaginea @@ -2162,7 +2112,6 @@ Servere de testare Folosește credențiale aleatorii Actualizare disponibilă: %s - Folosește chatul Hash-ul mesajului anterior este diferit. Această acțiune nu poate fi anulată - mesajele trimise și primite anterior celei selectate vor fi șterse. Poate dura câteva minute. Parolă greșită! @@ -2231,13 +2180,11 @@ %1$d erori de fișier:\n%2$s Conexiune blocată Conexiunea este blocată de operatorul serverului:\n%1$s. - criptate end-to-end, cu securitate post-cuantică în mesajele directe.]]> Bările de instrumente ale aplicației Securitate îmbunătățită ✅ Toate conversațiile vor fi eliminate din lista %s, iar lista va fi ștearsă Conexiunea ta a fost mutată la %s, dar a apărut o eroare la schimbarea profilului. Puteți migra baza de date exportată. - Serverele tale XFTP Utilizați gazdele .onion la Nu dacă proxy-ul SOCKS nu le acceptă.]]> Profilul tău de chat va fi trimis membrilor chatului Vei primi în continuare apeluri și notificări de la profilurile dezactivate atunci când acestea sunt active. @@ -2305,7 +2252,6 @@ Adaugă prieteni Adaugă membri echipei Conversația există deja! - Deschide în aplicația mobilă.]]> Continuă %s a fost deconectat]]> Despre operatori @@ -2317,11 +2263,9 @@ contact dezactivat contactul nu este gata Profilul, contactele și mesajele livrate sunt stocate pe dispozitiv. - Serverele tale ICE Adaugă la listă Modificați lista invitație acceptată - Îți poți partaja adresa sub formă de link sau cod QR - oricine se poate conecta cu tine. Acceptă Confidențialitatea ta Acceptă condițiile @@ -2338,11 +2282,9 @@ Poți vizualiza rapoartele tale în conversația cu administratorii. Schimbă ordinea Te vei conecta atunci când dispozitivul contactului tău va fi online, te rugăm să aștepți sau să verifici mai târziu! - scana codul QR în timpul apelului video sau contactul tău poate partaja un link de invitație.]]> Profilul tău %1$s va fi distribuit. Nu îți vei pierde contactele dacă ulterior îți ștergi adresa. Securitatea conexiunii - Serverele tale SMP Toate serverele Profilul tău este stocat pe dispozitiv și este partajat doar cu contactele tale. Serverele SimpleX nu pot vedea profilul tău. Baza ta de date actuală de chat va fi ȘTERSĂ și ÎNLOCUITĂ cu cea importată.\nAceastă acțiune nu poate fi anulată - profilul, contactele, mesajele și fișierele tale vor fi pierdute iremediabil. @@ -2354,18 +2296,15 @@ %s cu motivul: %s]]> %s a fost deconectat]]> (nou)]]> - Serverul tău Trebuie să îi permiți contactului tău să te sune pentru a-l putea suna. Adresa serverului tău Serverele tale Vei fi conectat la grup atunci când dispozitivul gazdei grupului va fi online, te rugăm să aștepți sau să verifici mai târziu! - Tu decizi cine se poate conecta. Vei fi conectat când cererea ta de conectare va fi acceptată, te rugăm să aștepți sau să verifici mai târziu! Acceptați ca observator Experiență de utilizare îmbunătățită 1 chat cu un membru Toate rapoartele vor fi arhivate pentru tine. - depozitul nostru GitHub.]]> Permiteți în următoarea fereastră de dialog să primească notificări instantaneu.]]> Setări adresă Arhivare rapoarte @@ -2375,7 +2314,6 @@ Prin utilizarea SimpleX Chat ești de acord să:\n- trimiți doar conținut legal în grupurile publice.\n- respecți ceilalți utilizatori – fără spam. doar cu un singur contact - partajează-l personal sau prin orice aplicație de mesagerie.]]> Nu trebuie să utilizați aceeași bază de date pe două dispozitive. - arătați codul QR în apelul video sau distribuiți linkul.]]> Utilizare de pe desktop în aplicația mobilă și scanează codul QR.]]> SimpleX rulează în fundal în loc să utilizeze notificări push.]]> Utilizarea bateriei aplicației / Nerestricționat în setările aplicației.]]> 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 9a4e550dbe..ba571e4d0a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml @@ -8,7 +8,6 @@ Вступить в группу? Ваш профиль будет отправлен контакту, от которого Вы получили эту ссылку. Вы соединитесь со всеми членами группы. - Соединиться соединен(а) ошибка @@ -48,8 +47,6 @@ В браузере Использование ссылки в браузере может уменьшить конфиденциальность и безопасность соединения. Ссылки на неизвестные сайты будут красными. - Ошибка при сохранении SMP-серверов - Пожалуйста, проверьте, что адреса SMP-серверов имеют правильный формат, каждый адрес на отдельной строке и не повторяется. Ошибка при сохранении настроек сети Превышено время соединения @@ -169,14 +166,11 @@ не прочитано Здравствуйте %1$s! - Здравствуйте! - Этот текст можно найти в Настройках Чаты соединяется… Вы приглашены в группу Вступить как %s соединяется… - Нажмите, чтобы начать чат Соединиться с разработчиками У Вас нет чатов @@ -224,7 +218,6 @@ Соединено Соединение с сервером не установлено Ошибка - Ожидает Поменять адрес получения? Адрес получения сообщений будет перемещён на другой сервер. Изменение адреса завершится после того как отправитель будет онлайн. @@ -246,12 +239,8 @@ Скопировано в буфер обмена Начать новый разговор - Создать ссылку-приглашение - Соединиться через ссылку или QR-код Сканировать QR-код Создать секретную группу - (чтобы отправить Вашему контакту) - (сканировать или вставить из буфера) (хранится только у членов группы) Разрешение не получено! @@ -320,18 +309,11 @@ Показать QR-код - Ошибка QR-кода - Этот QR-код не является ссылкой! Неверная ссылка! - Эта ссылка не является ссылкой-приглашением! Запрос на соединение отправлен! Соединение с группой будет установлено, когда хост группы будет онлайн. Пожалуйста, подождите или проверьте позже! Соединение будет установлено, когда Ваш запрос будет принят. Пожалуйста, подождите или проверьте позже! Соединение будет установлено, когда Ваш контакт будет онлайн. Пожалуйста, подождите или проверьте позже! - показать QR-код во время видеозвонка или поделиться ссылкой.]]> - Ваш профиль будет отправлен -\nВашему контакту - сосканировать QR-код во время видеозвонка, или Ваш контакт может отправить Вам ссылку.]]> Поделиться одноразовой ссылкой Соединиться @@ -350,9 +332,7 @@ Написать нам письмо Блокировка SimpleX Консоль - SMP-серверы Адрес сервера по умолчанию - Добавить серверы по умолчанию Добавить сервер Тестировать сервер Тестировать серверы @@ -361,8 +341,6 @@ Серверы не прошли тест: Сканировать QR-код сервера Ввести сервер вручную - Сервер по умолчанию - Ваш сервер Адрес Вашего сервера Использовать сервер Использовать для новых соединений @@ -375,7 +353,6 @@ Внести свой вклад Оценить приложение Использовать серверы, предоставленные SimpleX Chat? - Ваши SMP-серверы Используются серверы, предоставленные SimpleX Chat. Инфо Как использовать серверы @@ -421,15 +398,11 @@ Сохранить и уведомить членов группы Выйти без сохранения - Вы контролируете Ваш чат! - Платформа для сообщений и приложений, которая защищает Вашу личную информацию и безопасность. - Мы не храним Ваши контакты и сообщения (после доставки) на серверах. Создать профиль Ваш профиль, контакты и доставленные сообщения хранятся на Вашем устройстве. Профиль отправляется только Вашим контактам. Имя профиля не может содержать пробелы. Имя: - Создать О SimpleX Как форматировать @@ -442,7 +415,6 @@ секрет Соединиться через ссылку Эта строка не является ссылкой-приглашением! - Открыть в приложении.]]> входящий звонок… пропущенный звонок @@ -462,22 +434,11 @@ соединен(а) завершён - Будущее коммуникаций - Более конфиденциальный - Без идентификаторов пользователей. - Защищён от спама - Вы определяете, кто может соединиться. - Децентрализованный - Кто угодно может запустить сервер. Создать профиль Добавьте контакт Как это работает - Чтобы защитить Вашу конфиденциальность, SimpleX использует разные ID для каждого Вашего контакта. - Только пользовательские устройства хранят контакты, группы и сообщения. - GitHub репозитория.]]> - Использовать чат Вставьте полученную ссылку @@ -502,7 +463,6 @@ Принять Показывать Выключить - Ваши ICE-серверы WebRTC ICE-серверы Релей-сервер защищает Ваш IP-адрес, но может отслеживать продолжительность звонка. Релей-сервер используется только при необходимости. Другая сторона может видеть Ваш IP-адрес. @@ -556,7 +516,6 @@ Чаты Инструменты разработчика Экспериментальные функции - SOCKS-прокси Значок Темы Сообщения и файлы @@ -988,7 +947,6 @@ Удалить профиль чата для Эта настройка применяется к сообщениям в Вашем текущем профиле чата Отдельные транспортные сессии - Обновить режим отдельных сессий\? Имя профиля уже используется! Ошибка создания профиля! У Вас уже есть профиль с таким именем. Пожалуйста, выберите другое имя. @@ -1089,17 +1047,11 @@ Раскрыть профиль Видео будет получено, когда Ваш контакт будет онлайн, пожалуйста, подождите или проверьте позже! Раскрыть профиль чата - Ошибка при загрузке SMP-серверов - Ошибка при загрузке XFTP-серверов - Ошибка при сохранении XFTP-серверов - Проверьте, что адреса XFTP-серверов указаны в правильном формате и не дублируются. Сервер требует авторизации для загрузки, проверьте пароль. Сравнение файла Удалить файл Загрузка файла Загрузка файла - XFTP-серверы - Ваши XFTP-серверы Использовать .onion хосты в Нет если SOCKS-прокси их не поддерживает.]]> Ошибка аутентификации Нет кода доступа @@ -1154,8 +1106,6 @@ Разрешить Вашим контактам звонить Вам. Аудио/видео звонки Аудио/видео звонки запрещены. - " -\nДоступно в v5.1" Вы и Ваш контакт можете совершать звонки. Только Вы можете совершать звонки. Только Ваш контакт может совершать звонки. @@ -1177,7 +1127,6 @@ И Вы, и Ваш контакт можете добавлять реакции на сообщения. Одноразовая ссылка Адрес SimpleX - Когда Вы получите запрос на соединение, Вы можете принять или отклонить его. Включить код самоуничтожения Все данные приложения будут удалены. Если Вы введёте код самоуничтожения при открытии приложения: @@ -1234,11 +1183,9 @@ Если Вы не можете встретиться лично, покажите QR-код во время видеозвонка или поделитесь ссылкой. Чтобы соединиться с Вами, Ваш контакт может отсканировать QR-код или использовать ссылку в приложении. Вы не потеряете контакты, если позже удалите Ваш адрес. - Вы можете поделиться своим адресом в виде ссылки или QR-кода - любой может соединиться с Вами. Руководстве пользователя.]]> Ваши контакты сохранятся. Настроить тему - Создайте адрес, чтобы можно было соединиться с Вами. Все Ваши контакты сохранятся. Обновлённый профиль будет отправлен Вашим контактам. Добавьте адрес в свой профиль, чтобы Ваши SimpleX контакты могли поделиться им. Профиль будет отправлен Вашим SimpleX контактам. Создать адрес SimpleX @@ -1249,9 +1196,6 @@ Сохранить настройки\? Прекратить делиться Продолжить - Не создавать адрес - Вы можете создать его позже - Вы можете сделать его видимым для ваших контактов в SimpleX через Настройки. Адрес Введите приветственное сообщение… Просмотр @@ -1388,7 +1332,6 @@ Пересогласовать Пересогласовать шифрование\? Запретить отправлять файлы и медиа. - Соединиться Инкогнито Разрешить Открыть настройки приложения Выключить уведомления @@ -1396,7 +1339,6 @@ Звонки в фоне невозможны Приложение может быть выключено после 1 минуты в фоне. Нет информации от доставке - Вставьте полученную ссылку, чтобы соединиться с Вашим контактом… Будет отправлен новый случайный профиль. Ваш профиль %1$s будет отправлен. Выключить отчёты о доставке для групп\? @@ -1939,8 +1881,6 @@ Выбранные настройки чата запрещают это сообщение. Ошибка файла Вставить ссылку / Сканировать - Другие XFTP-серверы - Настроенные XFTP-серверы Загрузка %s (%s) Доступно обновление: %s Выключить @@ -2001,7 +1941,6 @@ Нет отфильтрованных контактов Ваши контакты Архивированные контакты - Настроенные SMP-серверы Показать процент Слабое Среднее @@ -2044,7 +1983,6 @@ Выбрано %d Настройки Вы по-прежнему можете просмотреть разговор с %1$s в списке чатов. - Другие SMP-серверы Выключено Установлено успешно Установить обновление @@ -2088,7 +2026,6 @@ Соединяйтесь с друзьями быстрее. Управляйте своей сетью Защищает ваш IP-адрес и соединения. - Открыть настройки серверов Полученные сообщения Ошибки приёма Архивируйте контакты чтобы продолжить переписку. @@ -2237,7 +2174,6 @@ Настройки адреса Добавьте сотрудников в разговор. Бизнес-адрес - сквозным шифрованием, с пост-квантовой безопасностью в прямых разговорах.]]> Приложение всегда выполняется в фоне Проверять сообщения каждые 10 минут Без фонового сервиса 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 dd8155f512..61b75fcf90 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/sk/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/sk/strings.xml @@ -9,8 +9,6 @@ Použiť inkognito profil Váš profil bude odoslaný kontaktu, od ktorého ste obdržali tento odkaz. Pripojíte sa ku všetkým členom skupiny. - Pripojiť sa - Spojiť sa inkognito Otvoriť chat Pripojiť sa na kanál %s Otvoriť nový chat @@ -92,7 +90,6 @@ Ďalšie sekundárne Pridať zoznam Pridať správu - Pridať prednastavené servery Pridať profil Pridať relé Pridať relé @@ -128,7 +125,6 @@ Všetky farebné režimy Všetky dáta sa pri zadaní vymažú. Všetky správy - end-to-end šifrovaním, s post-quantovým zabezpečením v priamych správach.]]> Všetky nové správy od %s budú skryté! Všetky nové správy od týchto členov budú skryté! Povoliť @@ -185,7 +181,6 @@ Verzia aplikácie: v%s Archivovať Archivované kontakty - \nDostupné vo verzii v5.1 Späť Pozadie Služba na pozadí je vždy spustená - notifikácie budú zobrazené hneď ako budú správy k dispozícii. @@ -220,7 +215,6 @@ Zrušiť náhľad súboru zrušené %s Zrušiť migráciu - Vytvoriť Vytvoriť Vytvoriť jednorazový odkaz Vytvoriť adresu @@ -230,7 +224,6 @@ Vytvoriť odkaz na skupinu Vytvoriť odkaz Vytvoriť zoznam - Vytvoriť jednorazovú pozvánku Vytvoriť profil Vytvoriť profil Vytvoriť tajnú skupinu @@ -256,7 +249,6 @@ %dd %d deň %d dní - Decentralizovaná Vymazať Vymazať Prijať @@ -296,7 +288,6 @@ Povoliť vašim kontaktom odosielať súbory a média. Povoliť vašim kontaktom posielať hlasové správy. Všetky hlásenia budú pre vás archivované. - Servery môže prevádzkovať ktokoľvek. arabčina, bulharčina, fínčina, hebrejčina, thajčina a ukrajinčina - vďaka používateľom a Weblate. Archivovať všetky hlásenia? Archivovať a nahrať @@ -466,8 +457,6 @@ Podmienky prijaté dňa: %s. %s.]]> Podmienky používania - Nastavené SMP servery - Nastavené XFTP servery Nastaviť relé Potvrdiť Potvrdiť vymazanie kontaktu? @@ -520,7 +509,6 @@ Verzia jadra: v%s Roh Opraviť meno na %s? - Vytvoriť adresu, aby sa s vami ľudia mohli spojiť. Vytvoriť chatový profil Vytvorený Vytvoriť nový profil v počítačovej aplikácii. 💻 @@ -910,7 +898,6 @@ %dmes Neposielať históriu novým členom. Neposielať históriu novým odberateľom. - Nevytvárať adresu Nepovoliť Nenechajte si ujsť dôležité správy. Znova nezobrazovať @@ -1294,8 +1281,6 @@ 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 @@ -1331,8 +1316,6 @@ Chyba pri ukladaní serverov Chyba pri ukladaní nastavení Chyba pri ukladaní nastavení - Chyba pri ukladaní SMP serverov - Chyba pri ukladaní XFTP serverov Chyba pri aktualizovaní zoznamu chatov Chyba pri aktualizovaní odkazu skupiny Chyba pri aktualizovaní konfigurácie siete @@ -1351,7 +1334,6 @@ Neplatný odkaz! neplatný formát správy Neplatné meno! - Neplatný QR kód Neplatný QR kód Neplatná adresa relé! Neplatná meno relé! @@ -1393,7 +1375,6 @@ 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? @@ -1401,8 +1382,6 @@ Alebo naskenovať QR kód Alebo vložiť odkaz archívu Chyba súboru - naskenovať QR kód vo video hovore, alebo váš kontakt môže zdieľať pozvánku.]]> - ukážte QR kód vo video hovore, alebo zdieľajte odkaz.]]> Ak sa nemôžte stretnúť osobne, ukážte QR kód vo video hovore, alebo zdieľajte odkaz. Ak ste dostali SimpleX Chat pozvánku, môžete ju otvoriť v prehliadači: Nesprávny bezpečnostný kód @@ -1445,8 +1424,6 @@ Uložiť nastavenia SimpleX adresy Uložiť SimpleX meno? Uložiť uvítaciu správu? - Vaše SMP servery - Vaše XFTP servery Vaše ICE servery ICE servery (jeden na riadok) Ujistite sa, že adresy WebRTC ICE serverov sú v správnom formáte, oddelené na riadkoch a nie sú duplikované. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml index da2aea2868..9e782ee24f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml @@ -75,7 +75,6 @@ ข้อมูลแอปทั้งหมดถูกลบแล้ว. รองเพิ่มเติม เพิ่มโปรไฟล์ - เพิ่มเซิร์ฟเวอร์ที่ตั้งไว้ล่วงหน้า แชทและข้อความทั้งหมดจะถูกลบ - การดำเนินการนี้ไม่สามารถยกเลิกได้! ที่อยู่ เพิ่มเซิร์ฟเวอร์โดยการสแกนรหัส QR @@ -92,8 +91,6 @@ อนุญาตข้อความเสียงเฉพาะเมื่อผู้ติดต่อของคุณอนุญาตเท่านั้น ผู้ติดต่อทั้งหมดของคุณจะยังคงเชื่อมต่ออยู่. การอัปเดตโปรไฟล์จะถูกส่งไปยังผู้ติดต่อของคุณ. เสมอ - " -\nพร้อมใช้งานใน v5.1" กลับ แฮชข้อความไม่ดี บริการพื้นหลังทำงานตลอดเวลา - การแจ้งเตือนจะแสดงทันทีที่มีข้อความ @@ -164,7 +161,6 @@ ธีมที่กำหนดเอง ID ฐานข้อมูลและตัวเลือกการแยกการส่งผ่าน ID ฐานข้อมูล - เชื่อมต่อ กำลังเชื่อมต่อ เชื่อมต่อผ่านลิงค์ติดต่อ\? เชื่อมต่อผ่านลิงค์เชิญ\? @@ -191,8 +187,6 @@ เวลาที่กําหนดเอง ยืนยัน คัดลอกไปที่คลิปบอร์ดแล้ว - เชื่อมต่อผ่านลิงค์ / คิวอาร์โค้ด - สร้างลิงก์เชิญแบบใช้ครั้งเดียว สร้างกลุ่มลับ ล้าง ล้างแชท\? @@ -211,10 +205,8 @@ เวอร์ชันหลัก: v%s ปรับแต่งธีม สร้างที่อยู่ - สร้างที่อยู่เพื่อให้ผู้อื่นเชื่อมต่อกับคุณ สร้างที่อยู่ SimpleX ดำเนินการต่อ - สร้าง สร้างโปรไฟล์ มีสี กําลังเชื่อมต่อสาย… @@ -277,14 +269,10 @@ ลบกลุ่ม\? การตรวจสอบอุปกรณ์ไม่ได้ถูกเปิดใช้งาน คุณสามารถเปิด SimpleX Lock ผ่านการตั้งค่าได้ เมื่อคุณเปิดใช้งานการตรวจสอบอุปกรณ์แล้ว ใส่ข้อความต้อนรับ… (ไม่บังคับ) - เกิดข้อผิดพลาดในการโหลดเซิร์ฟเวอร์ SMP - เกิดข้อผิดพลาดในการโหลดเซิร์ฟเวอร์ XFTP ข้อผิดพลาด ลบแล้ว ลิงค์เต็ม คำอธิบาย - เกิดข้อผิดพลาดในการบันทึกเซิร์ฟเวอร์ SMP - เกิดข้อผิดพลาดในการบันทึกเซิร์ฟเวอร์ XFTP เกิดข้อผิดพลาดในการอัปเดตการกำหนดค่าเครือข่าย ชื่อที่แสดงซ้ำ! เกิดข้อผิดพลาดในการสร้างโปรไฟล์! @@ -362,7 +350,6 @@ สวัสดี! \nเชื่อมต่อกับฉันผ่าน SimpleX Chat: %s ลบที่อยู่ - อย่าสร้างที่อยู่ ชื่อเต็ม: ชื่อที่แสดง: ออกโดยไม่บันทึก @@ -376,7 +363,6 @@ วิธีใช้มาร์กดาวน์ สิ้นสุดลงแล้ว มันทำงานอย่างไร - กระจายอำนาจแล้ว การโทรเสียงแบบ encrypted จากต้นจนจบ การโทรวิดีแบบ encrypted จากต้นจนจบ ปิดการใช้งาน @@ -517,8 +503,6 @@ เกิดข้อผิดพลาดในการรับไฟล์ หากคุณใส่รหัสผ่านนี้เมื่อเปิดแอป ข้อมูลแอปทั้งหมดจะถูกลบอย่างถาวร! หากคุณเลือกที่จะปฏิเสธ ผู้ส่งจะไม่ได้รับแจ้ง - หากคุณไม่สามารถพบกันในชีวิตจริงได้ คุณสามารถสแกนคิวอาร์โค้ดในวิดีโอคอล หรือผู้ติดต่อของคุณสามารถแชร์ลิงก์เชิญได้ - ให้แสดงคิวอาร์โค้ดในวิดีโอคอล หรือแชร์ลิงก์]]> หากคุณไม่สามารถพบกันในชีวิตจริงได้ ให้แสดงคิวอาร์โค้ดในวิดีโอคอล หรือแชร์ลิงก์ หากคุณยืนยัน เซิร์ฟเวอร์การส่งข้อความจะสามารถเห็นที่อยู่ IP ของคุณและผู้ให้บริการของคุณ - ซึ่งคือเซิร์ฟเวอร์ใดที่คุณกำลังเชื่อมต่ออยู่ หากคุณใส่รหัสผ่านทำลายตัวเองขณะเปิดแอป: @@ -558,7 +542,6 @@ ภาพ หากคุณได้รับลิงก์เชิญ SimpleX Chat คุณสามารถเปิดได้ในเบราว์เซอร์ของคุณ: ภาพตัวอย่างลิงค์ - รหัส QR ไม่ถูกต้อง ลิงค์ไม่ถูกต้อง! ศึกษาเพิ่มเติม รหัสความปลอดภัยไม่ถูกต้อง! @@ -566,7 +549,6 @@ เชิญเพื่อน ๆ มาคุยกันใน SimpleX Chat ตัวเอียง - มีภูมิคุ้มกันต่อสแปมและการละเมิด สร้างการเชื่อมต่อแบบส่วนตัว สามารถเปลี่ยนแปลงได้ในภายหลังผ่านการตั้งค่า สายวิดีโอเข้ามา @@ -617,7 +599,6 @@ กำลังเปิดฐานข้อมูล… ทำเครื่องหมายว่าลบแล้ว กลั่นกรองแล้ว - ตรวจสอบให้แน่ใจว่าที่อยู่เซิร์ฟเวอร์ XFTP อยู่ในรูปแบบที่ถูกต้อง แยกบรรทัดและไม่ซ้ำกัน การแจ้งเตือนเป็นระยะ จำเป็นต้องใช้รหัสผ่าน การแจ้งเตือนเป็นระยะปิดอยู่! @@ -637,7 +618,6 @@ สามารถส่งวิดีโอได้ครั้งละ 10 วิดีโอเท่านั้น โปรดติดต่อผู้ดูแลกลุ่ม การแจ้งเตือน - รอดำเนินการ โปรดขอให้ผู้ติดต่อของคุณเปิดใช้งานการส่งข้อความเสียง เฉพาะเจ้าของกลุ่มเท่านั้นที่สามารถเปิดใช้งานข้อความเสียงได้ ไม่มีรายละเอียด @@ -662,9 +642,6 @@ โฮสต์หัวหอมจะไม่ถูกใช้ รหัสผ่านที่จะแสดง สายที่ไม่ได้รับ - ผู้คนสามารถเชื่อมต่อกับคุณผ่านลิงก์ที่คุณแบ่งปันเท่านั้น - โปรโตคอลและโค้ดโอเพ่นซอร์ส – ใคร ๆ ก็สามารถเปิดใช้เซิร์ฟเวอร์ได้ - การเข้ารหัสแบบ encrypted จากต้นจนจบ 2 ชั้น]]> เป็นระยะ แปะลิงก์ที่ได้รับ ไม่มีการ encrypt จากต้นจนจบ @@ -744,7 +721,6 @@ กลั่นกรองโดย %s เป็นไปได้มากว่าผู้ติดต่อนี้ได้ลบการเชื่อมต่อกับคุณ ข้อผิดพลาดในการส่งข้อความ - ตรวจสอบให้แน่ใจว่าที่อยู่เซิร์ฟเวอร์ SMP อยู่ในรูปแบบที่ถูกต้อง แยกบรรทัดและไม่ซ้ำกัน โฮสต์หัวหอมจะถูกใช้เมื่อมี ผู้ติดต่อของคุณเท่านั้นที่สามารถส่งข้อความเสียงได้ การเปิดลิงก์ในเบราว์เซอร์อาจลดความเป็นส่วนตัวและความปลอดภัยของการเชื่อมต่อ ลิงก์ SimpleX ที่ไม่น่าเชื่อถือจะเป็นสีแดง @@ -758,14 +734,11 @@ คิวอาร์โค้ด คู่มือผู้ใช้ ]]> ที่อยู่เซิร์ฟเวอร์ที่ตั้งไว้ล่วงหน้า - เซิร์ฟเวอร์ที่ตั้งไว้ล่วงหน้า ให้คะแนนแอป พอร์ต พอร์ต %d ได้รับคำตอบ… ได้รับการยืนยัน… - นิยามความเป็นส่วนตัวใหม่ - GitHub repository ของเรา]]> การแจ้งเตือนส่วนตัว โปรดรายงานไปยังผู้พัฒนาแอป ความเป็นส่วนตัวและความปลอดภัย @@ -890,7 +863,6 @@ ส่งข้อความ ส่งข้อความสด ส่ง - (สแกนหรือวางจากคลิปบอร์ด) สแกนคิวอาร์โค้ด ตั้งชื่อผู้ติดต่อ การตั้งค่า @@ -972,7 +944,6 @@ หยุดส่งไฟล์ไหม\? หยุด หยุดรับไฟล์\? - แตะเพื่อเริ่มแชทใหม่ เปลี่ยนที่อยู่ผู้รับ\? เริ่มแชทใหม่ ขอบคุณสำหรับการติดตั้ง SimpleX Chat! @@ -984,20 +955,17 @@ %s ไม่ได้รับการยืนยัน %s ได้รับการยืนยันแล้ว ล็อค SimpleX - เซิร์ฟเวอร์ SMP เซิร์ฟเวอร์ทดสอบ เซิร์ฟเวอร์ทดสอบ บางเซิร์ฟเวอร์ผ่านการทดสอบล้มเหลว: การตั้งค่าพร็อกซี SOCKS Simplexmq: v%s (%2s) - แพลตฟอร์มแรกที่ไม่มีตัวระบุผู้ใช้ - ถูกออกแบบให้เป็นส่วนตัว ปิดลำโพง เปิดลำโพง ข้อความที่ข้ามไป ส่ง ระบบ สนับสนุน SimpleX Chat - พร็อกซี SOCKS หยุด หยุดแชท\? %s วินาที @@ -1008,8 +976,6 @@ ระบบ ขอบคุณผู้ใช้ – มีส่วนร่วมผ่าน Weblate! ขอบคุณผู้ใช้ – มีส่วนร่วมผ่าน Weblate! - แพลตฟอร์มการส่งข้อความและแอปพลิเคชันที่ปกป้องความเป็นส่วนตัวและความปลอดภัยของคุณ - การส่งข้อความส่วนตัวรุ่นต่อไป บทบาทจะถูกเปลี่ยนเป็น "%s" ทุกคนในกลุ่มจะได้รับแจ้ง การดำเนินการนี้ไม่สามารถยกเลิกได้ ไฟล์และสื่อที่ได้รับและส่งทั้งหมดจะถูกลบ รูปภาพความละเอียดต่ำจะยังคงอยู่ หากต้องการเปิดเผยโปรไฟล์ที่ซ่อนอยู่ของคุณ ให้ป้อนรหัสผ่านแบบเต็มในช่องค้นหาในหน้าโปรไฟล์แชทของคุณ @@ -1031,7 +997,6 @@ การ decrypt %1$d ข้อความล้มเหลว %1$s สมาชิก เชื่อมต่อกับ SimpleX Chat นักพัฒนาแอปเพื่อถามคำถามและรับการอัปเดต]]> - คุณสามารถแชร์ที่อยู่ของคุณเป็นลิงก์หรือรหัสคิวอาร์ - ใคร ๆ ก็สามารถเชื่อมต่อกับคุณได้ คุณเปลี่ยนที่อยู่ คุณเปลี่ยนบทบาทของตัวเองเป็น %s โปรไฟล์ปัจจุบันของคุณ @@ -1072,7 +1037,6 @@ ข้อความจะถูกทำเครื่องหมายว่ากลั่นกรองสำหรับสมาชิกทุกคน ยังไม่ได้อ่าน ส่งโดยไม่ได้รับอนุญาต - ยินดีต้อนรับ! ยินดีต้อนรับ %1$s! คุณได้รับเชิญให้เข้าร่วมกลุ่ม คุณไม่มีการแชท @@ -1101,7 +1065,6 @@ ดูรหัสความปลอดภัย ห้ามข้อความเสียง! คุณต้องอนุญาตให้ผู้ติดต่อของคุณส่งข้อความเสียงจึงจะสามารถส่งได้ - (เพื่อแบ่งปันกับผู้ติดต่อของคุณ) เพื่อเริ่มแชทใหม่ วิดีโอ เปิดเสียง @@ -1115,44 +1078,30 @@ SimpleX ทีม คุณจะเชื่อมต่อกับกลุ่มเมื่ออุปกรณ์โฮสต์ของกลุ่มออนไลน์อยู่ โปรดรอหรือตรวจสอบภายหลัง! คุณจะเชื่อมต่อเมื่อคำขอเชื่อมต่อของคุณได้รับการยอมรับ โปรดรอหรือตรวจสอบในภายหลัง! - โปรไฟล์แชทของคุณจะถูกส่ง -\nให้กับผู้ติดต่อของคุณ คุณจะเชื่อมต่อเมื่ออุปกรณ์ของผู้ติดต่อของคุณออนไลน์อยู่ โปรดรอหรือตรวจสอบภายหลัง! - เมื่อมีคนขอเชื่อมต่อ คุณสามารถยอมรับหรือปฏิเสธได้ - เปิดในแอปมือถือ]]> ในการตรวจสอบการเข้ารหัสแบบ encrypt จากต้นจนจบ กับผู้ติดต่อของคุณ ให้เปรียบเทียบ (หรือสแกน) รหัสบนอุปกรณ์ของคุณ การตั้งค่าของคุณ ที่อยู่ SimpleX ของคุณ โปรไฟล์แชทของคุณ ใช้เซิร์ฟเวอร์ - เซิร์ฟเวอร์ของคุณ ที่อยู่เซิร์ฟเวอร์ของคุณ เซิร์ฟเวอร์สำหรับการเชื่อมต่อใหม่ของโปรไฟล์การแชทปัจจุบันของคุณ ใช้สำหรับการเชื่อมต่อใหม่ - เซิร์ฟเวอร์ XFTP - เซิร์ฟเวอร์ SMP ของคุณ - เซิร์ฟเวอร์ XFTP ของคุณ เซิร์ฟเวอร์ ICE ของคุณ ใช้พร็อกซี SOCKS ใช้การเชื่อมต่ออินเทอร์เน็ตโดยตรงหรือไม่\? ใช้พร็อกซี SOCKS หรือไม่\? ใช้โฮสต์ .onion เมื่อพร้อมใช้งาน - อัปเดตโหมดการแยกการขนส่งไหม\? สีของธีม ผู้ติดต่อของคุณจะยังคงเชื่อมต่ออยู่ - คุณสามารถสร้างได้ในภายหลัง - คุณเป็นผู้ควบคุมการแชทของคุณ! โปรไฟล์นี้แชร์กับผู้ติดต่อของคุณเท่านั้น - เราไม่เก็บผู้ติดต่อหรือข้อความของคุณ (เมื่อส่งแล้ว) ไว้บนเซิร์ฟเวอร์ โปรไฟล์ รายชื่อผู้ติดต่อ และข้อความที่ส่งของคุณจะถูกจัดเก็บไว้ในอุปกรณ์ของคุณ คุณสามารถใช้มาร์กดาวน์เพื่อจัดรูปแบบข้อความ: รอคำตอบ… - ใช้แชท การสนทนาทางวิดีโอ การโทรของคุณ เซิร์ฟเวอร์ WebRTC ICE - เซิร์ฟเวอร์ ICE ของคุณ ผ่านรีเลย์ ปิดวิดีโอ เปิดวิดีโอ @@ -1216,16 +1165,12 @@ การดำเนินการนี้ไม่สามารถยกเลิกได้ - โปรไฟล์ ผู้ติดต่อ ข้อความ และไฟล์ของคุณจะสูญหายไปอย่างถาวร SimpleX – ใช้แบตเตอรี่เพียงไม่กี่เปอร์เซ็นต์ต่อวัน]]> คุณ: %1$s - เพื่อปกป้องความเป็นส่วนตัว แทนที่จะใช้ ID ผู้ใช้เหมือนที่แพลตฟอร์มอื่นๆใช้ SimpleX มีตัวระบุสำหรับคิวข้อความ โดยแยกจากกันสำหรับผู้ติดต่อแต่ละราย คุณจะต้องตรวจสอบสิทธิ์เมื่อคุณเริ่มหรือกลับมาใช้แอปพลิเคชันอีกครั้งหลังจากผ่านไป 30 วินาทีในพื้นหลัง คุณจะเข้าร่วมกลุ่มที่ลิงก์นี้อ้างถึงและเชื่อมต่อกับสมาชิกในกลุ่ม - ข้อความนี้มีอยู่ในการตั้งค่า รูปเยอะเกิน! วิดีโอเยอะเกิน! ที่อยู่ผู้รับจะถูกเปลี่ยนเป็นเซิร์ฟเวอร์อื่น การเปลี่ยนแปลงที่อยู่จะเสร็จสมบูรณ์หลังจากที่ผู้ส่งออนไลน์ เพื่อการเชื่อมต่อผ่านลิงค์ - ลิงค์นี้ไม่ใช่ลิงค์เชื่อมต่อที่ถูกต้อง! - รหัสคิวอาร์นี้ไม่ใช่ลิงก์! เพื่อการเชื่อมต่อ ผู้ติดต่อของคุณสามารถสแกนคิวอาร์โค้ดหรือใช้ลิงก์ในแอป คุณจะไม่สูญเสียรายชื่อผู้ติดต่อของคุณหากคุณลบที่อยู่ในภายหลัง สตริงนี้ไม่ใช่ลิงค์เชื่อมต่อ! diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml index 7b05806ea6..88148f022f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml @@ -1,7 +1,6 @@ Bildirimler - Bağlantı ya da karekod ile bağlan Gizli grup oluştur SimpleX adresin cevapsız çağrı @@ -77,7 +76,6 @@ Ara Göster İptal - (kodu okut ya da panodan yapıştır) Karekodu okut SimpleX adresi Sunucuları kaydet @@ -93,7 +91,6 @@ Tercihleri kaydet\? Profil parolasını kaydet Profil sadece konuştuğun kişilerle paylaşılır. - Mesajlaşmanın geleceği Ses kapalı Doğrulama iptal edildi Yeniden başlat @@ -141,7 +138,6 @@ Kimlik doğrulama başarısız SimpleX Adresi Mesaj, tüm üyeler için silinecek. - Gizliliğinizi ve güvenliğinizi koruyan mesajlaşma ve uygulama platformu. Gizlilik kipi Yetki, "%s" olarak değiştirilecek. Üye, yeni bir davet alacak. Yetki @@ -179,7 +175,6 @@ Profiliniz, bu bağlantıyı aldığınız kişiye gönderilecek. Adres bağlantısı üzerinden bağlan? hata - Bağlan bağlanıldı silindi dosya alma henüz desteklenmiyor @@ -225,7 +220,6 @@ renklendirilmiş Parolayı onayla geri çevrilmiş çağrı - Oluştur arama hatası bağlanıldı bağlanılıyor… @@ -313,10 +307,6 @@ kişi adres bağlantısı ile SimpleX bağlantı adresi Bağlantıyı tarayıcıda açmak, bağlantı gizliliğini ve güvenliğini azaltabilir. Güvenilmeyen SimpleX bağlantıları kırmızı olacaktır. - SMP sunucuları kaydedilirken hata oluştu - XFTP sunucuları kaydedilirken hata oluştu - SMP sunucuları yüklenirken hata oluştu - XFTP sunucuları yüklenirken hata oluştu Bağlantı yapılandırması güncellenirken hata Konuşma yüklenemedi Konuşmalar yüklenemedi @@ -381,7 +371,6 @@ Hata Kişiselleştirilmiş süre Panoya kopyalandı - Tek seferlik davet bağlantısı oluştur Karekodu okut ile karekodu okut]]> Bekleyen bağlantıları sil\? Sil @@ -394,14 +383,11 @@ Renk temalarını kişiselleştir Çekirdek sürümü: v%s Adresi sil\? - Kişilerin sana bağlanması için bir adres oluştur. SimpleX adresi oluştur Adresi sil Devam et Görseli sil - Konuşman, senin elinde! Profil oluştur - Merkezi olmayan Erişim kodunu onayla Yanlış erişim kodu Yeni erişim kodu @@ -507,7 +493,6 @@ Profil adı: %day %dsn - Adres oluşturma Dosyayı indir Düzelt TCP keep-alive özelliğini etkinleştir @@ -587,7 +572,6 @@ Gizli profil parolası Markdown nasıl kullanılır Nasıl çalışıyor - Spamdan etkilenmez Çağırıyı bitir. Kameranın karşı yüzüne geç Konuşma veri tabanı içe aktarılsın mı? @@ -611,8 +595,6 @@ Gizle Konuşulan kişileri ve mesajları gizle Uygulamayı, son kullanılanlar kısmından gizle. - bir görüntülü aramada karşıdakine karekodunu gösterebilir ya da konuştuğun kişiye bir katılım bağlantısı paylaşabilirsin.]]> - bir görüntülü aramada karşıdakinin karekodunu okutabilirsin ya da konuştuğun kişi seninle bir katılım bağlantısı paylaşabilir.]]> Eğer yüz yüze görüşemiyorsanız bir görüntülü aramada karşıdakine karekodunu gösterebilir ya da konuştuğun kişiye bir katılım bağlantısı paylaşabilirsin. Eğer geri çevirmeyi seçersen göndericiye bildirilmeyecek. Eğer SimplexX Chat katılım bağlantısı alırsan bu bağlantıyı tarayıcında açabilirsin: @@ -663,7 +645,6 @@ saat Sadece uygulama çalışırken bildirim alabileceksiniz, hiçbir arka plan hizmeti başlatılmayacaktır yeni mesaj - Hoşgeldin! Hoşgeldin %1$s! Görsel bekleniyor Görsel bekleniyor @@ -672,10 +653,8 @@ Dosya bekleniyor Sesli mesajlar yasaktır! sana bağlanmak istiyor! - İnsanlar bağlantı talebinde bulunduğunda, kabul edebilir veya reddedebilirsiniz. Mevcut olduğunda Otomatik kabul etme - Kişilerinizin veya mesajlarınızın hiçbirini (teslim edildikten sonra) sunucularda saklamıyoruz. yanıt bekleniyor… onay bekleniyor… Uygulama çalışıyorken @@ -710,7 +689,6 @@ Şuna cevap olarak %s olarak katılın Geçersiz link! - Geçersiz QR kodu Geçersiz sunucu adresi! Terminal için SimpleX Chat\'i yükleyin Aramaya bağlanılıyor @@ -780,10 +758,7 @@ Kişinizin cihazı çevrimiçi olduğunda bağlanacaksınız, lütfen bekleyin veya daha sonra kontrol edin! Daha fazla bilgi edinin Eğer sonradan bağlantınızı silseniz bile kişilerinizi kaybetmeyeceksiniz. - Sunucunuz Sunucu adresiniz - SMP sunucularınız - XFTP sunucularınız ICE sunucularını yapılandır Hadi SimpleX Chat\'te konuşalım Profiliniz cihazınızda saklanır ve sadece kişilerinizle paylaşılır. SimpleX sunucuları profilinizi göremez. @@ -793,8 +768,6 @@ Bu gruptan artık mesaj almayacaksınız. Sohbet geçmişi korunacaktır. Gruptan ayrıl\? ayrıldı - " -\nv5.1\'de mevcut" Sohbet profili ile (varsayılan) veya bağlantı ile (BETA). bağlantı önizleme resmi Profiliniz, kişileriniz ve gönderilmiş mesajlar cihazınızda saklanır. @@ -852,12 +825,10 @@ sen gözlemcisin Güvenlik kodunu görüntüle Sesli mesaj gönderebilmeniz için kişinizin de sesli mesaj göndermesine izin vermeniz gerekir. - XFTP sunucuları ICE sunucularınız Nasıl Mevcut profiliniz video arama (uçtan uca şifreli değil) - ICE sunucularınız Video kapalı Video açık Uygulama her başladığında parola girmeniz gerekir - parola cihazınızda saklanmaz. @@ -866,7 +837,6 @@ Biriyle gizli bir profil paylaştığınızda, bu profil sizi davet ettikleri gruplar için kullanılacaktır. İsteğe bağlı karşılama mesajı ile. Kişileriniz bağlı kalacaktır. - Daha sonra oluşturabilirsiniz Mesajları biçimlendirmek için markdown kullanabilirsiniz: Sohbet veritabanınız Mevcut sohbet veritabanınız SİLİNECEK ve içe aktarılan veritabanıyla DEĞİŞTİRİLECEKTİR. @@ -884,7 +854,6 @@ Sesli mesaj Sesli mesaj (%1$s) Sesli mesaj… - Adresinizi bir bağlantı veya QR kodu olarak paylaşabilirsiniz - herkes size bağlanabilir. bağlantı değiştirdiniz Daha sonra Ayarlardan etkinleştirebilirsin Bunları daha sonra uygulamanın Gizlilik ayarlarından etkinleştirebilirsiniz. @@ -892,7 +861,6 @@ \nBu bağlantıyı iptal edebilir ve kişiyi kaldırabilirsiniz (ve daha sonra yeni bir bağlantıyla deneyebilirsiniz). Kişiniz desteklenen maksimum boyuttan (%1$s) daha büyük bir dosya gönderdi. Kişiniz yüklemeyi tamamladığında video alınacaktır. - mobil uygulamada aç seçeneğine tıklayın.]]> SimpleX Chat geliştiricilerine bağlanabilirsiniz.]]> Bir kullanıcının profilini gizleyebilir veya sessize alabilirsiniz - menü için basılı tutun. Sohbet veritabanınızın en son sürümünü SADECE bir cihazda kullanmalısınız, aksi takdirde bazı kişilerden daha fazla mesaj alamayabilirsiniz. @@ -937,7 +905,6 @@ - daha stabil mesaj iletimi. \n- biraz daha iyi gruplar. \n- ve daha fazlası! - Sadece istemci cihazlar kullanıcı profillerini, kişileri, grupları ve gönderilen mesajları depolar. Grup tercihlerini sadece grup sahipleri değiştirebilir. metin yok Ağ durumu @@ -956,17 +923,13 @@ Hatırlayın veya güvenli bir şekilde saklayın - kaybolan bir parolayı kurtarmanın bir yolu yoktur! Sohbet konsolunu aç Sohbet profillerini değiştir. - Bu metin ayarlarda mevcut Filtrelenmiş sohbet yok Çok fazla görsel! Çok fazla video! Lütfen grup yöneticisiyle iletişime geçin - Bekleniyor Lütfen irtibat kişinizden sesli mesaj göndermeyi etkinleştirmesini isteyin. Canlı mesaj! Link ile bağlanmak için - Bu geçerli bir bağlantı linki değil - Bu QR kodu bir bağlantı değil! Kullanıcı Kılavuzu.]]> Yapıştır Bu dize bir bağlantı linki değil! @@ -987,8 +950,6 @@ Zaman dilimi, görsel/ses korumak için UTC kullan. Özel dosya adları Yeni bir sohbet başlatmak için - Kimin bağlanabileceğine siz karar verirsiniz. - Gizlilik yeniden tanımlanıyor Periyodik Gizli bildirimler Aldığın bağlantıyı yapıştır @@ -1010,14 +971,12 @@ Favorilerden çıkar Sohbeti gizli yap! Profil güncellemesi SimpleX kişilerinize gönderilecektir. - GitHub repomuzda daha fazlasını okuyun.]]> Lütfen geliştiricilere bildirin. Profil ve sunucu bağlantıları gizlemeyi kaldır Maksimum 40 saniye, anında alınır. Sesli mesaj kaydet İzin Reddedildi! - (irtibat kişinizle paylaşmak için) profil resmi profil fotoğrafı yer tutucusu Kişinizle uçtan uca şifrelemeyi doğrulamak için cihazlarınızdaki kodu karşılaştırın (veya tarayın). @@ -1028,7 +987,6 @@ Mesaj taslağı Sohbet profillerini parola ile koru! Daha az pil kullanımı - Gizliliği korumak için, SimpleX her bir konuşma için farklı bir ID kullanır. Bu kişiden mesaj almak için kullanılan sunucuya bağlanılmaya çalışılıyor (hata: %1$s). Alıcılar güncellemeleri siz yazdıkça görürler. Bilgilerinizi kullanarak giriş yapın @@ -1062,7 +1020,6 @@ Bağlantı isteğini tekrarla? detay yok Veri tabanı düzgün çalışmıyor. Daha fazla bilgi için dokunun - Açık kaynaklı protokol ve kod - sunucuları herkes çalıştırabilir. Aç Önizlemeyi göster Depolanan dosyaları ve medyayı şifrele @@ -1076,7 +1033,6 @@ Seç Sadece sen arama yapabilirsin. Yeni mobil cihaz - Sohbeti kullan Otomatik olarak bağlan Paylaş %s, %s ve %d diğer üye bağlandı @@ -1141,9 +1097,7 @@ Bağlantı paylaş SimpleX Ekibi %s, %s ve %s bağlandı - SOCKS vekili Masaüstür cihazlar - SMP sunucuları Uyumlu değil! Bağlantı güvenliğini onayla %1$s ile bağlan? @@ -1171,7 +1125,6 @@ Kodu mobilde onayla Medya paylaş… Parola, siz onu değiştirdikten veya uygulamayı yeniden başlattıktan sonra ayarlarda düz metin olarak depolanacak. - Kişinizle bağlantı kurmak için aldığınız bağlantıyı yapıştırın… Kapat gönderildi Canlı mesaj gönder @@ -1184,7 +1137,6 @@ SimpleX Kilit modu Dosya paylaş… Uygulama yeni mesajları periyodik olarak alır - günde pilin yüzde birkaçını kullanır. Uygulama anlık bildirimleri kullanmaz - cihazınızdan gelen veriler sunuculara gönderilmez. - Herhangi bir kullanıcı tanımlayıcısı yok. Hoparlör kapalı Şifreleme çalışıyor ve yeni bir şifreleme anlaşması gerekli değil. Yoksa bağlantı hataları ortaya çıkabilir! Göster @@ -1218,8 +1170,6 @@ Profiliniz %1$s paylaşılacaktır. Rastgele bir profil kullanarak grup oluştur. Gruba zaten bu bağlantı üzerinden katılıyorsunuz. - Sohbet profiliniz kişinize -\ngönderilecek Gizli bir profil paylaştığınız kişiyi ana profilinizi kullandığınız gruba davet etmeye çalışıyorsunuz güvenlik kodu değiştirildi Bluetooth desteği ve diğer iyileştirmeler. @@ -1270,13 +1220,11 @@ alınmış, yasaklanmış Sunucu yükleme için yetki gerektiriyor, şifreyi kontrol edin. Şifreleme yeniden aşma hatası - Ön ayarlı sunucu %s iptal edildi silinmiş kişi TCP bağlantısı zaman aşımına uğradı uygulama/veritabanı içinde farklı değişim: %s / %s SimpleX Chat serverları kullanılıyor. - Ön ayarlı sunucular ekle gizli Bütün gruplar için devre dışı bırak Yeni üyeler için gösterilecek mesajı seç! @@ -1284,7 +1232,6 @@ yönetildi Üye bağlantısı oluşturulurken hata Tüm kişiler için iletim bilgisi gönderme özelliği etkinleştirilecek - XFTP sunucu adreslerinin doğru formatta olduğundan, satırın ayrılmış ve kopyalanmamış olduğundan emin olun. Yenile Lütfen dikkat: Mesaj ve dosya aktarımları SOCKS proxy üzerinden bağlanır. Aramalar doğrudan bağlantı kullanır.]]> Telefon uygulamasında aç seçeneğine tıkla, sonra uygulama içinden Bağlan seçeneğine tıkla.]]> @@ -1293,7 +1240,6 @@ %s ve %s Bağlantılarını koru Alınmış mesaj - Ayarlardan SimpleX kişilerinize görünür yapabilirsiniz. Grubuna bağlanılsın mı? Telefon veya bilgisayar uygulamalarını bağla! 🔗 Yeni bir rasgele profil paylaşılacaktır. @@ -1325,7 +1271,6 @@ .onion ana bilgisayarlarını kullan seçeneğini eğer SOCKS vekili destek vermiyorsa devre dışı bırak]]> Rasgele şifreler düz metin olarak ayarlarda saklanacaktır. \nBunu sonra değiştirebilirsin. - SMP sunucu adreslerinin doğru formatta olduğundan, satırın ayrılmış ve kopyalanmamış olduğundan emin olun. Sahte İsimle katılmak için tıkla Onion ana bilgisayarları kullanılmayacaktır. Dışarıya çıkarmak için parola belirle @@ -1338,7 +1283,6 @@ Bağlanmış bilgisayar ayarları Veritabanı kimlikleri ve Taşıma izolasyonu seçeneği. PING sayısı - Ulaşım izolasyonu modu güncellensin mi? Rasgele Bağlanmış bilgisayarlar Yerel ağ aracılığıyla keşfet @@ -1354,7 +1298,6 @@ %1$d mesaj %2$s tarafından yönetildi Ayarlardaki parola silinsin mi? Alıcılar etkinleştirilsin mi? - Yeni bir sohbet başlatmak için tıkla Kişinin engelini kaldır Yönlendirici sunucusu sadece lazım ise kullanılacak. Diğer taraf IP adresini görebilir. %s ın bağlantısı kesildi]]> @@ -1454,7 +1397,6 @@ Bir canlı mesaj gönder - bu yazdıklarını anlık olarak alıcıya(lara) güncelleyen bir mesajdır Şifre Yöneticisindeki parola silinsin mi? Lere gönder - Takma adla bağlan Her zaman yönlendirici kullan. Kilidini aç %s ten gelen mesajlar gösterilecek! @@ -1874,7 +1816,6 @@ ara Profil değiştirme sırasında hata oluştu. Sohbet profili seç - XFTP sunucuları yapılandırıldı Medya ve dosya sunucuları Kimlik bilgilerini proxy ile kullanmayın. Proxy kayıt edilirken hata oluştu. @@ -1953,10 +1894,8 @@ Tamamlandı Silindi Silme hatası - Sunucu ayarlarını aç Dosya durumu: %s Yeni mesaj - Diğer SMP sunucuları Yeniden bağlan Direkt gönderildi. Tüm Profiller @@ -1985,8 +1924,6 @@ Üye inaktif Henüz direkt bağlantı yok mesaj admin tarafından yönlendirildi. Mesaj sunucuları - SMP sunucları yapılandırıldı - Diğer XFTP sunucuları Medyayı bulanıklaştır. Kişiler silinecek - bu geri alınamaz ! Sohbeti sakla. @@ -2156,7 +2093,6 @@ %d raporu arşivleyelim mi? Arşiv Raporu arşivleyelim mi? - uçtan uca şifreli olarak gönderilir ve doğrudan mesajlarda kuantum sonrası güvenlik sağlanır.]]> Veritabanı şifresini okurken hata oluştu Sohbeti sil Sohbet silinsin mi? 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 78a0b532df..4f49834c7f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml @@ -23,7 +23,6 @@ Дозволити безповоротне видалення повідомлень, тільки якщо ваш контакт дозволяє вам. (24 години) Дозволити голосові повідомлення\? Пароль застосунку замінено паролем самознищення. - Додати попередньо встановлені сервери Резервне копіювання даних застосунку Додати на інший пристрій Сховище ключів Android використовується для безпечного збереження ключової фрази - це дозволяє службі сповіщень працювати. @@ -108,8 +107,6 @@ Запит на отримання зображення Прикріпити Аудіо/відео дзвінки - " -\nДоступно в версії 5.1" Аудіо/відео дзвінки заборонені. SimpleX Як ви, так і ваш контакт можуть здійснювати дзвінки. @@ -119,7 +116,6 @@ Приєднатися до групи? Ваш профіль буде відправлено контакту, з якого ви отримали це посилання. Ви приєднаєтеся до всіх учасників групи. - Підключити підключено помилка підключення @@ -159,8 +155,6 @@ Через браузер Відкриття посилання в браузері може зменшити конфіденційність та безпеку з\'єднання. Ненадійні посилання SimpleX будуть виділені червоним кольором. Не вдалося завантажити чати - Помилка збереження серверів SMP - Переконайтеся, що адреси серверів SMP вірного формату, розділені переносами рядків і не дублюються. Помилка оновлення конфігурації мережі Не вдалося завантажити чат Будь ласка, оновіть додаток і зверніться до розробників. @@ -211,7 +205,6 @@ Голосове повідомлення Голосове повідомлення… Контакт і всі повідомлення будуть видалені - цього не можна скасувати! - Очікує Змінити адресу для отримання? Переглянути код безпеки Ви повинні дозволити вашому контакту надсилати голосові повідомлення, щоб мати змогу надсилати їх. @@ -227,8 +220,6 @@ аватар не встановлено QR-код довідка - покажіть QR-код у відеовиклику, або поділіться посиланням.]]> - Ваш профіль буде відправлено \nвашому контакту Одноразове запрошення Невірний код безпеки! Для перевірки end-to-end шифрування порівняйте (або скануйте) код на своїх пристроях. @@ -237,7 +228,6 @@ Допомога з Markdown Блокування SimpleX Консоль чату - Сервери SMP Сканувати QR-код сервера Використовувати для нових підключень Видалити сервер @@ -253,25 +243,18 @@ Якщо доступно Ні .Onion-хости будуть використовуватися, якщо доступні. - Оновити режим ізоляції транспорту? simplexmq: v%s (%2s) Створити адресу Поділитися посиланням Видалити адресу Повне ім\'я: - Ви керуєте своїм чатом! - Платформа обміну повідомленнями і застосунок, які захищають вашу конфіденційність та безпеку. Введіть своє ім\'я: дзвінок в процесі запуск… - Ніяких ідентифікаторів користувачів. - Децентралізована - Використовувати чат Як це впливає на батарею Миттєво Виклик вже завершено! Виклики - Ваші сервери ICE Відкрити через реле Динамік увімкнено @@ -279,7 +262,6 @@ Відхилений виклик %1$d пропущено повідомлень Чати - SOCKS-проксі Помилка при запуску чату Зупинити Імпортувати @@ -307,9 +289,6 @@ Заборонити надсилання повідомлень, які зникають. Забороняйте надсилання голосових повідомлень. Французький інтерфейс - Помилка збереження серверів XFTP - Переконайтеся, що адреси серверів XFTP вірного формату, розділені переносами рядків і не дублюються. - Помилка завантаження серверів XFTP Помилка додавання учасників Помилка приєднання до групи Неможливо отримати файл @@ -326,7 +305,6 @@ Чати підключення… підключення… - Торкніться, щоб розпочати новий чат Чат із розробниками У вас немає чатів ви спостерігач @@ -347,19 +325,14 @@ Якщо ви виберете відхилити, відправник НЕ буде повідомлений. Очистити чат Неправильне посилання! - Це посилання не є дійсним з\'єднувальним посиланням! Запит на з\'єднання відправлено! - Відкрити у мобільному додатку.]]> Сканувати код Скануйте код безпеки з додатка вашого контакту. Невірна адреса сервера! Перевірте адресу сервера і спробуйте ще раз. - Сервери XFTP Встановити SimpleX Chat для терміналу Внести вклад Оцініть додаток - Ваші сервери SMP - Ваші сервери XFTP Використання серверів SimpleX Chat. Як користуватися дзвінок… @@ -405,8 +378,6 @@ Видалити адресу\? Ім\'я профілю: очікування підтвердження… - Приватність перевизначена - Ви вирішуєте, хто може під\'єднатися. зашифрований e2e аудіовиклик Відкрийте SimpleX Chat для прийняття виклику e2e зашифровано @@ -455,7 +426,6 @@ Вийти без збереження Сховати профіль Пароль для відображення - Створити без зашифрування e2e контакт має зашифрування e2e Хеш попереднього повідомлення інший. @@ -517,7 +487,6 @@ аватар Більше Створити профіль - GitHub.]]> Відео увімкнено Це може трапитися, якщо ви або ваше з\'єднання використовували застарілу резервну копію бази даних. Відновити резервну копію бази даних @@ -531,7 +500,6 @@ хвилини Китайський та іспанський інтерфейс підключення %1$d - Помилка завантаження серверів SMP Дубль імені відображення! Помилка відправлення повідомлення Відправник скасував передачу файлу. @@ -568,8 +536,6 @@ Відкликати несанкціонована відправка Вітаємо, %1$s! - Вітаємо! - Цей текст доступний у налаштуваннях Запрошуємо вас до групи Поділитися повідомленням… Поділитися медіа… @@ -606,14 +572,12 @@ \nВи можете скасувати це з\'єднання і видалити контакт (і спробувати пізніше за допомогою нового посилання). Логотип SimpleX Електронна пошта - Цей QR-код не є посиланням! Ви будете підключені до групи, коли пристрій власник групи буде в мережі, зачекайте або перевірте пізніше! Підключення відбудеться, коли ваш запит на підключення буде прийнято. Будь ласка, зачекайте або спробуйте пізніше! Поділитися 1-разовим посиланням Дізнатися більше Щоб підключитися, ваш контакт може сканувати QR-код або використовувати посилання у додатку. Якщо ви не можете зустрітися особисто, покажіть QR-код у відеовиклику або поділіться посиланням. - Ви можете поділитися своєю адресою в якості посилання або QR-коду - кожен може підключитися до вас. Вставити Цей рядок не є з\'єднувальним посиланням! Код безпеки @@ -623,7 +587,6 @@ Markdown у повідомленнях Надсилайте питання та ідеї Ввести сервер вручну - Попередньо встановлений сервер Адреса вашого сервера Сервери для нових підключень до вашого поточного профілю Використовувати сервери SimpleX Chat? @@ -634,7 +597,6 @@ Показати параметри розробника Ідентифікатори бази даних та опція ізоляції транспорту. Сповіщення перестануть працювати, поки ви не перезапустите додаток - Ви можете створити його пізніше Ваш поточний профіль Видалити зображення Зберегти налаштування? @@ -647,7 +609,6 @@ Ви можете використовувати markdown для форматування повідомлень: Створіть свій профіль Створіть приватне підключення - Тільки клієнтські пристрої зберігають профілі, контакти, групи та повідомлення. Приватні сповіщення Споживає більше акумулятора! Додаток завжди працює у фоновому режимі – сповіщення відображаються миттєво.]]> Вставте отримане посилання @@ -822,7 +783,6 @@ Надіслати Підтвердити Інший час - Створити одноразове запрошення Сканувати QR-код Фото Відео @@ -830,7 +790,6 @@ Контакт ще не підключений! Тестувати сервери Зберегти сервери - Ваш сервер Тест сервера не вдався! Деякі сервери не пройшли тест: Використовувати сервер @@ -841,7 +800,6 @@ Налаштування теми Поділитися адресою з контактами SimpleX? підключення дзвінка… - Ми не зберігаємо жодні з ваших контактів чи повідомлень (після доставки) на серверах. очікування відповіді… Як це працює відеовиклик @@ -889,7 +847,6 @@ Дякуємо користувачам – приєднуйтеся через Weblate! Режим блокування SimpleX Системна аутентифікація - Для захисту вашої конфіденційності SimpleX використовує окремі ID для кожного вашого контакту. Коли додаток запущено Періодично контакт не має зашифрування e2e @@ -998,12 +955,9 @@ Скасувати живе повідомлення Скинути без деталей - Підключитися за посиланням / QR-кодом Очистити - Неправильний QR-код Підключення відбудеться, коли пристрій вашого контакту буде онлайн. Будь ласка, зачекайте або спробуйте пізніше! Ви не втратите свої контакти, якщо ви пізніше видалите свою адресу. - Коли люди просять про з\'єднання, ви можете його прийняти чи відхилити. Посібнику користувача.]]> SimpleX-адреса Скинути підтвердження @@ -1021,7 +975,6 @@ Порт Обов\'язково Кольори інтерфейсу - Створіть адресу, щоб дозволити людям підключатися до вас. Контакти залишатимуться підключеними. Створити SimpleX-адресу Оновлення профілю буде відправлено вашим SimpleX контактам. @@ -1035,13 +988,11 @@ Запросити друзів Давайте говорити в SimpleX Chat Продовжити - Не створювати адресу отримано відповідь… отримано підтвердження… підключення… підключено завершено - Стійкий до спаму %1$s хоче підключитися до вас через зашифрований e2e відеовиклик Ігнорувати @@ -1164,8 +1115,6 @@ кольоровий дзвінок завершено %1$s помилка дзвінка - Майбутнє обміну повідомленнями - Кожен може хостити сервери. Інструменти розробника Експериментальні функції Дзвінки @@ -1178,8 +1127,6 @@ Зупинити відправлення файлу Зупинити відправлення файлу? Створити секретну групу - (щоб поділитися з вашим контактом) - (сканувати або вставити з буферу обміну) підключитися до розробників SimpleX Chat, щоб задати будь-які питання і отримувати оновлення.]]> Сканувати QR-код.]]> Адреса SimpleX @@ -1218,7 +1165,6 @@ вас видалили власник видалено - сканувати QR-код у відеовиклику, або ваш контакт може поділитися посиланням на запрошення.]]> Як користуватися Підключення Використовувати .onion-хости на Ні, якщо SOCKS-проксі їх не підтримує.]]> @@ -1251,7 +1197,6 @@ Перервати зміну адреси Дозволити надсилання файлів та медіафайлів. Файли та медіа заборонені. - Підключити інкогніто Використовувати поточний профіль Дозволити Вимкнути сповіщення @@ -1333,7 +1278,6 @@ Навіть коли вимкнено в розмові. Чорновик повідомлення Тільки власники групи можуть включити файли та медіа. - Вставте посилання, яке ви отримали, щоб підключитися до свого контакту… Надсилання повідомлень про доставку вимкнено для %d груп %s, %s і %s підключилися Їх можна замінити в налаштуваннях контактів і груп. @@ -1539,7 +1483,6 @@ Створення посилання… Налаштування для розробників Показувати повільні виклики API - Ви можете зробити це видимим для ваших контактів у SimpleX через Налаштування. %s заблокований %s розблокований ви заблокували %s @@ -1841,7 +1784,6 @@ Адреса сервера призначення %1$s несумісна з налаштуваннями сервера переадресації %2$s. Файл не знайдено — ймовірно, файл був видалений або скасований. Вставити / Сканувати посилання - Налаштовані XFTP сервери Бета Статус файлу Надіслано повідомлень @@ -1876,7 +1818,6 @@ Сервер переадресації %1$s не зміг з\'єднатися з цільовим сервером %2$s. Будь ласка, спробуйте пізніше. Повідомлення може бути доставлено пізніше, якщо учасник стане активним. Нічого не вибрано - Відкрити налаштування сервера Версія сервера несумісна з вашим додатком: %1$s. Повідомлення будуть позначені як модеровані для всіх учасників. Оновлювати додаток автоматично @@ -1993,11 +1934,8 @@ Розмову видалено! Видалити без сповіщення Ви можете надсилати повідомлення %1$s з архівованих контактів. - Налаштовані SMP сервери Ніяких відфільтрованих контактів - Інші SMP сервери Ваші контакти - Інші XFTP сервери Показати відсоток Перевірити оновлення Вимкнено @@ -2202,7 +2140,6 @@ Без фонової служби Сповіщення та батарея Додаток завжди працює у фоні. - зашифрованими end-to-end, з пост-квантовою безпекою в особистих повідомленнях.]]> Покинути чат? Учасник буде видалений з чату — це неможливо скасувати! Бізнес чати 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 4b8ec8c4b7..9c63cff9ee 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml @@ -30,7 +30,6 @@ 30 giây Chấp nhận yêu cầu kết nối? Chấp nhận bằng hồ sơ ẩn danh - Thêm các máy chủ được cài sẵn Thêm vào một thiết bị khác cuộc gọi được chấp nhận đồng ý mã hóa… @@ -138,8 +137,6 @@ Địa chỉ máy tính xấu Thêm liên hệ: để tạo đường dẫn mời mới, hoặc kết nối qua đường dẫn bạn nhận được.]]> hàm băm tin nhắn xấu - " -\nCó sẵn ở v5.1" Tự động chấp nhận Quay về Tự động chấp nhận hình ảnh @@ -277,7 +274,6 @@ Đã kết nối đã kết nối Kết nối trực tiếp? - Kết nối đã kết nối Tự động kết nối đã kết nối @@ -293,7 +289,6 @@ đang kết nối đang kết nối… đang kết nối… - Kết nối ẩn danh đang kết nối Đã kết nối tới máy tính đang kết nối (đã được thông báo) @@ -302,7 +297,6 @@ đang kết nối (lời mời giới thiệu) Kết nối tới máy tính đang ở trong tình trạng không tốt Kết nối đã bị ngắt - Kết nối qua đường dẫn / mã QR Kết nối tới chính bạn? Yêu cầu kết nối đã được gửi! Kết nối qua đường dẫn @@ -340,7 +334,6 @@ liên hệ không có bảo mật đầu cuối Tùy chọn liên hệ Sao chép - Tạo Liên hệ có thể đánh dấu tin nhắn để xóa; bạn vẫn sẽ có thể xem được chúng. Biểu tượng ngữ cảnh Tiếp tục @@ -353,7 +346,6 @@ Tạo hồ sơ mới trong ứng dụng trên máy tính. 💻 Không thể gửi tin nhắn Tạo tệp - Tạo một địa chỉ để cho mọi người kết nối với bạn. Tạo đường dẫn nhóm Tạo đường dẫn Được tạo ra tại @@ -370,7 +362,6 @@ Các màu chế độ tối Tối Lỗi nghiêm trọng - Tạo đường dẫn lời mời dùng một lần Đang tạo đường dẫn… Tạo hồ sơ Tùy chỉnh và chia sẻ các chủ đề màu sắc. @@ -419,7 +410,6 @@ Xóa hồ sơ trò chuyện? Xóa %d tin nhắn? Xóa địa chỉ - Phi tập trung Xóa Xóa cơ sở dữ liệu khỏi thiết bị này Xóa sau @@ -482,8 +472,6 @@ Beta Kiểm tra cập nhật Đang kết nối - Các máy chủ SMP đã được cấu hình - Các máy chủ XFTP đã được cấu hình Bản cập nhật ứng dụng đã được tải xuống Kiểm tra cập nhật Đã kết nối @@ -555,7 +543,6 @@ Kiểm soát mạng của bạn Kết nối và trạng thái máy chủ. Cài đặt nâng cao - Bất kỳ ai cũng có thể tạo máy chủ. Tiếp tục Kết nối nhanh hơn với bạn bè. Cơ sở dữ liệu trò chuyện đã được xuất @@ -587,7 +574,6 @@ Tên hiển thị không thể chứa khoảng trắng. Khám phá qua mạng cục bộ Tải về không thành công - Không tạo địa chỉ Không hiển thị lại Tải về tệp tin Đang tải về kho lưu trữ @@ -711,8 +697,6 @@ Lỗi nhận tệp Lỗi lưu hồ sơ nhóm Lỗi lưu tệp - Lỗi tải máy chủ SMP - Lỗi tải máy chủ XFTP Lỗi kết nối lại máy chủ Lỗi kết nối lại máy chủ Lỗi @@ -721,8 +705,6 @@ lỗi hiển thị tin nhắn lỗi hiển thị nội dung Lỗi lưu máy chủ ICE - Lỗi lưu máy chủ XFTP - Lỗi lưu máy chủ SMP Lỗi gửi tin nhắn Lỗi khởi động kết nối trò chuyện Lỗi dừng kết nối trò chuyện @@ -895,7 +877,6 @@ Chuyển tiếp tối đa 20 tin nhắn cùng một lúc. Cách sử dụng máy chủ của bạn Giao diện Hungary và Thổ Nhĩ Kỳ - Miễn nhiễm với tin nhắn rác Nhập cơ sở dữ liệu trò chuyện? Nếu bạn nhập mã tự hủy của mình khi mở ứng dụng: Máy chủ ICE (một dòng mỗi máy) @@ -909,10 +890,8 @@ Hình ảnh Hình ảnh đã được lưu vào Thư viện Nếu bạn nhận được đường dẫn mời SimpleX Chat, bạn có thể mở nó trong trình duyệt của mình: - quét mã QR trong cuộc gọi video, hoặc liên hệ của bạn có thể chia sẻ một đường dẫn mời.]]> Nếu bạn xác nhận, các máy chủ truyền tin nhắn sẽ có thể biết địa chỉ IP, và nhà cung cấp của bạn - máy chủ nào mà bạn đang kết nối. Nếu bạn chọn từ chối người gửi sẽ KHÔNG được thông báo. - cho liên hệ của bạn xem mã QR trong cuộc gọi video, hoặc chia sẻ đường dẫn.]]> Nhập Bỏ qua không hoạt động @@ -963,7 +942,6 @@ Tên hiển thị không hợp lệ! Đường dẫn kết nối không hợp lệ Thông báo tức thời! - Mã QR không hợp lệ Thông báo tức thời Thông báo tức thời đã bị tắt! Đường dẫn tệp không hợp lệ @@ -977,8 +955,6 @@ Mời thành viên Tên cục bộ Cảnh báo chuyển gửi tin nhắn - Đảm bảo địa chỉ máy chủ SMP ở đúng định dạng, dòng được phân tách và không bị trùng lặp. - Đảm bảo địa chỉ máy chủ XFTP ở đúng định dạng, dòng được phân tách và không bị trùng lặp. Liên kết với điện thoại Thành viên không hoạt động Tin nhắn đã được chuyển tiếp @@ -1172,7 +1148,6 @@ bật Không có gì để chuyển tiếp! Thông báo sẽ dừng hoạt động cho đến khi bạn khởi động lại ứng dụng - Không có thông tin định danh người dùng. không có văn bản tắt tắt` @@ -1195,7 +1170,6 @@ OK Chỉ xóa cuộc trò chuyện Chỉ bạn mới có thể gửi tin nhắn thoại. - Chỉ thiết bị cuối mới lưu trữ các hồ sơ người dùng, liên hệ, nhóm, và tin nhắn Chỉ chủ nhóm mới có thể bật tính năng tin nhắn thoại. Chỉ bạn mới có thể gửi tin nhắn tự xóa. Đường dẫn lời mời dùng một lần @@ -1222,7 +1196,6 @@ Dịch vụ onion sẽ được sử dụng khi có sẵn. Chỉ liên hệ của bạn mới có thể gửi tin nhắn thoại. chủ sở hữu - Mở cài đặt máy chủ Mở đường dẫn trong trình duyệt có thể làm giảm sự riêng tư và bảo mật của kết nối. Đường dẫn SimpleX không đáng tin cậy sẽ được đánh dấu màu đỏ. Chỉ liên hệ của bạn mới có thể xóa tin nhắn mà không thể phục hồi (bạn có thể đánh dấu chúng để xóa). (24 giờ) Mở cài đặt ứng dụng @@ -1248,14 +1221,10 @@ Mở nhóm khác các lỗi khác - Các máy chủ SMP khác - Các máy chủ XFTP khác - Dán đường dẫn mà bạn nhận được để kết nối với liên hệ của bạn… Dán đường dẫn Mật khẩu Định kỳ Đang chờ xử lý - Đang chờ xử lý Không tìm thấy mật khẩu trong Keystore, vui lòng nhập thủ công. Điều này có thể xảy ra nếu bạn khôi phục dữ liệu ứng dụng bằng một công cụ sao lưu. Nếu không phải như vậy, xin vui lòng liên hệ với nhà phát triển. Thành viên trước đây %1$s Dán đường dẫn để kết nối! @@ -1301,14 +1270,12 @@ Lưu lại bản nháp tin nhắn cuối cùng, với các tệp đính kèm. Đang chuẩn bị tải xuống Xin vui lòng cập nhật ứng dụng và liên lạc với các nhà phát triển. - Máy chủ cài sẵn Xin vui lòng thử lại sau. Xem trước Xin vui lòng lưu trữ mật khẩu một cách an toàn, bạn sẽ KHÔNG thể thay đổi nếu bạn làm mất nó. Giao diện tiếng Ba Lan Xin vui lòng khởi động lại ứng dụng. Các máy chủ đã kết nối trước đó - Định hình lại sự riêng tư Quyền riêng tư & bảo mật Bản cập nhật hồ sơ sẽ được gửi đến các liên hệ của bạn. Cấm thả cảm xúc tin nhắn. @@ -1359,7 +1326,6 @@ Cài đặt địa chỉ %s.]]> %s.]]> - Kho lưu trữ GitHub của chúng tôi.]]> %s.]]> %s.]]> %s.]]> @@ -1374,7 +1340,6 @@ %1$s rồi.]]> %1$s!]]> %1$s rồi.]]> - Mở trong ứng dụng di động.]]> Các bên vận hành máy chủ kết nối với các nhà phát triển SimpleX Chat để hỏi bất kỳ câu hỏi nào và nhận thông tin cập nhật.]]> không được sử dụng cùng một cơ sở dữ liệu trên hai thiết .]]> @@ -1403,7 +1368,6 @@ Cuộc trò chuyện đã tồn tại! Thêm bạn bè đã chấp nhận lời mời - mã hóa đầu cuối, với bảo mật sau ượng tử trong các tin nhắn trực tiếp.]]> Giới thiệu về các nhà cung cấp Thêm các thành viên nhóm của bạn vào các cuộc trò chuyện. Địa chỉ doanh nghiệp @@ -1609,7 +1573,6 @@ Đã chọn %d Gửi tin nhắn trực tiếp để kết nối Đang lưu %1$s tin nhắn - (quét hoặc dán từ bảng nháp) Gửi tin nhắn trực tiếp Lưu lời chào? gửi thất bại @@ -1817,7 +1780,6 @@ Loa ngoài bật Âm thanh đã bị tắt Ổn định - Proxy SOCKS Các nhóm nhỏ (tối đa 20 thành viên) Một vài lỗi không nghiêm trọng đã xảy ra trong lúc nhập: Loa ngoài tắt @@ -1843,7 +1805,6 @@ Một số tệp đã không được xuất Loa ngoài Bắt đầu kết nối trò chuyện? - Các máy chủ SMP Bắt đầu kết nối trò chuyện Tin nhắn rác Mạnh @@ -1902,12 +1863,10 @@ Cảm ơn bạn đã cài đặt SimpleX Chat! Kiểm tra máy chủ Kiểm tra các máy chủ - Nhấn để bắt đầu một cuộc trò chuyện mới Nhấn để dán đường dẫn Đuôi Chuyển đổi Kết nối đã chạm giới hạn của các tin nhắn chưa được gửi đi, liên hệ của bạn có thể đang ngoại tuyến. - Tương lai của nhắn tin Hình ảnh không thể được giải mã. Xin vui lòng thử lại với một hình ảnh khác hoặc liên lạc với các nhà phát triển. Các tin nhắn sẽ bị xóa cho tất cả các thành viên. Mã hóa đang hoạt động và thỏa thuận mã hóa mới là không bắt buộc. Nó có thể dẫn đến các lỗi kết nối! @@ -1955,7 +1914,6 @@ Tính năng này chưa được hỗ trợ. Hãy thử bản phát hành tiếp theo. Dấu tick thứ hai mà chúng ta từng thiếu! ✅ Những máy chủ cho tệp mới của hồ sơ trò chuyện hiện tại của bạn - Nền tảng ứng dụng và nhắn tin bảo vệ sự riêng tư và bảo mật của bạn. Bên vận hành được cài sẵn thứ hai trong ứng dụng! Chức vụ sẽ được đổi thành %s. Tất cả mọi người trong cuộc trò chuyện sẽ được thông báo. Văn bản bạn vừa dán không phải là một đường dẫn SimpleX. @@ -1967,11 +1925,8 @@ 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 Tin nhắn này đã bị xóa hoặc vẫn chưa được nhận. - Mã QR này không phải là một đường dẫn! - Đường dẫn này không phải là một đường dẫn kết nối hợp lệ! Thời gian chờ đã hết trong khi kết nối tới máy tính Để cho phép một ứng dụng di động kết nối tới máy tính, mở cổng này trong tường lửa của bạn, nếu bạn có bật nó lên - Để bảo vệ sự riêng tư của bạn, SimpleX sử dụng các ID riêng biệt cho mỗi liên hệ bạn có. Để nhận thông báo, xin vui lòng nhập mật khẩu cơ sở dữ liệu Quá nhiều ảnh! Quá nhiều video! @@ -1985,7 +1940,6 @@ Tiêu đề Chuyển đổi ẩn danh khi kết nối. Để bảo vệ múi giờ, các tệp hình ảnh/âm thanh sử dụng UTC. - Văn bản này có sẵn trong cài đặt Để được thông báo về các bản phát hành mới, bật kiểm tra định kỳ cho các phiên bản Ổn định hoặc Beta. Để ẩn các tin nhắn không mong muốn. Để tiết lộ hồ sơ ẩn của bạn, nhập đầy đủ mật khẩu vào trường tìm kiếm trong trang Các hồ sơ trò chuyện của bạn. @@ -2014,7 +1968,6 @@ Các phiên truyền tải Các máy chủ không xác định! gửi mà không được cho phép - (để chia sẻ với liên hệ của bạn) Bỏ ẩn Bật Tổng @@ -2028,7 +1981,6 @@ Tối đa tới 100 tin nhắn cuối cùng là được gửi tới các thành viên mới. Sử dụng các thông tin đăng nhập proxy khác cho mỗi kết nối. Sử dụng các thông tin đăng nhập proxy khác nhau cho mỗi hồ sơ. - Cập nhật chế độ cách ly truyền tải? Hủy liên kết máy tính? Tải lên tệp Có bản cập nhật: %s @@ -2076,7 +2028,6 @@ Sử dụng hồ sơ ẩn danh mới qua %1$s Sử dụng proxy SOCKS - Sử dụng SimpleX Chat Sử dụng cho các kết nối mới Sử dụng máy chủ Sử dụng proxy SOCKS? @@ -2117,7 +2068,6 @@ cuộc gọi video (không được mã hóa đầu cuối) qua đường dẫn địa chỉ liên lạc Video - Chúng tôi không lưu bất kỳ liên hệ hay tin nhắn nào của bạn (một khi đã được gửi) trên các máy chủ. Xin chào %1$s! Khi có sẵn Các tin nhắn thoại bị cấm! @@ -2132,7 +2082,6 @@ Màu nền hình nền Đang chờ máy tính… Đang chờ di động để kết nối: - Xin chào! Đang chờ video muốn kết nối với bạn! Đang chờ tệp @@ -2165,19 +2114,15 @@ Tắt tính năng xóa tin nhắn Đặt tên cuộc trò chuyện… Không có Tor hoặc VPN, địa chỉ IP của bạn sẽ bị lộ ra cho các relay XFTP sau đây:\n%1$s. - Các máy chủ XFTP Khi có nhiều hơn một bên vận hành được kích hoạt, không ai trong số họ có siêu dữ liệu để biết được ai trò chuyện với ai. Với các tệp và đa phương tiện được mã hóa. Mức sử dụng pin đã được giảm xuống. Mức sử dụng pin đã được giảm xuống. Với lời chào tùy chọn. - Khi mọi người gửi yêu cầu kết nối, bạn có thể chấp nhận hoặc từ chối nó. Khóa sai hoặc địa chỉ khối tệp không xác định - khả năng cao tệp đã bị xóa. 1 năm Việc này không thể được hoàn tác - các tin nhắn đã được gửi và nhận trong cuộc trò chuyện này sớm hơn thời gian được chọn sẽ bị xóa. có - Bạn có thể hiển thị nó cho các liên hệ SimpleX của mình thông qua Cài đặt. - Bạn có thể tạo nó sau Bạn có thể thay đổi nói trong cài đặt Giao diện. Bạn đang tham gia nhóm thông qua đường dẫn này. Bạn có thể bật vào lúc sau thông qua Cài đặt @@ -2228,9 +2173,7 @@ bạn đã thay đổi địa chỉ Bạn đã yêu cầu kết nối thông qua địa chỉ này rồi! Bạn đã mời một liên hệ - Bạn có thể chia sẻ địa chỉ của mình dưới dạng một đường dẫn hoặc mã QR - bất kỳ ai cũng có thể kết nối với bạn. Bạn có thể xem đường dẫn mời lần nữa trong chi tiết kết nối. - Bạn kiểm soát cuộc trò chuyện của mình! bạn là quan sát viên Bạn có thể chia sẻ địa chỉ này với các liên hệ của mình để họ kết nối với %s. Bạn cần cho phép liên hệ của mình gửi tin nhắn thoại để có thể gửi cho họ. @@ -2239,7 +2182,6 @@ bạn đã thay đổi địa chỉ cho %s Bạn có thể sử dụng markdown để định dạng tin nhắn: Bạn có thể bắt đầu kết nối trò chuyện thông qua phần Cài đặt / Cơ sở dữ liệu ở trên ứng dụng hoặc bằng cách khởi động lại ứng dụng. - Bạn quyết định ai có thể kết nối tới. Cơ sở dữ liệu trò chuyện của bạn Sự riêng tư của bạn Cơ sở dữ liệu trò chuyện hiện tại của bạn sẽ bị XÓA và THAY THẾ bằng cái được nhập vào.\nViệc này không thể được hoàn tác - hồ sơ, các liên hệ, tin nhắn và tệp của bạn sẽ biến mất mà không thể khôi phục. @@ -2248,7 +2190,6 @@ Hồ sơ, các liên hệ và những tin nhắn đã được gửi của bạn được lưu trữ trên thiết bị bạn dùng. Các liên hệ của bạn có thể cho phép xóa tin nhắn hoàn toàn. Thu phóng - Các máy chủ SMP của bạn Bạn đã chia sẻ đường dẫn dùng một lần Hồ sơ trò chuyện của bạn sẽ được gửi tới các thành viên nhóm Hồ sơ hiện tại của bạn @@ -2258,7 +2199,6 @@ Bạn đã gửi lời mời nhóm Bạn đã từ chối lời mời nhóm bạn đã bỏ chặn %s - Hồ sơ trò chuyện của bạn sẽ được gửi\ntới liên hệ của bạn bạn đã chia sẻ đường dẫn ẩn danh dùng một lần Địa chỉ SimpleX của bạn Liên hệ của bạn cần phải trực tuyến để cho kết nối hoàn thành.\nBạn có thể hủy kết nối này và xóa liên hệ (và thử lại sau với một đường dẫn mới). @@ -2267,7 +2207,6 @@ Hồ sơ trò chuyện của bạn sẽ được gửi tới các thành viên có liên lạc Bạn sẽ được kết nối khi yêu cầu kết nối của bạn được chấp nhận, xin vui lòng đợi hoặc kiểm tra sau! Hồ sơ %1$s sẽ được chia sẻ. - Các máy chủ XFTP của bạn Hồ sơ của bạn sẽ được gửi tới liên hệ mà bạn đã nhận từ người đó đường dẫn này. Bạn sẽ kết nối với tất cả các thành viên nhóm. Bạn đã chia sẻ một đường dẫn tệp không hợp lệ. Báo cáo vấn đề tới các nhà phát triển ứng dụng. @@ -2281,9 +2220,7 @@ Các liên hệ của bạn Các hồ sơ trò chuyện của bạn Cài đặt của bạn - Máy chủ của bạn Địa chỉ máy chủ của bạn - Các máy chủ ICE của bạn Thông tin định danh của bạn có thể bị gửi mà không được mã hóa. Hồ sơ của bạn được lưu trên thiết bị bạn dùng và chỉ được chia sẻ với các liên hệ bạn có. Các máy chủ SimpleX không thể xem hồ sơ của bạn. Kết nối của bạn đã bị chuyển tới %s nhưng một lỗi không mong muốn đã xảy ra trong khi chuyển hướng bạn đến hồ sơ. 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 2d5ccbe4b9..804316d18c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml @@ -23,7 +23,6 @@ 接受连接请求? 接受隐身聊天 管理员可以创建链接以加入群。 - 添加预设服务器 通过链接连接 已建立连接 连接 %1$d @@ -64,7 +63,6 @@ 通过一次性链接进行连接? 通过联系人地址进行连接? 加入群? - 通过群链接/二维码连接 总是通过中继连接 允许你的联系人不不可逆地删除已发送消息。(24小时) 联系人允许 @@ -169,13 +167,11 @@ 只有群主可以改变群偏好设置。 保存偏好设置? 设置群偏好设置 - 重新定义隐私 改进的隐私和安全 隐身聊天 加入群中 加入隐身聊天 隐身模式 - 点击开始一个新聊天 你的随机资料 通过联系地址链接隐身 通过群链接隐身 @@ -223,7 +219,6 @@ 彩色 已连接 连接 - 连接 文件 聊天资料 按聊天资料(默认)或按连接(BETA)。 @@ -269,10 +264,7 @@ 扫描二维码 从应用程序扫描显示的二维码。]]> 删除待定连接? 如何使用 markdown - 创建 显示名不能包含空格。 - 分散式 - 不受垃圾和骚扰消息影响 端到端加密视频通话 忽视 视频通话来电 @@ -326,7 +318,6 @@ 创建群链接 创建私密群 创建链接 - 创建一次性邀请链接 创建队列 创建私密群 不同的名字、头像和传输隔离。 @@ -398,7 +389,6 @@ 更新网络配置错误 删除联系人请求错误 保存群资料错误 - 保存 SMP 服务器错误 保存 ICE 服务器错误 发送消息错误 完整链接 @@ -433,7 +423,6 @@ 邀请已过期! 实时消息! 链接预览图片 - 无效的二维码 它如何影响电量 它可能在以下情况发生: \n1. 消息在发送客户端 2 天后或在服务器上 30 天后过期。 \n2. 消息解密失败,因为你或你的联系人使用了旧的数据库备份。 \n3.连接被破坏。 离开群? @@ -484,7 +473,6 @@ 设置联系人姓名…… 已收到回复…… 已受到确认…… - 仅客户端设备存储用户个人资料、联系人、群和消息。 视频通话(非端到端加密) 定期 私密通知 @@ -556,7 +544,6 @@ 拒接来电 点对点 错误:%s - 扫描视频通话中的二维码,或者你的联系人可以分享邀请链接。]]> 你的通话 通过中继 未接来电 @@ -564,13 +551,11 @@ 语音消息 语音消息 SimpleX Chat 通话 - 在视频通话中出示二维码,或分享链接。]]> 你的聊天资料 未接来电 待定来电 你的联系人删除了此链接,或者其为已使用的一次性链接。\n要进行连接,请你的联系人创建新链接。 你已经连接到 %1$s。 - 你的聊天资料将被发送 \n给你的联系人 资料和服务器连接 更新网络设置? 只有你可以不可逆地删除消息(你的联系人可以将它们标记为删除)。(24小时) @@ -619,7 +604,6 @@ %d 月 %d 秒 确保 WebRTC ICE 服务器地址格式正确、每行分开且不重复。 - 确保 SMP 服务器地址格式正确、每行分开且不重复。 Markdown 帮助 标记为已验证 建立私密连接 @@ -674,7 +658,6 @@ 发送人已取消文件传输。 分享 发送实时消息 - 此文本在设置中可用 未读 保存的 WebRTC ICE 服务器将被删除。 %s 已验证 @@ -684,7 +667,6 @@ 保存并通知联系人 保存并通知联系人 拒绝 - 为了保护隐私,SimpleX 对你的每一个联系人使用不同的 ID。 TCP 连接超时 收到,禁止 设定1天 @@ -692,7 +674,6 @@ 在浏览器中打开链接可能会降低连接的隐私和安全性。SimpleX 上不受信任的链接将显示为红色。 恢复数据库备份后请输入之前的密码。 此操作无法撤消。 请更新应用程序并联系开发者。 - 任何人都可以托管服务器。 粘贴 PING 次数 禁止发送语音消息。 @@ -711,28 +692,22 @@ SimpleX 消息 %s 未验证 感谢用户——通过 Weblate 做出贡献! - 没有用户标识符。 完全去中心化 - 仅对成员可见。 图像无法解码。 请尝试不同的图像或联系开发者。 主题 此操作无法撤消——所有接收和发送的文件和媒体都将被删除。 低分辨率图片将保留。 角色将更改为%s。 该成员将收到新的邀请。 此操作无法撤消——早于所选的发送和接收的消息将被删除。 这可能需要几分钟时间。 - 此二维码不是链接! 接收地址将变更到不同的服务器。地址更改将在发件人上线后完成。 - 此链接不是有效的连接链接! 开始新的聊天 要与你的联系人验证端到端加密,请比较(或扫描)你设备上的代码。 取消静音 - 更新传输隔离模式? - (从剪贴板扫描或粘贴) 保护队列 揭示 打开 发送失败 未经授权发送 太多图片! - 待办的 更改接收地址? 请让你的联系人启用发送语音消息。 录制语音消息 @@ -741,22 +716,17 @@ 发送 发送实时消息——它会在你键入时为收件人更新 开始新聊天 - (与你的联系人分享) 通过链接连接 设置联系人姓名 你接受的连接将被取消! 你与之共享此链接的联系人将无法连接! 显示二维码 发送问题和想法 - 保护你的隐私和安全的消息传递和应用程序平台。 删去 - 你决定谁可以连接。 - 下一代私密通讯软件 粘贴你收到的链接 已跳过消息 支持 SimpleX Chat 发送链接预览 - SOCKS 代理 停止聊天程序? 停止聊天以便导出、导入或删除聊天数据库。在聊天停止期间,你将无法收发消息。 恢复数据库备份 @@ -774,7 +744,6 @@ 分享一次性链接 此字符串不是连接链接! 给我们发电子邮件 - SMP 服务器 尚不支持发送文件 尝试连接到用于从该连接接收消息的服务器。 尚不支持接收文件 @@ -786,7 +755,6 @@ SimpleX 群链接 SimpleX 链接 发送人已删除连接请求。 - 预设服务器 二维码 传输隔离 分享链接 @@ -810,8 +778,6 @@ 重置颜色 减少电池使用量 为了保护时区,图像/语音文件使用 UTC。 - 使用聊天 - GitHub 存储库 中阅读更多内容。]]> 打开聊天控制台 停止聊天程序 权限被拒绝! @@ -833,7 +799,6 @@ 查看安全码 语音消息 (%1$s) 等待图像中 - 欢迎! 欢迎 %1$s! 当你的联系人设备在线时,你将可以连接,请稍等或稍后查看! 评价此应用程序 @@ -856,7 +821,6 @@ 当你启动应用或在应用程序驻留后台超过30 秒后,你将需要进行身份验证。 连接到 SimpleX Chat 开发者提出任何问题并接收更新 。]]> 你已接受连接 - 你的 SMP 服务器 %1$d 条已跳过消息 %ds 更新内容 @@ -874,11 +838,9 @@ 你的 SimpleX 地址 为终端安装 SimpleX Chat 使用 SimpleX Chat 服务器? - 我们不会在服务器上存储你的任何联系人或消息(一旦发送)。 WebRTC ICE 服务器 中继服务器保护你的 IP 地址,但它可以观察通话的持续时间。 中继服务器仅在必要时使用。其他人可能会观察到你的IP地址。 - 你的 ICE 服务器 视频关闭 你可以通过应用设置/数据库或重启应用开始聊天。 你将 %s 的角色更改为 %s @@ -886,19 +848,16 @@ 你已更改地址 你可以共享链接或二维码——任何人都可以加入该群。如果你稍后将其删除,你不会失去该群的成员。 间接(%1$s) - 在移动应用程序中打开按钮。]]> SimpleX 你将连接到所有群成员。 通过群链接 通过一次性链接 通过联系地址链接 通过浏览器 - 你的服务器 当可用时 使用 .onion 主机 你的 ICE 服务器 simplexmq: v%s (%2s) - 你的聊天由你掌控! 你可以使用 markdown 来编排消息格式: %dh %d 天 @@ -1012,20 +971,14 @@ 视频将在你的联系人完成上传后收到。 服务器需要授权来上传,检查密码。 上传文件 - XFTP 服务器 - 你的 XFTP 服务器 Use .onion hosts 设置为否。]]> 使用 SOCKS 代理 端口 删除文件 对比文件 主机 - 确保 XFTP 服务器地址格式正确、行分隔且不重复。 创建文件 下载文件 - 加载 SMP 服务器错误 - 加载 XFTP 服务器错误 - 保存 SMP 服务器时出错 端口 %d SOCKS 代理设置 SimpleX 锁定模式 @@ -1079,8 +1032,6 @@ 文件将从服务器中删除。 吊销 音频/视频通话 - " -\n在 v5.1 版本中可用" 应用程序密码 波兰语界面 感谢用户——通过 Weblate 做出贡献! @@ -1127,10 +1078,8 @@ 消失于 设置地址错误 自定义主题 - 创建一个地址,让人们与你联系。 创建 SimpleX 地址 输入欢迎消息……(可选) - 不创建地址 输入欢迎消息…… 深色主题 导出主题 @@ -1169,10 +1118,8 @@ 打开数据库中…… 更改聊天资料 你的联系人可以扫描二维码或使用应用程序中的链接来建立连接。 - 你可以将你的地址作为链接或二维码共享——任何人都可以连接到你。 如果你不能亲自见面,可以在视频通话中展示二维码,或分享链接。 了解更多 - 当人们请求连接时,你可以接受或拒绝它。 如果你以后删除你的地址,你不会丢失你的联系人。 用户指南中阅读更多。]]> 界面颜色 @@ -1185,7 +1132,6 @@ 你好! \n用 SimpleX Chat 与我联系:%s 让我们一起在 SimpleX Chat 里聊天 - 你可以以后创建它 分享地址… 你可以与你的联系人分享该地址,让他们与 %s 联系。 预览 @@ -1262,7 +1208,6 @@ 与 %s 协调加密中… 该功能还没支持。请尝试下一个版本。 允许 - 隐身连接 确认发起私聊? 发送 %s: %s @@ -1334,7 +1279,6 @@ 将送达回执发送给 启用已读回执时出错! 更改密码或重启应用后,密码将以明文形式保存在设置中。 - 粘贴你收到的链接以与你的联系人联系… 送达回执 没有选中的聊天 可以加密 @@ -1501,7 +1445,6 @@ %2$s 审核了 %1$d 条消息 显示内容出错 显示消息出错 - 你可以通过设置让它对你的 SimpleX 联系人可见。 未发送历史消息给新成员。 重试 相机不可用 @@ -1849,8 +1792,6 @@ 私密路由出错 已转发的消息 尚无直接连接,消息由管理员转发。 - 其他 SMP 服务器 - 其他 XFTP 服务器 粘贴链接/扫描 显示百分比 不活跃 @@ -1931,8 +1872,6 @@ 总计 块已上传 订阅被忽略 - 已配置的 SMP 服务器 - 已配置的 XFTP 服务器 当前个人资料 传输会话 已上传 @@ -1941,7 +1880,6 @@ 成员不活跃 如果成员变得活跃,可能会在之后传输消息。 发送的消息 - 打开服务器设置 检查更新 检查更新 停用 @@ -2190,7 +2128,6 @@ 请减小消息大小或删除媒体并再次发送。 将你的团队成员加入对话。 企业地址 - 端到端加密,私信具备后量子密码安全性。]]> 无后台服务 每 10 分钟检查消息 它如何帮助隐私 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 2909f01636..357099ddef 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml @@ -14,7 +14,6 @@ 要在端口啟用 SOCKS 代理伺服器嗎 %d?在啟用這個選項之前,必須先啟用代理伺服器。 管理員 然後,選按: - 新增預設伺服器 新增伺服器 接受 認證無效 @@ -166,8 +165,6 @@ 自動銷毀訊息 開啟新的對話 建立私密群組 - 建立一次性邀請連結 - (分享給你的聯絡人) 點擊按鈕 感謝你安裝 SimpleX Chat! 靜音 @@ -234,7 +231,6 @@ 加入群組? 你的個人檔案將傳送給你接收此連結的聯絡人。 你將連接至此群組內的所有成員。 - 連接 錯誤 連接中 你已連接到此聯絡人使用的伺服器以接收訊息。 @@ -243,8 +239,6 @@ 已刪除 已標記為已刪除 在瀏覽器中開啟連結可能會有隱私疑慮和不確定性。不受 SimpleX 信任的連結會顯示紅色。 - 儲存 SMP 伺服器時出錯 - 請確保 SMP 伺服器連結是正確的格式,每行也分隔且不重複。 更新網路配置時出錯 聊天載入失敗 多個聯絡人載入失敗 @@ -293,13 +287,10 @@ 未經授權傳送 傳送失敗 歡迎 %1$s! - 歡迎! - 在設定中可用這文字 連接中… 你被邀請加入至群組 以 %s 身份加入 連接中… - 點擊以開啟新的對話 找不到檔案 語音訊息 (%1$s) 語音訊息… @@ -356,7 +347,6 @@ 傳送實況的訊息 直播訊息! 傳送實況訊息 - 這會即時顯示你在輸入中的文字 - (掃描或使用剪貼薄貼上) 權限被拒絕! SimpleX 在背景運作而不是使用推送通知。]]> 定期通知 @@ -378,7 +368,6 @@ 已連接 已斷開連接 錯誤 - 待處理 切換接收的聯絡地址? 此功能目前還是實驗階段!對方需要使用 4.2 版本或更高的版本才能成功生效。地址修改後,你會在對話中看到新的訊息 - 請你於修改聯絡地址後,測試是否仍然能夠接收來自這聯絡人(或群組內的成員)的訊息。 查看安全碼 @@ -396,13 +385,11 @@ 沒有詳細資料 一次性邀請連結 已複製至你的剪貼薄 - 透過連結/QR 圖碼連接 掃描二維碼 (僅由群組成員儲存) 想和你對話! SimpleX 徽標 透過連結連接 - 預設伺服器 你的伺服器地址 刪除伺服器 你的 ICE 伺服器 @@ -416,7 +403,6 @@ 使用連結連接 掃描二維碼。]]> 設定 - 這個二維碼不是一個連結! 當可用時 核心版本:v%s simplexmq: v%s (%2s) @@ -454,20 +440,13 @@ 電郵 更多 顯示二維碼 - 無效的 QR 圖碼 無效的連結! - 這個連結不是一個有效的連接連結! 已傳送連接請求 當群組的建立人上線,你便會成功連接至群組,請耐心等待! 當你的連接請求已被接受,你便會連接成功,請耐心等待! 當你的聯絡人上線,你便會連接成功,請耐心等待! - 可於視訊通話中出示你的二維碼,或者分享連結。]]> - 你的個人檔案會傳送給 -\n你的聯絡人 - 視訊通話中掃描二維碼 ,或者你可以分享一個邀請連結給此聯絡人。]]> 貼上 這些字串不是連接連結! - 在電話應用程式內的開啟 按鈕。]]> 一次性邀請連結 掃描二維碼 錯誤的安全碼! @@ -481,7 +460,6 @@ 無效的伺服器地址! 此伺服器用於你目前的個人檔案 使用 SimpleX Chat 伺服器? - 你的 SMP 伺服器 目前使用 SimpleX Chat 伺服器。 如何 配置 ICE 伺服器 @@ -496,7 +474,6 @@ 儲存並通知你的多個聯絡人 你的個人檔案,聯絡人和傳送的訊息只會儲存於你的個人裝置內。 你的個人檔案只會和你的聯絡人分享。 - 建立 如何使用 Markdown 語法 你可以使用 Markdown 語法以更清楚標明訊息: 刪除線 @@ -510,9 +487,6 @@ 使用 SOCKS 代理伺服器 儲存並通知群組內的聯絡人 退出並且不儲存記錄 - 你的對話由你控制! - 一個保護你的隱私和傳送安全通訊的應用程式平台。 - 我們不會在伺服器內儲存你的任何聯絡人和訊息(一旦傳送)。 建立個人檔案 已確認回應… 連接到 SimpleX Chat 開發人員提出任何問題並同意更新。]]> @@ -535,7 +509,6 @@ 資料庫密碼和匯出 傳送電郵 SimpleX 鎖定 - SMP 伺服器 預設伺服器地址 測試伺服器 測試多個伺服器 @@ -543,7 +516,6 @@ 伺服器測試失敗! 有一些伺服器測試失敗: 掃描伺服器的二維碼 - 你的伺服器 連接時需要使用 Onion 主機。 \n請注意:如果沒有 .onion 地址,你將無法連接到伺服器。 聊天個人檔案 透過群組連結 @@ -638,16 +610,10 @@ 儲存群組檔案 語音訊息於這個聊天室是禁用的。 允許你的聯絡人可以完全刪除訊息。 - 沒有用戶識別符。 - 未來的訊息平台 - 去中心化的 - 你決定誰可以連接。 - 重新定義隱私 建立你的個人檔案 這是如何運作 它如何影響電池 私下連接 - 任何人都可以託管伺服器。 忽略 語音通話來電 貼上你收到的連結 @@ -670,7 +636,6 @@ 幫助 SimpleX Chat 聊天 開發者工具 - SOCKS 代理伺服器 重新啟動應用程式以匯入對話資料庫。 刪除所有檔案 啟用自動銷毀訊息? @@ -781,7 +746,6 @@ 聯絡人允許 %ds 私人通知 - GitHub內查看更多。]]> 視訊通話來電 掛斷 點對點 @@ -847,7 +811,6 @@ 正在修改聯絡地址為 %s … 受加密的資料庫密碼會再次更新和儲存於金鑰庫。 當發生: \n1. 訊息將在傳送至客戶端後兩天或在伺服器內三十天時過時。 \n2. 訊息解密失敗,因為你或你的聯絡人用了舊的資料庫備份 \n3. 連接被破壞。 - 只有客戶端裝置儲存個人檔案、聯絡人、群組,和訊息。 請放置你的密碼於安全的地方,如果你遺失了密碼,將不可能修改你的密碼。 停止聊天室以匯出對話,匯入或刪除對話資料庫。當聊天室停止後你將不能接收或傳送訊息。 你正在使用匿名聊天模式進入此群組 - 為了避免分享你的真實個人檔案,邀請聯絡人是不允許的。 @@ -860,8 +823,6 @@ 透過聯絡人的邀請連結連接 透過一次性連結連接 傳輸隔離 - 更新傳輸隔離模式? - 為了保護你的隱私,SimpleX 對你的每個聯絡人使用不同的 ID。 當應用程式是運行中 透過設定啟用於上鎖畫面顯示來電通知。 這操作不能還原 - 你目前的個人檔案,聯絡人,訊息和檔案將不可逆地遺失。 @@ -876,13 +837,11 @@ 你已拒絕加入群組 連接中(宣布階段) 已選擇%d 個聯絡人 - 你的 ICE 伺服器 WebRTC ICE 伺服器 更新 添加更多身份選項 聯絡人頭像 個人檔案頭像占位符 - 不受垃圾和騷擾訊息影響 %1$s 希望透過以下方式聯絡你 開啟視訊 翻轉相機 @@ -914,7 +873,6 @@ 更新群組檔案 你修改了 %s 的身份為 %s 連接中(介紹階段) - 使用聊天 透過轉送 關閉視訊 你修改了自己的身份為 %s @@ -1011,10 +969,6 @@ 資料庫 IDs 和傳輸隔離選項。 降級和開啟對話 個人檔案密碼 - 儲存 XFTP 伺服器時出錯 - 請確保 XFTP 伺服器連結格式正確,隔行顯示且不重複。 - 加載 SMP 伺服器時出錯 - 加載 XFTP 伺服器時出錯 建立檔案 伺服器需要認證後才能上載,檢查密碼 對比檔案 @@ -1079,11 +1033,8 @@ 修改鎖定模式 未修改密碼! 語言/視訊通話 - " -\n在 v5.1中可用" 允許你的聯絡人與你進行通話。 上載檔案 - XFTP 伺服器 系統認證 你未能通過認證;請再試一次。 你可以透過設定啟用 SimpleX 鎖定。 @@ -1092,7 +1043,6 @@ \n當一些錯誤出現或你的連結被破壞時會發生。 %1$d 條訊息解密失敗。 使用SOCKS 代理伺服器 - 你的 XFTP 伺服器 %1$d 條訊息已跳過。 影片和檔案和最大上限為1gb 影片 @@ -1110,7 +1060,6 @@ 停止分享聯絡地址? 自動接受 繼續 - 不用建立聯絡地址 輸入歡迎訊息… (可選擇的) 你好!  \n透過 SimpleX Chat 來和我連接吧:%s @@ -1134,7 +1083,6 @@ 外加的輔助 地址 背景 - 建立一個聯絡地址讓其他用戶與你連接。 自定義主題 黑暗主題 設定聯絡地址時出錯 @@ -1156,12 +1104,9 @@ 已刪除所有的應用程式數據。 設定密碼 你可以與聯絡人分享此地址,讓他們使用 %s 進行連接。 - 你可以在稍後建立它 為了連接,你的聯絡人可以掃描二維碼或使用此應用程式的連結。 如果你不能面對面接觸此聯絡人,可於視訊通話中出示你的二維碼,或者分享連結。 當你在稍後刪除你的聯絡地址時,你並不會遺失你的聯絡人。 - 你可以將你的地址作為連接或二維碼以分享 - 任何人都可以連接到你。 - 當有人向你發出連接請求,你可以接受或拒絕請求。 你的聯絡人會保持連接。 你的所有聯絡人會保持連接。更新了的個人檔案將傳送給你的聯絡人。 新增地址至你的個人檔案,以便你的聯絡人可以與其他人分享。更新了的個人檔案將傳送給你的聯絡人。 @@ -1328,13 +1273,11 @@ 應用程式將為新的本機檔案(影片除外)加密。 檢查你的網路連接並重試 所有個人檔案 - 已設定的 SMP 伺服器 聊天主題 通話 允許降級 始終使用私密路由。 以導出聊天資料庫 - 已設定的 XFTP 伺服器 色彩模式 已儲存的聯絡人 模糊媒體 @@ -1475,7 +1418,6 @@ 輸入密碼短語 傳送的訊息 PC版處理中 - 隱身模式連接 已刪除對話! 目標伺服器錯誤:%1$s 聊天載入中… @@ -1723,7 +1665,6 @@ 禁止傳送檔案和媒體。 打開群組 只有群組所有者才能啟用檔案和媒體。 - 貼上你收到的連結以與你的聯絡人聯絡… 先前連接的伺服器 其他 無 @@ -1741,13 +1682,10 @@ 正在準備下載 只刪除對話 貼上連結 - 其他 SMP 伺服器 - 其他 XFTP 伺服器 請讓你的聯絡人啟用通話。 畫中畫通話 或安全分享此文件連結 無資訊,試試重新加載 - 打開伺服器設定 私密路由出錯 尚無直接連接,訊息由管理員轉發。 什麼也沒選中 @@ -2377,7 +2315,6 @@ 連結將會變短,且群組檔案會透過此連結分享。 分享舊地址 分享舊連結 - 你可以透過設定,讓你的 SimpleX 聯絡人看見它。 簡介: 簡介太長 要儲存加入審批設定嗎? @@ -2403,7 +2340,6 @@ 人類最古老的自由——不被監視地與另一個人交談——建立在不會背叛它的基礎設施之上。 因為我們摧毀了識別你身份的能力,讓你的自主權永遠不會被奪走。 在你的網路中自由交流。 - 端對端加密傳送,直接訊息具備後量子安全性。]]> 營運商承諾:\n- 保持獨立\n- 盡量減少中繼資料使用\n- 執行已驗證的開源程式碼 你承諾:\n- 只在公開群組中發佈合法內容\n- 尊重其他使用者——不發送垃圾訊息 私隱政策與使用條件。 diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index b7807cb4c0..b8975d80f8 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -24,11 +24,11 @@ android.nonTransitiveRClass=true kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.jvm.target=11 -android.version_name=7.1-beta.3 -android.version_code=378 +android.version_name=7.1-beta.4 +android.version_code=379 -desktop.version_name=7.1-beta.3 -desktop.version_code=161 +desktop.version_name=7.1-beta.4 +desktop.version_code=162 kotlin.version=2.1.20 gradle.plugin.version=8.7.0 diff --git a/apps/multiplatform/product/concepts.md b/apps/multiplatform/product/concepts.md index 5d707cf832..d29ac5c2e5 100644 --- a/apps/multiplatform/product/concepts.md +++ b/apps/multiplatform/product/concepts.md @@ -19,7 +19,7 @@ This document provides a structured mapping between product-level concepts, thei | # | Concept | Product Docs | Spec Docs | Source Files (Kotlin) | Source Files (Haskell) | |---|---------|-------------|-----------|----------------------|----------------------| -| PC1 | Chat List | [README.md](README.md) (Navigation Map) | [spec/client/chat-list.md](../spec/client/chat-list.md) | `common/.../views/chatlist/ChatListView.kt`, `ChatListNavLinkView.kt`, `ChatPreviewView.kt` | `Controller.hs` (`APIGetChats`) | +| PC1 | Chat List | [README.md](README.md) (Navigation Map) | [spec/client/chat-list.md](../spec/client/chat-list.md) | `common/.../views/chatlist/ChatListView.kt`, `ChatListNavLinkView.kt`, `ChatPreviewView.kt`, `GetStakeBanner.kt` | `Controller.hs` (`APIGetChats`) | | PC2 | Direct Chat | [README.md](README.md) (Messaging) | [spec/client/chat-view.md](../spec/client/chat-view.md) | `common/.../views/chat/ChatView.kt`, `ChatInfoView.kt` | `Types.hs` (`Contact`), `Messages.hs` | | PC3 | Group Chat | [README.md](README.md) (Groups) | [spec/client/chat-view.md](../spec/client/chat-view.md) | `common/.../views/chat/ChatView.kt`, `group/GroupChatInfoView.kt` | `Types.hs` (`GroupInfo`, `GroupMember`) | | PC4 | Message Composition | [README.md](README.md) (Messaging) | [spec/client/compose.md](../spec/client/compose.md) | `common/.../views/chat/ComposeView.kt`, `SendMsgView.kt`, `ComposeVoiceView.kt`, `ComposeImageView.kt`, `ComposeFileView.kt` | `Controller.hs` (`APISendMessages`) | diff --git a/apps/multiplatform/product/views/chat-list.md b/apps/multiplatform/product/views/chat-list.md index daa7907c5d..a09994d1e3 100644 --- a/apps/multiplatform/product/views/chat-list.md +++ b/apps/multiplatform/product/views/chat-list.md @@ -115,6 +115,7 @@ Each chat type provides specific dropdown menu items: | One-hand UI card (`ToggleChatListCard`) | `oneHandUICardShown == false` | Dismissible card introducing bottom toolbar mode with toggle switch | | Address creation card (`AddressCreationCard`) | `addressCreationCardShown == false` | Prompts user to create a SimpleX address; tappable card opens `UserAddressLearnMore` | | FAB (new chat button) | Standard mode, search empty, chat running | `FloatingActionButton` at bottom-right, pencil icon, opens `NewChatSheet` | +| Crowdfunding banner (`GetStakeBanner`) | `crowdfundingAvailable()` and not dismissed | Gradient card above the chats, and below the onboarding cards when there are no conversations; opens `GetStakeView`. The dismiss X appears once the banner has been tapped and there is at least one chat; dismissing hides it until hints are reset | ### Empty States @@ -134,3 +135,4 @@ Each chat type provides specific dropdown menu items: | `ChatPreviewView.kt` | `views/chatlist/ChatPreviewView.kt` | | `UserPicker.kt` | `views/chatlist/UserPicker.kt` | | `TagListView.kt` | `views/chatlist/TagListView.kt` | +| `GetStakeBanner.kt` | `views/chatlist/GetStakeBanner.kt` | diff --git a/apps/multiplatform/spec/README.md b/apps/multiplatform/spec/README.md index c5d9a3b4f7..d6a7ec20af 100644 --- a/apps/multiplatform/spec/README.md +++ b/apps/multiplatform/spec/README.md @@ -124,9 +124,9 @@ Common Module (commonMain) | Desktop Init | [`AppCommon.desktop.kt`](../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt#L21) | `fun initApp()` | 21 | | Common App Screen | [`App.kt`](../common/src/commonMain/kotlin/chat/simplex/common/App.kt#L47) | `fun AppScreen()` | 47 | | JNI Bridge | [`Core.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L18) | `external fun initHS()` | 18 | -| Chat Controller | [`SimpleXAPI.kt`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L493) | `object ChatController` | 493 | +| Chat Controller | [`SimpleXAPI.kt`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L525) | `object ChatController` | 525 | | Chat Model | [`ChatModel.kt`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L86) | `object ChatModel` | 86 | -| App Preferences | [`SimpleXAPI.kt`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L94) | `class AppPreferences` | 94 | +| App Preferences | [`SimpleXAPI.kt`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L102) | `class AppPreferences` | 102 | | Platform Interface | [`Platform.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt#L15) | `interface PlatformInterface` | 15 | | Notification Manager | [`NtfManager.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L19) | `abstract class NtfManager` | 19 | | Theme Manager | [`ThemeManager.kt`](../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt#L18) | `object ThemeManager` | 18 | diff --git a/apps/multiplatform/spec/client/chat-list.md b/apps/multiplatform/spec/client/chat-list.md index b0f3750659..e34da9613d 100644 --- a/apps/multiplatform/spec/client/chat-list.md +++ b/apps/multiplatform/spec/client/chat-list.md @@ -15,12 +15,13 @@ Source: `common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatLis 7. [Tag System](#7-tag-system) 8. [UserPicker](#8-userpicker) 9. [Source Files](#9-source-files) +10. [Crowdfunding Banner](#10-crowdfunding-banner) --- ## Executive Summary -The Chat List is the landing screen of SimpleX Chat, rendering all conversations for the active user. Built around `ChatListView` (line 126 in `ChatListView.kt`), it provides a searchable, filterable `LazyColumn` of chat previews with a toolbar, tag-based filtering, and a user-switching side panel. The view adapts between one-hand UI mode (toolbar at bottom, reversed list) and standard mode (toolbar at top). Search also accepts SimpleX links for direct connection. +The Chat List is the landing screen of SimpleX Chat, rendering all conversations for the active user. Built around `ChatListView` (line 179 in `ChatListView.kt`), it provides a searchable, filterable `LazyColumn` of chat previews with a toolbar, tag-based filtering, and a user-switching side panel. The view adapts between one-hand UI mode (toolbar at bottom, reversed list) and standard mode (toolbar at top). Search also accepts SimpleX links for direct connection. --- @@ -53,7 +54,7 @@ ChatListView ## 2. ChatListView Composable -**Location:** [`ChatListView.kt#L127`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L127) +**Location:** [`ChatListView.kt#L179`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L179) ```kotlin fun ChatListView( @@ -66,8 +67,8 @@ fun ChatListView( ### Initialization -- Shows "What's New" modal on first launch after update (line ~130), with a 1-second delay. -- On desktop, closing a chat resets audio/video players (line ~138). +- Shows "What's New" modal on first launch after update (line ~185), with a 1-second delay. +- On desktop, closing a chat resets audio/video players (line ~193). ### Layout Modes @@ -88,8 +89,8 @@ The `oneHandUI` preference (`appPrefs.oneHandUI.state`) controls the layout: ### Android-specific -- `SetNotificationsModeAdditions`: Notification permission setup (line ~184). -- `UserPicker`: Overlay side panel for user switching (line ~192). +- `SetNotificationsModeAdditions`: Notification permission setup (line ~243). +- `UserPicker`: Overlay side panel for user switching (line ~247). --- @@ -113,7 +114,7 @@ The `oneHandUI` preference (`appPrefs.oneHandUI.state`) controls the layout: ### Active Filter Types -Defined as sealed class `ActiveFilter` (line ~51): +Defined as sealed class `ActiveFilter` (line ~59): ```kotlin sealed class ActiveFilter { @@ -136,7 +137,7 @@ sealed class ActiveFilter { ### Search Filtering -The `filteredChats` function (line ~1188) applies filters in this order: +The `filteredChats` function (line ~1474) applies filters in this order: 1. **SimpleX link match:** If a pasted link resolved to a known contact/group, show only that chat. 2. **Text search:** Case-insensitive match against `chat.chatInfo.chatViewName`, `chat.chatInfo.fullName`, and `chat.chatInfo.localAlias`. @@ -147,7 +148,7 @@ The `filteredChats` function (line ~1188) applies filters in this order: ### Search Bar -`ChatListSearchBar` (line ~611) provides: +`ChatListSearchBar` (line ~765) provides: - Text input with search icon. - SimpleX link detection: When a pasted string contains a single SimpleX link, it triggers `planAndConnect` for connection, suppressing normal search. - Unread filter toggle button (right side, when search is empty). @@ -158,7 +159,7 @@ The `filteredChats` function (line ~1188) applies filters in this order: ## 5. Chat Preview -**Location:** [`ChatPreviewView.kt#L40`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt#L40) +**Location:** [`ChatPreviewView.kt#L41`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt#L41) ```kotlin fun ChatPreviewView( @@ -224,7 +225,7 @@ On desktop, the currently selected chat (`chatModel.chatId.value == chat.id`) re ### TagsView -**Location:** [`ChatListView.kt#L929`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L929) +**Location:** [`ChatListView.kt#L1214`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L1214) Renders a horizontally scrollable row of tag chips (via `TagsRow`, which is a platform-specific `expect` composable). @@ -244,7 +245,7 @@ Layout logic: ### TagListView -**Location:** [`TagListView.kt#L48`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt#L48) +**Location:** [`TagListView.kt#L47`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt#L47) Full-screen tag management view opened from the "+" button or long-press menu. @@ -312,3 +313,48 @@ Uses `AnimatedViewState` (`GONE`, `VISIBLE`, `HIDING`) with a `MutableStateFlow` | `ShareListView.kt` | Share target list (forwarding flow) | | `TagListView.kt` | Tag management and assignment view | | `UserPicker.kt` | User switching side panel | +| `GetStakeBanner.kt` | Crowdfunding banner and the shared banner card chrome | + +--- + + + +## 10. Crowdfunding Banner + +**Location:** [`GetStakeBanner.kt#L31`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/GetStakeBanner.kt#L31) + +```kotlin +fun GetStakeBanner(showDismiss: Boolean, onTap: () -> Unit, onDismiss: () -> Unit) +``` + +Gradient card inviting the user to invest on Wefunder. Shown only when `crowdfundingAvailable()` — always outside Play Store builds, and in Play builds only while the store country is the US or not yet known — and only while `getStakeBannerDismissed` is false. + +### Placement + +| Where | Condition | Layout | +|-------|-----------|--------| +| Chat list | in `ChatList`'s `LazyColumn`, after `ToggleChatListCard` and before the chats | `Box(Modifier.zIndex(1f).padding(16.dp))` | +| Onboarding | inside `ConnectOnboardingView` (`views/newchat/OnboardingCards.kt`), below the pager, so it shares the pages' width limit on desktop and their dimming while a start modal is open; opens the page in `ModalManager.center` on desktop, `ModalManager.start` on Android | `padding(start/end = DEFAULT_PADDING, bottom = 8.dp)`, in a `Column` where the pager takes `weight(1f)` | + +The list has a single banner slot, filled by an `if`/`else if` chain in priority order: the support-ended alert (`supportEnded()`), the renewal-failure alert (`badgeIssueFailed()`), the pitch, then the Wefunder banner. Each banner's `item` records itself in `ChatModel.chatListBanner` (`BadgeExpired`, `BadgeIssueFailed`, `BadgePitch`, `GetStake`) in a `SideEffect`, and the pitch and Wefunder conditions start with `chatModel.bannerSlotFree(banner)` — true only while nothing else was shown this app session — so dismissing a banner never puts another in its place until restart. The alerts have no such check: an alert takes the slot whenever present, and once shown it holds it. The pitch also requires `noShownBadge()` (`views/newchat/OnboardingCards.kt`), false until `BadgeModel.isCurrent` for the current user, so it cannot take the slot from a supporter whose badge loads a moment later. `ConnectOnboardingView` applies the same `bannerSlotFree` check and records `GetStake`. + +`crowdfundingAvailable()` launches an effect to load the store country, so both call sites read it in the composable body rather than inside the `LazyColumn` builder. + +### Dismissal + +| Preference | Set by | Effect | +|------------|--------|--------| +| `getStakeBannerTapped` | `openGetStake()` | the dismiss X appears from then on, while there are chats | +| `getStakeBannerDismissed` | the dismiss X | hides the banner in both placements | +| `supporterBannerTapped` | tapping the supporter pitch | the pitch's dismiss X appears from then on | +| `supporterBannerShown` | the pitch's dismiss X, through its "You can support SimpleX later in Settings." alert, and a successful code redemption | hides the pitch | + +The two badge alert banners always offer the X; only the pitch waits to be tapped once, so a user who has not looked at it cannot dismiss it unseen. + +Both are in `AppPreferences.hintPreferences`, so "Reset all hints" restores the banner. The X is never offered below the onboarding cards, so the banner cannot be dismissed before the user has a chat. + +Tapping the card runs `openGetStake()`, which opens `GetStakeView(showFirstImage = true)` as a card modal — `showFirstImage` decides whether the page repeats the first slide's image, which only What's New shows above its own link. + +### Shared card chrome + +`Modifier.bannerCard(onTap)` (size state, gradient brush, `heightIn`, `clip`, `background`, `clickable`) and `BannerDismissButton` are declared separately from `GetStakeBanner` so other banners can adopt the same chrome; the paddings stay with the caller. The gradient reuses `gradientPoints`, `lightStops` and `darkStops` from `views/newchat/OnboardingCards.kt`. diff --git a/apps/multiplatform/spec/impact.md b/apps/multiplatform/spec/impact.md index 3a96638310..ec302e30b5 100644 --- a/apps/multiplatform/spec/impact.md +++ b/apps/multiplatform/spec/impact.md @@ -92,6 +92,7 @@ Path prefix: `common/src/commonMain/kotlin/chat/simplex/common/` | Source File | Product Concepts Affected | Risk Level | Notes | |-------------|--------------------------|------------|-------| | `views/chatlist/ChatListView.kt` | PC1, PC28 | High | Main screen — chat list rendering and search | +| `views/chatlist/GetStakeBanner.kt` | PC1 | Low | Crowdfunding banner and shared banner card chrome | | `views/chatlist/ChatListNavLinkView.kt` | PC1, PC2, PC3 | Medium | Navigation from chat list item to chat | | `views/chatlist/ChatPreviewView.kt` | PC1, PC2, PC3, PC11 | Medium | Chat row preview rendering | | `views/chatlist/TagListView.kt` | PC28 | Medium | Chat tag filter UI | diff --git a/apps/multiplatform/spec/state.md b/apps/multiplatform/spec/state.md index 229c30d18e..5003bb60e5 100644 --- a/apps/multiplatform/spec/state.md +++ b/apps/multiplatform/spec/state.md @@ -152,6 +152,7 @@ Defined at [`ChatModel.kt line 86`](../common/src/commonMain/kotlin/chat/simplex | [`appOpenUrlConnecting`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L138) | `MutableState` | 138 | Whether a deep link connection is in progress | | [`newChatSheetVisible`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L141) | `MutableState` | 141 | Whether new chat bottom sheet is visible | | [`fullscreenGalleryVisible`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L144) | `MutableState` | 144 | Fullscreen gallery mode | +| [`chatListBanner`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L206) | `ChatListBanner?` (plain var) | 206 | The banner kind the chat list showed this app session; `bannerSlotFree(banner)` tells whether a kind may take the slot (see [chat-list.md](client/chat-list.md)) | | [`notificationPreviewMode`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L147) | `MutableState` | 147 | Notification content preview level | | [`showAuthScreen`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L156) | `MutableState` | 156 | Whether to show authentication screen | | [`showChatPreviews`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L158) | `MutableState` | 158 | Whether to show chat preview text in list | diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index 9847718e50..8eec717e30 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -614,14 +614,12 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o notifyAdminUsers $ "The group " <> groupReference toGroup <> " is updated" <> byMember <> "." checkRolesSendToApprove gr' n' processProfileChange gr byMember n' = - withDB' "getGroupLink" cc (\db -> runExceptT $ getGroupLink db user toGroup) >>= \case + getGroupAndRegLink cc user groupId >>= \case Left e -> linkReadError $ T.pack e - Right (Left SEGroupLinkNotFound {}) -> profileChange Nothing - Right (Left e) -> linkReadError $ tshow e - Right (Right gLink) -> profileChange $ Just gLink + Right (g, _, gLink_) -> profileChange g gLink_ where linkReadError e = logError $ "Error reading group link for " <> groupReference toGroup <> ": " <> e - profileChange gLink_ + profileChange g gLink_ | not (linkOnlyChange gLink_) = sendForApproval byMember n' | groupRegStatus gr == GRSActive = do notifyOwner gr $ @@ -629,7 +627,7 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o <> "!\nThe group is listed in directory." notifyAdminUsers $ "The group " <> groupReference toGroup <> " is updated" <> byMember <> " - only link or whitespace changes.\nThe group remained listed in directory." forM_ gLink_ $ \gLink -> - updateGroupLinkData cc user toGroup gLink >>= \case + updateGroupLinkData cc user g gLink >>= \case Right _ -> pure () Left e -> logError $ "Error updating group link data for " <> groupReference toGroup <> ": " <> tshow e | otherwise = pure () @@ -1332,7 +1330,7 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o deAdminCommand ct ciId cmd | knownCt `elem` adminUsers || knownCt `elem` superUsers = case cmd of DCApproveGroup {groupId, displayName = n, groupApprovalId, promote} -> - withGroupRegLink sendReply groupId n $ \g gr@GroupReg {userGroupRegId = ugrId, promoted} curLink_ -> + withGroupRegLink sendReply groupId n $ \gik@(GIK g _) gr@GroupReg {userGroupRegId = ugrId, promoted} curLink_ -> case groupRegStatus gr of GRSPendingApproval gaId | gaId == groupApprovalId -> do @@ -1349,7 +1347,7 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o let grPromoted' | promoted || knownCt `elem` superUsers = fromMaybe promoted promote | otherwise = False - gLink_ <- if isPublicGroup_ then pure (Right Nothing) else approvedGroupLink g curLink_ + gLink_ <- if isPublicGroup_ then pure (Right Nothing) else approvedGroupLink gik curLink_ case gLink_ of Left e -> sendReply e Right gLink' -> @@ -1513,14 +1511,14 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o mkSendReply :: Contact -> ChatItemId -> Text -> IO () mkSendReply ct ciId = sendComposedMessage cc ct (Just ciId) . MCText - withGroupRegLink :: (Text -> IO ()) -> GroupId -> GroupName -> (GroupInfo -> GroupReg -> Maybe GroupLink -> IO ()) -> IO () + withGroupRegLink :: (Text -> IO ()) -> GroupId -> GroupName -> (GroupInfoKeys -> GroupReg -> Maybe GroupLink -> IO ()) -> IO () withGroupRegLink sendReply gId = withGroupRegLink_ sendReply gId . Just - withGroupRegLink_ :: (Text -> IO ()) -> GroupId -> Maybe GroupName -> (GroupInfo -> GroupReg -> Maybe GroupLink -> IO ()) -> IO () + withGroupRegLink_ :: (Text -> IO ()) -> GroupId -> Maybe GroupName -> (GroupInfoKeys -> GroupReg -> Maybe GroupLink -> IO ()) -> IO () withGroupRegLink_ sendReply gId gName_ action = getGroupAndRegLink cc user gId >>= \case Left e -> sendReply $ "Group " <> tshow gId <> " error (getGroup): " <> T.pack e - Right (g@GroupInfo {groupProfile = GroupProfile {displayName}}, gr, gLink_) + Right (g@(GIK GroupInfo {groupProfile = GroupProfile {displayName}} _), gr, gLink_) | maybe False (displayName ==) gName_ -> action g gr gLink_ | otherwise -> @@ -1531,7 +1529,7 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o withGroupAndReg_ :: (Text -> IO ()) -> GroupId -> Maybe GroupName -> (GroupInfo -> GroupReg -> IO ()) -> IO () withGroupAndReg_ sendReply gId gName_ action = - withGroupRegLink_ sendReply gId gName_ $ \g gr _ -> action g gr + withGroupRegLink_ sendReply gId gName_ $ \(GIK g _) gr _ -> action g gr getOwnersInfo :: [(GroupInfo, GroupReg)] -> IO [((GroupInfo, GroupReg), Maybe (Either String Contact))] getOwnersInfo gs = @@ -1609,7 +1607,7 @@ getGroupLink' :: ChatController -> User -> GroupInfo -> IO (Either String GroupL getGroupLink' cc user gInfo = withDB "getGroupLink" cc $ \db -> withExceptT groupDBError $ getGroupLink db user gInfo -updateGroupLinkData :: ChatController -> User -> GroupInfo -> GroupLink -> IO (Either ChatError GroupLink) +updateGroupLinkData :: ChatController -> User -> GroupInfoKeys -> GroupLink -> IO (Either ChatError GroupLink) updateGroupLinkData cc user gInfo gLink = runReaderT (runExceptT $ setGroupLinkData NRMBackground user gInfo gLink) cc setGroupLinkRole :: ChatController -> GroupInfo -> GroupMemberRole -> IO (Maybe CreatedLinkContact) diff --git a/apps/simplex-directory-service/src/Directory/Store.hs b/apps/simplex-directory-service/src/Directory/Store.hs index 08e0cba7e1..db069e0f67 100644 --- a/apps/simplex-directory-service/src/Directory/Store.hs +++ b/apps/simplex-directory-service/src/Directory/Store.hs @@ -74,7 +74,7 @@ import Simplex.Chat.Names (claimDomain) import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Chat.Store import Simplex.Chat.Store.Groups -import Simplex.Chat.Store.Shared (groupInfoQueryFields, groupInfoQueryFrom) +import Simplex.Chat.Store.Shared (GroupKeysRow, groupInfoQueryFields, groupInfoQueryFrom, mkGroupKeys, toGroupInfo_) import Simplex.Chat.Types import Simplex.Chat.Types.Shared (GroupMemberRole (..)) import Simplex.Messaging.Agent.Protocol (CreatedConnLink (..), SimplexDomain) @@ -309,12 +309,17 @@ getGroupReg_ db gId = |] (Only gId) -getGroupAndRegLink :: ChatController -> User -> GroupId -> IO (Either String (GroupInfo, GroupReg, Maybe GroupLink)) +getGroupAndRegLink :: ChatController -> User -> GroupId -> IO (Either String (GroupInfoKeys, GroupReg, Maybe GroupLink)) getGroupAndRegLink cc user@User {userId, userContactId} gId = withDB "getGroupAndRegLink" cc $ \db -> do currentTs <- liftIO getCurrentTime - ExceptT $ firstRow (toGroupInfoRegLink currentTs (storeCxt cc) user) ("group " ++ show gId ++ " not found") $ - DB.query db (groupReqQuery <> " AND g.group_id = ?") (userId, userContactId, gId) + (g, gksData, gr, gLink_) <- + ExceptT $ firstRow (toGroupInfoKeysRegLink currentTs cxt user) ("group " ++ show gId ++ " not found") $ + DB.query db (groupReqQuery <> " AND g.group_id = ?") (userId, userContactId, gId) + gks <- withExceptT groupDBError $ mkGroupKeys db cxt g gksData + pure (GIK g gks, gr, gLink_) + where + cxt = storeCxt cc getUserGroupReg :: ChatController -> User -> ContactId -> UserGroupRegId -> IO (Either String (GroupInfo, GroupReg)) getUserGroupReg cc user@User {userId, userContactId} ctId ugrId = @@ -445,7 +450,12 @@ toGroupInfoReg currentTs cxt user row = let (g, gr, _) = toGroupInfoRegLink curr toGroupInfoRegLink :: UTCTime -> StoreCxt -> User -> (GroupInfoRow :. GroupRegRow :. GroupLinkRow) -> (GroupInfo, GroupReg, Maybe GroupLink) toGroupInfoRegLink currentTs cxt User {userContactId} (groupRow :. grRow :. linkRow) = - (toGroupInfo currentTs cxt userContactId [] groupRow, rowToGroupReg grRow, toMaybeGroupLink linkRow) + (toGroupInfo_ currentTs cxt userContactId [] groupRow, rowToGroupReg grRow, toMaybeGroupLink linkRow) + +toGroupInfoKeysRegLink :: UTCTime -> StoreCxt -> User -> (GroupInfoRow :. GroupRegRow :. GroupLinkRow) -> (GroupInfo, GroupKeysRow, GroupReg, Maybe GroupLink) +toGroupInfoKeysRegLink currentTs cxt User {userContactId} (groupRow :. grRow :. linkRow) = + let (g, gksData) = toGroupInfo currentTs cxt userContactId [] groupRow + in (g, gksData, rowToGroupReg grRow, toMaybeGroupLink linkRow) type GroupRegRow = (GroupId, UserGroupRegId, ContactId, Maybe GroupMemberId, GroupRegStatus, BoolInt, UTCTime) diff --git a/blog/20260919-simplex-supporter-badges.md b/blog/20260919-simplex-supporter-badges.md new file mode 100644 index 0000000000..ebe19ef431 --- /dev/null +++ b/blog/20260919-simplex-supporter-badges.md @@ -0,0 +1,64 @@ +--- +layout: layouts/article.html +title: "SimpleX Supporter Badges — Send Larger Files That Stay Available Longer, Without Being Identified" +date: 2026-09-19 +previewBody: blog_previews/20260919.html +image: images/20260919-badge-screen.png +permalink: "/blog/20260919-simplex-supporter-badges.html" +--- + +# SimpleX Supporter Badges — Send Larger Files That Stay Available Longer, Without Being Identified + +**Published:** Sep 19, 2026 + +You can now support SimpleX Chat and get a supporter badge, larger files and longer file storage — from v7.1 beta[^beta]. Watch how to buy a badge. + +## A paid feature that cannot identify you + + + + +A supporter badge is shown on your profile to your contacts, group members and channel subscribers. With a badge you can send files up to 2GB, or 5GB with a legend badge, instead of 1GB, and servers keep your files for longer — 7 days with a supporter badge and 21 days with a legend badge. + +A badge is an anonymous credential stored in your profile on your device. The credential itself is not sent to anyone: to show the badge to a contact or to present it to a server, the app generates a new zero-knowledge proof that reveals only the badge type and the expiry date, and no two proofs can be linked to each other or to the purchase, by the badge service, your contacts or servers. Credentials are issued for one month at a time, and all badges expire on the same day of the week, so a badge places you among all supporters of that week and nothing more. + +This is only possible because the network has no user identifiers — in other messengers a paid feature is attached to the account, so the operator knows who paid and what they do with the feature. + +Read more about badges in the [whitepaper](https://github.com/simplex-chat/simplex-chat/blob/master/docs/protocol/badges-overview.md): what they grant, how they are issued and presented, and their privacy and security model. + +## How to get a badge + + + +Buy a code on [simplex.chat/badges](https://simplex.chat/badges/), paying by card, Bitcoin or Monero, and redeem it in the app: open Settings, tap **Supporter perks**, and enter the code. The badge appears on your profile. The badge does not renew by itself, and no account is created. + +The v7.1 release will add purchases in the app: via the app store, or by card or cryptocurrency if you downloaded the app from GitHub or F-Droid. + +## What badges will do next + +The same mechanism will be used for other resources that cost the network more than the default. Two uses are coming: + +- **Backups.** A backup will be a link whose content the app updates in place, so the same link restores the latest state. A badge will extend how long the backup is kept on the servers — that is, how long the app can stay offline before the backup is lost. +- **Better limits on servers.** Servers that verify the badge will apply higher rate limits for creating messaging queues, uploading file chunks, and registering notification tokens. + +## Community Crowdfunding + + + +Investors in our [equity crowdfunding on Wefunder](https://wefunder.com/simplex.chat?utm_source=blog) receive badges as perks: + +| Investment | Badge | +|---|---| +| $100 | supporter, 1 month | +| $250 | supporter, 3 months | +| $1,000 | supporter, 12 months, or legend, 1 month | +| $2,500 | supporter, 12 months, or legend, 3 months | +| $10,000 | legend, 12 months | + +If you invest $500 or more by September 22, you will also receive [a public SimpleX name](https://simplex.domains?utm_source=blog) for 7 years[^name]. + +Learn more and invest on Wefunder: [https://wefunder.com/simplex.chat](https://wefunder.com/simplex.chat?utm_source=blog) + +[^name]: After September 22, investors of $500 or more receive a name for 5 years as early bird investors, and for 3 years after that — ahead of the public launch of names on December 12. + +[^beta]: v7.1 beta is available via [Play Store](https://play.google.com/store/apps/details?id=chat.simplex.app) (Android beta), [TestFlight](https://testflight.apple.com/join/DWuT2LQu) (iOS), our [F-Droid repo](https://simplex.chat/fdroid/) and [GitHub](https://github.com/simplex-chat/simplex-chat/releases) (Android and desktop). diff --git a/blog/README.md b/blog/README.md index 3a1c545b4f..b7940a370d 100644 --- a/blog/README.md +++ b/blog/README.md @@ -1,5 +1,14 @@ # Blog +Sep 19, 2026 [SimpleX Supporter Badges - Send Larger Files That Stay Available Longer, Without Being Identified](./20260919-simplex-supporter-badges.md) + +Supporter badges are available in v7.1 beta: a badge on your profile, larger files and longer file storage - and the purchase cannot be linked to your profile. + +Investors in our equity crowdfunding receive badges as perks. If you invest $500 or more by September 22, you will also receive a public SimpleX name for 7 years. + + +--- + Aug 20, 2026 [Equity Crowdfunding Launched - You Can Get a Stake in SimpleX Chat](./20260819-simplex-chat-crowdfunding.md) SimpleX is the first and the only messaging network without user identifiers of any kind. Our equity crowdfunding round is now launched on Wefunder, so network users, and anybody else, can get a stake in SimpleX Chat - the company that builds it. diff --git a/blog/images/20260819-wefunder.jpg b/blog/images/20260819-wefunder.jpg index 6586d4dfe7..252c3c1e85 100644 Binary files a/blog/images/20260819-wefunder.jpg and b/blog/images/20260819-wefunder.jpg differ diff --git a/blog/images/20260919-badge-screen.png b/blog/images/20260919-badge-screen.png new file mode 100644 index 0000000000..beece83da6 Binary files /dev/null and b/blog/images/20260919-badge-screen.png differ diff --git a/blog/images/20260919-buy-badge.jpg b/blog/images/20260919-buy-badge.jpg new file mode 100644 index 0000000000..da7625aee9 Binary files /dev/null and b/blog/images/20260919-buy-badge.jpg differ diff --git a/blog/images/20260919-phone-supporter-light.png b/blog/images/20260919-phone-supporter-light.png new file mode 100644 index 0000000000..07362c870d Binary files /dev/null and b/blog/images/20260919-phone-supporter-light.png differ diff --git a/blog/images/20260919-phone-supporter.png b/blog/images/20260919-phone-supporter.png new file mode 100644 index 0000000000..2f36b060c7 Binary files /dev/null and b/blog/images/20260919-phone-supporter.png differ diff --git a/bots/api/COMMANDS.md b/bots/api/COMMANDS.md index 21f67fe4d2..4c2a0c8f68 100644 --- a/bots/api/COMMANDS.md +++ b/bots/api/COMMANDS.md @@ -1538,15 +1538,15 @@ Connect via prepared SimpleX link. The link can be 1-time invitation link, conta **Syntax**: ``` -/_connect [ ] +/_connect [ incognito=on][ ] ``` ```javascript -'/_connect ' + userId + (preparedLink_ ? ' ' + CreatedConnLink.cmdString(preparedLink_) : '') // JavaScript +'/_connect ' + userId + (incognito ? ' incognito=on' : '') + (preparedLink_ ? ' ' + CreatedConnLink.cmdString(preparedLink_) : '') // JavaScript ``` ```python -'/_connect ' + str(userId) + ((' ' + CreatedConnLink_cmd_string(preparedLink_)) if preparedLink_ is not None else '') # Python +'/_connect ' + str(userId) + (' incognito=on' if incognito else '') + ((' ' + CreatedConnLink_cmd_string(preparedLink_)) if preparedLink_ is not None else '') # Python ``` **Responses**: diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index e79f8638ad..f1122979a9 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -105,7 +105,6 @@ This file is generated automatically. - [GroupFeature](#groupfeature) - [GroupFeatureEnabled](#groupfeatureenabled) - [GroupInfo](#groupinfo) -- [GroupKeys](#groupkeys) - [GroupLink](#grouplink) - [GroupLinkOwner](#grouplinkowner) - [GroupLinkPlan](#grouplinkplan) @@ -120,7 +119,6 @@ This file is generated automatically. - [GroupPreferences](#grouppreferences) - [GroupProfile](#groupprofile) - [GroupRelay](#grouprelay) -- [GroupRootKey](#grouprootkey) - [GroupShortLinkData](#groupshortlinkdata) - [GroupShortLinkInfo](#groupshortlinkinfo) - [GroupSummary](#groupsummary) @@ -163,7 +161,6 @@ This file is generated automatically. - [ProxyError](#proxyerror) - [PublicGroupAccess](#publicgroupaccess) - [PublicGroupData](#publicgroupdata) -- [PublicGroupKeys](#publicgroupkeys) - [PublicGroupProfile](#publicgroupprofile) - [RCErrorType](#rcerrortype) - [RatchetSyncState](#ratchetsyncstate) @@ -469,62 +466,24 @@ 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 +**Enum type**: +- "bad_request" +- "unsupported_version" +- "unknown_purchase_key" +- "unknown_offer_id" +- "offer_disabled" +- "offer_mismatch" +- "product_unavailable" +- "payment_not_entitled" +- "payment_pending" +- "provider_unavailable" +- "rate_limited" +- "code_invalid" +- "code_used" +- "code_expired" +- "receipt_invalid" +- "receipt_used" +- "internal" --- @@ -2535,19 +2494,9 @@ MemberSupport: - rosterVersion: int64? - membersRequireAttention: int - viaGroupLinkUri: string? -- groupKeys: [GroupKeys](#groupkeys)? - groupDomainVerified: bool? ---- - -## GroupKeys - -**Record type**: -- publicGroupKeys: [PublicGroupKeys](#publicgroupkeys)? -- memberPrivKey: string - - --- ## GroupLink @@ -2769,21 +2718,6 @@ UpdateRequired: - relayCap: [RelayCapabilities](#relaycapabilities) ---- - -## GroupRootKey - -**Discriminated union type**: - -Private: -- type: "private" -- rootPrivKey: string - -Public: -- type: "public" -- rootPubKey: string - - --- ## GroupShortLinkData @@ -3413,15 +3347,6 @@ NO_SESSION: - publicMemberCount: int64 ---- - -## PublicGroupKeys - -**Record type**: -- publicGroupId: string -- groupRootKey: [GroupRootKey](#grouprootkey) - - --- ## PublicGroupProfile diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index 7efae4bffc..e39e6ebbd3 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -140,7 +140,7 @@ chatCommandsDocsData = [ ("APIAddContact", [], "Create 1-time invitation link.", ["CRInvitation", "CRChatCmdError"], [], Just UNInteractive, "/_connect " <> Param "userId" <> OnOffParam "incognito" "incognito" (Just False)), -- `Maybe` in `connectTarget :: Maybe ConnectTarget` is used to signal parse failure to the runtime (the handler returns CEInvalidConnReq on Nothing); it is NOT API-level optionality. The parameter is required from callers. ("APIConnectPlan", [], "Determine SimpleX link type and if the bot is already connected via this link or name.", ["CRConnectionPlan", "CRChatCmdError"], [], Just UNInteractive, "/_connect plan " <> Param "userId" <> " " <> Param "connectTarget"), - ("APIConnect", [], "Connect via prepared SimpleX link. The link can be 1-time invitation link, contact address or group link.", ["CRSentConfirmation", "CRContactAlreadyExists", "CRSentInvitation", "CRChatCmdError"], [], Just UNInteractive, "/_connect " <> Param "userId" <> Optional "" (" " <> Param "$0") "preparedLink_"), + ("APIConnect", [], "Connect via prepared SimpleX link. The link can be 1-time invitation link, contact address or group link.", ["CRSentConfirmation", "CRContactAlreadyExists", "CRSentInvitation", "CRChatCmdError"], [], Just UNInteractive, "/_connect " <> Param "userId" <> OnOffParam "incognito" "incognito" (Just False) <> Optional "" (" " <> Param "$0") "preparedLink_"), ("Connect", [], "Connect via SimpleX link or name as string in the active user profile.", ["CRSentConfirmation", "CRContactAlreadyExists", "CRSentInvitation", "CRConnectionPlan", "CRSentInvitationToContact", "CRStartedConnectionToContact", "CRStartedConnectionToGroup", "CRChatCmdError"], [], Just UNInteractive, "/connect" <> Optional "" (" " <> Param "$0") "connTarget_"), ("APIAcceptContact", ["incognito"], "Accept contact request.", ["CRAcceptingContactRequest", "CRChatCmdError"], [], Just UNInteractive, "/_accept " <> Param "contactReqId"), ("APIRejectContact", [], "Reject contact request. The user who sent the request is **not notified**.", ["CRContactRequestRejected", "CRChatCmdError"], [], Nothing, "/_reject " <> Param "contactReqId") @@ -379,6 +379,7 @@ undocumentedCommands = "APIExportArchive", "APIForwardChatItems", "APIGetAppSettings", + "APIGetBadgeLedger", "APIGetBadgeState", "APIGetCallInvitations", "APIGetChat", diff --git a/bots/src/API/Docs/Responses.hs b/bots/src/API/Docs/Responses.hs index 5d01c03a39..18392ae1b5 100644 --- a/bots/src/API/Docs/Responses.hs +++ b/bots/src/API/Docs/Responses.hs @@ -134,6 +134,7 @@ undocumentedResponses = "CRAppSettings", "CRArchiveExported", "CRArchiveImported", + "CRBadgeLedger", "CRBadgeRedeemed", "CRBadgeState", "CRBroadcastSent", diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index 70777afa21..792f3e6fd5 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -217,7 +217,7 @@ chatTypesDocsData = (sti @AutoAccept, STRecord, "", [], "", ""), (sti @BadgeProof, STRecord, "", [], "", ""), (sti @BadgeRedeemError, STUnion, "BRE", [], "", ""), - (sti @BadgeServiceErrorCode, STUnion, "BSE", [], "", ""), + (sti @BadgeServiceErrorCode, STEnum' (consSep "BSE" '_'), "", ["BSEUnknown"], "", ""), (sti @BlockingInfo, STRecord, "", [], "", ""), (sti @BlockingReason, STEnum, "BR", [], "", ""), (sti @BrokerErrorType, STUnion, "", [], "", ""), @@ -291,8 +291,6 @@ chatTypesDocsData = (sti @GroupFeature, STEnum, "GF", [], "", ""), (sti @GroupFeatureEnabled, STEnum, "FE", [], "", ""), (sti @GroupInfo, STRecord, "", [], "", ""), - (sti @GroupKeys, STRecord, "", [], "", ""), - (sti @GroupRootKey, STUnion, "GRK", [], "", ""), (sti @GroupLink, STRecord, "", [], "", ""), (sti @GroupLinkOwner, STRecord, "", [], "", ""), (sti @GroupLinkPlan, STUnion, "GLP", [], "", ""), @@ -349,7 +347,6 @@ chatTypesDocsData = (sti @ProxyError, STUnion, "", [], "", ""), (sti @PublicGroupAccess, STRecord, "", [], "", ""), (sti @PublicGroupData, STRecord, "", [], "", ""), - (sti @PublicGroupKeys, STRecord, "", [], "", ""), (sti @PublicGroupProfile, STRecord, "", [], "", ""), (sti @RatchetSyncState, STEnum, "RS", [], "", ""), (sti @RCErrorType, STUnion, "RCE", [], "", ""), @@ -527,8 +524,6 @@ deriving instance Generic GroupChatScopeInfo deriving instance Generic GroupFeature deriving instance Generic GroupFeatureEnabled deriving instance Generic GroupInfo -deriving instance Generic GroupKeys -deriving instance Generic GroupRootKey deriving instance Generic GroupLink deriving instance Generic GroupLinkOwner deriving instance Generic GroupLinkPlan @@ -592,7 +587,6 @@ 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 diff --git a/cabal.project b/cabal.project index 81a7dc7e6f..5e00d0764b 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: ea43df2349f6d3dedd60d5e4aed21fd99316cad9 + tag: 900c45ffaee5eb7eef8ec9402bc4971e4eb7ef33 source-repository-package type: git diff --git a/docs/protocol/badges-overview.md b/docs/protocol/badges-overview.md new file mode 100644 index 0000000000..41ed3c5eaa --- /dev/null +++ b/docs/protocol/badges-overview.md @@ -0,0 +1,291 @@ +Revision 1, 2026-09-18 + +# SimpleX Supporter Badges + +## Table of contents + +- [Introduction](#introduction) + - [What is a badge](#what-is-a-badge) + - [What a badge grants](#what-a-badge-grants) + - [What a badge discloses](#what-a-badge-discloses) + - [Comparison with Signal badges](#comparison-with-signal-badges) + - [Non-goals](#non-goals) +- [Architecture](#architecture) + - [Participants](#participants) + - [Service requests](#service-requests) + - [Buying and redeeming](#buying-and-redeeming) + - [Monthly issuance](#monthly-issuance) + - [Presentation](#presentation) + - [Servers](#servers) +- [Cryptographic primitives](#cryptographic-primitives) +- [Security](#security) + - [Design objectives](#design-objectives) + - [Threat model](#threat-model) +- [Future work](#future-work) + + +## Introduction + +The goal is for SimpleX Chat to be partly funded by its users, who buy supporter badges. A badge is shown on the profile of the person who bought it, and it raises the limits that other users and servers apply to that person's files. This document describes what a badge is, what it grants, how it is issued and presented, and what it discloses about the holder. + +A badge is stored in the user's profile on the device, and it is presented as proofs that cannot be linked to each other or to the purchase. The SimpleX network has no accounts, and a paid feature must not introduce one: in other messengers a paid feature is attached to an account, and the operator sees in one record who paid and what they did with the feature. + +### What is a badge + +A badge is a credential issued by the badge service, a bot operated by SimpleX Chat. The credential is a [BBS signature](https://datatracker.ietf.org/doc/draft-irtf-cfrg-bbs-signatures/) over four values: a master key generated by the app; the expiry date; the badge type, currently `supporter` or `legend`; and a reserved field that is currently empty. + +The holder of a BBS signature can prove that the issuer signed certain values, disclose some of those values, and keep the other values and the signature itself secret. The credential itself is therefore not sent to anyone. When the badge is to be shown or used, the app generates a proof that discloses the badge type, the expiry date and the reserved field, names the issuer key by its index, and hides the master key and the signature. Two proofs from the same credential cannot be linked to each other, by the issuer or by anyone else. Each proof is generated for one context, whether a conversation, a file or a session with a server, and is not accepted in any other. + +The transport protocols of the network call the same credential an entitlement: a name, an expiry and an extra string, disclosed in a proof. The chat application puts the badge type into the name, so a server that verifies an entitlement and a contact who sees a badge are looking at different zero-knowledge proofs of the same credential. + +### What a badge grants + +- A badge is shown on the profile to contacts, to the members of groups, and to the subscribers of channels. +- A badge allows larger files. The default limit is 1GB; a supporter can send files up to 2GB, and a legend up to 5GB. The limit is applied by the recipient, whose app verifies the sender's proof before it accepts a file above the default size. +- A badge extends the storage of files on servers. An XFTP server stores a file for 48 hours by default. It may be configured to store files for longer when they are uploaded by the holder of a badge of a given type; the example values in the server configuration file are 7 and 21 days for supporter and legend badge holders, respectively. + +Two other uses are planned and described under [Future work](#future-work): + +- A backup will be a link whose content the app replaces each time it makes a new backup, so that the same link restores the latest state. A badge will raise the size of the backup and the time it is stored, and the storage time is then the period the app can remain offline before the backup is lost. +- Servers that verify the proof will apply higher rate limits when creating messaging queues, file chunks and notification tokens. + +### What a badge discloses + +The badge service holds the purchase key, the number of months bought, and the payment record of the purchase. A card payment is linked to the purchase, as in any other service; a user who wants the payment itself to be private may pay with cryptocurrency. In either case the record is held by the service alone, and the service does not learn where the badge is later shown or used, because a zero-knowledge proof contains nothing that refers back to the credential or the purchase. + +A party that verifies a proof learns the badge type and the expiry date. Since all credentials expiring in the same week have the same expiry date, these two values place the holder among the supporters whose badges expire that week. + +### Comparison with Signal badges + +Signal sells donation badges using [receipt credentials](https://eprint.iacr.org/2019/1416.pdf). The client obtains a credential against a payment without revealing its account, and then presents the credential once to the account server, which records its serial number, level and expiry against the account and attaches the badge to the account. The payment is not linked to the account cryptographically. The same mechanism is used for Signal's paid backups. + +The differences follow from Signal having accounts and SimpleX not having them. + +In Signal: + +- The server holds the list of accounts that have badges. +- The credential can be verified only by the server that issued it. +- The credential is presented once, after which the badge is a property of the account. +- The client requests the credential and redeems it in a single sequence of jobs without delay, so the server can match the request made under the payment with the redemption made under the account by their times and by network address. +- The expiry of a credential is rounded up to the next day. + +In SimpleX: + +- A server verifies a proof for one session and cannot link that session to any other. +- A credential can be verified against the issuer's public key by contacts, channel relays and independently operated servers. +- A credential is presented every time the badge is shown or used, with a new proof each time. +- The request for a renewed credential and the profile update that presents it are made on different days. +- All credentials expiring in the same week have the same expiry. + +| Property | Signal badges | SimpleX badges | +|---|---|---| +| Account required | Yes | No | +| Operator holds the list of badge holders | Yes | No | +| Verified by | The issuing server | Any party with the issuer's public key | +| Presentations per credential | One | Unlimited | +| Proof bound to the context of presentation | No | Yes | +| Expiry rounded up to | The next day | The end of the following Monday | +| Issuance and presentation on different days | No | Yes | +| Usable on independently operated servers | No | Yes | + +### Non-goals + +- A badge does not restrict anything that is available today: the defaults are unchanged, and a badge only raises them. +- A badge does not create an identity: it has no persistent identifier, it is not linked across conversations, and an incognito profile does not show it. +- A badge cannot be transferred: a code can be redeemed once, and the credential obtained with it is usable only with the master key it was issued for. +- A badge does not exempt its holder from the limits a server applies: it lowers the cost of a resource without removing the limit on it. +- A badge cannot be revoked; credentials are issued for one month at a time instead. + +## Architecture + +``` + ┌─────────────────┐ code ┌──────────────┐ purchase ┌──────────┐ + │ Web purchase │ ────────> │ Holder │ ────────────> │ Issuer │ + └─────────────────┘ │ (the app) │ <──────────── └──────────┘ + └──────┬───────┘ credential + │ every month + │ a new proof each time + ┌─────────────────────────┼───────────────────────┐ + │ │ │ + ┌────▼─────┐ ┌─────▼──────┐ ┌─────▼──────┐ + │ Contacts,│ │ File │ │ The user's │ + │ groups, │ │ recipients │ │ own servers│ + │ channels │ └────────────┘ └────────────┘ + └──────────┘ +``` + +### Participants + +- The issuer is the badge service, a bot on the SimpleX network with a contact address. It holds the secret key with which credentials are signed. Apps and servers hold a list of eight issuer public keys, and every credential includes the index of the key that signed it, so the service can move to the next key without a release of apps or servers. +- The holder is the app. It keeps the credential in the user's profile, generates proofs on the device, and renews the credential every month. +- The verifiers are contacts, group members, channel relays, the recipients of files, and the user's own servers. A verifier holds the issuer public keys and no other information about badges. + +### Service requests + +The app and the badge service communicate through one-off service requests, a primitive of the SimpleX agent described in [One-off requests to service addresses](https://github.com/simplex-chat/simplexmq/blob/master/rfcs/2026-07-11-service-rpc.md). Although the service has a contact address, it is not a contact of the app, and no conversation with it exists. + +A request is a single message to the service's address. The app establishes a double ratchet, with post-quantum key agreement, from the keys published in the service's address link, encrypts the request with the double ratchet, and sends it to the address queue on the service's server in the same way as any message to a server chosen by another party, that is, through a proxy server when private routing is enabled (which is the default). With the request the app sends the address of a reply queue that it created for this request on one of its own servers. The service decrypts the request with a receiving ratchet initialised from its private keys, sends the reply to the reply queue under the same ratchet, and deletes its state; the app deletes the reply queue and the ratchet when it has the reply or when the request times out. + +This exchange has the following properties: + +- Unlike a request made directly to the service over HTTP, the app does not connect to the service, nor the service to the app. Each communicates only with SMP servers, so the service does not see the app's network address, and with private routing the service's server does not see it either. +- Unlike a chat connection, which is a persistent channel through which all requests made over it can be linked to each other, a service request does not create persistent state. Each request uses new keys and a new reply queue, so two requests from the same app cannot be linked by the service or by servers. +- A reply that decrypts proves that it came from the holder of the keys published in the service's link and signed by its root key, so a server cannot substitute a reply. +- The badge service answers a repeated request with the same result and does not execute the operation twice, so the app can repeat a request whose reply was lost. + +A purchase is identified by an Ed25519 key pair that the app generates for it and does not use for any other purpose. Every request concerning the purchase, the redemption and each monthly renewal, is signed with this key, and the signature covers the request together with a value derived from the ratchet of that exchange, so it is valid for that exchange only and cannot be replayed. The agent verifies the signature and delivers the verified public key to the service with the request, and the service accepts a request about a purchase only when the verified key is the purchase key. + +### Buying and redeeming + +A badge is bought for one or more months, either on the web or in the app. + +On the web the purchase produces a code, which is then redeemed in the app. The code is the only data passed from the web site to the app: the web site does not learn which app redeems a code, and the app does not see the payment. The code is generated in the buyer's browser, and the service stores only its hash; at redemption the app presents the code itself, and the service matches it against the stored hash. A badge issued without a sale, for example in compensation for a problem, is a code generated by the operator and redeemed in the same way. + +In the app, the user pays by card, in cryptocurrency, or through the app store. The app requests an invoice from the service, or presents the receipt of the app store, and the service issues the credential once the payment is confirmed. + +In both cases the app generates the master key and the purchase key pair before the purchase. To redeem a code, the app sends the code and the master key to the service, which issues the credential. A code redeemed a second time with the same purchase key returns the same credential; a code redeemed with a different purchase key is refused. + +### Monthly issuance + +A credential is issued for one month at a time, however many months were bought. The service keeps a count of the months remaining for each purchase key, and issues the next credential when the app asks for it. Unused months, e.g. if the app was offline, lapse at the next badge issuance. + +Credentials are issued monthly to limit what the expiry date discloses. If credentials were issued for the whole term, a user who bought a year would hold a credential expiring on a day a year ahead, when few other credentials expire, and this date would be disclosed in every proof for a year. Instead, every credential is issued for one month, and its expiry is rounded to the end of the Monday following the end of the paid month (UTC). For example, if the paid month ends on Wednesday 14 October 2026, the credential expires at the end of Monday 19 October, and so does every credential whose month ends between Monday 12 and Sunday 18 October. All credentials whose paid month ends in the same week thus expire at the same instant. + +The renewal is split over two days for the same reason. The app requests the next credential on the day before the current one expires, and when the current one expires, it switches to the new one and sends its updated profile to its contacts. In the example above, the app asks the service for the new credential on Monday 19 October and starts showing it on Tuesday 20 October, so an observer of both the request and the profile update cannot correlate them by time. + +The renewal runs in the background and does not require any action from the user. Recipients accept a badge for seven days after its expiry, and servers for one day, so a renewal delayed by a few days is not visible to contacts. + +### Presentation + +A BBS proof is generated over a string, called the presentation header, and is verified only against the same string. The string binds the proof to the context in which it is presented. Without it, a proof received in one conversation could be copied and presented in another. Between apps the string is sent with the proof, and the recipient checks that it is the string it expects; servers know the string already, so it is not sent to them. + +The context of a conversation is the same string over which message signatures are computed: + +- In a direct chat it is a hash derived from the state of the end-to-end encryption, which only the two sides hold. +- In a group it is the member's identifier together with the member's signing key. +- In a channel it is the channel's identifier together with the member's identifier, or the channel's identifier alone when a message is sent in the name of the channel. + +A session with a server is identified by the TLS session identifier, which both sides derive from the TLS handshake and which differs on every connection. + +Each time the app sends its profile, whether to a new contact, to a group it joins, or to everyone when the profile is updated, it generates a new proof and includes it. The proof is bound to the conversation in which the profile is sent: in a direct chat to the connection with the contact, in a group to the member's identity in that group, and in a channel to the member's identity established by the channel's roster. In a group the badge is accepted only from a message signed with the member's key, and in a channel it is verified against the key that the roster establishes for the member. + +A file larger than the default limit is sent with two proofs. The first is placed in the file invitation, the message that announces the file, and is bound to the conversation and to the size of the file; the recipient's app verifies it when the invitation arrives. The second is placed in the file description, the record of where the chunks of the file are stored and how they are decrypted, and is bound in addition to a hash of the description and to the storage time of the file; the app verifies it before the download begins. + +### Servers + +The client presents the entitlement proof in the transport handshake, bound to the TLS session identifier. The server verifies it once, when the session is established, and applies the result to every command in the session. + +The client presents the proof only to its own servers, that is, to those configured for the profile, which are identified by the certificate fingerprint pinned in TLS. It does not present the proof to a server whose address it received from a contact or found in a file description, because a file description names the servers on which the chunks are stored, and these are chosen by the sender. If the app presented its proof to every server it connected to, a sender could store a file on a server of their own and learn, when the file is downloaded, that the person downloading it holds a badge. + +On XFTP servers the proof extends the storage time of files. The server grants the smaller of the time requested by the client and the maximum configured for the badge type, and returns the resulting expiry. SMP servers and notification servers accept the same proof in their handshakes but do not yet make use of it. + +The exchange between the app and the badge service, that is, the commands, responses and errors, is described in the [badge service protocol](./badges-rpc.md). + + +## Cryptographic primitives + +Badges are built on [BBS signatures](https://datatracker.ietf.org/doc/draft-irtf-cfrg-bbs-signatures/), in the BLS12-381-SHA-256 suite, implemented by [libbbs](https://github.com/Fraunhofer-AISEC/libbbs) over [blst](https://github.com/supranational/blst). A credential is a signature over four values under the header `SimpleX badges v1`; a proof discloses three of them and is 304 bytes long. BBS was chosen for four properties that the design needs together: + +- A proof can be verified by anyone who holds the issuer's public key, so contacts and independently operated servers verify badges without consulting the issuer. +- A proof discloses only the values selected. +- A proof cannot be linked to any other proof of the same credential. +- A proof is generated over a presentation header, so it can be bound to the context in which it is presented. + +Credentials of the kind Signal uses can be verified only by their issuer, and a receipt credential discloses its serial number when presented, so it is presented once. Single-use tokens, such as blind signatures and the tokens of Privacy Pass, are likewise spent when presented and do not include values such as an expiry or a type. + +Service requests are protected by the double ratchet of the [SimpleX agent](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/agent-protocol.md), with X448 key agreement and the sntrup761 KEM, established from keys published in the service's link. The purchase keys with which requests are signed, and the member keys that form part of the conversation context in groups, are Ed25519 keys. Codes are hashed with SHA-256, and the file description in the context of a file proof with SHA-512. + + +## Security + +### Design objectives + +1. A proof discloses no value that links it to another proof or to the purchase. +2. A proof used in one context, whether a session, a conversation or a file, cannot be used in another context. +3. The timing of presentations does not identify the holder: all credentials expiring in the same week share the same expiry, and the renewal request and the profile update are made on different days. +4. Requests to the badge service cannot be linked to each other, to a profile or to a network address, and a request about a purchase can be made only by the holder of the purchase key. +5. A credential cannot be forged: the issuer keys are fixed in apps and servers, and the app verifies a credential before storing it. +6. A missing or failed proof leaves the default limit in place, and a server cannot be configured to grant a badge type less than the default. + +### Threat model + +This threat model assumes the [SimpleX network threat model](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/security.md) and addresses the threats specific to badges. + +**The badge service** + +*can:* + +- See the purchase key of every badge, the master key generated for it, the number of months bought, and the payment record. +- Issue any credential, or refuse to issue one, as it holds the issuer key. + +*cannot:* + +- Connect a purchase with a profile, a contact, a group or a session with a server - a proof contains nothing that refers back to the purchase. +- Learn where a badge is shown or used. +- Learn the network address of the app - requests reach the service through SMP servers, on connections created for the request. + +**A contact, a group member or a channel relay** + +*can:* + +- See the badge type and the expiry date. + +*cannot:* + +- Tell whether a badge seen in another conversation belongs to the same person. +- Reuse a profile proof or a file proof in another context. +- Distinguish the holder from the other supporters whose badges expire in the same week. + +**A server operator** + +*can:* + +- See the badge type and the expiry date once in each session. +- Group together the sessions of the supporters whose badges expire in the same week. + +*cannot:* + +- Reuse the proof on another connection. +- Link a session to the purchase, to a profile or to sessions on other servers, except through the week of expiry. +- Obtain a proof from a client that does not use the server. + +**The server that hosts the badge service's address** + +*can:* + +- See that requests arrive for the service. + +*cannot:* + +- See the content of requests. +- See the network address of the app when private routing is used. +- See the replies, which are sent to a queue on a server chosen by the app. + +**One badge on many machines** + +Since proofs are unlinkable, a server cannot count the sessions of a single credential, so the benefit granted by a badge must be limited without counting sessions. For this reason a badge lowers the cost of a resource without removing the limit on it, and limits per session remain in force. + +**Compromise of the user's device or backup** + +An attacker who obtains the credential and the purchase key can use the badge until it expires and renew it while months remain. There is no revocation, so the loss is limited to the months bought. + +**Interception of a code** + +A code is a bearer secret until it is redeemed; once redeemed, it is refused to any other purchase key. + +**A passive network observer** + +*can:* + +- Observe SimpleX traffic between clients and servers. + +*cannot:* + +- See a proof - proofs to servers are inside TLS, in a handshake block of fixed size, and proofs to other users and requests to the badge service are inside end-to-end encrypted messages. +- Distinguish a session in which a proof was presented from a session without one. + +## Future work + +- Backups will be stored on XFTP servers, and a badge will raise their size and their storage time, as described under [What a badge grants](#what-a-badge-grants). +- SMP servers and notification servers will verify the proof. A server that verifies it can charge the holder of a badge less for creating queues, file chunks and notification tokens (under the planned proof-of-work scheme, the required effort is divided by a factor configured for each badge type), and can apply higher rate limits and larger quotas. +- Subscriptions that renew automatically, the transfer of remaining months to a new device, and the pausing of a prepaid badge are planned. diff --git a/flake.nix b/flake.nix index 0863222c92..84f84c34ec 100644 --- a/flake.nix +++ b/flake.nix @@ -393,6 +393,7 @@ "chat_encrypt_file" "chat_encrypt_media" "chat_migrate_init" + "chat_migrate_init_queue" "chat_parse_markdown" "chat_parse_server" "chat_parse_uri" @@ -515,6 +516,7 @@ "chat_encrypt_file" "chat_encrypt_media" "chat_migrate_init" + "chat_migrate_init_queue" "chat_parse_markdown" "chat_parse_server" "chat_parse_uri" diff --git a/libsimplex.dll.def b/libsimplex.dll.def index a7a66992a6..79b88dc782 100644 --- a/libsimplex.dll.def +++ b/libsimplex.dll.def @@ -2,7 +2,9 @@ LIBRARY libsimplex EXPORTS hs_init hs_init_with_rtsopts + hs_thread_done chat_migrate_init + chat_migrate_init_queue chat_close_store chat_send_cmd chat_send_cmd_retry diff --git a/packages/simplex-chat-client/types/typescript/package.json b/packages/simplex-chat-client/types/typescript/package.json index 7fdad94741..4d353cbdae 100644 --- a/packages/simplex-chat-client/types/typescript/package.json +++ b/packages/simplex-chat-client/types/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@simplex-chat/types", - "version": "0.11.3", + "version": "0.11.4", "description": "TypeScript types for SimpleX Chat bot libraries", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/simplex-chat-client/types/typescript/src/commands.ts b/packages/simplex-chat-client/types/typescript/src/commands.ts index a062760642..ca32159f6b 100644 --- a/packages/simplex-chat-client/types/typescript/src/commands.ts +++ b/packages/simplex-chat-client/types/typescript/src/commands.ts @@ -570,7 +570,7 @@ export namespace APIConnect { export type Response = CR.SentConfirmation | CR.ContactAlreadyExists | CR.SentInvitation | CR.ChatCmdError export function cmdString(self: APIConnect): string { - return '/_connect ' + self.userId + (self.preparedLink_ ? ' ' + T.CreatedConnLink.cmdString(self.preparedLink_) : '') + return '/_connect ' + self.userId + (self.incognito ? ' incognito=on' : '') + (self.preparedLink_ ? ' ' + T.CreatedConnLink.cmdString(self.preparedLink_) : '') } } diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index 170f5c5504..d7db7f259f 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -301,123 +301,24 @@ export namespace BadgeRedeemError { } } -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 BadgeServiceErrorCode { + Bad_request = "bad_request", + Unsupported_version = "unsupported_version", + Unknown_purchase_key = "unknown_purchase_key", + Unknown_offer_id = "unknown_offer_id", + Offer_disabled = "offer_disabled", + Offer_mismatch = "offer_mismatch", + Product_unavailable = "product_unavailable", + Payment_not_entitled = "payment_not_entitled", + Payment_pending = "payment_pending", + Provider_unavailable = "provider_unavailable", + Rate_limited = "rate_limited", + Code_invalid = "code_invalid", + Code_used = "code_used", + Code_expired = "code_expired", + Receipt_invalid = "receipt_invalid", + Receipt_used = "receipt_used", + Internal = "internal", } export enum BadgeStatus { @@ -2895,15 +2796,9 @@ export interface GroupInfo { rosterVersion?: number // int64 membersRequireAttention: number // int viaGroupLinkUri?: string - groupKeys?: GroupKeys groupDomainVerified?: boolean } -export interface GroupKeys { - publicGroupKeys?: PublicGroupKeys - memberPrivKey: string -} - export interface GroupLink { userContactLinkId: number // int64 connLinkContact: CreatedConnLink @@ -3097,26 +2992,6 @@ export interface GroupRelay { relayCap: RelayCapabilities } -export type GroupRootKey = GroupRootKey.Private | GroupRootKey.Public - -export namespace GroupRootKey { - export type Tag = "private" | "public" - - interface Interface { - type: Tag - } - - export interface Private extends Interface { - type: "private" - rootPrivKey: string - } - - export interface Public extends Interface { - type: "public" - rootPubKey: string - } -} - export interface GroupShortLinkData { groupProfile: GroupProfile publicGroupData?: PublicGroupData @@ -3747,11 +3622,6 @@ export interface PublicGroupData { publicMemberCount: number // int64 } -export interface PublicGroupKeys { - publicGroupId: string - groupRootKey: GroupRootKey -} - export interface PublicGroupProfile { groupType: GroupType groupLink: string diff --git a/packages/simplex-chat-nodejs/binding.gyp b/packages/simplex-chat-nodejs/binding.gyp index 09c63cecba..cfa6d61039 100644 --- a/packages/simplex-chat-nodejs/binding.gyp +++ b/packages/simplex-chat-nodejs/binding.gyp @@ -11,6 +11,8 @@ ], "cflags!": [ "-fno-exceptions" ], "cflags_cc!": [ "-fno-exceptions" ], + "xcode_settings": { "GCC_ENABLE_CPP_EXCEPTIONS": "YES" }, + "msvs_settings": { "VCCLCompilerTool": { "ExceptionHandling": 1 } }, "defines": [ "NAPI_DISABLE_CPP_EXCEPTIONS" ], "conditions": [ ["OS=='mac'", { diff --git a/packages/simplex-chat-nodejs/cpp/simplex.cc b/packages/simplex-chat-nodejs/cpp/simplex.cc index 97233eff6f..bcca2b994a 100644 --- a/packages/simplex-chat-nodejs/cpp/simplex.cc +++ b/packages/simplex-chat-nodejs/cpp/simplex.cc @@ -3,6 +3,14 @@ #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include #include "simplex.h" namespace simplex { @@ -81,6 +89,11 @@ class ResultAsyncWorker : public AsyncWorker { return ctrl_; } + // the worker thread reads this object's memory until the worker completes + void KeepAlive(Object obj) { + keep_alive_ = Persistent(obj); + } + protected: std::string result_; uintptr_t ctrl_ = 0; @@ -88,6 +101,7 @@ class ResultAsyncWorker : public AsyncWorker { private: ExecuteFn execute_fn_; ResultProcessor result_processor_; + ObjectReference keep_alive_; }; class BinaryAsyncWorker : public AsyncWorker { @@ -97,21 +111,25 @@ class BinaryAsyncWorker : public AsyncWorker { BinaryAsyncWorker(Function& callback, ExecuteFn execute_fn) : AsyncWorker(callback), execute_fn_(std::move(execute_fn)) {} + ~BinaryAsyncWorker() { + free(original_buf); + } + void Execute() override { execute_fn_(this); } void OnOK() override { HandleScope scope(Env()); - if (original_buf == nullptr || binary_len == 0) { - Callback().Call({Env().Null(), Env().Undefined()}); + char* buf = original_buf; + original_buf = nullptr; + if (binary_len == 0) { + free(buf); + Callback().Call({Env().Null(), Buffer::New(Env(), 0)}); return; } - char* data_ptr = original_buf + 5; - auto finalizer = [](Napi::Env env, char* finalize_data, char* orig) { - free(orig); - }; - Napi::Buffer buffer = Napi::Buffer::New(Env(), data_ptr, binary_len, finalizer, original_buf); + // Copies when the runtime forbids external buffers (Electron); the finalizer then runs immediately. + Buffer buffer = Buffer::NewOrCopy(Env(), buf + 5, binary_len, [](Napi::Env, char*, char* orig) { free(orig); }, buf); Callback().Call({Env().Null(), buffer}); } @@ -176,6 +194,159 @@ Napi::Promise CreatePromiseAndCallback(Env env, Function& cb_out) { return deferred.Promise(); } +const char* const RECEIVER_STOPPED = "chat receiver stopped"; + +struct RecvRequest { + int wait = 0; + std::shared_ptr deferred; +}; + +// Holds the event loop open only while receives are pending; used only on the JS main thread. +class PendingReceives { + public: + explicit PendingReceives(ThreadSafeFunction tsfn) : tsfn_(tsfn) {} + + const ThreadSafeFunction& Tsfn() const { + return tsfn_; + } + + void Add(Napi::Env env) { + if (count_++ == 0) tsfn_.Ref(env); + } + + void Remove(Napi::Env env) { + if (--count_ == 0) tsfn_.Unref(env); + } + + private: + ThreadSafeFunction tsfn_; + size_t count_ = 0; +}; + +// A blocking receive would hold a libuv pool thread for up to `wait`, stalling fs, dns and crypto. +class Receiver { + public: + // Returns nullptr with a pending JS exception if the TSFN cannot be created, throws std::system_error if the thread cannot start. + static std::shared_ptr Start(Napi::Env env, chat_ctrl ctrl) { + ThreadSafeFunction tsfn = ThreadSafeFunction::New(env, Function::New(env, [](const CallbackInfo&) {}), "chat_recv_msg_wait", 0, 1); + if (env.IsExceptionPending()) { + return nullptr; + } + auto receiver = std::make_shared(ctrl, tsfn); + try { + receiver->thread_ = std::thread(&Receiver::Run, receiver.get()); + } catch (const std::system_error&) { + tsfn.Release(); + throw; + } + return receiver; + } + + Receiver(chat_ctrl ctrl, ThreadSafeFunction tsfn) : ctrl_(ctrl), pending_(std::make_shared(tsfn)) {} + + Receiver(const Receiver&) = delete; + Receiver& operator=(const Receiver&) = delete; + + ~Receiver() { + Stop(); + } + + void Enqueue(Napi::Env env, RecvRequest request) { + pending_->Add(env); + { + std::lock_guard lock(mutex_); + queue_.push_back(std::move(request)); + } + cv_.notify_one(); + } + + void RequestStop() { + { + std::lock_guard lock(mutex_); + stop_ = true; + } + cv_.notify_one(); + } + + // Waits for the receive in progress, so it must not run on the JS main thread outside env teardown. + void Stop() { + RequestStop(); + if (thread_.joinable()) { + thread_.join(); + } + } + + private: + void Run() { + for (;;) { + RecvRequest request; + { + std::unique_lock lock(mutex_); + cv_.wait(lock, [this] { return stop_ || !queue_.empty(); }); + if (stop_) break; + request = std::move(queue_.front()); + queue_.pop_front(); + } + char* c_res = chat_recv_msg_wait(ctrl_, request.wait); + napi_status status = Settle(request.deferred, [c_res](Napi::Env env, Promise::Deferred& deferred) { + if (c_res == nullptr) { + deferred.Reject(Error::New(env, "chat_recv_msg_wait failed").Value()); + } else { + deferred.Resolve(String::New(env, c_res)); + free(c_res); + } + }); + if (status != napi_ok) { + free(c_res); + } + } + std::deque unserved; + { + std::lock_guard lock(mutex_); + unserved.swap(queue_); + } + for (RecvRequest& request : unserved) { + Settle(request.deferred, [](Napi::Env env, Promise::Deferred& deferred) { + deferred.Reject(Error::New(env, RECEIVER_STOPPED).Value()); + }); + } + pending_->Tsfn().Release(); + // Each OS thread that enters Haskell keeps an RTS task record until it calls hs_thread_done. + hs_thread_done(); + } + + template + napi_status Settle(std::shared_ptr deferred, SettleFn settle) { + // The callback may run after this Receiver is destroyed, so it owns the pending count. + std::shared_ptr pending = pending_; + return pending->Tsfn().BlockingCall([pending, deferred, settle](Napi::Env env, Function) { + settle(env, *deferred); + pending->Remove(env); + }); + } + + const chat_ctrl ctrl_; + const std::shared_ptr pending_; + std::mutex mutex_; + std::condition_variable cv_; + std::deque queue_; + bool stop_ = false; + std::thread thread_; +}; + +// Keyed by chat_ctrl, accessed only on the JS main thread. +using Receivers = std::unordered_map>; + +std::shared_ptr TakeReceiver(Receivers& receivers, chat_ctrl ctrl) { + auto it = receivers.find(reinterpret_cast(ctrl)); + if (it == receivers.end()) { + return nullptr; + } + std::shared_ptr receiver = std::move(it->second); + receivers.erase(it); + return receiver; +} + // Common result processors ResultAsyncWorker::ResultProcessor MigrateResultProcessor() { return [](ResultAsyncWorker* worker, Napi::Env env) { @@ -215,6 +386,39 @@ Value ChatMigrateInit(const CallbackInfo& args) { return promise; } +Value ChatMigrateInitQueue(const CallbackInfo& args) { + Env env = args.Env(); + if (args.Length() < 4 || !args[0].IsString() || !args[1].IsString() || !args[2].IsString() || !args[3].IsNumber()) { + TypeError::New(env, "Expected three string arguments and number").ThrowAsJavaScriptException(); + return env.Undefined(); + } + + std::string path = args[0].As().Utf8Value(); + std::string key = args[1].As().Utf8Value(); + std::string confirm = args[2].As().Utf8Value(); + Number queue_size_arg = args[3].As(); + int queue_size = queue_size_arg.Int32Value(); + if (static_cast(queue_size) != queue_size_arg.DoubleValue()) { + RangeError::New(env, "Expected 32-bit integer queue size").ThrowAsJavaScriptException(); + return env.Undefined(); + } + + Function cb; + Promise promise = CreatePromiseAndCallback(env, cb); + + auto execute_fn = [path, key, confirm, queue_size](ResultAsyncWorker* worker) { + chat_ctrl ctrl = nullptr; + char* c_res = chat_migrate_init_queue(path.c_str(), key.c_str(), confirm.c_str(), queue_size, &ctrl); + worker->SetCtrl(reinterpret_cast(ctrl)); + HandleCResult(worker, c_res, "chat_migrate_init_queue"); + }; + + ResultAsyncWorker* worker = new ResultAsyncWorker(cb, std::move(execute_fn), MigrateResultProcessor()); + worker->Queue(); + + return promise; +} + Value ChatCloseStore(const CallbackInfo& args) { Env env = args.Env(); if (args.Length() < 1 || !args[0].IsBigInt()) { @@ -223,11 +427,15 @@ Value ChatCloseStore(const CallbackInfo& args) { } chat_ctrl ctrl = FromChatCtrlBigInt(args[0]); + std::shared_ptr receiver = TakeReceiver(*static_cast(args.Data()), ctrl); Function cb; Promise promise = CreatePromiseAndCallback(env, cb); - auto execute_fn = [ctrl](ResultAsyncWorker* worker) { + auto execute_fn = [ctrl, receiver](ResultAsyncWorker* worker) { + if (receiver) { + receiver->Stop(); + } char* c_res = chat_close_store(ctrl); HandleCResult(worker, c_res, "chat_close_store"); }; @@ -271,44 +479,63 @@ Value ChatRecvMsgWait(const CallbackInfo& args) { chat_ctrl ctrl = FromChatCtrlBigInt(args[0]); int wait = static_cast(args[1].As().Int32Value()); + Receivers& receivers = *static_cast(args.Data()); - Function cb; - Promise promise = CreatePromiseAndCallback(env, cb); + auto deferred = std::make_shared(Promise::Deferred::New(env)); + auto it = receivers.find(reinterpret_cast(ctrl)); + if (it == receivers.end()) { + std::shared_ptr receiver; + try { + receiver = Receiver::Start(env, ctrl); + } catch (const std::system_error& e) { + deferred->Reject(Error::New(env, e.what()).Value()); + return deferred->Promise(); + } + if (!receiver) { + return env.Undefined(); + } + it = receivers.emplace(reinterpret_cast(ctrl), std::move(receiver)).first; + } + it->second->Enqueue(env, {wait, deferred}); - auto execute_fn = [ctrl, wait](ResultAsyncWorker* worker) { - char* c_res = chat_recv_msg_wait(ctrl, wait); - HandleCResult(worker, c_res, "chat_recv_msg_wait"); - }; - - ResultAsyncWorker* worker = new ResultAsyncWorker(cb, std::move(execute_fn)); - worker->Queue(); - - return promise; + return deferred->Promise(); } Value ChatWriteFile(const CallbackInfo& args) { Env env = args.Env(); - if (args.Length() < 3 || !args[0].IsBigInt() || !args[1].IsString() || !args[2].IsArrayBuffer()) { - TypeError::New(env, "Expected bigint (ctrl), string (path), ArrayBuffer").ThrowAsJavaScriptException(); + if (args.Length() < 3 || !args[0].IsBigInt() || !args[1].IsString() || !(args[2].IsArrayBuffer() || args[2].IsTypedArray())) { + TypeError::New(env, "Expected bigint (ctrl), string (path), ArrayBuffer or Uint8Array").ThrowAsJavaScriptException(); return env.Undefined(); } chat_ctrl ctrl = FromChatCtrlBigInt(args[0]); std::string path = args[1].As().Utf8Value(); - ArrayBuffer ab = args[2].As(); - char* data = static_cast(ab.Data()); - size_t len = ab.ByteLength(); + char* data; + size_t len; + if (args[2].IsArrayBuffer()) { + ArrayBuffer ab = args[2].As(); + data = static_cast(ab.Data()); + len = ab.ByteLength(); + } else { + TypedArray view = args[2].As(); + data = static_cast(view.ArrayBuffer().Data()) + view.ByteOffset(); + len = view.ByteLength(); + } + if (len > static_cast(INT_MAX)) { + RangeError::New(env, "Buffer is too large").ThrowAsJavaScriptException(); + return env.Undefined(); + } Function cb; Promise promise = CreatePromiseAndCallback(env, cb); - auto execute_fn = [ctrl, path, ab, data, len](ResultAsyncWorker* worker) { - (void)ab; // to keep ArrayBuffer alive + auto execute_fn = [ctrl, path, data, len](ResultAsyncWorker* worker) { char* c_res = chat_write_file(ctrl, path.c_str(), data, static_cast(len)); HandleCResult(worker, c_res, "chat_write_file"); }; ResultAsyncWorker* worker = new ResultAsyncWorker(cb, std::move(execute_fn)); + worker->KeepAlive(args[2].As()); worker->Queue(); return promise; @@ -410,10 +637,19 @@ Value ChatDecryptFile(const CallbackInfo& args) { Object Init(Env env, Object exports) { haskell_init(); + auto* receivers = new Receivers(); + // Stopping all receivers before joining any bounds teardown by the longest in-flight receive. + env.AddCleanupHook([receivers]() { + for (auto& entry : *receivers) { + entry.second->RequestStop(); + } + delete receivers; + }); exports.Set("chat_migrate_init", Function::New(env, ChatMigrateInit)); - exports.Set("chat_close_store", Function::New(env, ChatCloseStore)); + exports.Set("chat_migrate_init_queue", Function::New(env, ChatMigrateInitQueue)); + exports.Set("chat_close_store", Function::New(env, ChatCloseStore, "chat_close_store", receivers)); exports.Set("chat_send_cmd", Function::New(env, ChatSendCmd)); - exports.Set("chat_recv_msg_wait", Function::New(env, ChatRecvMsgWait)); + exports.Set("chat_recv_msg_wait", Function::New(env, ChatRecvMsgWait, "chat_recv_msg_wait", receivers)); exports.Set("chat_write_file", Function::New(env, ChatWriteFile)); exports.Set("chat_read_file", Function::New(env, ChatReadFile)); exports.Set("chat_encrypt_file", Function::New(env, ChatEncryptFile)); diff --git a/packages/simplex-chat-nodejs/cpp/simplex.h b/packages/simplex-chat-nodejs/cpp/simplex.h index 8e579626ed..ddc052946a 100644 --- a/packages/simplex-chat-nodejs/cpp/simplex.h +++ b/packages/simplex-chat-nodejs/cpp/simplex.h @@ -11,11 +11,13 @@ extern "C" void hs_init(int argc, char **argv[]); extern "C" void hs_init_with_rtsopts(int * argc, char **argv[]); +extern "C" void hs_thread_done(void); typedef long* chat_ctrl; // the last parameter is used to return the pointer to chat controller extern "C" char *chat_migrate_init(const char *path, const char *key, const char *confirm, chat_ctrl *ctrl); +extern "C" char *chat_migrate_init_queue(const char *path, const char *key, const char *confirm, const int queueSize, chat_ctrl *ctrl); extern "C" char *chat_close_store(chat_ctrl ctrl); extern "C" char *chat_reopen_store(chat_ctrl ctrl); extern "C" char *chat_send_cmd(chat_ctrl ctrl, const char *cmd); diff --git a/packages/simplex-chat-nodejs/docs/Namespace.bot.md b/packages/simplex-chat-nodejs/docs/Namespace.bot.md index 4b9171d0f7..7adeeec0c7 100644 --- a/packages/simplex-chat-nodejs/docs/Namespace.bot.md +++ b/packages/simplex-chat-nodejs/docs/Namespace.bot.md @@ -21,3 +21,4 @@ It automates creating and updating of the bot profile, address and bot commands ## Functions - [run](bot.Function.run.md) +- [subscribeChatItems](bot.Function.subscribeChatItems.md) diff --git a/packages/simplex-chat-nodejs/docs/api.Class.ChatApi.md b/packages/simplex-chat-nodejs/docs/api.Class.ChatApi.md index b81677b976..287099906b 100644 --- a/packages/simplex-chat-nodejs/docs/api.Class.ChatApi.md +++ b/packages/simplex-chat-nodejs/docs/api.Class.ChatApi.md @@ -26,7 +26,7 @@ Defined in: [src/api.ts:103](../src/api.ts#L103) > **get** **ctrl**(): `bigint` -Defined in: [src/api.ts:329](../src/api.ts#L329) +Defined in: [src/api.ts:344](../src/api.ts#L344) Chat controller reference @@ -42,7 +42,7 @@ Chat controller reference > **get** **initialized**(): `boolean` -Defined in: [src/api.ts:315](../src/api.ts#L315) +Defined in: [src/api.ts:330](../src/api.ts#L330) Chat controller is initialized @@ -58,7 +58,7 @@ Chat controller is initialized > **get** **started**(): `boolean` -Defined in: [src/api.ts:322](../src/api.ts#L322) +Defined in: [src/api.ts:337](../src/api.ts#L337) Chat controller is started @@ -72,7 +72,7 @@ Chat controller is started > **apiAcceptContactRequest**(`contactReqId`): `Promise`\<`Contact`\> -Defined in: [src/api.ts:731](../src/api.ts#L731) +Defined in: [src/api.ts:750](../src/api.ts#L750) Accept contact request. Network usage: interactive. @@ -93,7 +93,7 @@ Network usage: interactive. > **apiAcceptMember**(`groupId`, `groupMemberId`, `memberRole`): `Promise`\<`GroupMember`\> -Defined in: [src/api.ts:551](../src/api.ts#L551) +Defined in: [src/api.ts:570](../src/api.ts#L570) Accept group member. Requires Admin role. Network usage: background. @@ -122,7 +122,7 @@ Network usage: background. > **apiAddMember**(`groupId`, `contactId`, `memberRole`): `Promise`\<`GroupMember`\> -Defined in: [src/api.ts:531](../src/api.ts#L531) +Defined in: [src/api.ts:550](../src/api.ts#L550) Add contact to group. Requires bot to have Admin role. Network usage: interactive. @@ -151,7 +151,7 @@ Network usage: interactive. > **apiBlockMembersForAll**(`groupId`, `groupMemberIds`, `blocked`): `Promise`\<`void`\> -Defined in: [src/api.ts:571](../src/api.ts#L571) +Defined in: [src/api.ts:590](../src/api.ts#L590) Block members. Requires Moderator role. Network usage: background. @@ -180,7 +180,7 @@ Network usage: background. > **apiCancelFile**(`fileId`): `Promise`\<`void`\> -Defined in: [src/api.ts:521](../src/api.ts#L521) +Defined in: [src/api.ts:540](../src/api.ts#L540) Cancel file. Network usage: background. @@ -199,9 +199,9 @@ Network usage: background. ### apiChatItemReaction() -> **apiChatItemReaction**(`chatType`, `chatId`, `chatItemId`, `add`, `reaction`): `Promise`\<`ChatItemDeletion`[]\> +> **apiChatItemReaction**(`chatType`, `chatId`, `chatItemId`, `add`, `reaction`): `Promise`\<`ACIReaction`\> -Defined in: [src/api.ts:495](../src/api.ts#L495) +Defined in: [src/api.ts:513](../src/api.ts#L513) Add/remove message reaction. Network usage: background. @@ -230,15 +230,15 @@ Network usage: background. #### Returns -`Promise`\<`ChatItemDeletion`[]\> +`Promise`\<`ACIReaction`\> *** ### apiConnect() -> **apiConnect**(`userId`, `incognito`, `preparedLink?`): `Promise`\<[`ConnReqType`](api.Enumeration.ConnReqType.md)\> +> **apiConnect**(`userId`, `incognito`, `preparedLink`): `Promise`\<[`ConnReqType`](api.Enumeration.ConnReqType.md)\> -Defined in: [src/api.ts:700](../src/api.ts#L700) +Defined in: [src/api.ts:719](../src/api.ts#L719) Connect via prepared SimpleX link. The link can be 1-time invitation link, contact address or group link Network usage: interactive. @@ -253,7 +253,7 @@ Network usage: interactive. `boolean` -##### preparedLink? +##### preparedLink `CreatedConnLink` @@ -267,7 +267,7 @@ Network usage: interactive. > **apiConnectActiveUser**(`connLink`): `Promise`\<[`ConnReqType`](api.Enumeration.ConnReqType.md)\> -Defined in: [src/api.ts:709](../src/api.ts#L709) +Defined in: [src/api.ts:728](../src/api.ts#L728) Connect via SimpleX link as string in the active user profile. Network usage: interactive. @@ -288,7 +288,7 @@ Network usage: interactive. > **apiConnectPlan**(`userId`, `connectionLink`): `Promise`\<\[`ConnectionPlan`, `CreatedConnLink`\]\> -Defined in: [src/api.ts:690](../src/api.ts#L690) +Defined in: [src/api.ts:709](../src/api.ts#L709) Determine SimpleX link type and if the bot is already connected via this link. Network usage: interactive. @@ -313,7 +313,7 @@ Network usage: interactive. > **apiCreateActiveUser**(`profile?`): `Promise`\<`User`\> -Defined in: [src/api.ts:849](../src/api.ts#L849) +Defined in: [src/api.ts:887](../src/api.ts#L887) Create new user profile Network usage: no. @@ -334,7 +334,7 @@ Network usage: no. > **apiCreateGroupLink**(`groupId`, `memberRole`): `Promise`\<`string`\> -Defined in: [src/api.ts:631](../src/api.ts#L631) +Defined in: [src/api.ts:650](../src/api.ts#L650) Create group link. Network usage: interactive. @@ -359,7 +359,7 @@ Network usage: interactive. > **apiCreateLink**(`userId`): `Promise`\<`string`\> -Defined in: [src/api.ts:677](../src/api.ts#L677) +Defined in: [src/api.ts:696](../src/api.ts#L696) Create 1-time invitation link. Network usage: interactive. @@ -380,7 +380,7 @@ Network usage: interactive. > **apiCreateMemberContact**(`groupId`, `groupMemberId`): `Promise`\<`Contact`\> -Defined in: [src/api.ts:915](../src/api.ts#L915) +Defined in: [src/api.ts:953](../src/api.ts#L953) Create a direct message contact with a group member. Returns the created contact. @@ -406,7 +406,7 @@ Network usage: interactive. > **apiCreateUserAddress**(`userId`): `Promise`\<`CreatedConnLink`\> -Defined in: [src/api.ts:346](../src/api.ts#L346) +Defined in: [src/api.ts:361](../src/api.ts#L361) Create bot address. Network usage: interactive. @@ -427,7 +427,7 @@ Network usage: interactive. > **apiDeleteChat**(`chatType`, `chatId`, `deleteMode?`): `Promise`\<`void`\> -Defined in: [src/api.ts:771](../src/api.ts#L771) +Defined in: [src/api.ts:809](../src/api.ts#L809) Delete chat. Network usage: background. @@ -456,7 +456,7 @@ Network usage: background. > **apiDeleteChatItems**(`chatType`, `chatId`, `chatItemIds`, `deleteMode`): `Promise`\<`ChatItemDeletion`[]\> -Defined in: [src/api.ts:470](../src/api.ts#L470) +Defined in: [src/api.ts:488](../src/api.ts#L488) Delete message. Network usage: background. @@ -489,7 +489,7 @@ Network usage: background. > **apiDeleteGroupLink**(`groupId`): `Promise`\<`void`\> -Defined in: [src/api.ts:653](../src/api.ts#L653) +Defined in: [src/api.ts:672](../src/api.ts#L672) Delete group link. Network usage: background. @@ -510,7 +510,7 @@ Network usage: background. > **apiDeleteMemberChatItem**(`groupId`, `chatItemIds`): `Promise`\<`ChatItemDeletion`[]\> -Defined in: [src/api.ts:485](../src/api.ts#L485) +Defined in: [src/api.ts:503](../src/api.ts#L503) Moderate message. Requires Moderator role (and higher than message author's). Network usage: background. @@ -535,7 +535,7 @@ Network usage: background. > **apiDeleteUser**(`userId`, `delSMPQueues`, `viewPwd?`): `Promise`\<`void`\> -Defined in: [src/api.ts:879](../src/api.ts#L879) +Defined in: [src/api.ts:917](../src/api.ts#L917) Delete user profile. Network usage: background. @@ -564,7 +564,7 @@ Network usage: background. > **apiDeleteUserAddress**(`userId`): `Promise`\<`void`\> -Defined in: [src/api.ts:356](../src/api.ts#L356) +Defined in: [src/api.ts:371](../src/api.ts#L371) Deletes a user address. Network usage: background. @@ -585,7 +585,7 @@ Network usage: background. > **apiGetActiveUser**(): `Promise`\<`User` \| `undefined`\> -Defined in: [src/api.ts:829](../src/api.ts#L829) +Defined in: [src/api.ts:867](../src/api.ts#L867) Get active user profile Network usage: no. @@ -600,7 +600,7 @@ Network usage: no. > **apiGetChat**(`chatType`, `chatId`, `count`): `Promise`\<`any`\> -Defined in: [src/api.ts:819](../src/api.ts#L819) +Defined in: [src/api.ts:857](../src/api.ts#L857) Get chat items. Network usage: no. @@ -625,11 +625,48 @@ Network usage: no. *** +### apiGetChats() + +> **apiGetChats**(`userId`, `pagination`, `query?`, `pendingConnections?`): `Promise`\<`AChat`[]\> + +Defined in: [src/api.ts:794](../src/api.ts#L794) + +Get chat previews (paginated). +Network usage: no. + +Prefer this over apiListContacts / apiListGroups for any scan: those +methods load every record into memory in a single response and will fail +on large databases. + +#### Parameters + +##### userId + +`number` + +##### pagination + +`Last` + +##### query? + +`ChatListQuery` = `...` + +##### pendingConnections? + +`boolean` = `false` + +#### Returns + +`Promise`\<`AChat`[]\> + +*** + ### apiGetGroupLink() > **apiGetGroupLink**(`groupId`): `Promise`\<`GroupLink`\> -Defined in: [src/api.ts:662](../src/api.ts#L662) +Defined in: [src/api.ts:681](../src/api.ts#L681) Get group link. Network usage: no. @@ -650,7 +687,7 @@ Network usage: no. > **apiGetGroupLinkStr**(`groupId`): `Promise`\<`string`\> -Defined in: [src/api.ts:668](../src/api.ts#L668) +Defined in: [src/api.ts:687](../src/api.ts#L687) #### Parameters @@ -668,7 +705,7 @@ Defined in: [src/api.ts:668](../src/api.ts#L668) > **apiGetUserAddress**(`userId`): `Promise`\<`UserContactLink` \| `undefined`\> -Defined in: [src/api.ts:366](../src/api.ts#L366) +Defined in: [src/api.ts:381](../src/api.ts#L381) Get bot address and settings. Network usage: no. @@ -689,7 +726,7 @@ Network usage: no. > **apiJoinGroup**(`groupId`): `Promise`\<`GroupInfo`\> -Defined in: [src/api.ts:541](../src/api.ts#L541) +Defined in: [src/api.ts:560](../src/api.ts#L560) Join group. Network usage: interactive. @@ -710,7 +747,7 @@ Network usage: interactive. > **apiLeaveGroup**(`groupId`): `Promise`\<`GroupInfo`\> -Defined in: [src/api.ts:591](../src/api.ts#L591) +Defined in: [src/api.ts:610](../src/api.ts#L610) Leave group. Network usage: background. @@ -731,7 +768,7 @@ Network usage: background. > **apiListContacts**(`userId`): `Promise`\<`Contact`[]\> -Defined in: [src/api.ts:751](../src/api.ts#L751) +Defined in: [src/api.ts:770](../src/api.ts#L770) Get contacts. Network usage: no. @@ -752,7 +789,7 @@ Network usage: no. > **apiListGroups**(`userId`, `contactId?`, `search?`): `Promise`\<`GroupInfo`[]\> -Defined in: [src/api.ts:761](../src/api.ts#L761) +Defined in: [src/api.ts:780](../src/api.ts#L780) Get groups. Network usage: no. @@ -781,7 +818,7 @@ Network usage: no. > **apiListMembers**(`groupId`): `Promise`\<`GroupMember`[]\> -Defined in: [src/api.ts:601](../src/api.ts#L601) +Defined in: [src/api.ts:620](../src/api.ts#L620) Get group members. Network usage: no. @@ -802,7 +839,7 @@ Network usage: no. > **apiListUsers**(): `Promise`\<`UserInfo`[]\> -Defined in: [src/api.ts:859](../src/api.ts#L859) +Defined in: [src/api.ts:897](../src/api.ts#L897) Get all user profiles Network usage: no. @@ -817,7 +854,7 @@ Network usage: no. > **apiNewGroup**(`userId`, `groupProfile`): `Promise`\<`GroupInfo`\> -Defined in: [src/api.ts:611](../src/api.ts#L611) +Defined in: [src/api.ts:630](../src/api.ts#L630) Create group. Network usage: no. @@ -842,7 +879,7 @@ Network usage: no. > **apiReceiveFile**(`fileId`): `Promise`\<`AChatItem`\> -Defined in: [src/api.ts:511](../src/api.ts#L511) +Defined in: [src/api.ts:529](../src/api.ts#L529) Receive file. Network usage: no. @@ -863,7 +900,7 @@ Network usage: no. > **apiRejectContactRequest**(`contactReqId`): `Promise`\<`void`\> -Defined in: [src/api.ts:741](../src/api.ts#L741) +Defined in: [src/api.ts:760](../src/api.ts#L760) Reject contact request. The user who sent the request is **not notified**. Network usage: no. @@ -884,7 +921,7 @@ Network usage: no. > **apiRemoveMembers**(`groupId`, `memberIds`, `withMessages?`): `Promise`\<`GroupMember`[]\> -Defined in: [src/api.ts:581](../src/api.ts#L581) +Defined in: [src/api.ts:600](../src/api.ts#L600) Remove members. Requires Admin role. Network usage: background. @@ -913,7 +950,7 @@ Network usage: background. > **apiSendMemberContactInvitation**(`contactId`, `message?`): `Promise`\<`Contact`\> -Defined in: [src/api.ts:926](../src/api.ts#L926) +Defined in: [src/api.ts:964](../src/api.ts#L964) Send a direct message invitation to a group member contact. The contact must have been created with [apiCreateMemberContact](#apicreatemembercontact). @@ -939,7 +976,7 @@ Network usage: interactive. > **apiSendMessages**(`chat`, `messages`, `liveMessage?`): `Promise`\<`AChatItem`[]\> -Defined in: [src/api.ts:415](../src/api.ts#L415) +Defined in: [src/api.ts:432](../src/api.ts#L432) Send messages. Network usage: background. @@ -968,7 +1005,7 @@ Network usage: background. > **apiSendTextMessage**(`chat`, `text`, `inReplyTo?`): `Promise`\<`AChatItem`[]\> -Defined in: [src/api.ts:437](../src/api.ts#L437) +Defined in: [src/api.ts:455](../src/api.ts#L455) Send text message. Network usage: background. @@ -997,7 +1034,7 @@ Network usage: background. > **apiSendTextReply**(`chatItem`, `text`): `Promise`\<`AChatItem`[]\> -Defined in: [src/api.ts:445](../src/api.ts#L445) +Defined in: [src/api.ts:463](../src/api.ts#L463) Send text message in reply to received message. Network usage: background. @@ -1022,7 +1059,7 @@ Network usage: background. > **apiSetActiveUser**(`userId`, `viewPwd?`): `Promise`\<`User`\> -Defined in: [src/api.ts:869](../src/api.ts#L869) +Defined in: [src/api.ts:907](../src/api.ts#L907) Set active user profile Network usage: no. @@ -1047,7 +1084,7 @@ Network usage: no. > **apiSetAddressSettings**(`userId`, `__namedParameters`): `Promise`\<`void`\> -Defined in: [src/api.ts:398](../src/api.ts#L398) +Defined in: [src/api.ts:415](../src/api.ts#L415) Set bot address settings. Network usage: interactive. @@ -1072,7 +1109,7 @@ Network usage: interactive. > **apiSetAutoAcceptMemberContacts**(`userId`, `onOff`): `Promise`\<`void`\> -Defined in: [src/api.ts:808](../src/api.ts#L808) +Defined in: [src/api.ts:846](../src/api.ts#L846) Set auto-accept member contacts. Network usage: no. @@ -1097,7 +1134,7 @@ Network usage: no. > **apiSetContactCustomData**(`contactId`, `customData?`): `Promise`\<`void`\> -Defined in: [src/api.ts:798](../src/api.ts#L798) +Defined in: [src/api.ts:836](../src/api.ts#L836) Set contact custom data. Network usage: no. @@ -1122,7 +1159,7 @@ Network usage: no. > **apiSetContactPrefs**(`contactId`, `preferences`): `Promise`\<`void`\> -Defined in: [src/api.ts:905](../src/api.ts#L905) +Defined in: [src/api.ts:943](../src/api.ts#L943) Configure chat preference overrides for the contact. Network usage: background. @@ -1147,7 +1184,7 @@ Network usage: background. > **apiSetGroupCustomData**(`groupId`, `customData?`): `Promise`\<`void`\> -Defined in: [src/api.ts:788](../src/api.ts#L788) +Defined in: [src/api.ts:826](../src/api.ts#L826) Set group custom data. Network usage: no. @@ -1172,7 +1209,7 @@ Network usage: no. > **apiSetGroupLinkMemberRole**(`groupId`, `memberRole`): `Promise`\<`void`\> -Defined in: [src/api.ts:644](../src/api.ts#L644) +Defined in: [src/api.ts:663](../src/api.ts#L663) Set member role for group link. Network usage: no. @@ -1197,7 +1234,7 @@ Network usage: no. > **apiSetMembersRole**(`groupId`, `groupMemberIds`, `memberRole`): `Promise`\<`void`\> -Defined in: [src/api.ts:561](../src/api.ts#L561) +Defined in: [src/api.ts:580](../src/api.ts#L580) Set members role. Requires Admin role. Network usage: background. @@ -1226,7 +1263,7 @@ Network usage: background. > **apiSetProfileAddress**(`userId`, `enable`): `Promise`\<`UserProfileUpdateSummary`\> -Defined in: [src/api.ts:384](../src/api.ts#L384) +Defined in: [src/api.ts:399](../src/api.ts#L399) Add address to bot profile. Network usage: interactive. @@ -1251,7 +1288,7 @@ Network usage: interactive. > **apiUpdateChatItem**(`chatType`, `chatId`, `chatItemId`, `msgContent`, `liveMessage`): `Promise`\<`ChatItem`\> -Defined in: [src/api.ts:453](../src/api.ts#L453) +Defined in: [src/api.ts:471](../src/api.ts#L471) Update message. Network usage: background. @@ -1288,7 +1325,7 @@ Network usage: background. > **apiUpdateGroupProfile**(`groupId`, `groupProfile`): `Promise`\<`GroupInfo`\> -Defined in: [src/api.ts:621](../src/api.ts#L621) +Defined in: [src/api.ts:640](../src/api.ts#L640) Update group profile. Network usage: background. @@ -1313,7 +1350,7 @@ Network usage: background. > **apiUpdateProfile**(`userId`, `profile`): `Promise`\<`UserProfileUpdateSummary` \| `undefined`\> -Defined in: [src/api.ts:889](../src/api.ts#L889) +Defined in: [src/api.ts:927](../src/api.ts#L927) Update user profile. Network usage: background. @@ -1338,9 +1375,10 @@ Network usage: background. > **close**(): `Promise`\<`void`\> -Defined in: [src/api.ts:148](../src/api.ts#L148) +Defined in: [src/api.ts:158](../src/api.ts#L158) -Close chat database. +Stop chat controller and close chat database. +The database is not closed if stopping fails. Usually doesn't need to be called in chat bots. #### Returns @@ -1353,7 +1391,7 @@ Usually doesn't need to be called in chat bots. > **off**\<`K`\>(`event`, `subscriber?`): `void` -Defined in: [src/api.ts:287](../src/api.ts#L287) +Defined in: [src/api.ts:302](../src/api.ts#L302) Unsubscribe all or a specific handler from a specific event. @@ -1387,7 +1425,7 @@ An optional subscriber function for the event. > **offAny**(`receiver?`): `void` -Defined in: [src/api.ts:303](../src/api.ts#L303) +Defined in: [src/api.ts:318](../src/api.ts#L318) Unsubscribe all or a specific handler from any events. @@ -1411,7 +1449,7 @@ An optional subscriber function for the event. > **on**\<`K`\>(`subscribers`): `void` -Defined in: [src/api.ts:197](../src/api.ts#L197) +Defined in: [src/api.ts:212](../src/api.ts#L212) Subscribe multiple event handlers at once. @@ -1441,7 +1479,7 @@ If the same function is subscribed to event. > **on**\<`K`\>(`event`, `subscriber`): `void` -Defined in: [src/api.ts:205](../src/api.ts#L205) +Defined in: [src/api.ts:220](../src/api.ts#L220) Subscribe a handler to a specific event. @@ -1479,7 +1517,7 @@ If the same function is subscribed to event. > **onAny**(`receiver`): `void` -Defined in: [src/api.ts:228](../src/api.ts#L228) +Defined in: [src/api.ts:243](../src/api.ts#L243) Subscribe a handler to any event. @@ -1505,7 +1543,7 @@ If the same function is subscribed to event. > **once**\<`K`\>(`event`, `subscriber`): `void` -Defined in: [src/api.ts:239](../src/api.ts#L239) +Defined in: [src/api.ts:254](../src/api.ts#L254) Subscribe a handler to a specific event to be delivered one time. @@ -1543,13 +1581,13 @@ If the same function is subscribed to event. > **recvChatEvent**(`wait?`): `Promise`\<`ChatEvent` \| `undefined`\> -Defined in: [src/api.ts:338](../src/api.ts#L338) +Defined in: [src/api.ts:353](../src/api.ts#L353) #### Parameters ##### wait? -`number` = `5_000_000` +`number` = `500_000` #### Returns @@ -1561,7 +1599,7 @@ Defined in: [src/api.ts:338](../src/api.ts#L338) > **sendChatCmd**(`cmd`): `Promise`\<`ChatResponse`\> -Defined in: [src/api.ts:334](../src/api.ts#L334) +Defined in: [src/api.ts:349](../src/api.ts#L349) #### Parameters @@ -1579,7 +1617,7 @@ Defined in: [src/api.ts:334](../src/api.ts#L334) > **startChat**(): `Promise`\<`void`\> -Defined in: [src/api.ts:122](../src/api.ts#L122) +Defined in: [src/api.ts:124](../src/api.ts#L124) Start chat controller. Must be called with the existing user profile. @@ -1593,10 +1631,10 @@ Start chat controller. Must be called with the existing user profile. > **stopChat**(): `Promise`\<`void`\> -Defined in: [src/api.ts:136](../src/api.ts#L136) +Defined in: [src/api.ts:147](../src/api.ts#L147) Stop chat controller. -Must be called before closing the database. +`close` calls it before closing the database. Usually doesn't need to be called in chat bots. #### Returns @@ -1611,7 +1649,7 @@ Usually doesn't need to be called in chat bots. > **wait**\<`K`\>(`event`): `Promise`\<`ChatEvent` & `object`\> -Defined in: [src/api.ts:247](../src/api.ts#L247) +Defined in: [src/api.ts:262](../src/api.ts#L262) Waits for specific event, with an optional predicate. Returns `undefined` on timeout if specified. @@ -1636,7 +1674,7 @@ Returns `undefined` on timeout if specified. > **wait**\<`K`\>(`event`, `predicate`): `Promise`\<`ChatEvent` & `object`\> -Defined in: [src/api.ts:248](../src/api.ts#L248) +Defined in: [src/api.ts:263](../src/api.ts#L263) Waits for specific event, with an optional predicate. Returns `undefined` on timeout if specified. @@ -1665,7 +1703,7 @@ Returns `undefined` on timeout if specified. > **wait**\<`K`\>(`event`, `timeout`): `Promise`\ -Defined in: [src/api.ts:249](../src/api.ts#L249) +Defined in: [src/api.ts:264](../src/api.ts#L264) Waits for specific event, with an optional predicate. Returns `undefined` on timeout if specified. @@ -1694,7 +1732,7 @@ Returns `undefined` on timeout if specified. > **wait**\<`K`\>(`event`, `predicate`, `timeout`): `Promise`\ -Defined in: [src/api.ts:250](../src/api.ts#L250) +Defined in: [src/api.ts:265](../src/api.ts#L265) Waits for specific event, with an optional predicate. Returns `undefined` on timeout if specified. @@ -1727,9 +1765,9 @@ Returns `undefined` on timeout if specified. ### init() -> `static` **init**(`db`, `confirm?`): `Promise`\<`ChatApi`\> +> `static` **init**(`db`, `confirm?`, `queueSize?`): `Promise`\<`ChatApi`\> -Defined in: [src/api.ts:110](../src/api.ts#L110) +Defined in: [src/api.ts:111](../src/api.ts#L111) Initializes the ChatApi. @@ -1747,6 +1785,12 @@ Database configuration (sqlite or postgres). Migration confirmation mode. +##### queueSize? + +`number` + +Size of internal queues, the core default is used when omitted. + #### Returns `Promise`\<`ChatApi`\> diff --git a/packages/simplex-chat-nodejs/docs/bot.Function.run.md b/packages/simplex-chat-nodejs/docs/bot.Function.run.md index 3c33e6c7d4..0af2644bc2 100644 --- a/packages/simplex-chat-nodejs/docs/bot.Function.run.md +++ b/packages/simplex-chat-nodejs/docs/bot.Function.run.md @@ -8,7 +8,7 @@ > **run**(`__namedParameters`): `Promise`\<\[[`ChatApi`](api.Class.ChatApi.md), `User`, `UserContactLink` \| `undefined`\]\> -Defined in: [src/bot.ts:47](../src/bot.ts#L47) +Defined in: [src/bot.ts:48](../src/bot.ts#L48) ## Parameters diff --git a/packages/simplex-chat-nodejs/docs/bot.Function.subscribeChatItems.md b/packages/simplex-chat-nodejs/docs/bot.Function.subscribeChatItems.md new file mode 100644 index 0000000000..5397cf3aeb --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/bot.Function.subscribeChatItems.md @@ -0,0 +1,27 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [bot](Namespace.bot.md) / subscribeChatItems + +# Function: subscribeChatItems() + +> **subscribeChatItems**(`bot`, `onMessage`, `commands`): `void` + +Defined in: [src/bot.ts:108](../src/bot.ts#L108) + +## Parameters + +### bot + +[`ChatApi`](api.Class.ChatApi.md) + +### onMessage + +((`chatItem`, `content`) => `void` \| `Promise`\<`void`\>) \| `undefined` + +### commands + +## Returns + +`void` diff --git a/packages/simplex-chat-nodejs/docs/bot.Interface.BotConfig.md b/packages/simplex-chat-nodejs/docs/bot.Interface.BotConfig.md index 4624b1608b..f763f414da 100644 --- a/packages/simplex-chat-nodejs/docs/bot.Interface.BotConfig.md +++ b/packages/simplex-chat-nodejs/docs/bot.Interface.BotConfig.md @@ -6,7 +6,7 @@ # Interface: BotConfig -Defined in: [src/bot.ts:35](../src/bot.ts#L35) +Defined in: [src/bot.ts:36](../src/bot.ts#L36) ## Properties @@ -14,7 +14,7 @@ Defined in: [src/bot.ts:35](../src/bot.ts#L35) > **dbOpts**: [`BotDbOpts`](bot.TypeAlias.BotDbOpts.md) -Defined in: [src/bot.ts:37](../src/bot.ts#L37) +Defined in: [src/bot.ts:38](../src/bot.ts#L38) *** @@ -22,7 +22,7 @@ Defined in: [src/bot.ts:37](../src/bot.ts#L37) > `optional` **events?**: [`EventSubscribers`](api.TypeAlias.EventSubscribers.md) -Defined in: [src/bot.ts:44](../src/bot.ts#L44) +Defined in: [src/bot.ts:45](../src/bot.ts#L45) *** @@ -30,7 +30,7 @@ Defined in: [src/bot.ts:44](../src/bot.ts#L44) > `optional` **onCommands?**: `object` -Defined in: [src/bot.ts:41](../src/bot.ts#L41) +Defined in: [src/bot.ts:42](../src/bot.ts#L42) #### Index Signature @@ -42,7 +42,7 @@ Defined in: [src/bot.ts:41](../src/bot.ts#L41) > `optional` **onMessage?**: (`chatItem`, `content`) => `void` \| `Promise`\<`void`\> -Defined in: [src/bot.ts:39](../src/bot.ts#L39) +Defined in: [src/bot.ts:40](../src/bot.ts#L40) #### Parameters @@ -64,7 +64,7 @@ Defined in: [src/bot.ts:39](../src/bot.ts#L39) > **options**: [`BotOptions`](bot.Interface.BotOptions.md) -Defined in: [src/bot.ts:38](../src/bot.ts#L38) +Defined in: [src/bot.ts:39](../src/bot.ts#L39) *** @@ -72,4 +72,4 @@ Defined in: [src/bot.ts:38](../src/bot.ts#L38) > **profile**: `Profile` -Defined in: [src/bot.ts:36](../src/bot.ts#L36) +Defined in: [src/bot.ts:37](../src/bot.ts#L37) diff --git a/packages/simplex-chat-nodejs/docs/bot.Interface.BotOptions.md b/packages/simplex-chat-nodejs/docs/bot.Interface.BotOptions.md index eee56b879a..c5d5df5193 100644 --- a/packages/simplex-chat-nodejs/docs/bot.Interface.BotOptions.md +++ b/packages/simplex-chat-nodejs/docs/bot.Interface.BotOptions.md @@ -6,7 +6,7 @@ # Interface: BotOptions -Defined in: [src/bot.ts:11](../src/bot.ts#L11) +Defined in: [src/bot.ts:12](../src/bot.ts#L12) ## Properties @@ -14,7 +14,7 @@ Defined in: [src/bot.ts:11](../src/bot.ts#L11) > `optional` **addressSettings?**: [`BotAddressSettings`](api.Interface.BotAddressSettings.md) -Defined in: [src/bot.ts:15](../src/bot.ts#L15) +Defined in: [src/bot.ts:16](../src/bot.ts#L16) *** @@ -22,7 +22,7 @@ Defined in: [src/bot.ts:15](../src/bot.ts#L15) > `optional` **allowFiles?**: `boolean` -Defined in: [src/bot.ts:16](../src/bot.ts#L16) +Defined in: [src/bot.ts:17](../src/bot.ts#L17) *** @@ -30,7 +30,7 @@ Defined in: [src/bot.ts:16](../src/bot.ts#L16) > `optional` **commands?**: `ChatBotCommand`[] -Defined in: [src/bot.ts:17](../src/bot.ts#L17) +Defined in: [src/bot.ts:18](../src/bot.ts#L18) *** @@ -38,7 +38,7 @@ Defined in: [src/bot.ts:17](../src/bot.ts#L17) > `optional` **createAddress?**: `boolean` -Defined in: [src/bot.ts:12](../src/bot.ts#L12) +Defined in: [src/bot.ts:13](../src/bot.ts#L13) *** @@ -46,7 +46,7 @@ Defined in: [src/bot.ts:12](../src/bot.ts#L12) > `optional` **logContacts?**: `boolean` -Defined in: [src/bot.ts:19](../src/bot.ts#L19) +Defined in: [src/bot.ts:20](../src/bot.ts#L20) *** @@ -54,7 +54,7 @@ Defined in: [src/bot.ts:19](../src/bot.ts#L19) > `optional` **logNetwork?**: `boolean` -Defined in: [src/bot.ts:20](../src/bot.ts#L20) +Defined in: [src/bot.ts:21](../src/bot.ts#L21) *** @@ -62,7 +62,7 @@ Defined in: [src/bot.ts:20](../src/bot.ts#L20) > `optional` **updateAddress?**: `boolean` -Defined in: [src/bot.ts:13](../src/bot.ts#L13) +Defined in: [src/bot.ts:14](../src/bot.ts#L14) *** @@ -70,7 +70,7 @@ Defined in: [src/bot.ts:13](../src/bot.ts#L13) > `optional` **updateProfile?**: `boolean` -Defined in: [src/bot.ts:14](../src/bot.ts#L14) +Defined in: [src/bot.ts:15](../src/bot.ts#L15) *** @@ -78,4 +78,4 @@ Defined in: [src/bot.ts:14](../src/bot.ts#L14) > `optional` **useBotProfile?**: `boolean` -Defined in: [src/bot.ts:18](../src/bot.ts#L18) +Defined in: [src/bot.ts:19](../src/bot.ts#L19) diff --git a/packages/simplex-chat-nodejs/docs/bot.TypeAlias.BotDbOpts.md b/packages/simplex-chat-nodejs/docs/bot.TypeAlias.BotDbOpts.md index b035f41355..5aeb298a09 100644 --- a/packages/simplex-chat-nodejs/docs/bot.TypeAlias.BotDbOpts.md +++ b/packages/simplex-chat-nodejs/docs/bot.TypeAlias.BotDbOpts.md @@ -15,3 +15,7 @@ Defined in: [src/bot.ts:7](../src/bot.ts#L7) ### confirmMigrations? > `optional` **confirmMigrations?**: [`MigrationConfirmation`](core.Enumeration.MigrationConfirmation.md) + +### queueSize? + +> `optional` **queueSize?**: `number` diff --git a/packages/simplex-chat-nodejs/docs/core.Class.ChatAPIError.md b/packages/simplex-chat-nodejs/docs/core.Class.ChatAPIError.md index 5bd0722f0c..3953d777da 100644 --- a/packages/simplex-chat-nodejs/docs/core.Class.ChatAPIError.md +++ b/packages/simplex-chat-nodejs/docs/core.Class.ChatAPIError.md @@ -6,7 +6,7 @@ # Class: ChatAPIError -Defined in: [src/core.ts:92](../src/core.ts#L92) +Defined in: [src/core.ts:95](../src/core.ts#L95) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:92](../src/core.ts#L92) > **new ChatAPIError**(`message`, `chatError?`): `ChatAPIError` -Defined in: [src/core.ts:93](../src/core.ts#L93) +Defined in: [src/core.ts:96](../src/core.ts#L96) #### Parameters @@ -44,7 +44,7 @@ Defined in: [src/core.ts:93](../src/core.ts#L93) > **chatError**: `ChatError` \| `undefined` = `undefined` -Defined in: [src/core.ts:93](../src/core.ts#L93) +Defined in: [src/core.ts:96](../src/core.ts#L96) *** @@ -52,7 +52,7 @@ Defined in: [src/core.ts:93](../src/core.ts#L93) > **message**: `string` -Defined in: [src/core.ts:93](../src/core.ts#L93) +Defined in: [src/core.ts:96](../src/core.ts#L96) #### Inherited from diff --git a/packages/simplex-chat-nodejs/docs/core.Class.ChatInitError.md b/packages/simplex-chat-nodejs/docs/core.Class.ChatInitError.md index 0feceae4fd..649090e3ac 100644 --- a/packages/simplex-chat-nodejs/docs/core.Class.ChatInitError.md +++ b/packages/simplex-chat-nodejs/docs/core.Class.ChatInitError.md @@ -6,7 +6,7 @@ # Class: ChatInitError -Defined in: [src/core.ts:116](../src/core.ts#L116) +Defined in: [src/core.ts:119](../src/core.ts#L119) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:116](../src/core.ts#L116) > **new ChatInitError**(`message`, `dbMigrationError`): `ChatInitError` -Defined in: [src/core.ts:117](../src/core.ts#L117) +Defined in: [src/core.ts:120](../src/core.ts#L120) #### Parameters @@ -44,7 +44,7 @@ Defined in: [src/core.ts:117](../src/core.ts#L117) > **dbMigrationError**: [`DBMigrationError`](core.TypeAlias.DBMigrationError.md) -Defined in: [src/core.ts:117](../src/core.ts#L117) +Defined in: [src/core.ts:120](../src/core.ts#L120) *** @@ -52,7 +52,7 @@ Defined in: [src/core.ts:117](../src/core.ts#L117) > **message**: `string` -Defined in: [src/core.ts:117](../src/core.ts#L117) +Defined in: [src/core.ts:120](../src/core.ts#L120) #### Inherited from diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorMigration.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorMigration.md index 02cf84b763..fea7bfb531 100644 --- a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorMigration.md +++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorMigration.md @@ -6,7 +6,7 @@ # Interface: ErrorMigration -Defined in: [src/core.ts:144](../src/core.ts#L144) +Defined in: [src/core.ts:152](../src/core.ts#L152) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:144](../src/core.ts#L144) > **dbFile**: `string` -Defined in: [src/core.ts:146](../src/core.ts#L146) +Defined in: [src/core.ts:154](../src/core.ts#L154) *** @@ -26,7 +26,7 @@ Defined in: [src/core.ts:146](../src/core.ts#L146) > **migrationError**: [`MigrationError`](core.TypeAlias.MigrationError.md) -Defined in: [src/core.ts:147](../src/core.ts#L147) +Defined in: [src/core.ts:155](../src/core.ts#L155) *** @@ -34,7 +34,7 @@ Defined in: [src/core.ts:147](../src/core.ts#L147) > **type**: `"errorMigration"` -Defined in: [src/core.ts:145](../src/core.ts#L145) +Defined in: [src/core.ts:153](../src/core.ts#L153) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorNotADatabase.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorNotADatabase.md index 18e2429081..eb0bfb11cb 100644 --- a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorNotADatabase.md +++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorNotADatabase.md @@ -6,7 +6,7 @@ # Interface: ErrorNotADatabase -Defined in: [src/core.ts:139](../src/core.ts#L139) +Defined in: [src/core.ts:147](../src/core.ts#L147) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:139](../src/core.ts#L139) > **dbFile**: `string` -Defined in: [src/core.ts:141](../src/core.ts#L141) +Defined in: [src/core.ts:149](../src/core.ts#L149) *** @@ -26,7 +26,7 @@ Defined in: [src/core.ts:141](../src/core.ts#L141) > **type**: `"errorNotADatabase"` -Defined in: [src/core.ts:140](../src/core.ts#L140) +Defined in: [src/core.ts:148](../src/core.ts#L148) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorSQL.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorSQL.md index 4d85b04197..ffaa364f9b 100644 --- a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorSQL.md +++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorSQL.md @@ -6,7 +6,7 @@ # Interface: ErrorSQL -Defined in: [src/core.ts:150](../src/core.ts#L150) +Defined in: [src/core.ts:158](../src/core.ts#L158) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:150](../src/core.ts#L150) > **dbFile**: `string` -Defined in: [src/core.ts:152](../src/core.ts#L152) +Defined in: [src/core.ts:160](../src/core.ts#L160) *** @@ -26,7 +26,7 @@ Defined in: [src/core.ts:152](../src/core.ts#L152) > **migrationSQLError**: `string` -Defined in: [src/core.ts:153](../src/core.ts#L153) +Defined in: [src/core.ts:161](../src/core.ts#L161) *** @@ -34,7 +34,7 @@ Defined in: [src/core.ts:153](../src/core.ts#L153) > **type**: `"errorSQL"` -Defined in: [src/core.ts:151](../src/core.ts#L151) +Defined in: [src/core.ts:159](../src/core.ts#L159) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidConfirmation.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidConfirmation.md index 34dea63aed..67711030da 100644 --- a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidConfirmation.md +++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidConfirmation.md @@ -6,7 +6,7 @@ # Interface: InvalidConfirmation -Defined in: [src/core.ts:135](../src/core.ts#L135) +Defined in: [src/core.ts:139](../src/core.ts#L139) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:135](../src/core.ts#L135) > **type**: `"invalidConfirmation"` -Defined in: [src/core.ts:136](../src/core.ts#L136) +Defined in: [src/core.ts:140](../src/core.ts#L140) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidQueueSize.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidQueueSize.md new file mode 100644 index 0000000000..9093b5e9b1 --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidQueueSize.md @@ -0,0 +1,25 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [core](Namespace.core.md) / [DBMigrationError](core.Namespace.DBMigrationError.md) / InvalidQueueSize + +# Interface: InvalidQueueSize + +Defined in: [src/core.ts:143](../src/core.ts#L143) + +## Extends + +- `Interface` + +## Properties + +### type + +> **type**: `"invalidQueueSize"` + +Defined in: [src/core.ts:144](../src/core.ts#L144) + +#### Overrides + +`Interface.type` diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.TypeAlias.Tag.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.TypeAlias.Tag.md index a3ef341601..b04f2f5af1 100644 --- a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.TypeAlias.Tag.md +++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.TypeAlias.Tag.md @@ -6,6 +6,6 @@ # Type Alias: Tag -> **Tag** = `"invalidConfirmation"` \| `"errorNotADatabase"` \| `"errorMigration"` \| `"errorSQL"` +> **Tag** = `"invalidConfirmation"` \| `"invalidQueueSize"` \| `"errorNotADatabase"` \| `"errorMigration"` \| `"errorSQL"` -Defined in: [src/core.ts:129](../src/core.ts#L129) +Defined in: [src/core.ts:133](../src/core.ts#L133) diff --git a/packages/simplex-chat-nodejs/docs/core.Enumeration.MigrationConfirmation.md b/packages/simplex-chat-nodejs/docs/core.Enumeration.MigrationConfirmation.md index 7dfd4991bf..48e250c7d4 100644 --- a/packages/simplex-chat-nodejs/docs/core.Enumeration.MigrationConfirmation.md +++ b/packages/simplex-chat-nodejs/docs/core.Enumeration.MigrationConfirmation.md @@ -6,7 +6,7 @@ # Enumeration: MigrationConfirmation -Defined in: [src/core.ts:101](../src/core.ts#L101) +Defined in: [src/core.ts:104](../src/core.ts#L104) Migration confirmation mode @@ -16,7 +16,7 @@ Migration confirmation mode > **Console**: `"console"` -Defined in: [src/core.ts:104](../src/core.ts#L104) +Defined in: [src/core.ts:107](../src/core.ts#L107) *** @@ -24,7 +24,7 @@ Defined in: [src/core.ts:104](../src/core.ts#L104) > **Error**: `"error"` -Defined in: [src/core.ts:105](../src/core.ts#L105) +Defined in: [src/core.ts:108](../src/core.ts#L108) *** @@ -32,7 +32,7 @@ Defined in: [src/core.ts:105](../src/core.ts#L105) > **YesUp**: `"yesUp"` -Defined in: [src/core.ts:102](../src/core.ts#L102) +Defined in: [src/core.ts:105](../src/core.ts#L105) *** @@ -40,4 +40,4 @@ Defined in: [src/core.ts:102](../src/core.ts#L102) > **YesUpDown**: `"yesUpDown"` -Defined in: [src/core.ts:103](../src/core.ts#L103) +Defined in: [src/core.ts:106](../src/core.ts#L106) diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatCloseStore.md b/packages/simplex-chat-nodejs/docs/core.Function.chatCloseStore.md index deeb3213fd..4b0324b92c 100644 --- a/packages/simplex-chat-nodejs/docs/core.Function.chatCloseStore.md +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatCloseStore.md @@ -8,7 +8,7 @@ > **chatCloseStore**(`ctrl`): `Promise`\<`void`\> -Defined in: [src/core.ts:17](../src/core.ts#L17) +Defined in: [src/core.ts:20](../src/core.ts#L20) Close chat store diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatDecryptFile.md b/packages/simplex-chat-nodejs/docs/core.Function.chatDecryptFile.md index 434aeeaae8..b6786fe1cb 100644 --- a/packages/simplex-chat-nodejs/docs/core.Function.chatDecryptFile.md +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatDecryptFile.md @@ -8,7 +8,7 @@ > **chatDecryptFile**(`fromPath`, `__namedParameters`, `toPath`): `Promise`\<`void`\> -Defined in: [src/core.ts:73](../src/core.ts#L73) +Defined in: [src/core.ts:76](../src/core.ts#L76) Decrypt file diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatEncryptFile.md b/packages/simplex-chat-nodejs/docs/core.Function.chatEncryptFile.md index 6aa0ad2923..9e30e55a23 100644 --- a/packages/simplex-chat-nodejs/docs/core.Function.chatEncryptFile.md +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatEncryptFile.md @@ -8,7 +8,7 @@ > **chatEncryptFile**(`ctrl`, `fromPath`, `toPath`): `Promise`\<[`CryptoArgs`](core.Interface.CryptoArgs.md)\> -Defined in: [src/core.ts:65](../src/core.ts#L65) +Defined in: [src/core.ts:68](../src/core.ts#L68) Encrypt file diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatMigrateInit.md b/packages/simplex-chat-nodejs/docs/core.Function.chatMigrateInit.md index 9116026f56..dbe4912520 100644 --- a/packages/simplex-chat-nodejs/docs/core.Function.chatMigrateInit.md +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatMigrateInit.md @@ -6,9 +6,9 @@ # Function: chatMigrateInit() -> **chatMigrateInit**(`dbPath`, `dbKey`, `confirm`): `Promise`\<`bigint`\> +> **chatMigrateInit**(`dbPath`, `dbKey`, `confirm`, `queueSize?`): `Promise`\<`bigint`\> -Defined in: [src/core.ts:7](../src/core.ts#L7) +Defined in: [src/core.ts:8](../src/core.ts#L8) Initialize chat controller @@ -26,6 +26,12 @@ Initialize chat controller [`MigrationConfirmation`](core.Enumeration.MigrationConfirmation.md) +### queueSize? + +`number` + +Size of internal queues, the core default is used when omitted. + ## Returns `Promise`\<`bigint`\> diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatReadFile.md b/packages/simplex-chat-nodejs/docs/core.Function.chatReadFile.md index 27de43e63c..f6713f22d8 100644 --- a/packages/simplex-chat-nodejs/docs/core.Function.chatReadFile.md +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatReadFile.md @@ -6,9 +6,9 @@ # Function: chatReadFile() -> **chatReadFile**(`path`, `__namedParameters`): `Promise`\<`ArrayBuffer`\> +> **chatReadFile**(`path`, `__namedParameters`): `Promise`\<`Buffer`\<`ArrayBufferLike`\>\> -Defined in: [src/core.ts:58](../src/core.ts#L58) +Defined in: [src/core.ts:61](../src/core.ts#L61) Read buffer from encrypted file @@ -24,4 +24,4 @@ Read buffer from encrypted file ## Returns -`Promise`\<`ArrayBuffer`\> +`Promise`\<`Buffer`\<`ArrayBufferLike`\>\> diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatRecvMsgWait.md b/packages/simplex-chat-nodejs/docs/core.Function.chatRecvMsgWait.md index 9bf44d6523..719cf610a1 100644 --- a/packages/simplex-chat-nodejs/docs/core.Function.chatRecvMsgWait.md +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatRecvMsgWait.md @@ -8,7 +8,7 @@ > **chatRecvMsgWait**(`ctrl`, `wait`): `Promise`\<`ChatEvent` \| `undefined`\> -Defined in: [src/core.ts:37](../src/core.ts#L37) +Defined in: [src/core.ts:40](../src/core.ts#L40) Receive chat event diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatSendCmd.md b/packages/simplex-chat-nodejs/docs/core.Function.chatSendCmd.md index 2dfcba45b4..4296a714cf 100644 --- a/packages/simplex-chat-nodejs/docs/core.Function.chatSendCmd.md +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatSendCmd.md @@ -8,7 +8,7 @@ > **chatSendCmd**(`ctrl`, `cmd`): `Promise`\<`ChatResponse`\> -Defined in: [src/core.ts:25](../src/core.ts#L25) +Defined in: [src/core.ts:28](../src/core.ts#L28) Send chat command as string diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatWriteFile.md b/packages/simplex-chat-nodejs/docs/core.Function.chatWriteFile.md index 3b1d770fbb..4ca640d8c4 100644 --- a/packages/simplex-chat-nodejs/docs/core.Function.chatWriteFile.md +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatWriteFile.md @@ -8,7 +8,7 @@ > **chatWriteFile**(`ctrl`, `path`, `buffer`): `Promise`\<[`CryptoArgs`](core.Interface.CryptoArgs.md)\> -Defined in: [src/core.ts:50](../src/core.ts#L50) +Defined in: [src/core.ts:53](../src/core.ts#L53) Write buffer to encrypted file @@ -24,7 +24,7 @@ Write buffer to encrypted file ### buffer -`ArrayBuffer` +`ArrayBuffer` \| `Uint8Array`\<`ArrayBufferLike`\> ## Returns diff --git a/packages/simplex-chat-nodejs/docs/core.Interface.APIResult.md b/packages/simplex-chat-nodejs/docs/core.Interface.APIResult.md index 8d18997ec4..2ede6d9ffb 100644 --- a/packages/simplex-chat-nodejs/docs/core.Interface.APIResult.md +++ b/packages/simplex-chat-nodejs/docs/core.Interface.APIResult.md @@ -6,7 +6,7 @@ # Interface: APIResult\ -Defined in: [src/core.ts:87](../src/core.ts#L87) +Defined in: [src/core.ts:90](../src/core.ts#L90) ## Type Parameters @@ -20,7 +20,7 @@ Defined in: [src/core.ts:87](../src/core.ts#L87) > `optional` **error?**: `ChatError` -Defined in: [src/core.ts:89](../src/core.ts#L89) +Defined in: [src/core.ts:92](../src/core.ts#L92) *** @@ -28,4 +28,4 @@ Defined in: [src/core.ts:89](../src/core.ts#L89) > `optional` **result?**: `R` -Defined in: [src/core.ts:88](../src/core.ts#L88) +Defined in: [src/core.ts:91](../src/core.ts#L91) diff --git a/packages/simplex-chat-nodejs/docs/core.Interface.CryptoArgs.md b/packages/simplex-chat-nodejs/docs/core.Interface.CryptoArgs.md index eddcb0bc5a..51a55b86b6 100644 --- a/packages/simplex-chat-nodejs/docs/core.Interface.CryptoArgs.md +++ b/packages/simplex-chat-nodejs/docs/core.Interface.CryptoArgs.md @@ -6,7 +6,7 @@ # Interface: CryptoArgs -Defined in: [src/core.ts:111](../src/core.ts#L111) +Defined in: [src/core.ts:114](../src/core.ts#L114) File encryption key and nonce @@ -16,7 +16,7 @@ File encryption key and nonce > **fileKey**: `string` -Defined in: [src/core.ts:112](../src/core.ts#L112) +Defined in: [src/core.ts:115](../src/core.ts#L115) *** @@ -24,4 +24,4 @@ Defined in: [src/core.ts:112](../src/core.ts#L112) > **fileNonce**: `string` -Defined in: [src/core.ts:113](../src/core.ts#L113) +Defined in: [src/core.ts:116](../src/core.ts#L116) diff --git a/packages/simplex-chat-nodejs/docs/core.Interface.UpMigration.md b/packages/simplex-chat-nodejs/docs/core.Interface.UpMigration.md index 32f6c267aa..9e1fca49b4 100644 --- a/packages/simplex-chat-nodejs/docs/core.Interface.UpMigration.md +++ b/packages/simplex-chat-nodejs/docs/core.Interface.UpMigration.md @@ -6,7 +6,7 @@ # Interface: UpMigration -Defined in: [src/core.ts:185](../src/core.ts#L185) +Defined in: [src/core.ts:193](../src/core.ts#L193) ## Properties @@ -14,7 +14,7 @@ Defined in: [src/core.ts:185](../src/core.ts#L185) > **upName**: `string` -Defined in: [src/core.ts:186](../src/core.ts#L186) +Defined in: [src/core.ts:194](../src/core.ts#L194) *** @@ -22,4 +22,4 @@ Defined in: [src/core.ts:186](../src/core.ts#L186) > **withDown**: `boolean` -Defined in: [src/core.ts:187](../src/core.ts#L187) +Defined in: [src/core.ts:195](../src/core.ts#L195) diff --git a/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTREDifferent.md b/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTREDifferent.md index 8dab81a3a3..f85fb492be 100644 --- a/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTREDifferent.md +++ b/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTREDifferent.md @@ -6,7 +6,7 @@ # Interface: MTREDifferent -Defined in: [src/core.ts:206](../src/core.ts#L206) +Defined in: [src/core.ts:214](../src/core.ts#L214) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:206](../src/core.ts#L206) > **downMigrations**: `string`[] -Defined in: [src/core.ts:208](../src/core.ts#L208) +Defined in: [src/core.ts:216](../src/core.ts#L216) *** @@ -26,7 +26,7 @@ Defined in: [src/core.ts:208](../src/core.ts#L208) > **type**: `"different"` -Defined in: [src/core.ts:207](../src/core.ts#L207) +Defined in: [src/core.ts:215](../src/core.ts#L215) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTRENoDown.md b/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTRENoDown.md index 1de634e40e..f031ac8982 100644 --- a/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTRENoDown.md +++ b/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTRENoDown.md @@ -6,7 +6,7 @@ # Interface: MTRENoDown -Defined in: [src/core.ts:201](../src/core.ts#L201) +Defined in: [src/core.ts:209](../src/core.ts#L209) ## Extends @@ -14,20 +14,20 @@ Defined in: [src/core.ts:201](../src/core.ts#L201) ## Properties +### dbMigrations + +> **dbMigrations**: `string`[] + +Defined in: [src/core.ts:211](../src/core.ts#L211) + +*** + ### type > **type**: `"noDown"` -Defined in: [src/core.ts:202](../src/core.ts#L202) +Defined in: [src/core.ts:210](../src/core.ts#L210) #### Overrides `Interface.type` - -*** - -### upMigrations - -> **upMigrations**: [`UpMigration`](core.Interface.UpMigration.md) - -Defined in: [src/core.ts:203](../src/core.ts#L203) diff --git a/packages/simplex-chat-nodejs/docs/core.MTRError.TypeAlias.Tag.md b/packages/simplex-chat-nodejs/docs/core.MTRError.TypeAlias.Tag.md index 768baa0068..59501b539a 100644 --- a/packages/simplex-chat-nodejs/docs/core.MTRError.TypeAlias.Tag.md +++ b/packages/simplex-chat-nodejs/docs/core.MTRError.TypeAlias.Tag.md @@ -8,4 +8,4 @@ > **Tag** = `"noDown"` \| `"different"` -Defined in: [src/core.ts:195](../src/core.ts#L195) +Defined in: [src/core.ts:203](../src/core.ts#L203) diff --git a/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEDowngrade.md b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEDowngrade.md index a2742cbff2..f6202b9456 100644 --- a/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEDowngrade.md +++ b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEDowngrade.md @@ -6,7 +6,7 @@ # Interface: MEDowngrade -Defined in: [src/core.ts:174](../src/core.ts#L174) +Defined in: [src/core.ts:182](../src/core.ts#L182) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:174](../src/core.ts#L174) > **downMigrations**: `string`[] -Defined in: [src/core.ts:176](../src/core.ts#L176) +Defined in: [src/core.ts:184](../src/core.ts#L184) *** @@ -26,7 +26,7 @@ Defined in: [src/core.ts:176](../src/core.ts#L176) > **type**: `"downgrade"` -Defined in: [src/core.ts:175](../src/core.ts#L175) +Defined in: [src/core.ts:183](../src/core.ts#L183) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEUpgrade.md b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEUpgrade.md index 08fe7d56e3..7467895b74 100644 --- a/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEUpgrade.md +++ b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEUpgrade.md @@ -6,7 +6,7 @@ # Interface: MEUpgrade -Defined in: [src/core.ts:169](../src/core.ts#L169) +Defined in: [src/core.ts:177](../src/core.ts#L177) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:169](../src/core.ts#L169) > **type**: `"upgrade"` -Defined in: [src/core.ts:170](../src/core.ts#L170) +Defined in: [src/core.ts:178](../src/core.ts#L178) #### Overrides @@ -28,6 +28,6 @@ Defined in: [src/core.ts:170](../src/core.ts#L170) ### upMigrations -> **upMigrations**: [`UpMigration`](core.Interface.UpMigration.md) +> **upMigrations**: [`UpMigration`](core.Interface.UpMigration.md)[] -Defined in: [src/core.ts:171](../src/core.ts#L171) +Defined in: [src/core.ts:179](../src/core.ts#L179) diff --git a/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MigrationError.md b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MigrationError.md index cd811a5747..7b2546fcd5 100644 --- a/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MigrationError.md +++ b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MigrationError.md @@ -6,7 +6,7 @@ # Interface: MigrationError -Defined in: [src/core.ts:179](../src/core.ts#L179) +Defined in: [src/core.ts:187](../src/core.ts#L187) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:179](../src/core.ts#L179) > **mtrError**: [`MTRError`](core.TypeAlias.MTRError.md) -Defined in: [src/core.ts:181](../src/core.ts#L181) +Defined in: [src/core.ts:189](../src/core.ts#L189) *** @@ -26,7 +26,7 @@ Defined in: [src/core.ts:181](../src/core.ts#L181) > **type**: `"migrationError"` -Defined in: [src/core.ts:180](../src/core.ts#L180) +Defined in: [src/core.ts:188](../src/core.ts#L188) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.MigrationError.TypeAlias.Tag.md b/packages/simplex-chat-nodejs/docs/core.MigrationError.TypeAlias.Tag.md index 5ef6e70b08..e2e1dcb33f 100644 --- a/packages/simplex-chat-nodejs/docs/core.MigrationError.TypeAlias.Tag.md +++ b/packages/simplex-chat-nodejs/docs/core.MigrationError.TypeAlias.Tag.md @@ -8,4 +8,4 @@ > **Tag** = `"upgrade"` \| `"downgrade"` \| `"migrationError"` -Defined in: [src/core.ts:163](../src/core.ts#L163) +Defined in: [src/core.ts:171](../src/core.ts#L171) diff --git a/packages/simplex-chat-nodejs/docs/core.Namespace.DBMigrationError.md b/packages/simplex-chat-nodejs/docs/core.Namespace.DBMigrationError.md index 95ddfa5b24..889a10ddba 100644 --- a/packages/simplex-chat-nodejs/docs/core.Namespace.DBMigrationError.md +++ b/packages/simplex-chat-nodejs/docs/core.Namespace.DBMigrationError.md @@ -12,6 +12,7 @@ - [ErrorNotADatabase](core.DBMigrationError.Interface.ErrorNotADatabase.md) - [ErrorSQL](core.DBMigrationError.Interface.ErrorSQL.md) - [InvalidConfirmation](core.DBMigrationError.Interface.InvalidConfirmation.md) +- [InvalidQueueSize](core.DBMigrationError.Interface.InvalidQueueSize.md) ## Type Aliases diff --git a/packages/simplex-chat-nodejs/docs/core.TypeAlias.DBMigrationError.md b/packages/simplex-chat-nodejs/docs/core.TypeAlias.DBMigrationError.md index 6473b3ef60..3f4797fb8e 100644 --- a/packages/simplex-chat-nodejs/docs/core.TypeAlias.DBMigrationError.md +++ b/packages/simplex-chat-nodejs/docs/core.TypeAlias.DBMigrationError.md @@ -6,6 +6,6 @@ # Type Alias: DBMigrationError -> **DBMigrationError** = [`InvalidConfirmation`](core.DBMigrationError.Interface.InvalidConfirmation.md) \| [`ErrorNotADatabase`](core.DBMigrationError.Interface.ErrorNotADatabase.md) \| [`ErrorMigration`](core.DBMigrationError.Interface.ErrorMigration.md) \| [`ErrorSQL`](core.DBMigrationError.Interface.ErrorSQL.md) +> **DBMigrationError** = [`InvalidConfirmation`](core.DBMigrationError.Interface.InvalidConfirmation.md) \| [`InvalidQueueSize`](core.DBMigrationError.Interface.InvalidQueueSize.md) \| [`ErrorNotADatabase`](core.DBMigrationError.Interface.ErrorNotADatabase.md) \| [`ErrorMigration`](core.DBMigrationError.Interface.ErrorMigration.md) \| [`ErrorSQL`](core.DBMigrationError.Interface.ErrorSQL.md) -Defined in: [src/core.ts:122](../src/core.ts#L122) +Defined in: [src/core.ts:125](../src/core.ts#L125) diff --git a/packages/simplex-chat-nodejs/docs/core.TypeAlias.MTRError.md b/packages/simplex-chat-nodejs/docs/core.TypeAlias.MTRError.md index 11aa5b7c24..66d6a092dc 100644 --- a/packages/simplex-chat-nodejs/docs/core.TypeAlias.MTRError.md +++ b/packages/simplex-chat-nodejs/docs/core.TypeAlias.MTRError.md @@ -8,4 +8,4 @@ > **MTRError** = [`MTRENoDown`](core.MTRError.Interface.MTRENoDown.md) \| [`MTREDifferent`](core.MTRError.Interface.MTREDifferent.md) -Defined in: [src/core.ts:190](../src/core.ts#L190) +Defined in: [src/core.ts:198](../src/core.ts#L198) diff --git a/packages/simplex-chat-nodejs/docs/core.TypeAlias.MigrationError.md b/packages/simplex-chat-nodejs/docs/core.TypeAlias.MigrationError.md index c15b679769..6c0ba77541 100644 --- a/packages/simplex-chat-nodejs/docs/core.TypeAlias.MigrationError.md +++ b/packages/simplex-chat-nodejs/docs/core.TypeAlias.MigrationError.md @@ -8,4 +8,4 @@ > **MigrationError** = [`MEUpgrade`](core.MigrationError.Interface.MEUpgrade.md) \| [`MEDowngrade`](core.MigrationError.Interface.MEDowngrade.md) \| [`MigrationError`](core.MigrationError.Interface.MigrationError.md) -Defined in: [src/core.ts:157](../src/core.ts#L157) +Defined in: [src/core.ts:165](../src/core.ts#L165) diff --git a/packages/simplex-chat-nodejs/package.json b/packages/simplex-chat-nodejs/package.json index 0960334edc..f405be260d 100644 --- a/packages/simplex-chat-nodejs/package.json +++ b/packages/simplex-chat-nodejs/package.json @@ -1,6 +1,6 @@ { "name": "simplex-chat", - "version": "7.1.0-beta.3", + "version": "7.1.0-beta.4", "main": "dist/index.js", "types": "dist/index.d.ts", "files": [ @@ -24,7 +24,7 @@ "docs": "typedoc" }, "dependencies": { - "@simplex-chat/types": "^0.11.3", + "@simplex-chat/types": "^0.11.4", "extract-zip": "^2.0.1", "fast-deep-equal": "^3.1.3", "node-addon-api": "^8.5.0" diff --git a/packages/simplex-chat-nodejs/src/api.ts b/packages/simplex-chat-nodejs/src/api.ts index 958304a8ea..8c11e9fde3 100644 --- a/packages/simplex-chat-nodejs/src/api.ts +++ b/packages/simplex-chat-nodejs/src/api.ts @@ -106,13 +106,15 @@ export class ChatApi { * Initializes the ChatApi. * @param {DbConfig} db - Database configuration (sqlite or postgres). * @param {core.MigrationConfirmation} [confirm=core.MigrationConfirmation.YesUp] - Migration confirmation mode. + * @param {number} [queueSize] - Size of internal queues, the core default is used when omitted. */ static async init( db: DbConfig, - confirm = core.MigrationConfirmation.YesUp + confirm = core.MigrationConfirmation.YesUp, + queueSize?: number ): Promise { const [path, key] = dbConfigToMigrateArgs(db) - const ctrl = await core.chatMigrateInit(path, key, confirm) + const ctrl = await core.chatMigrateInit(path, key, confirm, queueSize) return new ChatApi(ctrl) } @@ -120,39 +122,52 @@ export class ChatApi { * Start chat controller. Must be called with the existing user profile. */ async startChat(): Promise { + if (this.eventsLoop) throw new Error("chat already started") + const ctrl = this.ctrl this.receiveEvents = true this.eventsLoop = this.runEventsLoop() - const r = await this.sendChatCmd(CC.StartChat.cmdString({mainApp: true, enableSndFiles: true})) + let r: ChatResponse + try { + r = await core.chatSendCmd(ctrl, CC.StartChat.cmdString({mainApp: true, enableSndFiles: true, serviceRequests: false})) + } catch (e) { + await this.stopEventsLoop() + throw e + } if (r.type !== "chatStarted" && r.type !== "chatRunning") { + await this.stopEventsLoop() throw new ChatCommandError("error starting chat", r) } } - + /** * Stop chat controller. - * Must be called before closing the database. + * `close` calls it before closing the database. * Usually doesn't need to be called in chat bots. */ async stopChat(): Promise { const r = await this.sendChatCmd("/_stop") - if (r.type !== "chatStopped") throw new ChatCommandError("error starting chat", r) - this.receiveEvents = false - if (this.eventsLoop) await this.eventsLoop - this.eventsLoop = undefined + if (r.type !== "chatStopped") throw new ChatCommandError("error stopping chat", r) + await this.stopEventsLoop() } /** - * Close chat database. + * Stop chat controller and close chat database. + * The database is not closed if stopping fails. * Usually doesn't need to be called in chat bots. */ async close(): Promise { - this.receiveEvents = false - if (this.eventsLoop) await this.eventsLoop - this.eventsLoop = undefined + // a running controller keeps using the database connections that closing frees + await this.stopChat() await core.chatCloseStore(this.ctrl) this.ctrl_ = undefined } - + + private async stopEventsLoop(): Promise { + this.receiveEvents = false + if (this.eventsLoop) await this.eventsLoop + this.eventsLoop = undefined + } + private async runEventsLoop(): Promise { while (this.receiveEvents) { try { @@ -335,7 +350,7 @@ export class ChatApi { return await core.chatSendCmd(this.ctrl, cmd) } - async recvChatEvent(wait: number = 5_000_000): Promise { + async recvChatEvent(wait: number = 500_000): Promise { return await core.chatRecvMsgWait(this.ctrl, wait) } @@ -386,8 +401,10 @@ export class ChatApi { switch (r.type) { case "userProfileUpdated": return r.updateSummary + case "userProfileNoChange": + return {updateSuccesses: 0, updateFailures: 0, changedContacts: []} default: - throw new ChatCommandError("error loading user address", r) + throw new ChatCommandError("error setting profile address", r) } } @@ -460,7 +477,7 @@ export class ChatApi { updatedMessage: {msgContent, mentions: {}}, }) ) - if (r.type === "chatItemUpdated") return r.chatItem.chatItem + if (r.type === "chatItemUpdated" || r.type === "chatItemNotChanged") return r.chatItem.chatItem throw new ChatCommandError("error updating chat item", r) } @@ -499,10 +516,10 @@ export class ChatApi { chatItemId: number, add: boolean, reaction: T.MsgReaction - ) { + ): Promise { const r = await this.sendChatCmd(CC.APIChatItemReaction.cmdString({chatRef: {chatType, chatId}, chatItemId, add, reaction})) - if (r.type === "chatItemsDeleted") return r.chatItemDeletions - throw new ChatCommandError("error setting item reaction", r) + if (r.type === "chatItemReaction") return r.reaction + throw new ChatCommandError("error setting item reaction", r) } /** @@ -512,6 +529,7 @@ export class ChatApi { async apiReceiveFile(fileId: number): Promise { const r = await this.sendChatCmd(CC.ReceiveFile.cmdString({fileId, userApprovedRelays: true})) if (r.type === "rcvFileAccepted") return r.chatItem + if (r.type === "rcvFileAcceptedSndCancelled") throw new ChatCommandError("file cancelled by sender", r) throw new ChatCommandError("error receiving file", r) } @@ -698,7 +716,7 @@ export class ChatApi { * Connect via prepared SimpleX link. The link can be 1-time invitation link, contact address or group link * Network usage: interactive. */ - async apiConnect(userId: number, incognito: boolean, preparedLink?: T.CreatedConnLink): Promise { + async apiConnect(userId: number, incognito: boolean, preparedLink: T.CreatedConnLink): Promise { const r = await this.sendChatCmd(CC.APIConnect.cmdString({userId, incognito, preparedLink_: preparedLink})) return this.handleConnectResult(r) } @@ -740,7 +758,7 @@ export class ChatApi { * Network usage: no. */ async apiRejectContactRequest(contactReqId: number): Promise { - const r = await this.sendChatCmd(CC.APIRejectContact.cmdString({contactReqId})) + const r = await this.sendChatCmd(CC.APIRejectContact.cmdString({contactReqId, notify: false})) if (r.type === "contactRequestRejected") return throw new ChatCommandError("error rejecting contact request", r) } diff --git a/packages/simplex-chat-nodejs/src/bot.ts b/packages/simplex-chat-nodejs/src/bot.ts index f6cb753d27..68e787ca3e 100644 --- a/packages/simplex-chat-nodejs/src/bot.ts +++ b/packages/simplex-chat-nodejs/src/bot.ts @@ -6,6 +6,7 @@ import equal = require("fast-deep-equal") export type BotDbOpts = api.DbConfig & { confirmMigrations?: core.MigrationConfirmation + queueSize?: number } export interface BotOptions { @@ -45,10 +46,9 @@ export interface BotConfig { } export async function run({profile, dbOpts, options = defaultOpts, onMessage, onCommands = {}, events = {}}: BotConfig): Promise<[api.ChatApi, T.User, T.UserContactLink | undefined]> { - const bot = await api.ChatApi.init(dbOpts, dbOpts.confirmMigrations || core.MigrationConfirmation.YesUp) + const bot = await api.ChatApi.init(dbOpts, dbOpts.confirmMigrations || core.MigrationConfirmation.YesUp, dbOpts.queueSize) const opts = fullOptions(options) - if (onMessage) subscribeMessages(bot, onMessage) - if (Object.keys(onCommands).length > 0) subscribeCommands(bot, onCommands) + if (onMessage || Object.keys(onCommands).length > 0) subscribeChatItems(bot, onMessage, onCommands) if (Object.keys(events).length > 0) bot.on(events) subscribeLogEvents(bot, opts) const botProfile = mkBotProfile(profile, opts) @@ -105,40 +105,27 @@ function mkBotProfile(profile: T.Profile, opts: Required): T.Profile return profile } -function subscribeMessages(bot: api.ChatApi, onMessage: (chatItem: T.AChatItem, content: T.MsgContent) => void | Promise) { +export function subscribeChatItems( + bot: api.ChatApi, + onMessage: ((chatItem: T.AChatItem, content: T.MsgContent) => void | Promise) | undefined, + commands: {[K in string]?: ((chatItem: T.AChatItem, command: util.BotCommand) => void | Promise)} +) { bot.on("newChatItems", async ({chatItems}) => { for (const ci of chatItems) { - if (ci.chatItem.content.type === "rcvMsgContent") { - try { - const p = onMessage(ci, ci.chatItem.content.msgContent) - if (p instanceof Promise) await p - } catch (e) { - console.log("message processing error", e) - } + const content = ci.chatItem.content + if (content.type !== "rcvMsgContent") continue + const cmd = util.ciBotCommand(ci.chatItem) + const cmdFunc = cmd && (commands[cmd.keyword] || commands[""]) + try { + if (cmd && cmdFunc) await cmdFunc(ci, cmd) + else if (onMessage) await onMessage(ci, content.msgContent) + } catch (e) { + console.log(cmd && cmdFunc ? `${cmd.keyword} command processing error` : "message processing error", e) } } }) } -function subscribeCommands(bot: api.ChatApi, commands: {[K in string]?: ((chatItem: T.AChatItem, command: util.BotCommand) => void | Promise)}) { - bot.on("newChatItems", async (evt) => { - for (const ci of evt.chatItems) { - const cmd = util.ciBotCommand(ci.chatItem) - if (cmd) { - const cmdFunc = commands[cmd.keyword] || commands[""] - if (cmdFunc) { - try { - const p = cmdFunc(ci, cmd) - if (p instanceof Promise) await p - } catch(e) { - console.log(`${cmd} command processing error`, e) - } - } - } - } - }) -} - function subscribeLogEvents(bot: api.ChatApi, opts: Required) { if (opts.logContacts) { bot.on({ diff --git a/packages/simplex-chat-nodejs/src/core.ts b/packages/simplex-chat-nodejs/src/core.ts index 949c2356af..49664fed71 100644 --- a/packages/simplex-chat-nodejs/src/core.ts +++ b/packages/simplex-chat-nodejs/src/core.ts @@ -3,9 +3,12 @@ import * as simplex from "./simplex" /** * Initialize chat controller + * @param {number} [queueSize] - Size of internal queues, the core default is used when omitted. */ -export async function chatMigrateInit(dbPath: string, dbKey: string, confirm: MigrationConfirmation): Promise { - const [ctrl, res] = await simplex.chat_migrate_init(dbPath, dbKey, confirm) +export async function chatMigrateInit(dbPath: string, dbKey: string, confirm: MigrationConfirmation, queueSize?: number): Promise { + const [ctrl, res] = queueSize === undefined + ? await simplex.chat_migrate_init(dbPath, dbKey, confirm) + : await simplex.chat_migrate_init_queue(dbPath, dbKey, confirm, queueSize) const json = JSON.parse(res) if (json.type === 'ok') return ctrl throw new ChatInitError("Database or migration error (see dbMigrationError property)", json as DBMigrationError) @@ -47,7 +50,7 @@ export async function chatRecvMsgWait(ctrl: bigint, wait: number): Promise { +export async function chatWriteFile(ctrl: bigint, path: string, buffer: ArrayBuffer | Uint8Array): Promise { const res = await simplex.chat_write_file(ctrl, path, buffer) return cryptoArgsResult(res) } @@ -55,7 +58,7 @@ export async function chatWriteFile(ctrl: bigint, path: string, buffer: ArrayBuf /** * Read buffer from encrypted file */ -export async function chatReadFile(path: string, {fileKey, fileNonce}: CryptoArgs): Promise { +export async function chatReadFile(path: string, {fileKey, fileNonce}: CryptoArgs): Promise { return await simplex.chat_read_file(path, fileKey, fileNonce) } @@ -121,12 +124,13 @@ export class ChatInitError extends Error { export type DBMigrationError = | DBMigrationError.InvalidConfirmation + | DBMigrationError.InvalidQueueSize | DBMigrationError.ErrorNotADatabase // invalid/corrupt database file or incorrect encryption key | DBMigrationError.ErrorMigration | DBMigrationError.ErrorSQL export namespace DBMigrationError { - export type Tag = "invalidConfirmation" | "errorNotADatabase" | "errorMigration" | "errorSQL" + export type Tag = "invalidConfirmation" | "invalidQueueSize" | "errorNotADatabase" | "errorMigration" | "errorSQL" interface Interface { type: Tag @@ -136,6 +140,10 @@ export namespace DBMigrationError { type: "invalidConfirmation" } + export interface InvalidQueueSize extends Interface { + type: "invalidQueueSize" + } + export interface ErrorNotADatabase extends Interface { type: "errorNotADatabase" dbFile: string @@ -168,7 +176,7 @@ export namespace MigrationError { export interface MEUpgrade extends Interface { type: "upgrade" - upMigrations: UpMigration + upMigrations: UpMigration[] } export interface MEDowngrade extends Interface { @@ -200,7 +208,7 @@ export namespace MTRError { export interface MTRENoDown extends Interface { type: "noDown" - upMigrations: UpMigration + dbMigrations: string[] } export interface MTREDifferent extends Interface { diff --git a/packages/simplex-chat-nodejs/src/download-libs.js b/packages/simplex-chat-nodejs/src/download-libs.js index 0acff40daf..cb92699e69 100644 --- a/packages/simplex-chat-nodejs/src/download-libs.js +++ b/packages/simplex-chat-nodejs/src/download-libs.js @@ -4,8 +4,12 @@ const path = require('path'); const extract = require('extract-zip'); const GITHUB_REPO = 'simplex-chat/simplex-chat-libs'; -const RELEASE_TAG = 'v7.1.0-beta.3'; +const RELEASE_TAG = 'v7.1.0-beta.4'; const BACKEND = (process.env.SIMPLEX_BACKEND || process.env.npm_config_simplex_backend || 'sqlite').toLowerCase(); +// A locally built libsimplex, copied into libs/ rather than loaded in place: +// the addon's RUNPATH is $ORIGIN/../../libs. +const LIBS_DIR_OVERRIDE = process.env.SIMPLEX_LIBS_DIR || process.env.npm_config_simplex_libs_dir; +const LIB_NAMES = ['libsimplex.so', 'libsimplex.dylib', 'libsimplex.dll']; if (BACKEND !== 'sqlite' && BACKEND !== 'postgres') { console.error(`✗ Invalid SIMPLEX_BACKEND: "${BACKEND}". Must be "sqlite" or "postgres".`); @@ -83,8 +87,31 @@ function isAlreadyInstalled() { } } +// No version check: the files behind SIMPLEX_LIBS_DIR change on every rebuild. +function installFromOverride() { + if (!fs.existsSync(LIBS_DIR_OVERRIDE)) { + throw new Error(`SIMPLEX_LIBS_DIR does not exist: ${LIBS_DIR_OVERRIDE}`); + } + const lib = LIB_NAMES.find((name) => fs.existsSync(path.join(LIBS_DIR_OVERRIDE, name))); + if (!lib) { + throw new Error(`No ${LIB_NAMES.join(' / ')} in SIMPLEX_LIBS_DIR: ${LIBS_DIR_OVERRIDE}`); + } + console.log(`Using libraries from SIMPLEX_LIBS_DIR: ${LIBS_DIR_OVERRIDE}`); + cleanLibsDirectory(); + copyDirSync(LIBS_DIR_OVERRIDE, LIBS_DIR); + // Not a release tag: a later install without the variable sees a mismatch + // and replaces these files with the released ones. + fs.writeFileSync(INSTALLED_FILE, `${LIBS_DIR_OVERRIDE}:${BACKEND}`, 'utf-8'); + console.log(`✓ Installed ${lib} and its runtime libraries from ${LIBS_DIR_OVERRIDE}`); +} + async function install() { try { + if (LIBS_DIR_OVERRIDE) { + installFromOverride(); + return; + } + // Check if already installed if (isAlreadyInstalled()) { return; diff --git a/packages/simplex-chat-nodejs/src/simplex.d.ts b/packages/simplex-chat-nodejs/src/simplex.d.ts index 10c2f6608a..1e0ca825a6 100644 --- a/packages/simplex-chat-nodejs/src/simplex.d.ts +++ b/packages/simplex-chat-nodejs/src/simplex.d.ts @@ -1,10 +1,11 @@ // These functions are defined in CPP add-on ../cpp/simplex.cc export function chat_migrate_init(dbPath: string, dbKey: string, confirm: string): Promise<[bigint, string]> +export function chat_migrate_init_queue(dbPath: string, dbKey: string, confirm: string, queueSize: number): Promise<[bigint, string]> export function chat_close_store(ctrl: bigint): Promise export function chat_send_cmd(ctrl: bigint, cmd: string): Promise export function chat_recv_msg_wait(ctrl: bigint, wait: number): Promise -export function chat_write_file(ctrl: bigint, path: string, buffer: ArrayBuffer): Promise -export function chat_read_file(path: string, key: string, nonce: string): Promise +export function chat_write_file(ctrl: bigint, path: string, buffer: ArrayBuffer | Uint8Array): Promise +export function chat_read_file(path: string, key: string, nonce: string): Promise export function chat_encrypt_file(ctrl: bigint, fromPath: string, toPath: string): Promise export function chat_decrypt_file(fromPath: string, key: string, nonce: string, toPath: string): Promise diff --git a/packages/simplex-chat-nodejs/src/util.ts b/packages/simplex-chat-nodejs/src/util.ts index f7365e731c..dffb0ce1bc 100644 --- a/packages/simplex-chat-nodejs/src/util.ts +++ b/packages/simplex-chat-nodejs/src/util.ts @@ -78,7 +78,7 @@ export interface BotCommand { export function ciBotCommand(chatItem: T.ChatItem): BotCommand | undefined { const msg = ciContentText(chatItem)?.trim() if (msg) { - const r = msg.match(/^\/([^\s]+)(.*)/) + const r = msg.match(/^\/([^\s]+)([\s\S]*)/) if (r && r.length >= 3) { return {keyword: r[1], params: r[2].trim()} } diff --git a/packages/simplex-chat-nodejs/tests/api.test.ts b/packages/simplex-chat-nodejs/tests/api.test.ts index 99d511371c..e3ddca2440 100644 --- a/packages/simplex-chat-nodejs/tests/api.test.ts +++ b/packages/simplex-chat-nodejs/tests/api.test.ts @@ -58,8 +58,8 @@ describe("API tests (use preset servers)", () => { await bob.stopChat() await alice.close() await bob.close() - await expect(alice.startChat).rejects.toThrow() - await expect(bob.startChat).rejects.toThrow() + await expect(alice.startChat()).rejects.toThrow("chat api controller not initialized") + await expect(bob.startChat()).rejects.toThrow("chat api controller not initialized") expect(servers.length).toBe(2) expect(servers[0] !== servers[1]).toBe(true) expect(eventCount > 0).toBe(true) diff --git a/packages/simplex-chat-nodejs/tests/api.unit.test.ts b/packages/simplex-chat-nodejs/tests/api.unit.test.ts new file mode 100644 index 0000000000..c5a4367b11 --- /dev/null +++ b/packages/simplex-chat-nodejs/tests/api.unit.test.ts @@ -0,0 +1,102 @@ +import {ChatResponse, T} from "@simplex-chat/types" +import * as api from "../src/api" +import * as core from "../src/core" + +const user = {userId: 1} as T.User + +async function chatWithResponse(response: object): Promise { + jest.spyOn(core, "chatMigrateInit").mockResolvedValue(BigInt(1)) + jest.spyOn(core, "chatSendCmd").mockResolvedValue(response as ChatResponse) + return api.ChatApi.init({type: "sqlite", filePrefix: "unused"}) +} + +afterEach(() => jest.restoreAllMocks()) + +describe("documented success responses", () => { + it("apiChatItemReaction returns the reaction", async () => { + const reaction = {chatReaction: {reaction: {type: "emoji", emoji: "👍"}}} + const chat = await chatWithResponse({type: "chatItemReaction", user, added: true, reaction}) + await expect(chat.apiChatItemReaction(T.ChatType.Direct, 1, 2, true, {type: "emoji", emoji: "👍"})).resolves.toEqual(reaction) + }) + + it("apiUpdateChatItem accepts chatItemNotChanged", async () => { + const chatItem = {meta: {itemId: 2}} + const chat = await chatWithResponse({type: "chatItemNotChanged", user, chatItem: {chatItem}}) + await expect(chat.apiUpdateChatItem(T.ChatType.Direct, 1, 2, {type: "text", text: "same"}, false)).resolves.toEqual(chatItem) + }) + + it("apiSetProfileAddress accepts userProfileNoChange", async () => { + const chat = await chatWithResponse({type: "userProfileNoChange", user}) + await expect(chat.apiSetProfileAddress(1, true)).resolves.toEqual({updateSuccesses: 0, updateFailures: 0, changedContacts: []}) + }) + + it("apiReceiveFile reports a file cancelled by sender", async () => { + const chat = await chatWithResponse({type: "rcvFileAcceptedSndCancelled", user, rcvFileTransfer: {}}) + await expect(chat.apiReceiveFile(3)).rejects.toThrow("file cancelled by sender") + }) +}) + +describe("startChat lifecycle", () => { + function chatWithResponses(...responses: object[]): Promise { + jest.spyOn(core, "chatMigrateInit").mockResolvedValue(BigInt(1)) + jest.spyOn(core, "chatRecvMsgWait").mockImplementation(() => new Promise(resolve => setTimeout(() => resolve(undefined), 10))) + const send = jest.spyOn(core, "chatSendCmd") + for (const r of responses) send.mockResolvedValueOnce(r as ChatResponse) + return api.ChatApi.init({type: "sqlite", filePrefix: "unused"}) + } + + it("rejects a second start", async () => { + const chat = await chatWithResponses({type: "chatStarted"}, {type: "chatStopped"}) + await chat.startChat() + await expect(chat.startChat()).rejects.toThrow("chat already started") + await chat.stopChat() + }) + + it("stops the events loop when start fails", async () => { + const chat = await chatWithResponses({type: "chatCmdError"}) + await expect(chat.startChat()).rejects.toThrow("error starting chat") + expect(chat.started).toBe(false) + }) + + it("rejects start after close", async () => { + const chat = await chatWithResponses({type: "chatStopped"}) + jest.spyOn(core, "chatCloseStore").mockResolvedValue() + await chat.close() + await expect(chat.startChat()).rejects.toThrow("chat api controller not initialized") + }) + + it("stops the chat before closing the store", async () => { + const chat = await chatWithResponses() + const calls: string[] = [] + jest.mocked(core.chatSendCmd).mockImplementation(async (_ctrl, cmd) => { + calls.push(`send ${cmd}`) + return {type: "chatStopped"} as ChatResponse + }) + jest.spyOn(core, "chatCloseStore").mockImplementation(async () => { calls.push("closeStore") }) + await chat.close() + expect(calls).toEqual(["send /_stop", "closeStore"]) + expect(chat.initialized).toBe(false) + }) + + it("does not close the store when stopping fails", async () => { + const chat = await chatWithResponses({type: "chatCmdError"}) + const closeStore = jest.spyOn(core, "chatCloseStore").mockResolvedValue() + await expect(chat.close()).rejects.toThrow("error stopping chat") + expect(closeStore).not.toHaveBeenCalled() + expect(chat.initialized).toBe(true) + }) + + it("reports stop failures as stop errors", async () => { + const chat = await chatWithResponses({type: "chatStarted"}, {type: "chatCmdError"}, {type: "chatStopped"}) + await chat.startChat() + await expect(chat.stopChat()).rejects.toThrow("error stopping chat") + expect(chat.started).toBe(true) + await chat.stopChat() + }) + + it("receives with a 500 ms wait", async () => { + const chat = await chatWithResponses() + await chat.recvChatEvent() + expect(core.chatRecvMsgWait).toHaveBeenCalledWith(BigInt(1), 500_000) + }) +}) diff --git a/packages/simplex-chat-nodejs/tests/bot.unit.test.ts b/packages/simplex-chat-nodejs/tests/bot.unit.test.ts new file mode 100644 index 0000000000..39f9358996 --- /dev/null +++ b/packages/simplex-chat-nodejs/tests/bot.unit.test.ts @@ -0,0 +1,50 @@ +import {ChatEvent, T} from "@simplex-chat/types" +import * as api from "../src/api" +import {subscribeChatItems} from "../src/bot" + +type Handler = (evt: ChatEvent) => Promise + +function fakeBot(): {bot: api.ChatApi, deliver: (items: T.AChatItem[]) => Promise} { + let handler: Handler | undefined + const bot = {on: (_event: string, h: Handler) => { handler = h }} as unknown as api.ChatApi + const deliver = (chatItems: T.AChatItem[]) => handler!({type: "newChatItems", chatItems} as unknown as ChatEvent) + return {bot, deliver} +} + +function item(type: "rcvMsgContent" | "sndMsgContent", text: string): T.AChatItem { + return {chatItem: {content: {type, msgContent: {type: "text", text}}}} as unknown as T.AChatItem +} + +describe("subscribeChatItems", () => { + it("sends a known command only to its handler", async () => { + const {bot, deliver} = fakeBot() + const calls: string[] = [] + subscribeChatItems(bot, async () => { calls.push("message") }, {help: async () => { calls.push("help") }}) + await deliver([item("rcvMsgContent", "/help")]) + expect(calls).toEqual(["help"]) + }) + + it("sends an unknown command to the fallback handler", async () => { + const {bot, deliver} = fakeBot() + const calls: string[] = [] + subscribeChatItems(bot, async () => { calls.push("message") }, {"": async (_ci, cmd) => { calls.push(`fallback:${cmd.keyword}`) }}) + await deliver([item("rcvMsgContent", "/unknown")]) + expect(calls).toEqual(["fallback:unknown"]) + }) + + it("sends unhandled commands and text to onMessage", async () => { + const {bot, deliver} = fakeBot() + const calls: string[] = [] + subscribeChatItems(bot, async (_ci, content) => { calls.push(`message:${(content as T.MsgContent & {text: string}).text}`) }, {help: async () => { calls.push("help") }}) + await deliver([item("rcvMsgContent", "/unknown"), item("rcvMsgContent", "hello")]) + expect(calls).toEqual(["message:/unknown", "message:hello"]) + }) + + it("ignores sent items", async () => { + const {bot, deliver} = fakeBot() + const calls: string[] = [] + subscribeChatItems(bot, async () => { calls.push("message") }, {help: async () => { calls.push("help") }}) + await deliver([item("sndMsgContent", "/help"), item("sndMsgContent", "hello")]) + expect(calls).toEqual([]) + }) +}) diff --git a/packages/simplex-chat-nodejs/tests/commands.test.ts b/packages/simplex-chat-nodejs/tests/commands.test.ts new file mode 100644 index 0000000000..61ce43bd63 --- /dev/null +++ b/packages/simplex-chat-nodejs/tests/commands.test.ts @@ -0,0 +1,13 @@ +import {CC} from "@simplex-chat/types" + +describe("APIConnect.cmdString", () => { + const preparedLink_ = {connFullLink: "L"} + + it("renders incognito=on", () => { + expect(CC.APIConnect.cmdString({userId: 1, incognito: true, preparedLink_})).toBe("/_connect 1 incognito=on L") + }) + + it("omits incognito when off", () => { + expect(CC.APIConnect.cmdString({userId: 1, incognito: false, preparedLink_})).toBe("/_connect 1 L") + }) +}) diff --git a/packages/simplex-chat-nodejs/tests/core.test.ts b/packages/simplex-chat-nodejs/tests/core.test.ts index 141f35746d..8eb106a5b5 100644 --- a/packages/simplex-chat-nodejs/tests/core.test.ts +++ b/packages/simplex-chat-nodejs/tests/core.test.ts @@ -1,3 +1,4 @@ +import {execFile, spawnSync} from "child_process"; import * as fs from "fs"; import * as path from "path"; import {core} from "../src/index"; @@ -9,17 +10,34 @@ describe("Core tests", () => { beforeEach(() => fs.mkdirSync(tmpDir, {recursive: true})); afterEach(() => fs.rmSync(tmpDir, {recursive: true, force: true})); + async function stopAndClose(ctrl: bigint): Promise { + await expect(core.chatSendCmd(ctrl, "/_stop")).resolves.toMatchObject({type: "chatStopped"}); + await core.chatCloseStore(ctrl); + } + it("should initialize chat controller", async () => { const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp); expect(typeof ctrl).toBe("bigint"); - await expect(core.chatCloseStore(ctrl)).resolves.toBe(undefined); + await expect(stopAndClose(ctrl)).resolves.toBe(undefined); await expect(core.chatMigrateInit(dbPath, "wrong_key", core.MigrationConfirmation.YesUp)).rejects.toMatchObject({ message: "Database or migration error (see dbMigrationError property)", dbMigrationError: expect.objectContaining({type: "errorNotADatabase"}) }); }); - + + it("should initialize chat controller with queue size", async () => { + const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp, 65536); + expect(typeof ctrl).toBe("bigint"); + await expect(stopAndClose(ctrl)).resolves.toBe(undefined); + + await expect(core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp, 0)).rejects.toMatchObject({ + dbMigrationError: {type: "invalidQueueSize"} + }); + await expect(core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp, 2 ** 31)).rejects.toThrow("Expected 32-bit integer queue size"); + await expect(core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp, 1.5)).rejects.toThrow("Expected 32-bit integer queue size"); + }); + it("should send command and receive event", async () => { const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp); @@ -41,7 +59,7 @@ describe("Core tests", () => { chatError: expect.objectContaining({type: "error"}) }); - await core.chatCloseStore(ctrl); + await stopAndClose(ctrl); }); it("should write/read encrypted file from/to buffer", async () => { @@ -59,7 +77,25 @@ describe("Core tests", () => { await expect(core.chatWriteFile(ctrl, path.join(tmpDir, "unknown", "unknown.txt"), buffer)).rejects.toThrow(); await expect(core.chatReadFile(path.join(tmpDir, "unknown.txt"), cryptoArgs)).rejects.toThrow(); - await core.chatCloseStore(ctrl); + await stopAndClose(ctrl); + }); + + it("should write the view of a Uint8Array and read an empty file", async () => { + const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp); + + const viewPath = path.join(tmpDir, "view.txt"); + const viewArgs = await core.chatWriteFile(ctrl, viewPath, Buffer.from("xxabcxx").subarray(2, 5)); + const view = await core.chatReadFile(viewPath, viewArgs); + expect(Buffer.isBuffer(view)).toBe(true); + expect(view.toString()).toBe("abc"); + + const emptyPath = path.join(tmpDir, "empty.txt"); + const emptyArgs = await core.chatWriteFile(ctrl, emptyPath, new Uint8Array(0)); + const empty = await core.chatReadFile(emptyPath, emptyArgs); + expect(Buffer.isBuffer(empty)).toBe(true); + expect(empty.length).toBe(0); + + await stopAndClose(ctrl); }); it("should encrypt/decrypt file", async () => { @@ -80,6 +116,118 @@ describe("Core tests", () => { await expect(core.chatEncryptFile(ctrl, path.join(tmpDir, "unknown.txt"), encryptedPath)).rejects.toThrow(); await expect(core.chatDecryptFile(path.join(tmpDir, "unknown.txt"), cryptoArgs, decryptedPath)).rejects.toThrow(); - await core.chatCloseStore(ctrl); + await stopAndClose(ctrl); }); + + it("should not block the libuv pool while receiving", async () => { + const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp); + const receives = [1, 2, 3, 4].map(() => core.chatRecvMsgWait(ctrl, 2_000_000)); + const start = Date.now(); + await fs.promises.stat(tmpDir); + expect(Date.now() - start).toBeLessThan(200); + await Promise.all(receives); + await stopAndClose(ctrl); + }, 10000); + + const itOnLinux = process.platform === "linux" ? it : it.skip; + + // Thread count is read from /proc/self/task, which only exists on Linux. + itOnLinux("should keep the thread count constant while receiving", async () => { + const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp); + for (let i = 0; i < 10; i++) await core.chatRecvMsgWait(ctrl, 1); + const threadCount = () => fs.readdirSync("/proc/self/task").length; + const warmCount = threadCount(); + const counts = new Set(); + for (let i = 0; i < 200; i++) { + await core.chatRecvMsgWait(ctrl, 1); + counts.add(threadCount()); + } + expect({warmCount, counts: [...counts]}).toEqual({warmCount, counts: [warmCount]}); + await stopAndClose(ctrl); + }, 30000); + + it("should receive on two controllers concurrently", async () => { + const ctrlA = await core.chatMigrateInit(path.join(tmpDir, "simplex_a"), "key", core.MigrationConfirmation.YesUp); + const ctrlB = await core.chatMigrateInit(path.join(tmpDir, "simplex_b"), "key", core.MigrationConfirmation.YesUp); + const start = Date.now(); + await expect(Promise.all([core.chatRecvMsgWait(ctrlA, 1_000_000), core.chatRecvMsgWait(ctrlB, 1_000_000)])) + .resolves.toEqual([undefined, undefined]); + const elapsed = Date.now() - start; + expect(elapsed).toBeGreaterThanOrEqual(900); + expect(elapsed).toBeLessThan(1800); + await stopAndClose(ctrlA); + await stopAndClose(ctrlB); + }, 10000); + + it("should receive events of one controller in order", async () => { + const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp); + for (const action of ["first", "second"]) { + await expect(core.chatSendCmd(ctrl, `/debug event {"type": "timedAction", "action": "${action}", "durationMilliseconds": 1}`)) + .resolves.toMatchObject({type: "cmdOk"}); + } + const events = await Promise.all([core.chatRecvMsgWait(ctrl, 500_000), core.chatRecvMsgWait(ctrl, 500_000)]); + expect(events).toMatchObject([ + {type: "timedAction", action: "first"}, + {type: "timedAction", action: "second"} + ]); + await stopAndClose(ctrl); + }, 10000); + + it("should close the store while a receive is in flight", async () => { + const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp); + // starts the receiver thread, so the next request only has to wake it + await core.chatRecvMsgWait(ctrl, 1); + const settle = (p: Promise) => p.then((event) => ({event}), (e: Error) => ({error: e.message})); + const receives = Promise.all([settle(core.chatRecvMsgWait(ctrl, 3_000_000)), settle(core.chatRecvMsgWait(ctrl, 3_000_000))]); + // the thread enters the first receive within this margin even under load, so the second one is still queued at close + await new Promise((resolve) => setTimeout(resolve, 500)); + await expect(core.chatSendCmd(ctrl, "/_stop")).resolves.toMatchObject({type: "chatStopped"}); + let closed = false; + const close = core.chatCloseStore(ctrl).then(() => { closed = true; }); + const timerStart = Date.now(); + const timerDelay = await new Promise((resolve) => setTimeout(() => resolve(Date.now() - timerStart), 10)); + expect({closed, timerDelayBelow100ms: timerDelay < 100}).toEqual({closed: false, timerDelayBelow100ms: true}); + await close; + expect(await receives).toEqual([{event: undefined}, {error: "chat receiver stopped"}]); + }, 10000); + + it("should let the process exit while a receiver is idle", () => { + const childDbPath = path.resolve(tmpDir, "simplex_child"); + const script = ` + const simplex = require("./build/Release/simplex.node"); + simplex.chat_migrate_init(${JSON.stringify(childDbPath)}, "key", "yesUp") + .then(([ctrl]) => simplex.chat_recv_msg_wait(ctrl, 1)) + .then((res) => console.log("received " + JSON.stringify(res))); + `; + const child = spawnSync(process.execPath, ["-e", script], {cwd: path.join(__dirname, ".."), timeout: 10000, encoding: "utf8"}); + if (child.status !== 0 || child.signal !== null) console.log("child stderr:", child.stderr); + expect({status: child.status, signal: child.signal, stdout: child.stdout.trim()}) + .toEqual({status: 0, signal: null, stdout: 'received ""'}); + }, 15000); + + it("should not crash when closing stopped controllers repeatedly", async () => { + const script = ` + const fs = require("fs"), path = require("path"); + const simplex = require("./build/Release/simplex.node"); + (async () => { + for (let i = 0; i < 40; i++) { + const dir = fs.mkdtempSync(path.join(${JSON.stringify(path.resolve(tmpDir))}, "close-")); + const [ctrl] = await simplex.chat_migrate_init(path.join(dir, "simplex"), "key", "yesUp"); + await simplex.chat_send_cmd(ctrl, "/v"); + await simplex.chat_send_cmd(ctrl, "/_stop"); + const res = await simplex.chat_close_store(ctrl); + fs.rmSync(dir, {recursive: true, force: true}); + if (res !== "") throw new Error("close failed: " + res); + } + })(); + `; + const runChild = () => new Promise<{code: number | null, signal: NodeJS.Signals | null, stderr: string}>((resolve) => { + const child = execFile(process.execPath, ["-e", script], {cwd: path.join(__dirname, ".."), timeout: 150000}, (_error, _stdout, stderr) => + resolve({code: child.exitCode, signal: child.signalCode, stderr})); + }); + const childCount = 3; + const results = await Promise.all(Array.from({length: childCount}, runChild)); + for (const r of results) if (r.code !== 0 || r.signal !== null) console.log("child stderr:", r.stderr); + expect(results.map(({code, signal}) => ({code, signal}))).toEqual(Array(childCount).fill({code: 0, signal: null})); + }, 180000); }); diff --git a/packages/simplex-chat-nodejs/tests/util.test.ts b/packages/simplex-chat-nodejs/tests/util.test.ts index 4fe3140edc..29224cf1cc 100644 --- a/packages/simplex-chat-nodejs/tests/util.test.ts +++ b/packages/simplex-chat-nodejs/tests/util.test.ts @@ -34,4 +34,8 @@ describe("ciBotCommand", () => { const ci = {content: {type: "rcvDeleted"}} as T.ChatItem expect(ciBotCommand(ci)).toBeUndefined() }) + + it("keeps multi-line params", () => { + expect(ciBotCommand(rcvText("/review line1\nline2"))).toEqual({keyword: "review", params: "line1\nline2"}) + }) }) diff --git a/packages/simplex-chat-python/src/simplex_chat/_native.py b/packages/simplex-chat-python/src/simplex_chat/_native.py index 4c408479bb..009ff9a1e0 100644 --- a/packages/simplex-chat-python/src/simplex_chat/_native.py +++ b/packages/simplex-chat-python/src/simplex_chat/_native.py @@ -16,7 +16,7 @@ import urllib.request import zipfile from ctypes import POINTER, c_char_p, c_int, c_uint8, c_void_p from pathlib import Path -from typing import Literal +from typing import Any, Literal from ._version import LIBS_VERSION @@ -166,7 +166,8 @@ _backend: Backend | None = None def _load_libc() -> ctypes.CDLL: if sys.platform == "win32": - return ctypes.CDLL("msvcrt") + # libsimplex.dll allocates results with UCRT malloc; msvcrt free would corrupt the heap. + return ctypes.CDLL("ucrtbase") return ctypes.CDLL(None) # libc on POSIX is the process's own symbol table @@ -255,3 +256,19 @@ def lib() -> ctypes.CDLL: if _lib is None: raise RuntimeError("lib_for() must be called before lib()") return _lib + + +QUEUE_SIZE_UNSUPPORTED = ( + "loaded libsimplex does not export chat_migrate_init_queue; queue size needs a newer libsimplex" +) + + +def migrate_init_queue() -> Any: + """`chat_migrate_init_queue`, which older libsimplex releases do not export.""" + try: + fn = lib().chat_migrate_init_queue + except AttributeError as e: + raise RuntimeError(QUEUE_SIZE_UNSUPPORTED) from e + fn.argtypes = [c_char_p, c_char_p, c_char_p, c_int, POINTER(c_void_p)] + fn.restype = c_void_p + return fn diff --git a/packages/simplex-chat-python/src/simplex_chat/_version.py b/packages/simplex-chat-python/src/simplex_chat/_version.py index e3f2fa5338..9c60a54bb1 100644 --- a/packages/simplex-chat-python/src/simplex_chat/_version.py +++ b/packages/simplex-chat-python/src/simplex_chat/_version.py @@ -5,5 +5,5 @@ Bump both together for normal releases. For wrapper-only fixes use a PEP 440 post-release: __version__ = "6.5.2.post1", LIBS_VERSION unchanged. """ -__version__ = "7.1.0b3" # PEP 440 — read by hatchling for wheel metadata -LIBS_VERSION = "7.1.0-beta.3" # simplex-chat-libs release tag (no 'v' prefix) +__version__ = "7.1.0b4" # PEP 440 — read by hatchling for wheel metadata +LIBS_VERSION = "7.1.0-beta.4" # simplex-chat-libs release tag (no 'v' prefix) diff --git a/packages/simplex-chat-python/src/simplex_chat/api.py b/packages/simplex-chat-python/src/simplex_chat/api.py index e3d36c45df..876230ef39 100644 --- a/packages/simplex-chat-python/src/simplex_chat/api.py +++ b/packages/simplex-chat-python/src/simplex_chat/api.py @@ -2,7 +2,9 @@ from __future__ import annotations +import asyncio import json +from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass from typing import Any, Literal @@ -65,17 +67,20 @@ class ChatApi: def __init__(self, ctrl: int): self._ctrl: int | None = ctrl self._started = False + self._recv_executor: ThreadPoolExecutor | None = None @classmethod async def init( cls, db: Db, confirm: MigrationConfirmation = MigrationConfirmation.YES_UP, + queue_size: int | None = None, ) -> ChatApi: path_or_prefix, key_or_conn, backend = _db_to_migrate_args(db) # Trigger lazy lib load with the right backend BEFORE chat_migrate_init. - _native.lib_for(backend) - ctrl = await core.chat_migrate_init(path_or_prefix, key_or_conn, confirm) + # It may download ~100 MB, so it must not block the event loop. + await asyncio.to_thread(_native.lib_for, backend) + ctrl = await core.chat_migrate_init(path_or_prefix, key_or_conn, confirm, queue_size) return cls(ctrl) @property @@ -114,6 +119,14 @@ class ChatApi: self._started = False async def close(self) -> None: + """Stop the chat and close its store; the store stays open if stopping fails.""" + # a running controller keeps using the database connections that closing frees + await self.stop_chat() + if self._recv_executor is not None: + # Waits for a receive already in flight (up to wait_us) so the store + # never closes underneath one; run off-loop since shutdown blocks. + await asyncio.to_thread(self._recv_executor.shutdown, wait=True) + self._recv_executor = None await core.chat_close_store(self.ctrl) self._ctrl = None self._started = False @@ -122,7 +135,14 @@ class ChatApi: return await core.chat_send_cmd(self.ctrl, cmd) async def recv_chat_event(self, wait_us: int = 500_000) -> CEvt.ChatEvent | None: - return await core.chat_recv_msg_wait(self.ctrl, wait_us) + ctrl = self.ctrl # raises before touching the executor if close() was called + if self._recv_executor is None: + # A receive blocks for up to wait_us almost back to back, so it would + # otherwise pin one of the default executor's few worker threads. + self._recv_executor = ThreadPoolExecutor( + max_workers=1, thread_name_prefix="simplex-recv" + ) + return await core.chat_recv_msg_wait(ctrl, wait_us, self._recv_executor) # ------------------------------------------------------------------ # # Address commands @@ -158,6 +178,8 @@ class ChatApi: ) if r["type"] == "userProfileUpdated": return r["updateSummary"] + if r["type"] == "userProfileNoChange": + return {"updateSuccesses": 0, "updateFailures": 0, "changedContacts": []} raise ChatCommandError("error setting profile address", r) async def api_set_address_settings(self, user_id: int, settings: T.AddressSettings) -> None: @@ -236,6 +258,8 @@ class ChatApi: ) if r["type"] == "chatItemUpdated": return r["chatItem"]["chatItem"] + if r["type"] == "chatItemNotChanged": + return r["chatItem"]["chatItem"] raise ChatCommandError("error updating chat item", r) async def api_delete_chat_items( @@ -302,6 +326,8 @@ class ChatApi: ) if r["type"] == "rcvFileAccepted": return r["chatItem"] + if r["type"] == "rcvFileAcceptedSndCancelled": + raise ChatCommandError("file cancelled by sender", r) raise ChatCommandError("error receiving file", r) async def api_cancel_file(self, file_id: int) -> None: @@ -477,12 +503,13 @@ class ChatApi: self, user_id: int, incognito: bool, - prepared_link: T.CreatedConnLink | None = None, + prepared_link: T.CreatedConnLink, ) -> ConnReqType: - args: CC.APIConnect = {"userId": user_id, "incognito": incognito} - if prepared_link is not None: - args["preparedLink_"] = prepared_link - r = await self.send_chat_cmd(CC.APIConnect_cmd_string(args)) + r = await self.send_chat_cmd( + CC.APIConnect_cmd_string( + {"userId": user_id, "incognito": incognito, "preparedLink_": prepared_link} + ) + ) return self._handle_connect_result(r) async def api_connect_active_user(self, conn_link: str) -> ConnReqType: diff --git a/packages/simplex-chat-python/src/simplex_chat/bot.py b/packages/simplex-chat-python/src/simplex_chat/bot.py index b3e5b5ec03..2fa0ed39b4 100644 --- a/packages/simplex-chat-python/src/simplex_chat/bot.py +++ b/packages/simplex-chat-python/src/simplex_chat/bot.py @@ -90,6 +90,7 @@ class Bot(Client): welcome: str | T.MsgContent | None = None, commands: list[BotCommand] | None = None, confirm_migrations: MigrationConfirmation = MigrationConfirmation.YES_UP, + queue_size: int | None = None, create_address: bool = True, update_address: bool = True, update_profile: bool = True, @@ -103,6 +104,7 @@ class Bot(Client): profile=profile, db=db, confirm_migrations=confirm_migrations, + queue_size=queue_size, update_profile=update_profile, log_contacts=log_contacts, log_network=log_network, diff --git a/packages/simplex-chat-python/src/simplex_chat/client.py b/packages/simplex-chat-python/src/simplex_chat/client.py index 8ec955b54a..476dd70ac3 100644 --- a/packages/simplex-chat-python/src/simplex_chat/client.py +++ b/packages/simplex-chat-python/src/simplex_chat/client.py @@ -149,6 +149,7 @@ class Client: profile: Profile, db: Db, confirm_migrations: MigrationConfirmation = MigrationConfirmation.YES_UP, + queue_size: int | None = None, update_profile: bool = True, log_contacts: bool = False, log_network: bool = False, @@ -156,6 +157,7 @@ class Client: self._profile = profile self._db = db self._confirm_migrations = confirm_migrations + self._queue_size = queue_size self._update_profile = update_profile self._log_contacts = log_contacts self._log_network = log_network @@ -343,7 +345,7 @@ class Client: # do post-start setup (profile sync; Bot adds address sync). # `_stop_event` is never cleared: a stop requested during startup has # to survive into the receive loop. A stopped client is spent. - self._api = await ChatApi.init(self._db, self._confirm_migrations) + self._api = await ChatApi.init(self._db, self._confirm_migrations, self._queue_size) try: user = await self._ensure_active_user() await self._api.start_chat() @@ -362,11 +364,6 @@ class Client: api = self._api if api is None: return - if api.started: - try: - await api.stop_chat() - except Exception: - log.exception("stop_chat failed during init rollback") try: await api.close() except Exception: @@ -379,13 +376,16 @@ class Client: if api is None: return # Null out the reference up-front so the Client appears closed even - # if stop_chat / close raise — otherwise `client.api` would still + # if close raises — otherwise `client.api` would still # hand back a half-shutdown controller after `async with` exits. self._api = None try: - await api.stop_chat() - finally: await api.close() + except BaseException: + # A failed stop leaves the store open; keep it so the caller can retry. + if api.initialized: + self._api = api + raise async def _post_start(self, user: T.User) -> None: """Hook for subclasses to add work between `start_chat` and serving. @@ -617,7 +617,7 @@ class Client: # message resolve a future no one is waiting on. if waiter in waiters: waiters.remove(waiter) - if not waiters: + if not waiters and self._reply_waiters.get(contact_id) is waiters: self._reply_waiters.pop(contact_id, None) async def _receive_loop(self) -> None: diff --git a/packages/simplex-chat-python/src/simplex_chat/core.py b/packages/simplex-chat-python/src/simplex_chat/core.py index 4fc847f7de..adebde1b56 100644 --- a/packages/simplex-chat-python/src/simplex_chat/core.py +++ b/packages/simplex-chat-python/src/simplex_chat/core.py @@ -9,6 +9,7 @@ from __future__ import annotations import asyncio import ctypes import json +from concurrent.futures import Executor from enum import StrEnum from typing import Any, TypedDict @@ -102,7 +103,9 @@ async def chat_send_cmd(ctrl: int, cmd: str) -> CR.ChatResponse: raise ChatAPIError(f"invalid chat command result: {raw[:200]}") -async def chat_recv_msg_wait(ctrl: int, wait_us: int = 500_000) -> CEvt.ChatEvent | None: +async def chat_recv_msg_wait( + ctrl: int, wait_us: int = 500_000, executor: Executor | None = None +) -> CEvt.ChatEvent | None: def _call() -> str: # On timeout, the C side returns a non-NULL pointer to a single NUL byte # (see Mobile.hs `fromMaybe ""`), so `_read_and_free` returns "" — no @@ -110,7 +113,10 @@ async def chat_recv_msg_wait(ctrl: int, wait_us: int = 500_000) -> CEvt.ChatEven ptr = _native.lib().chat_recv_msg_wait(ctrl, wait_us) return _read_and_free(ptr) - raw = await asyncio.to_thread(_call) + if executor is None: + raw = await asyncio.to_thread(_call) + else: + raw = await asyncio.get_running_loop().run_in_executor(executor, _call) if not raw: return None parsed = json.loads(raw) @@ -122,17 +128,29 @@ async def chat_recv_msg_wait(ctrl: int, wait_us: int = 500_000) -> CEvt.ChatEven raise ChatAPIError(f"invalid chat event: {raw[:200]}") -async def chat_migrate_init(db_path: str, db_key: str, confirm: MigrationConfirmation) -> int: - """Initialize chat controller. Returns opaque ctrl pointer as Python int.""" +async def chat_migrate_init( + db_path: str, + db_key: str, + confirm: MigrationConfirmation, + queue_size: int | None = None, +) -> int: + """Initialize chat controller. Returns opaque ctrl pointer as Python int. + + `queue_size` is the size of internal queues; the core default is used when None. + """ + # ctypes silently wraps ints that do not fit C int. + if queue_size is not None and ctypes.c_int(queue_size).value != queue_size: + raise ValueError(f"queue_size {queue_size} does not fit C int") + + init_queue = _native.migrate_init_queue() if queue_size is not None else None def _call() -> tuple[int, str]: ctrl = ctypes.c_void_p() - ptr = _native.lib().chat_migrate_init( - db_path.encode("utf-8"), - db_key.encode("utf-8"), - confirm.encode("utf-8"), - ctypes.byref(ctrl), - ) + args = (db_path.encode("utf-8"), db_key.encode("utf-8"), confirm.encode("utf-8")) + if init_queue is None: + ptr = _native.lib().chat_migrate_init(*args, ctypes.byref(ctrl)) + else: + ptr = init_queue(*args, queue_size, ctypes.byref(ctrl)) return (ctrl.value or 0, _read_and_free(ptr)) ctrl_val, raw = await asyncio.to_thread(_call) diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_commands.py b/packages/simplex-chat-python/src/simplex_chat/types/_commands.py index d10ed0cc04..10cc5eaa94 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_commands.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_commands.py @@ -498,7 +498,7 @@ class APIConnect(TypedDict): def APIConnect_cmd_string(self: APIConnect) -> str: - return '/_connect ' + str(self['userId']) + ((' ' + T.CreatedConnLink_cmd_string(self.get('preparedLink_'))) if self.get('preparedLink_') is not None else '') + return '/_connect ' + str(self['userId']) + (' incognito=on' if self['incognito'] else '') + ((' ' + T.CreatedConnLink_cmd_string(self.get('preparedLink_'))) if self.get('preparedLink_') is not None else '') APIConnect_Response = CR.SentConfirmation | CR.ContactAlreadyExists | CR.SentInvitation | CR.ChatCmdError 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 38e0318af1..ab06a24f53 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -220,83 +220,7 @@ BadgeRedeemError = ( 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"] +BadgeServiceErrorCode = Literal["bad_request", "unsupported_version", "unknown_purchase_key", "unknown_offer_id", "offer_disabled", "offer_mismatch", "product_unavailable", "payment_not_entitled", "payment_pending", "provider_unavailable", "rate_limited", "code_invalid", "code_used", "code_expired", "receipt_invalid", "receipt_used", "internal"] BadgeStatus = Literal["active", "expired", "expiredOld", "failed", "unknownKey"] @@ -2031,13 +1955,8 @@ class GroupInfo(TypedDict): rosterVersion: NotRequired[int] # int64 membersRequireAttention: int # int viaGroupLinkUri: NotRequired[str] - groupKeys: NotRequired["GroupKeys"] groupDomainVerified: NotRequired[bool] -class GroupKeys(TypedDict): - publicGroupKeys: NotRequired["PublicGroupKeys"] - memberPrivKey: str - class GroupLink(TypedDict): userContactLinkId: int # int64 connLinkContact: "CreatedConnLink" @@ -2172,18 +2091,6 @@ class GroupRelay(TypedDict): relayLink: NotRequired[str] relayCap: "RelayCapabilities" -class GroupRootKey_private(TypedDict): - type: Literal["private"] - rootPrivKey: str - -class GroupRootKey_public(TypedDict): - type: Literal["public"] - rootPubKey: str - -GroupRootKey = GroupRootKey_private | GroupRootKey_public - -GroupRootKey_Tag = Literal["private", "public"] - class GroupShortLinkData(TypedDict): groupProfile: "GroupProfile" publicGroupData: NotRequired["PublicGroupData"] @@ -2626,10 +2533,6 @@ class PublicGroupAccess(TypedDict): class PublicGroupData(TypedDict): publicMemberCount: int # int64 -class PublicGroupKeys(TypedDict): - publicGroupId: str - groupRootKey: "GroupRootKey" - class PublicGroupProfile(TypedDict): groupType: "GroupType" groupLink: str diff --git a/packages/simplex-chat-python/src/simplex_chat/util.py b/packages/simplex-chat-python/src/simplex_chat/util.py index e5fbbf3fab..92f2fa52cf 100644 --- a/packages/simplex-chat-python/src/simplex_chat/util.py +++ b/packages/simplex-chat-python/src/simplex_chat/util.py @@ -101,7 +101,7 @@ def ci_content_text(chat_item: T.ChatItem) -> str | None: return None -_BOT_COMMAND_RE = re.compile(r"^/([^\s]+)(.*)$") +_BOT_COMMAND_RE = re.compile(r"^/([^\s]+)(.*)$", re.DOTALL) def ci_bot_command(chat_item: T.ChatItem) -> tuple[str, str] | None: diff --git a/packages/simplex-chat-python/tests/test_api.py b/packages/simplex-chat-python/tests/test_api.py index 09b5ce4c03..1b20ec0639 100644 --- a/packages/simplex-chat-python/tests/test_api.py +++ b/packages/simplex-chat-python/tests/test_api.py @@ -7,6 +7,7 @@ shape it accepts. from __future__ import annotations +import asyncio from typing import Any import pytest @@ -172,3 +173,46 @@ async def test_a_failed_custom_data_write_raises(): api = FakeCtrl({"type": "chatCmdError"}) with pytest.raises(ChatCommandError): await api.api_merge_group_custom_data({"groupId": 9}, "mine", 1) + + +# ---------------------------------------------------------------------- # +# Documented success responses +# ---------------------------------------------------------------------- # + + +def test_update_chat_item_accepts_not_changed(): + chat_item = {"meta": {"itemId": 2}} + api = FakeCtrl({"type": "chatItemNotChanged", "chatItem": {"chatItem": chat_item}}) + msg_content = {"type": "text", "text": "same"} + assert asyncio.run(api.api_update_chat_item("direct", 1, 2, msg_content)) == chat_item + + +def test_set_profile_address_accepts_no_change(): + api = FakeCtrl({"type": "userProfileNoChange"}) + summary = asyncio.run(api.api_set_profile_address(1, True)) + assert summary == {"updateSuccesses": 0, "updateFailures": 0, "changedContacts": []} + + +def test_receive_file_reports_cancelled_by_sender(): + api = FakeCtrl({"type": "rcvFileAcceptedSndCancelled", "rcvFileTransfer": {}}) + with pytest.raises(ChatCommandError, match="file cancelled by sender"): + asyncio.run(api.api_receive_file(3)) + + +async def test_init_loads_library_off_the_event_loop(monkeypatch): + import threading + + from simplex_chat import _native, core + from simplex_chat.api import SqliteDb + + threads: list[int] = [] + monkeypatch.setattr(_native, "lib_for", lambda _backend: threads.append(threading.get_ident())) + + async def fake_migrate_init(*_args): + return 1 + + monkeypatch.setattr(core, "chat_migrate_init", fake_migrate_init) + + loop_thread = threading.get_ident() + await ChatApi.init(SqliteDb(file_prefix="/tmp/unused")) + assert threads and threads[0] != loop_thread diff --git a/packages/simplex-chat-python/tests/test_client_and_waiters.py b/packages/simplex-chat-python/tests/test_client_and_waiters.py index d40c74a0eb..1ecf146ec3 100644 --- a/packages/simplex-chat-python/tests/test_client_and_waiters.py +++ b/packages/simplex-chat-python/tests/test_client_and_waiters.py @@ -14,11 +14,13 @@ import pytest from simplex_chat import ( Bot, BotProfile, + ChatCommandError, Client, ContactAlreadyExistsError, Profile, SqliteDb, ) +from simplex_chat.core import MigrationConfirmation class FakeApi: @@ -292,6 +294,31 @@ def test_send_and_wait_parallel_different_contacts(): assert (a, b) == ("A", "B") +def test_send_and_wait_keeps_waiter_registered_during_previous_cleanup(): + bot, _api = _bot_with_fake_api() + + def reply(text: str) -> dict[str, Any]: + return {"type": "newChatItems", "chatItems": [ + { + "chatInfo": {"type": "direct", "contact": {"contactId": 42}}, + "chatItem": {"content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": text}}}, + } + ]} + + async def go() -> tuple[str, str]: + first = asyncio.create_task(bot.send_and_wait(42, "a", timeout=2.0)) + await asyncio.sleep(0) + # Created before the reply is dispatched, so it registers before `first` runs its cleanup. + second = asyncio.create_task(bot.send_and_wait(42, "b", timeout=2.0)) + await bot._dispatch_event(reply("ra")) # type: ignore[arg-type] + await asyncio.sleep(0) + await asyncio.sleep(0) + await bot._dispatch_event(reply("rb")) # type: ignore[arg-type] + return (await first).text or "", (await second).text or "" + + assert asyncio.run(go()) == ("ra", "rb") + + # --------------------------------------------------------------------------- # connect_to # --------------------------------------------------------------------------- @@ -466,6 +493,10 @@ def test_aexit_nulls_api_even_if_close_raises(monkeypatch): async def stop_chat(self): pass + @property + def initialized(self): + return False + async def close(self): raise RuntimeError("close failed") @@ -498,6 +529,83 @@ def test_aexit_nulls_api_even_if_close_raises(monkeypatch): asyncio.run(go()) +def test_aexit_keeps_api_for_retry_when_stop_fails(monkeypatch): + """A failed stop leaves the store open, so the Client must keep the + controller: dropping it would leak the store with no way to close it.""" + import simplex_chat.client as client_mod + + stop_results = ["chatCmdError", "chatStopped"] + closed: list[bool] = [False] + + class _FailingStopApi: + @classmethod + async def init(cls, *_a, **_kw): + return cls() + + @property + def initialized(self): + return not closed[0] + + async def start_chat(self): + pass + + async def close(self): + response = stop_results.pop(0) + if response != "chatStopped": + raise ChatCommandError("error stopping chat", {"type": response}) + closed[0] = True + + async def api_get_active_user(self): + return {"userId": 1, "profile": {"displayName": "x"}} + + async def send_chat_cmd(self, _cmd): + return {"type": "cmdOk"} + + monkeypatch.setattr(client_mod, "ChatApi", _FailingStopApi) + + c = Client(profile=Profile(display_name="x"), db=SqliteDb(file_prefix="/tmp/test")) + + async def go(): + with pytest.raises(ChatCommandError, match="error stopping chat"): + async with c: + pass + assert c._api is not None, "controller dropped while its store is still open" + assert closed == [False] + await c.__aexit__(None, None, None) + assert closed == [True] + assert c._api is None + + asyncio.run(go()) + + +@pytest.mark.parametrize("queue_size", [None, 65536]) +def test_bot_passes_queue_size_to_chat_api_init(monkeypatch, queue_size): + import simplex_chat.client as client_mod + + init_args: list[tuple[Any, ...]] = [] + + class StopInit(RuntimeError): + pass + + class FakeChatApi: + @classmethod + async def init(cls, *args): + init_args.append(args) + raise StopInit + + monkeypatch.setattr(client_mod, "ChatApi", FakeChatApi) + db = SqliteDb(file_prefix="/tmp/test") + bot = Bot(profile=BotProfile(display_name="x"), db=db, queue_size=queue_size) + + async def go(): + with pytest.raises(StopInit): + async with bot: + pytest.fail("should not enter the with-block") + + asyncio.run(go()) + assert init_args == [(db, MigrationConfirmation.YES_UP, queue_size)] + + def test_aenter_rolls_back_partial_init_on_post_start_failure(monkeypatch): """If anything in __aenter__ raises after ChatApi.init succeeded — including _post_start — the controller must be closed. Otherwise the with-block isn't @@ -550,7 +658,7 @@ def test_aenter_rolls_back_partial_init_on_post_start_failure(monkeypatch): pytest.fail("should not enter the with-block") asyncio.run(go()) - assert closed == ["stop", "close"], f"controller not cleaned up: {closed}" + assert closed == ["close"], f"controller not cleaned up: {closed}" assert c._api is None, "Client._api should be reset to None after rollback" diff --git a/packages/simplex-chat-python/tests/test_codegen.py b/packages/simplex-chat-python/tests/test_codegen.py index c5842f5d56..20a9400d18 100644 --- a/packages/simplex-chat-python/tests/test_codegen.py +++ b/packages/simplex-chat-python/tests/test_codegen.py @@ -39,3 +39,9 @@ def test_chat_ref_cmd_string_direct(): """Sanity check the codegen fix for ChatRef-bearing commands.""" assert T.ChatRef_cmd_string({"chatType": "direct", "chatId": 7}) == "@7" assert T.ChatRef_cmd_string({"chatType": "group", "chatId": 42}) == "#42" + + +def test_api_connect_cmd_string_renders_incognito(): + link = {"connFullLink": "L"} + assert CC.APIConnect_cmd_string({"userId": 1, "incognito": True, "preparedLink_": link}) == "/_connect 1 incognito=on L" + assert CC.APIConnect_cmd_string({"userId": 1, "incognito": False, "preparedLink_": link}) == "/_connect 1 L" diff --git a/packages/simplex-chat-python/tests/test_core_migrate_init.py b/packages/simplex-chat-python/tests/test_core_migrate_init.py new file mode 100644 index 0000000000..f18b6d34f0 --- /dev/null +++ b/packages/simplex-chat-python/tests/test_core_migrate_init.py @@ -0,0 +1,123 @@ +"""core.chat_migrate_init picks the FFI export by queue_size, with a fake libsimplex.""" + +from __future__ import annotations + +import asyncio +import json +from typing import Any + +import pytest + +from simplex_chat import core +from simplex_chat.core import ChatInitError, MigrationConfirmation + +CTRL = 42 + + +class FakeLib: + """Records calls; each export writes CTRL to the out-param and returns the JSON result.""" + + def __init__(self, result: dict[str, Any]) -> None: + self.result = json.dumps(result) + self.calls: list[tuple[str, tuple[Any, ...]]] = [] + + def _init(self, name: str, args: tuple[Any, ...]) -> str: + *call_args, ctrl_ref = args + self.calls.append((name, tuple(call_args))) + ctrl_ref._obj.value = CTRL + return self.result + + def chat_migrate_init(self, *args: Any) -> str: + return self._init("chat_migrate_init", args) + + @property + def chat_migrate_init_queue(self) -> Any: + lib = self + + class Fn: + argtypes: Any = None + restype: Any = None + + def __call__(self, *args: Any) -> str: + return lib._init("chat_migrate_init_queue", args) + + return Fn() + + +class OldLib(FakeLib): + """A libsimplex released before chat_migrate_init_queue existed.""" + + def __getattribute__(self, name: str) -> Any: + if name == "chat_migrate_init_queue": + raise AttributeError(name) + return super().__getattribute__(name) + + +@pytest.fixture +def fake_lib(monkeypatch: pytest.MonkeyPatch): + def install(result: dict[str, Any]) -> FakeLib: + lib = FakeLib(result) + monkeypatch.setattr(core._native, "lib", lambda: lib) + monkeypatch.setattr(core, "_read_and_free", lambda ptr: ptr) + return lib + + return install + + +def migrate_init(queue_size: int | None = None) -> int: + return asyncio.run( + core.chat_migrate_init("/tmp/db", "key", MigrationConfirmation.YES_UP, queue_size) + ) + + +def test_without_queue_size_uses_chat_migrate_init(fake_lib): + lib = fake_lib({"type": "ok"}) + assert migrate_init() == CTRL + assert lib.calls == [("chat_migrate_init", (b"/tmp/db", b"key", b"yesUp"))] + + +def test_with_queue_size_uses_chat_migrate_init_queue(fake_lib): + lib = fake_lib({"type": "ok"}) + assert migrate_init(65536) == CTRL + assert lib.calls == [("chat_migrate_init_queue", (b"/tmp/db", b"key", b"yesUp", 65536))] + + +def test_invalid_queue_size_result_raises_init_error(fake_lib): + fake_lib({"type": "invalidQueueSize"}) + with pytest.raises(ChatInitError) as e: + migrate_init(0) + assert e.value.db_migration_error == {"type": "invalidQueueSize"} + + +@pytest.mark.parametrize("queue_size", [2**31, -(2**31) - 1]) +def test_queue_size_outside_c_int_is_rejected_before_ffi(fake_lib, queue_size): + lib = fake_lib({"type": "ok"}) + with pytest.raises(ValueError, match="does not fit C int"): + migrate_init(queue_size) + assert lib.calls == [] + + +def test_queue_size_on_old_lib_raises_clear_error(monkeypatch): + lib = OldLib({"type": "ok"}) + monkeypatch.setattr(core._native, "lib", lambda: lib) + with pytest.raises(RuntimeError, match="does not export chat_migrate_init_queue"): + migrate_init(65536) + assert lib.calls == [] + + +def test_setup_signatures_accepts_old_lib(): + class Fn: + argtypes: Any = None + restype: Any = None + + class Lib: + def __getattr__(self, name: str) -> Fn: + if name == "chat_migrate_init_queue": + raise AttributeError(name) + fn = Fn() + setattr(self, name, fn) + return fn + + from simplex_chat import _native + + _native._setup_signatures(Lib()) # type: ignore[arg-type] diff --git a/packages/simplex-chat-python/tests/test_native_cache.py b/packages/simplex-chat-python/tests/test_native_cache.py index c2938ee3e4..6c4f2a923d 100644 --- a/packages/simplex-chat-python/tests/test_native_cache.py +++ b/packages/simplex-chat-python/tests/test_native_cache.py @@ -91,3 +91,13 @@ def test_atomic_install(tmp_path, monkeypatch): _download(target, "sqlite") assert (target / "libsimplex.so").read_text() == "fake-so" assert (target / "libHS-stub.so").read_text() == "fake-hs" + + +def test_libc_on_windows_is_ucrt(monkeypatch): + loaded: list[str | None] = [] + monkeypatch.setattr("sys.platform", "win32") + monkeypatch.setattr("ctypes.CDLL", lambda name: loaded.append(name)) + from simplex_chat import _native + + _native._load_libc() + assert loaded == ["ucrtbase"] diff --git a/packages/simplex-chat-python/tests/test_recv_executor.py b/packages/simplex-chat-python/tests/test_recv_executor.py new file mode 100644 index 0000000000..ef160d5cf9 --- /dev/null +++ b/packages/simplex-chat-python/tests/test_recv_executor.py @@ -0,0 +1,207 @@ +"""ChatApi receives run on a dedicated per-instance thread, not the default pool. + +Uses a fake libsimplex (see tests/test_core_migrate_init.py for the pattern): +`core._native.lib` and `core._read_and_free` are monkeypatched so `chat_recv_msg_wait` +sleeps for a controlled time and returns a scripted result. +""" + +from __future__ import annotations + +import asyncio +import json +import threading +import time +from concurrent.futures import ThreadPoolExecutor +from typing import Any + +import pytest + +from simplex_chat import ChatApi, ChatCommandError + +RECV_SLEEP = 0.3 + + +class FakeRecvLib: + """Fake chat_recv_msg_wait: blocks for `sleep` seconds, then returns a scripted result. + + `events` records "stop" / "recv_start" / "recv_end" / "close_store" in call order, across + all ChatApi instances sharing this fake, so tests can assert ordering between stop, receive + and store-close calls (not just that they happened). + """ + + def __init__( + self, + sleep: float = RECV_SLEEP, + results: list[str] | None = None, + stop_response: str = "chatStopped", + ) -> None: + self.sleep = sleep + self._results = iter(results or []) + self._stop_response = stop_response + self.calls: list[tuple[int, int]] = [] # (ctrl, thread ident) + self.events: list[str] = [] + self._lock = threading.Lock() + + def chat_recv_msg_wait(self, ctrl: int, wait_us: int) -> str: + with self._lock: + self.events.append("recv_start") + time.sleep(self.sleep) + with self._lock: + self.events.append("recv_end") + self.calls.append((ctrl, threading.get_ident())) + return next(self._results, "") + + def chat_send_cmd(self, ctrl: int, cmd: bytes) -> str: + assert cmd == b"/_stop", f"unexpected command {cmd!r}" + with self._lock: + self.events.append("stop") + return json.dumps({"result": {"type": self._stop_response}}) + + def chat_close_store(self, ctrl: int) -> str: + with self._lock: + self.events.append("close_store") + return "" + + +@pytest.fixture +def fake_lib(monkeypatch: pytest.MonkeyPatch): + def install( + sleep: float = RECV_SLEEP, + results: list[str] | None = None, + stop_response: str = "chatStopped", + ) -> FakeRecvLib: + lib = FakeRecvLib(sleep=sleep, results=results, stop_response=stop_response) + monkeypatch.setattr("simplex_chat.core._native.lib", lambda: lib) + monkeypatch.setattr("simplex_chat.core._read_and_free", lambda ptr: ptr) + return lib + + return install + + +def _recv_thread_names() -> list[str]: + return [t.name for t in threading.enumerate() if t.name.startswith("simplex-recv")] + + +async def test_receives_do_not_use_the_default_executor(fake_lib): + fake_lib(sleep=RECV_SLEEP) + loop = asyncio.get_running_loop() + loop.set_default_executor(ThreadPoolExecutor(max_workers=1)) + + apis = [ChatApi(ctrl=i) for i in range(3)] + recv_tasks = [asyncio.create_task(api.recv_chat_event()) for api in apis] + try: + await asyncio.sleep(0.05) # let all three receives claim their own thread + + start = time.monotonic() + await asyncio.to_thread(lambda: None) + elapsed = time.monotonic() - start + + await asyncio.gather(*recv_tasks) + finally: + for api in apis: + await api.close() + + assert elapsed < 0.1 + + +async def test_one_receive_thread_per_chatapi_reused(fake_lib): + lib = fake_lib(sleep=0.02) + api = ChatApi(ctrl=1) + other_api = ChatApi(ctrl=2) + try: + for _ in range(3): + await api.recv_chat_event() + + idents = {ident for ctrl, ident in lib.calls if ctrl == 1} + assert len(idents) == 1 + recv_ident = idents.pop() + thread = next(t for t in threading.enumerate() if t.ident == recv_ident) + assert thread.name.startswith("simplex-recv") + assert thread.ident != threading.get_ident() + + await other_api.recv_chat_event() + other_idents = {ident for ctrl, ident in lib.calls if ctrl == 2} + assert other_idents and other_idents != {thread.ident} + finally: + await api.close() + await other_api.close() + + +def test_no_thread_until_first_receive(): + # A bare ThreadPoolExecutor spawns no worker thread until the first submit, + # so the real assertion is the attribute itself, not threading.enumerate(). + api = ChatApi(ctrl=1) + assert api._recv_executor is None + + +async def test_close_shuts_down_the_executor_without_blocking_the_loop(fake_lib): + fake_lib(sleep=RECV_SLEEP) + api = ChatApi(ctrl=1) + recv_task = asyncio.create_task(api.recv_chat_event()) + await asyncio.sleep(0.05) # let the receive claim its executor thread + assert _recv_thread_names() != [] + + sleep_task = asyncio.create_task(asyncio.sleep(0.01)) + close_task = asyncio.create_task(api.close()) + + await asyncio.wait_for(sleep_task, timeout=0.2) + assert not close_task.done() # shutdown still waiting on the in-flight receive + + await close_task + await recv_task + + assert _recv_thread_names() == [] + + +async def test_close_shuts_down_executor_before_closing_the_store(fake_lib): + lib = fake_lib(sleep=RECV_SLEEP) + api = ChatApi(ctrl=1) + recv_task = asyncio.create_task(api.recv_chat_event()) + try: + await asyncio.sleep(0.05) # ensure the receive is in flight before close() starts + await api.close() + await recv_task + finally: + if not recv_task.done(): + recv_task.cancel() + + # recv_end (executor drained) must precede close_store: a receive must never + # be in flight while the store closes underneath it. + assert lib.events == ["recv_start", "stop", "recv_end", "close_store"] + + +async def test_close_stops_the_chat_before_closing_the_store(fake_lib): + lib = fake_lib() + api = ChatApi(ctrl=1) + await api.close() + assert lib.events == ["stop", "close_store"] + assert not api.initialized + + +async def test_close_does_not_close_the_store_when_stop_fails(fake_lib): + lib = fake_lib(stop_response="chatCmdError") + api = ChatApi(ctrl=1) + with pytest.raises(ChatCommandError, match="error stopping chat"): + await api.close() + assert lib.events == ["stop"] + assert api.initialized + + +async def test_recv_chat_event_after_close_raises_before_touching_executor(fake_lib): + fake_lib() + api = ChatApi(ctrl=1) + await api.close() + with pytest.raises(RuntimeError, match="controller not initialized"): + await api.recv_chat_event() + assert api._recv_executor is None + + +async def test_receive_parses_event_json_and_none_on_timeout(fake_lib): + event: dict[str, Any] = {"type": "chatItemUpdated", "chatItem": {}} + fake_lib(sleep=0.01, results=[json.dumps({"result": event}), ""]) + api = ChatApi(ctrl=1) + try: + assert await api.recv_chat_event() == event + assert await api.recv_chat_event() is None + finally: + await api.close() diff --git a/packages/simplex-chat-python/tests/test_util.py b/packages/simplex-chat-python/tests/test_util.py index 3ea0d87e6d..d5a43eabaa 100644 --- a/packages/simplex-chat-python/tests/test_util.py +++ b/packages/simplex-chat-python/tests/test_util.py @@ -167,6 +167,11 @@ def test_ci_bot_command_no_text(): assert util.ci_bot_command(ci) is None +def test_ci_bot_command_multiline_params(): + ci = {"content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": "/review line1\nline2"}}} + assert util.ci_bot_command(ci) == ("review", "line1\nline2") + + def test_reaction_text_emoji(): r = {"chatReaction": {"reaction": {"type": "emoji", "emoji": "🎉"}}} assert util.reaction_text(r) == "🎉" diff --git a/plans/2026-09-12-fix-passcode-submit-split-screen.md b/plans/2026-09-12-fix-passcode-submit-split-screen.md new file mode 100644 index 0000000000..f5051e6721 --- /dev/null +++ b/plans/2026-09-12-fix-passcode-submit-split-screen.md @@ -0,0 +1,147 @@ +# Fix Submit button not visible in passcode view in split screen + +## Problem + +On Android in **split screen**, the "Enter Passcode" view shows the title and the +keypad but **no Cancel/Submit buttons** — the app cannot be unlocked with a passcode +without leaving split screen. + +The same `PasscodeView` also backs `SetAppPasscodeView`, so "New Passcode" / +"Confirm passcode" (Privacy & security → passcode, and the passcode onboarding in +`AppLock.setPasscode`) are affected identically. + +Desktop is affected too whenever the window is short (below ~590dp of height at a +typical width), since desktop always uses the vertical layout. + +## Cause + +Two independent defects, both invisible at full screen. + +### 1. The keypad consumes the space the buttons need + +`PasscodeView.VerticalLayout` is a `Column` of three children — title, keypad, +buttons `Row` — and the keypad's size is derived from the constraints it is offered +(`PasswordEntry.kt`): + +```kotlin +val s = if (appPlatform.isAndroid) minOf(maxWidth, maxHeight) / 4 - 1.dp else ... +``` + +`Column` measures unweighted children in order, each with `maxHeight` reduced by what +the previous ones took, so the keypad — the **second** child — is offered *all* +remaining height and a 4-row grid then occupies exactly that. The buttons `Row` is +measured last, from what is left, and `Modifier.heightIn(min = 70.dp)` cannot rescue +it: `heightIn` constrains its target into the incoming constraints, so a 70dp minimum +against a 1dp maximum yields 1dp. + +At full screen the bug is dormant because `maxWidth` is the smaller term there — the +keypad is sized by width and the leftover height goes to the buttons. It appears as +soon as the remaining height drops below the width, which every split-screen half does. + +Measured with a Compose harness that renders these composables at fixed window sizes +(411dp wide, density 1, `Surface(Modifier.fillMaxSize())` as in `LocalAuthentication.kt`): + +| window | keypad `maxHeight` | key size | buttons Row height | Submit | +|---|---|---|---|---| +| 411×914 (full screen) | 805 | 101.75 | 70 @ y=763 | 108×40 ✓ | +| 411×520 | 411 | 101.75 | **0** | 108×0 | +| 411×480 | 371 | 91.75 | **0** | 108×0 | +| 411×445 (split half) | 336 | 83.0 | **1** @ y=444 | 108×**1** | +| 411×400 | 291 | 71.75 | **0** | 108×0 | + +`HorizontalLayout` has the same defect in the width axis: its keypad is 4 keys wide +and sized `minOf(maxWidth, maxHeight) / 3.5f`, so the keypad's *width* grows with the +available *height*, and the Cancel/Submit `Column` — the **second** child of the `Row` +— gets the remainder. At 411×365 it is offered `maxWidth = 24dp` and Submit measures +**0×0**; at 411×445 it is offered **0dp** and Submit is placed at **x=441**, outside a +411dp-wide window. + +This branch matters in split screen because `windowOrientation()` reads +`Configuration.orientation`, which is computed from the *activity window* bounds, not +the device: a split half of a tall phone is `PORTRAIT` (~411×445) while a half of a +16:9 phone is `LANDSCAPE` (~411×365). Both layouts are reachable, and both were broken. + +### 2. No window insets + +`MainActivity` calls `enableEdgeToEdge()`, and this screen never applied insets — at +full screen `SpaceEvenly` happened to leave the buttons ~80dp clear of the navigation +bar. Once the buttons are laid out correctly in a split half, they land at the window +edge: with fix 1 alone, Submit occupies y=390…430 of a 445dp window, and a 48dp +3-button navigation bar covers y=397…445 — **33 of its 40dp**, so the button is drawn +but not reliably tappable in the bottom split window. + +## Fix + +`Modifier.weight(1f, fill = false)` on the keypad in the vertical layout and on the +keypad column in the horizontal layout (`PasscodeView.kt`), which requires a `modifier` +parameter on `PasscodeEntry` (`PasswordEntry.kt`): + +```kotlin +PasscodeEntry(passcode, true, Modifier.weight(1f, fill = false)) +``` + +`Column`/`Row` measure **weighted children after all unweighted ones**, so the title +and the buttons are measured first at their natural size and the keypad receives what +is left — the inverse of the current order, with no size constant to keep in sync. +`fill = false` keeps the keypad at its own size rather than stretching it, and because +a weighted child makes the container expand to the incoming bounded maximum, the +`SpaceEvenly` / `SpaceBetween` distribution at full screen is unchanged. + +Plus `Modifier.systemBarsPadding()` on both layout roots. + +Measured after the fix: + +| window | branch | key size | Submit | +|---|---|---|---| +| 411×445, 24dp status bar (top split half) | vertical | 59.5 | 108×40 @ y=390…430 ✓ | +| 411×445, 48dp nav bar (bottom split half) | vertical | 53.5 | 108×40 @ y=342…382, clear of the bar ✓ | +| 411×365, 48dp nav bar | horizontal | 29.3 | 108×40 @ x=263…371 ✓ | +| 411×914 (full screen) | vertical | **101.75 (unchanged)** | 108×40, column y=24…866 | + +The insets go on the `Column`/`Row` themselves rather than a wrapping `Box`: Material +`Surface` lays its content out with `propagateMinConstraints = true`, so the layout root +receives the window size as a *minimum*. A plain `Box` in between drops that minimum +(its own default is `false`), the `Column` becomes wrap-content, `SpaceEvenly` has no +slack left to distribute, and the whole screen top-aligns — measured as the buttons +moving from y=763 to y=544 at full screen. Keeping the padding on the root preserves +the propagation. + +## Scope / non-goals + +- Both files are `commonMain`, so the fix covers Android and desktop; on desktop + `systemBarsPadding()` resolves to zero insets and only the `weight` change has an + effect (short desktop windows). +- The keypad necessarily gets smaller in a small window: 53.5–59.5dp keys in a portrait + split half, 29.3dp in the 411×365 horizontal case, where the title, reason and + passcode text consume ~170dp before the keypad is measured. Making the passcode + screen itself more compact below some height (smaller title, dropping the reason + line) is a separate design change and is not attempted here. +- `systemBarsPadding()` also shrinks the keypad in full-screen landscape (79.6 → 59.0dp). + That is the same 72dp the bars always occupied — previously the keypad's bottom row + extended ~8dp under the navigation bar — but it is a visible change on a screen that + did not show the reported bug. +- Not changed: `windowOrientation()` branching on `Configuration.orientation`. Selecting + the layout from the measured aspect ratio (as iOS does) would be a better fit for + resizable windows, but it changes behaviour on every device rather than fixing this bug. + +## iOS + +Not affected, and for a structural reason worth recording: iOS picks its layout from the +*measured* geometry rather than an orientation flag — +`if g.size.width < g.size.height * 2 / 3` (`PasscodeView.swift`) — so a short, wide +window (the geometry that breaks Android's vertical layout) selects iOS's horizontal +layout, whose keypad is sized from height (`s = height / 5`) with the buttons column +explicitly bounded to `height / 5 * 3 * 0.97`. The vertical layout's width-driven keypad +(`s = width / 3`) is only ever used when the window is at least 1.5× taller than wide, +where it fits by construction. No iOS change required. + +## Verification + +- Compose measurement harness at the window sizes tabulated above, before and after, + for both layout branches and for 24dp/48dp/72dp inset combinations. +- Android arm64 debug APK (`bash ~/build/android.sh`) — manual check of Submit in both + the top and the bottom split-screen half, and no visual change at full screen. +- Linux x86_64 AppImage (`bash ~/build/linux.sh`) — manual check with the window resized + short, confirming the desktop vertical layout keeps Cancel/Submit visible. +- Also exercise "New Passcode" / "Confirm passcode" (`SetAppPasscodeView`) in split + screen, which share `PasscodeView`. diff --git a/plans/2026-09-13-group-keys-sum-type.md b/plans/2026-09-13-group-keys-sum-type.md new file mode 100644 index 0000000000..5d8e8e98ab --- /dev/null +++ b/plans/2026-09-13-group-keys-sum-type.md @@ -0,0 +1,252 @@ +# Group keys as a sum type + +Branch: `master`, on top of `core: refactor groups`. + +## Summary + +`GroupInfo.groupKeys` is removed, and the user's private keys leave every API response and event. + +Group keys become a sum type with one constructor per kind of group, each holding the user's member key. + +A group and its keys come from one query. Every read of keys is a read of the group. + +The member key is written at every group insert, and generated at the first read of a row created before this change. `createUserMemberKey` is removed. + +## Terms + +- **p2p group** — `use_relays = 0`. The user's member key signs messages. +- **public group** — `use_relays = 1`. Identified by `public_group_id` and the group root key. +- **member key** — `groups.member_priv_key`, the user's own key in the group. +- **root key** — the group's identity key. The owner holds it as `GRKPrivate`; everyone else holds `GRKPublic`. +- **relay request** — a group row a relay creates on `XGrpRelayInv`, before it fetches the group link. +- **prepared channel** — a public group prepared from a link, before `APIConnectPreparedGroup` stores the root key. + +## 1. The type + +`Simplex/Chat/Types.hs`. + +```haskell +data GroupKeys + = GKGroup + { memberPrivKey :: C.PrivateKeyEd25519 + } + | GKPublicGroup + { groupRootKey :: GroupRootKey, + memberPrivKey :: C.PrivateKeyEd25519 + } + | GKRelayRequest + { memberPrivKey :: C.PrivateKeyEd25519 + } + | GKPreparedPublicGroup + { memberPrivKey :: C.PrivateKeyEd25519 + } + deriving (Eq, Show) + +isPublicGroup :: GroupKeys -> Bool + +data GroupInfoKeys = GIK GroupInfo GroupKeys +``` + +`GroupInfoKeys` is the group read with its keys. A function that signs takes it in place of `GroupInfo`; the name `gInfo` denotes whichever of the two a scope holds. + +`PublicGroupKeys` is removed. `GroupRootKey` is unchanged, and its JSON instance is removed with those of `GroupKeys` and `PublicGroupKeys`. + +`GroupInfo` loses `groupKeys` and keeps every other field, `rosterVersion` included. Its `deriveJSON` then emits public fields only. + +`RequestEntity` becomes `REBusinessChat GroupInfoKeys GroupMember`. + +`PreparedChatEntity` becomes `PCEGroup {groupInfo :: GroupInfoKeys, hostMember}`. + +`ReceivedGroupInvitation` gains `groupKeys :: GroupKeys`. + +## 2. Reading + +`Simplex/Chat/Store/Shared.hs`. + +`StoreCxt` gains the generator, named `drg` because `random` collides with `ChatController.random` wherever both records are in scope. + +```haskell +data StoreCxt = StoreCxt {vr :: VersionRangeChat, badgeKeys :: Map Int BBSPublicKey, drg :: TVar ChaChaDRG} +``` + +```haskell +storeCxt :: ChatController -> StoreCxt +``` + +`toGroupInfo` returns `(GroupInfo, GroupKeysRow)`; `toGroupInfo_` returns the group alone. + +```haskell +mkGroupKeys :: DB.Connection -> StoreCxt -> GroupInfo -> GroupKeysRow -> ExceptT StoreError IO GroupKeys +``` + +The member key is taken from the row, or generated and stored. The constructor follows: + +| `use_relays` | `public_group_id` | root key | result | +| --- | --- | --- | --- | +| 0 | — | — | `GKGroup` | +| 1 | present | present | `GKPublicGroup` | +| 1 | present | absent | `GKPreparedPublicGroup` | +| 1 | absent | — | `GKRelayRequest` | + +### Reads + +| function | returns | +| --- | --- | +| `getGroupInfoRow` | `(GroupInfo, GroupKeysRow)` | +| `getGroupInfoKeys` | `GroupInfoKeys` | +| `getGroupInfo` | `GroupInfo`, as `fst <$> getGroupInfoRow` | +| `getGroupKeys_` | `(Group, GroupKeys)` | +| `getGroup` | `Group`, as `fst <$> getGroupKeys_` | + +All five issue one `groupInfoQuery`. `getGroupKeys_` and `getGroup` add the member query. + +`getGroupInfoKeys` returns the group with `membership.memberPubKey` set from the member key it materialized, so the pair agrees on a row created before this change. + +A site that needs keys switches its existing read to `getGroupInfoKeys` or `getGroupKeys_`. + +### Reads that return keys with their entity + +| function | returns | +| --- | --- | +| `getConnectionEntityKeys` | `(ConnectionEntity, Maybe GroupKeysRow)` | +| `getConnectionEntity` | `ConnectionEntity`, as `fst <$> getConnectionEntityKeys` | +| `getGroupInvitation` | `ReceivedGroupInvitation`, with `groupKeys` | +| `createGroupInvitation` | `(GroupInfoKeys, GroupMemberId)` | +| `createBusinessRequestGroup` | `(GroupInfoKeys, GroupMember)` | +| `updatePreparedRelayedGroup` | `GroupInfoKeys` | +| `getRelayServedGroups` | `[GroupInfoKeys]` | +| `getAcceptedBusinessChat` | `Maybe (GroupInfo, GroupKeysRow)` | +| `getGroupAndRegLink` (directory service) | `(GroupInfoKeys, GroupReg, Maybe GroupLink)` | + +### Reads that discard the keys + +`toGroupInfo_` builds the group for `getBaseGroupDetails`, `getRelayInactiveGroups` and `toGroupInfoRegLink`. + +## 3. Message handling + +`getUserEntity` reads the entity with `getConnectionEntityKeys` and builds the keys from the row with `mkGroupKeys` in the same transaction; the first message on a row created before this change writes the member key. `processAgentMessageConn` takes `Maybe GroupKeys` and passes `GroupInfoKeys` to `processGroupMessage`. + +Handlers that send take `GroupInfoKeys`: `xGrpInfo`, `xGrpRosterAck`, `xGrpRosterRequest`, `xGrpLinkAcpt`, `xGrpMemNew`, `xGrpMemRole`, `xGrpMemDel`, `xGrpLeave`, `xGrpMsgForward`, `applyAtRosterVersion`, `bFileChunkGroup`, `receiveRosterChunk`, `rosterCompletion`, `sendRosterAck`. `updatePublicGroupData` in `Internal.hs` takes `GroupInfo` and `GroupKeys` and returns the updated `GroupInfo`. + +An entity of a group connection without keys raises `CEInternalError`. + +## 4. Writing the member key + +One statement writes `groups.member_priv_key` after this change, in `setUserMemberKey`: + +```sql +UPDATE groups +SET member_priv_key = COALESCE(member_priv_key, ?), updated_at = ? +WHERE group_id = ? +RETURNING member_priv_key +``` + +`group_members.member_pub_key` for the membership row is set from the returned key. + +```haskell +setUserMemberKey :: DB.Connection -> GroupId -> GroupMemberId -> C.PrivateKeyEd25519 -> ExceptT StoreError IO C.PrivateKeyEd25519 +``` + +### Inserts + +Every insert writes a member key. `createRelayRequestGroup` generates one and passes its public half to `createContactMemberInv_`, closing the TODO it held. + +`createNewGroup` takes a non-optional `GroupKeys` and derives `use_relays` from `isPublicGroup`. + +### Updates that stop writing the member key + +`updateGroupMemberKeys` is replaced by `setGroupRootKey`, which writes `root_pub_key` alone. `updateRelayGroupKeys` keeps `group_type`, `group_link` and `public_group_id`, and calls it. + +### Callers that stop generating keys + +| caller | change | +| --- | --- | +| `APIConnectPreparedGroup` | writes the root key alone | +| `createRelayLink` | signs the relay link with the stored member key | + +A relay's member key and its relay-link root key are the same key. Members read it from the link as `FixedLinkData.rootKey`. + +The owner keeps the split: the root key authorises owner keys through `OwnerAuth`, and the member key signs messages and shares. + +### `createUserMemberKey` + +Removed, with its six calls. Each caller takes `GroupKeys` from its own read. + +## 5. The error + +```haskell +| SEGroupNotFound {groupId :: GroupId} +``` + +A relay request and a prepared channel read as their own constructors, so every read returns them. + +## 6. Consumers of the keys + +`groupBindingData` reads `publicGroupId` from `groupProfile.publicGroup`, so it takes `GroupInfo`. Everything that verifies — `verifyGroupSig`, `withVerifiedMsg`, `xInfoMember`, `storeMemberKey`, `verifyKey`, `rcvGroupChatBinding` — takes `GroupInfo` alone. The receive path is unchanged. + +`sndGroupChatBinding` asserts the user's own member key, which `membership.memberPubKey` holds as the public half. + +```haskell +groupMemberKey :: GroupKeys -> MemberKey +``` + +| site | uses | +| --- | --- | +| `groupLinkData` | root key as `GRKPrivate`, member key | +| `groupMsgSigning` | member key | +| `groupMemberKey` | member key | +| `encodeXMemberConnInfo` | member key | +| `APIShareChatMsgContent` | root key as the owner test, member key to sign | + +### Functions that take `GroupInfoKeys` in place of `GroupInfo` + +`Internal.hs`: `acceptGroupJoinRequestAsync`, `acceptBusinessJoinRequestAsync`, `groupLinkData`, `setGroupLinkData`, `setGroupLinkData'`, `setGroupLinkDataAsync`, `introduceToModerators`, `introduceToAll`, `introduceToRemaining`, `introduceMember`, `introduceInChannel`, `serveRoster`, `sendInlineBlobChunks`, `sendRelayCapIfNeeded`, `sendGroupMemberMessages`, `sendGroupMessage`, `sendGroupMessage'`, `sendRoster`, `broadcastRoster`, `sendGroupRosterToRelay`, `sendGroupMessages`, `sendGroupSignedMessages`, `sendGroupProfileUpdate`, `sendGroupMessages_`, `groupMsgSigning`, `encodeXMemberConnInfo`, `allowAgentConnectionAsync` (as `Maybe GroupInfoKeys`). + +`Commands.hs`: `delEventSigned`, `changeRoleInvitedMems`, `deleteMemsSend`, `deletePendingMember`, `blockMembers`, `sendGroupContentMessages`, `sendGroupContentMessages_`, `getCommandGroupChatItems`, `delGroupChatItemsForMembers`, `sendGrpInvitation`, `connectToRelay`, `leaveChannelRelay`, `leaveGroupSendMsg`, `runUpdateGroupProfile`. `changeRoleCurrentMems` takes `Group` and `GroupKeys`. `newGroup` takes `GroupKeys` and loses its `Bool`. + +`joinContact` takes `Maybe (Maybe GroupInfoKeys)` and `Maybe MemberId`. + +`Subscriber.hs`: `processGroupMessage` and every handler under it that sends; `acceptJoin`, `getLinkDataCreateRelayLink`. + +Directory service: `updateGroupLinkData` and the `withGroupRegLink` callbacks. + +`saveConnInfo` returns `Maybe GroupInfoKeys`. + +## 7. Queries + +The query count is unchanged. Every site that needs keys takes them from a read it already performs: + +| site | before | after | +| --- | --- | --- | +| commands holding a group | `getGroupInfo` | `getGroupInfoKeys` | +| commands holding a group and members | `getGroup` | `getGroupKeys_` | +| `processAgentMessageConn` | `getConnectionEntity` | `getConnectionEntityKeys` | +| `APIJoinGroup` | `getGroupInvitation` | same, with `groupKeys` in the record | +| `APIConnectPreparedGroup` | `getGroupInfo` | `getGroupInfoRow` | +| business request | `getGroupInfo` | `getGroupInfoRow` | +| directory service link update | `getGroupLink` | `getGroupAndRegLink` | + +## 8. Schema + +The schema is unchanged. The columns keep their meaning: + +- `groups.member_priv_key` — written at insert, or at the first read of a row created before this change. +- `groups.root_priv_key` — the owner's root key. +- `groups.root_pub_key` — every other member's copy of the root key. +- `group_profiles.public_group_id` — the public group identity. + +## 9. Tests + +`testGroupMemberKeyGenerated` (`tests/ChatTests/Groups.hs`): a p2p group whose member key columns are NULL on both sides. The first send stores a key, the profile update carries and is signed by that key, the peer stores it from the update and verifies the next signed event with it, `member_pub_key` of the membership is the public half of `member_priv_key`, and a second send leaves the key unchanged. + +Covered by the existing suites: relay request and prepared channel flows (`chat relay tests`), relay link signing (`chat relay tests`), `GroupInfo` JSON (`Bot API docs`, once the generated files are writable). + +## Open + +`bots/api/TYPES.md`, `packages/simplex-chat-client/types/typescript/src/types.ts` and `packages/simplex-chat-python/src/simplex_chat/types/_types.py` still declare `GroupKeys`. The `Bot API docs` test regenerates them once they are writable; they are owned by root. + +## Out of scope + +- The sum type in API responses and events. +- Moving relay request data out of the `groups` row. +- `groupSummary.publicMemberCount` moving into `GKPublicGroup`, since both apps decode `GroupSummary`. diff --git a/plans/2026-09-17-badge-renewal-failure-alerts.md b/plans/2026-09-17-badge-renewal-failure-alerts.md new file mode 100644 index 0000000000..c2002b1d58 --- /dev/null +++ b/plans/2026-09-17-badge-renewal-failure-alerts.md @@ -0,0 +1,89 @@ +# Plan: tell the user when badge renewal is failing + +## The problem + +When the monthly renewal fails, the worker retries silently — with backoff for network failures, a day later for anything else — and the user learns nothing. Nothing else marks the failure: the badge is retired only when the paid months run out (`retireExpiredBadge`), so a badge whose renewal keeps failing stays on the profile with an expired credential, and contacts see it as expired. + +## What changes + +**1. Core remembers the failure.** Four new columns on `badge_purchases`. Three are written only by the renewal request path (`requestBadgeIssue`), never by retirement or presentation: `issue_failed_since` (the first failed attempt of the current run), `issue_error_at` and `issue_error` (the last failure: when, and what). A renewal request counts as failed when it ends without a new credential stored: the service refused, the request threw (timeout, network, undecodable or unexpected reply), or the issued credential did not verify — that last case stores the ledger row today and is still recorded as a failure. A stored issuance clears the three columns; nothing else does, so a purchase retired while failing keeps its last failure. The fourth column, `next_wake_at`, is the wake the worker is about to wait for, written at the end of every pass whether it failed or not. + +`BadgeState` gains `issueError :: Maybe BadgeIssueError` — `{failedSince, lastAttemptAt, reason}`, present once the recorded failure is worth telling the user about by §2's rule, so a failure that is still being retried is not shown — and `nextWakeAt :: Maybe UTCTime`. `reason` is a sum of the failure kinds below. That is the whole UI contract; nothing else is added to the wire. + +**2. Core decides when it is an alert.** A new alert kind, `BAIssueFailed`, derived from stored state like the others. It is raised when a failure is recorded and either + +- the reason is terminal — a service refusal without `retryAfter`, an invalid credential, an unexpected response — at once; or +- the reason is transient — timeout, network, a refusal with `retryAfter` (rate-limited, provider unavailable, payment pending) — and the shown credential's expiry has passed: from that moment contacts see the badge as expired, so the failure is visible and worth a word. Requests start a day before expiry, so this is roughly "failing for a day". Whether the service gave `retryAfter` is the service's own view of transience and holds for codes this version does not know. + +Once `paidThrough` has passed, the alert is the usual Support ended, which takes precedence; `derivedBadgeAlert` raises Support ended on `paidThrough <= now` alone, without today's `balanceMonths == 0` condition. + +`episode` is `issue_failed_since`, so one alert per run of failures; acknowledging it (the existing `/_badge ack`, snooze included) silences that run and a later run alerts again. A successful renewal clears the state, and with it the alert and the Error section; a failure after that is a new run with a new episode. + +**3. Chat list: the banner slot, in error dress.** The existing one-slot banner (`SupportSimpleXBanner`) gets a warning variant — the same hero, title "Badge renewal failed" in red, subtitle "Tap for details" — shown for `BAIssueFailed` under the same conditions as Support ended (same user, same slot before the pitch, also suppresses the onboarding cards). Tapping opens the badges screen as the other banners do — the badge is still shown while renewal fails, so the router lands on Your Badge. Dismiss offers "Remind me later" / "Dismiss" through the same ack calls as Support ended. Not a one-shot modal alert: the failure lasts days and a modal is gone after one tap. + +**4. Your Badge: an "Error" section** below "How it works", above the developer-tools section, shown while `issueError` is present — that is, from the moment the failure meets §2's criteria, and still after the banner has been dismissed: the ack answers the alert, not the failure. It is built like the "Connection failed" section of `GroupMemberInfoView`: on iOS a `Section` whose header is `HStack(spacing: 6) { Image(systemName: "exclamationmark.triangle").foregroundColor(.red); Text("Error") }`, on Kotlin `SectionView(title = stringResource(MR.strings.error), icon = painterResource(MR.images.ic_warning), iconTint = Color.Red, leadingIcon = true)`, and the reason as a sentence in `secondary` color the way `connFailedErr` is shown there. Then `infoRow`s "Since" with `failedSince` and, only when it differs, "Last attempt" with `lastAttemptAt`; then a "Contact SimpleX team" button that opens the team address as "Send questions and ideas" does (iOS: dismiss, then `ChatModel.shared.appOpenUrl = simplexTeamURL`; Kotlin: `uriHandler.openVerifiedSimplexUri(simplexTeamUri)`). No "Retry" button: opening Your Badge signals the worker (`APIGetBadgeState` → `startBadgeWork`), which runs a pass at once after a terminal failure; during a transient one it is already retrying on its own schedule (30 s, doubling to an hour) and takes the signal up when that loop ends. + +The sentences: for a service refusal, the code's own text from `badgeServiceErrorText` — one table per app, shared with the redeem alert, with sentences for `code_invalid`, `code_used`, `code_expired`, `rate_limited`, `unsupported_version`, `unknown_purchase_key` ("The badge service does not recognize this badge.") and `internal` ("The badge service reported an internal error.") — and for any other code "The badge service refused the renewal: %@" with the code; "The badge service did not respond."; "The badge service could not be reached."; "The badge issued by the service cannot be verified."; "Unexpected error: %@". + +**5. Developer tools** (the Credential section): "Next check" from `nextWakeAt` when present, and while `issueError` is present "Error" with the raw reason (tag plus payload, e.g. `serviceError final code_used`), for support. The CLI's `/_badge state` prints the same: `, next check YYYY-MM-DD HH:MM` on the state line, and a second line `renewal failing since YYYY-MM-DD, last YYYY-MM-DD HH:MM: `. + +## Specifics + +**Types** (`Simplex.Chat.Badges.Types`, next to `BadgeAlert`): + +```haskell +data BadgeIssueFailure + = BIFServiceError {code :: BadgeServiceErrorCode, retryable :: Bool} -- retryable = the service gave retryAfter + | BIFServiceTimeout -- the agent's own service-request timeout + | BIFNetwork {agentError :: Text} -- the agent error as text, for support + | BIFInvalidCredential + | BIFUnexpected {message :: Text} -- undecodable or unexpected reply, or any other throw + +data BadgeIssueError = BadgeIssueError {failedSince :: UTCTime, lastAttemptAt :: UTCTime, reason :: BadgeIssueFailure} +``` + +`BadgeIssueFailure` is stored as text the way `CIStatus` is (`Messages.hs`, `instance StrEncoding (CIStatus d)` and its `ToField`/`FromField` through `strEncode`/`strDecode`): a tag and space-separated payload — `service_error retry|final `, `service_timeout`, `network `, `invalid_credential`, `unexpected ` (the unbounded field always last, so any content parses). To the UI it goes as `sumTypeJSON $ dropPrefix "BIF"`, exactly as `BadgeRedeemError` does (`sumTypeJSON $ dropPrefix "BRE"`), so both apps mirror it the way they mirror that one — an iOS `enum … : Decodable, Hashable`, a Kotlin `@Serializable sealed class` with `@SerialName` per case — with no hand-written decoder. `BadgeIssueError` and the new `BadgeState` fields use `defaultJSON`. `BadgeAlertKind` gains `BAIssueFailed`, text `issue_failed` in its `TextEncoding` (the ack command and the `alert_acked_kind` column), JSON `issueFailed` from the existing `enumJSON` derivation. + +Classification of a thrown request error, next to `badgeErrorRetry` and by its rule: `AGENT (A_SERVICE ASETimeout)` → `BIFServiceTimeout`; `temporaryOrHostError` → `BIFNetwork` with the agent error's text; any other agent error → `BIFUnexpected` with the agent error's text; anything else → `BIFUnexpected` with the error's text. Transient: `BIFServiceTimeout`, `BIFNetwork`, `BIFServiceError` with `retryable`, and `BIFServiceError` with code `internal` — the service withholds `retryAfter` from it so as not to be pressed while failing, not because the fault is final. Terminal: the rest. + +`BadgeIssueFailure`'s parser falls back, as `CIStatus`'s does, to `BIFUnexpected` with the row's text for a value it cannot read, so a row written by another version or by hand cannot fail every read of the purchase. + +**Schema.** `M20260918_badge_issue_errors`, SQLite and Postgres, registered in both `Migrations.hs` and the cabal file, with a down migration: + +```sql +ALTER TABLE badge_purchases ADD COLUMN issue_failed_since TEXT; -- TIMESTAMPTZ in Postgres, as alert_snooze_until +ALTER TABLE badge_purchases ADD COLUMN issue_error_at TEXT; +ALTER TABLE badge_purchases ADD COLUMN issue_error TEXT; +ALTER TABLE badge_purchases ADD COLUMN next_wake_at TEXT; +``` + +**Store** (`Store/Badges.hs`): `UserBadgePurchase` gains `issueError :: Maybe BadgeIssueError` and `nextWakeAt :: Maybe UTCTime`. `getBadgePurchase` reads the three columns; `issueError` is present when all three are. `setBadgeIssueError db purchaseId now failure`: `SET issue_failed_since = COALESCE(issue_failed_since, ?), issue_error_at = ?, issue_error = ?`. `storeBadgeIssuance` sets the three to `NULL`, so an issuance and the clearing are one transaction. `setBadgeNextWake db purchaseId at_`. + +**Worker** (`Library/Commands.hs`): + +- `requestBadgeIssue` records every failing outcome through `setBadgeIssueError` before it returns or rethrows: the `BSPError` branch (`BIFServiceError code (isJust retryAfter)`), the unexpected-response branch (`BIFUnexpected`), a credential that does not verify (`BIFInvalidCredential`, alongside the statement it still applies), no credential while the ledger as restated by the statement still has months (`BIFUnexpected`, "badge service issued no credential"), `applyBadgeStatement` answering `False` (`BIFUnexpected` with the internal error's text), and any throw from the request itself, caught with `catchAllErrors`, recorded by the classification above and rethrown so `retryBadgeError` still decides the retry. No credential with the months run out is not a failure: the statement's debits bring the balance to nothing and Support ended follows from `paidThrough`. A successful issuance clears through `storeBadgeIssuance`. +- `updateUserBadge` writes the wake it returns with `setBadgeNextWake` before emitting, and emits `CEvtBadgeChanged` when `retired || issued || failed`, where `failed` is a `Left` from `requestBadgeIssue` (the invalid-credential case is covered by `issued`). `retryBadgeError` gets the user id and the delay, writes `next_wake_at` (now plus the delay `withRetryInterval` hands the callback, or plus `badgeStalledInterval`) and emits `CEvtBadgeChanged` before it loops or stalls. So the state the apps receive after a failure already carries the next attempt. +- `shownIssueError now purchase shownCred`: the recorded failure filtered by §2's rule, with the transient threshold read off `shownBadgeCredential`. `derivedBadgeAlert now purchase shownCred balance`: Support ended when `paidThrough <= now`; else `BAIssueFailed {episode = strEncode failedSince, date = failedSince}` from `shownIssueError`; else nothing. `unansweredBadgeAlert` and `getUserBadgeState` pass the purchase and the shown credential through; ack, snooze and the emitted-occurrence key work unchanged. +- `getUserBadgeState` fills `issueError` from `shownIssueError` and `nextWakeAt` from the purchase, so state and alert cannot disagree on what is shown. + +**Apps.** `BadgeState` gains the two optional fields; `BadgeIssueError` and `BadgeIssueFailure` are mirrored as ordinary decodable types (iOS: `BadgeState` becomes `Decodable, Hashable`, nothing encodes it; Kotlin: sealed class with `@SerialName` tags `serviceError`, `serviceTimeout`, `network`, `invalidCredential`, `unexpected`). `BadgeAlertKind` gains `issueFailed`, and `badgeAlertKindParam` maps it to `issue_failed`. `SupportSimpleXBanner` takes a `warning` flag for §3; the chat list computes `badgeIssueFailed` next to `supportEnded` and shows the banner under the same conditions, with the dismiss alert titled "Badge renewal failed". Your Badge gets §4 and §5. New Kotlin strings: about thirteen keys. + +## Tests + +`BadgeServiceTests`, with the test clock: a refusal (delete the purchase from the service's table, so it answers `unknown_purchase_key` without `retryAfter`) alerts on the next pass and `/_badge state` shows the error; a stopped service alerts only once the clock passes the credential's expiry, not before (the request timeout may need to be configurable for the test to run in seconds); a restart in between keeps `issue_failed_since`; the service back and a successful renewal clears state and alert; ack silences the run, and a new run after a success alerts again with a new episode; a service reply with no credential records nothing when the statement's debit ran the months out (Support ended follows), and records `unexpected` when months are left. + +`BadgeTests`: `internal` counts as transient and other refusals without `retryAfter` do not; an agent error is recorded as the agent error's text; a stored value the parser does not know reads back as `unexpected` with that text. + +## Decisions taken without asking + +- Persist in the DB, not in memory: the run's first-failure time must survive a restart, or every restart re-alerts. +- A new migration, not an edit of `M20260915`: 7.1 beta devices have applied it. +- Terminal vs transient is decided from the failure's kind, not from a count of attempts: counts depend on backoff timing, kinds do not. +- The transient threshold is the credential's expiry rather than a fixed 24 h, because that is when the failure becomes visible to contacts; the two nearly coincide. +- The banner, not a modal, for the reason in §3; a modal can be added later if the banner is missed. +- `reason` is typed (not a text) so the sentences in §4 are localizable, and the service code inside it is the `BadgeServiceErrorCode` the apps already decode. +- "Contact SimpleX team" reuses the existing address flow rather than pre-filling a message. + +## Not in this plan + +The service-side `//purchase ` lookup; a user-facing ledger; alerts for the other kinds (`BARenewalApproaching`, `BAPaymentIssue`) that need payments. diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 64a6dce26f..926d6e8860 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."ea43df2349f6d3dedd60d5e4aed21fd99316cad9" = "1jkh6zfyfxbx49s0pggina1yadk3zlb0ls5crgka6aiw1w25dxr0"; + "https://github.com/simplex-chat/simplexmq.git"."900c45ffaee5eb7eef8ec9402bc4971e4eb7ef33" = "1iygb9hkc86ky553jg5apc8vfbjj0lghjaywikc0m7h1yk7zibxg"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/scripts/simplex-chat-reproduce-builds.sh b/scripts/simplex-chat-reproduce-builds.sh index d0f121c6ea..5d18b226a8 100755 --- a/scripts/simplex-chat-reproduce-builds.sh +++ b/scripts/simplex-chat-reproduce-builds.sh @@ -148,8 +148,9 @@ for os_pair in ${oses}; do -t "${container_name}" \ sh -c 'rm -rf ./dist-newstyle ./apps/multiplatform' - # Also restore git to previous state + # Also restore git to previous state + re-initialize submodules git reset --hard && git clean -dfx + git submodule update --init --recursive # Stop containers, delete images docker stop "${container_name}" diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 086bf63e97..5b8486a12e 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 7.1.0.6 +version: 7.1.0.7 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat @@ -166,6 +166,7 @@ library Simplex.Chat.Store.Postgres.Migrations.M20260828_file_expiry Simplex.Chat.Store.Postgres.Migrations.M20260904_file_badges Simplex.Chat.Store.Postgres.Migrations.M20260915_user_badges + Simplex.Chat.Store.Postgres.Migrations.M20260918_badge_issue_errors else exposed-modules: Simplex.Chat.Archive @@ -341,6 +342,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20260828_file_expiry Simplex.Chat.Store.SQLite.Migrations.M20260904_file_badges Simplex.Chat.Store.SQLite.Migrations.M20260915_user_badges + Simplex.Chat.Store.SQLite.Migrations.M20260918_badge_issue_errors other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat/Badges/Service.hs b/src/Simplex/Chat/Badges/Service.hs index d876dbb059..725009d839 100644 --- a/src/Simplex/Chat/Badges/Service.hs +++ b/src/Simplex/Chat/Badges/Service.hs @@ -42,7 +42,6 @@ import Simplex.Chat.Badges import Simplex.Chat.Badges.Types import Simplex.Chat.PaymentService import qualified Simplex.Messaging.Crypto as C -import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, taggedObjectJSON) import Simplex.Messaging.Version (VersionRange, VersionScope, mkVersionRange) import Simplex.Messaging.Version.Internal (Version (..)) @@ -205,74 +204,6 @@ data StatementDebitType | SDUnknown {tag :: Text, json :: J.Object} deriving (Show) -data BadgeServiceErrorCode - = BSEBadRequest - | BSEUnsupportedVersion - | BSEUnknownPurchaseKey - | BSEUnknownOfferId - | BSEOfferDisabled - | BSEOfferMismatch - | BSEProductUnavailable - | BSEPaymentNotEntitled - | BSEPaymentPending - | BSEProviderUnavailable - | BSERateLimited - | BSECodeInvalid - | BSECodeUsed - | BSECodeExpired - | BSEReceiptInvalid - | BSEReceiptUsed - | BSEInternal - | BSEUnknown Text -- forwards-compatible: service is deployed ahead of clients - deriving (Eq, Show) - -instance TextEncoding BadgeServiceErrorCode where - textEncode = \case - BSEBadRequest -> "bad_request" - BSEUnsupportedVersion -> "unsupported_version" - BSEUnknownPurchaseKey -> "unknown_purchase_key" - BSEUnknownOfferId -> "unknown_offer_id" - BSEOfferDisabled -> "offer_disabled" - BSEOfferMismatch -> "offer_mismatch" - BSEProductUnavailable -> "product_unavailable" - BSEPaymentNotEntitled -> "payment_not_entitled" - BSEPaymentPending -> "payment_pending" - BSEProviderUnavailable -> "provider_unavailable" - BSERateLimited -> "rate_limited" - BSECodeInvalid -> "code_invalid" - BSECodeUsed -> "code_used" - BSECodeExpired -> "code_expired" - BSEReceiptInvalid -> "receipt_invalid" - BSEReceiptUsed -> "receipt_used" - BSEInternal -> "internal" - BSEUnknown t -> t - textDecode s = Just $ case s of - "bad_request" -> BSEBadRequest - "unsupported_version" -> BSEUnsupportedVersion - "unknown_purchase_key" -> BSEUnknownPurchaseKey - "unknown_offer_id" -> BSEUnknownOfferId - "offer_disabled" -> BSEOfferDisabled - "offer_mismatch" -> BSEOfferMismatch - "product_unavailable" -> BSEProductUnavailable - "payment_not_entitled" -> BSEPaymentNotEntitled - "payment_pending" -> BSEPaymentPending - "provider_unavailable" -> BSEProviderUnavailable - "rate_limited" -> BSERateLimited - "code_invalid" -> BSECodeInvalid - "code_used" -> BSECodeUsed - "code_expired" -> BSECodeExpired - "receipt_invalid" -> BSEReceiptInvalid - "receipt_used" -> BSEReceiptUsed - "internal" -> BSEInternal - t -> BSEUnknown t - -instance ToJSON BadgeServiceErrorCode where - toJSON = textToJSON - toEncoding = textToEncoding - -instance FromJSON BadgeServiceErrorCode where - parseJSON = textParseJSON "BadgeServiceErrorCode" - $(pure []) instance FromJSON StatementCreditType where diff --git a/src/Simplex/Chat/Badges/Types.hs b/src/Simplex/Chat/Badges/Types.hs index 4c81cb50ac..1038b2b64f 100644 --- a/src/Simplex/Chat/Badges/Types.hs +++ b/src/Simplex/Chat/Badges/Types.hs @@ -3,6 +3,7 @@ {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE GeneralizedNewtypeDeriving #-} {-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TemplateHaskell #-} @@ -18,6 +19,9 @@ module Simplex.Chat.Badges.Types LedgerCreditType (..), LedgerDebitType (..), BadgeAlertKind (..), + BadgeServiceErrorCode (..), + BadgeIssueFailure (..), + BadgeIssueError (..), BadgeFunding (..), BadgePurchase (..), BadgeLedgerEntry (..), @@ -28,11 +32,14 @@ module Simplex.Chat.Badges.Types BadgeState (..), ) where -import Data.Aeson (FromJSON, ToJSON) +import Control.Applicative ((<|>)) +import Data.Aeson (FromJSON (..), ToJSON (..)) import qualified Data.Aeson as J import qualified Data.Aeson.TH as JQ +import qualified Data.Attoparsec.ByteString.Char8 as A import Data.Int (Int64) import Data.Text (Text) +import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Time.Clock (UTCTime) import Data.Word (Word8) import Simplex.Chat.Badges hiding (BadgePurchase (..)) @@ -42,7 +49,8 @@ import Simplex.Messaging.Agent.Protocol (UserId) import Simplex.Messaging.Agent.Store.DB (fromTextField_) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, taggedObjectJSON) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, sumTypeJSON, taggedObjectJSON) +import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8) #if defined(dbPostgres) import Database.PostgreSQL.Simple.FromField (FromField (..)) import Database.PostgreSQL.Simple.ToField (ToField (..)) @@ -108,7 +116,7 @@ data LedgerDebitType deriving (Eq, Show) -- unconfirmed draft -data BadgeAlertKind = BARenewalApproaching | BAPaymentIssue | BASubscriptionEnded | BAPrepaidEnding | BASupportEnded +data BadgeAlertKind = BARenewalApproaching | BAPaymentIssue | BASubscriptionEnded | BAPrepaidEnding | BASupportEnded | BAIssueFailed deriving (Eq, Show) instance TextEncoding BadgeAlertKind where @@ -118,12 +126,14 @@ instance TextEncoding BadgeAlertKind where BASubscriptionEnded -> "subscription_ended" BAPrepaidEnding -> "prepaid_ending" BASupportEnded -> "support_ended" + BAIssueFailed -> "issue_failed" textDecode = \case "renewal_approaching" -> Just BARenewalApproaching "payment_issue" -> Just BAPaymentIssue "subscription_ended" -> Just BASubscriptionEnded "prepaid_ending" -> Just BAPrepaidEnding "support_ended" -> Just BASupportEnded + "issue_failed" -> Just BAIssueFailed _ -> Nothing instance FromField BadgeAlertKind where fromField = fromTextField_ textDecode @@ -215,10 +225,49 @@ data BadgeAlertPrice = BadgeAlertPrice } deriving (Show) --- | The user's badge as the badge surfaces render it. The purchase keys are deliberately absent: --- this travels to the UI and over remote control, and they are secrets that stay in core. +data BadgeServiceErrorCode + = BSEBadRequest + | BSEUnsupportedVersion + | BSEUnknownPurchaseKey + | BSEUnknownOfferId + | BSEOfferDisabled + | BSEOfferMismatch + | BSEProductUnavailable + | BSEPaymentNotEntitled + | BSEPaymentPending + | BSEProviderUnavailable + | BSERateLimited + | BSECodeInvalid + | BSECodeUsed + | BSECodeExpired + | BSEReceiptInvalid + | BSEReceiptUsed + | BSEInternal + | BSEUnknown Text -- forwards-compatible: service is deployed ahead of clients + deriving (Eq, Show) + +-- | Why a renewal request ended without a credential stored. retryable is the service's own view of +-- transience: it gave retryAfter, which holds for codes this version does not know. +data BadgeIssueFailure + = BIFServiceError {code :: BadgeServiceErrorCode, retryable :: Bool} + | BIFServiceTimeout + | BIFNetwork {agentError :: Text} + | BIFInvalidCredential + | BIFUnexpected {message :: Text} + deriving (Eq, Show) + +data BadgeIssueError = BadgeIssueError + { failedSince :: UTCTime, + lastAttemptAt :: UTCTime, + reason :: BadgeIssueFailure + } + deriving (Eq, Show) + +-- | The user's badge as the badge surfaces render it. The private purchase key is deliberately +-- absent: this travels to the UI and over remote control, and it is a secret that stays in core. data BadgeState = BadgeState { badgePurchaseId :: Int64, + purchaseKey :: C.PublicKeyEd25519, -- the purchase's identifier on the service badgeType :: BadgeType, shown :: BoolDef, monthsLeft :: Int, @@ -226,7 +275,9 @@ data BadgeState = BadgeState -- payments returns here with the payment types, which this slice neither writes nor encodes renewsAt :: Maybe UTCTime, willRenew :: Bool, - alert :: Maybe BadgeAlert + alert :: Maybe BadgeAlert, + issueError :: Maybe BadgeIssueError, + nextWakeAt :: Maybe UTCTime } deriving (Show) @@ -262,6 +313,88 @@ instance FromField BadgeCodePaymentStatus where fromField = fromTextField_ textD instance ToField BadgeCodePaymentStatus where toField = toField . textEncode +instance TextEncoding BadgeServiceErrorCode where + textEncode = \case + BSEBadRequest -> "bad_request" + BSEUnsupportedVersion -> "unsupported_version" + BSEUnknownPurchaseKey -> "unknown_purchase_key" + BSEUnknownOfferId -> "unknown_offer_id" + BSEOfferDisabled -> "offer_disabled" + BSEOfferMismatch -> "offer_mismatch" + BSEProductUnavailable -> "product_unavailable" + BSEPaymentNotEntitled -> "payment_not_entitled" + BSEPaymentPending -> "payment_pending" + BSEProviderUnavailable -> "provider_unavailable" + BSERateLimited -> "rate_limited" + BSECodeInvalid -> "code_invalid" + BSECodeUsed -> "code_used" + BSECodeExpired -> "code_expired" + BSEReceiptInvalid -> "receipt_invalid" + BSEReceiptUsed -> "receipt_used" + BSEInternal -> "internal" + BSEUnknown t -> t + textDecode s = Just $ case s of + "bad_request" -> BSEBadRequest + "unsupported_version" -> BSEUnsupportedVersion + "unknown_purchase_key" -> BSEUnknownPurchaseKey + "unknown_offer_id" -> BSEUnknownOfferId + "offer_disabled" -> BSEOfferDisabled + "offer_mismatch" -> BSEOfferMismatch + "product_unavailable" -> BSEProductUnavailable + "payment_not_entitled" -> BSEPaymentNotEntitled + "payment_pending" -> BSEPaymentPending + "provider_unavailable" -> BSEProviderUnavailable + "rate_limited" -> BSERateLimited + "code_invalid" -> BSECodeInvalid + "code_used" -> BSECodeUsed + "code_expired" -> BSECodeExpired + "receipt_invalid" -> BSEReceiptInvalid + "receipt_used" -> BSEReceiptUsed + "internal" -> BSEInternal + t -> BSEUnknown t + +instance ToJSON BadgeServiceErrorCode where + toJSON = textToJSON + toEncoding = textToEncoding + +instance FromJSON BadgeServiceErrorCode where + parseJSON = textParseJSON "BadgeServiceErrorCode" + +instance StrEncoding BadgeIssueFailure where + strEncode = \case + BIFServiceError {code, retryable} -> "service_error " <> (if retryable then "retry " else "final ") <> encodeUtf8 (textEncode code) + BIFServiceTimeout -> "service_timeout" + BIFNetwork {agentError} -> "network " <> encodeUtf8 agentError + BIFInvalidCredential -> "invalid_credential" + BIFUnexpected {message} -> "unexpected " <> encodeUtf8 message + -- a row this version cannot read is reported as it stands rather than failing every read of the purchase + strP = (knownP <* A.endOfInput) <|> (BIFUnexpected . safeDecodeUtf8 <$> A.takeByteString) + where + knownP = + A.takeWhile1 (/= ' ') >>= \case + "service_error" -> serviceErrorP + "service_timeout" -> pure BIFServiceTimeout + "network" -> BIFNetwork <$> restP + "invalid_credential" -> pure BIFInvalidCredential + "unexpected" -> BIFUnexpected <$> restP + _ -> fail "bad BadgeIssueFailure" + -- the code is encoded last and read to the end, so a code this version does not know reads back whole + serviceErrorP = do + retryable_ <- A.space *> retryableP + code_ <- A.space *> codeP + pure BIFServiceError {code = code_, retryable = retryable_} + retryableP = + A.takeWhile1 (/= ' ') >>= \case + "retry" -> pure True + "final" -> pure False + _ -> fail "bad BadgeIssueFailure retry flag" + codeP = A.takeByteString >>= maybe (fail "bad BadgeServiceErrorCode") pure . textDecode . safeDecodeUtf8 + restP = safeDecodeUtf8 <$> (A.space *> A.takeByteString) + +instance ToField BadgeIssueFailure where toField = toField . decodeLatin1 . strEncode + +instance FromField BadgeIssueFailure where fromField = fromTextField_ $ eitherToMaybe . strDecode . encodeUtf8 + $(JQ.deriveJSON (enumJSON $ dropPrefix "BIS") ''BadgeItemStatus) $(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "OD") ''OfferDiscount) @@ -272,4 +405,8 @@ $(JQ.deriveJSON defaultJSON ''BadgeAlertPrice) $(JQ.deriveJSON defaultJSON ''BadgeAlert) +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "BIF") ''BadgeIssueFailure) + +$(JQ.deriveJSON defaultJSON ''BadgeIssueError) + $(JQ.deriveJSON defaultJSON ''BadgeState) diff --git a/src/Simplex/Chat/Bot/Store.hs b/src/Simplex/Chat/Bot/Store.hs index 1f5a2924d5..bfa5b04ebc 100644 --- a/src/Simplex/Chat/Bot/Store.hs +++ b/src/Simplex/Chat/Bot/Store.hs @@ -4,8 +4,7 @@ {-# LANGUAGE ScopedTypeVariables #-} module Simplex.Chat.Bot.Store - ( storeCxt, - withDB, + ( withDB, withDB', ) where @@ -20,10 +19,6 @@ import Simplex.Messaging.Agent.Store.Common (withTransaction) import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Util (catchAll) -storeCxt :: ChatController -> StoreCxt -storeCxt ChatController {config} = mkStoreCxt config -{-# INLINE storeCxt #-} - withDB' :: Text -> ChatController -> (DB.Connection -> IO a) -> IO (Either String a) withDB' cxt cc a = withDB cxt cc $ ExceptT . fmap Right . a diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 9d23162a2e..675f21f7d2 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -84,7 +84,7 @@ import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Client (HostMode (..), SMPProxyFallback (..), SMPProxyMode (..), SMPWebPortServers (..), SocksMode (..)) import qualified Simplex.Messaging.Crypto as C import Simplex.Chat.Badges (BadgeCredential, FileSizeLimits, LocalBadge) -import Simplex.Chat.Badges.Service (BadgeServiceErrorCode) +import Simplex.Chat.Badges.Service (BadgeServiceErrorCode, StatementEntry) import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeAlertKind, BadgeState (..)) import Simplex.Messaging.Crypto.BBS (BBSPublicKey) import Simplex.Messaging.Crypto.File (CryptoFile (..)) @@ -228,9 +228,9 @@ newWebPreviewState = do -- | Builds the read-only context threaded through store functions from chat config. -- The single construction point, so new store-wide config (e.g. server keys) is added in one place. -mkStoreCxt :: ChatConfig -> StoreCxt -mkStoreCxt ChatConfig {chatVRange, badgePublicKeys} = StoreCxt chatVRange badgePublicKeys -{-# INLINE mkStoreCxt #-} +storeCxt :: ChatController -> StoreCxt +storeCxt ChatController {config = ChatConfig {chatVRange, badgePublicKeys}, random} = StoreCxt chatVRange badgePublicKeys random +{-# INLINE storeCxt #-} data RandomAgentServers = RandomAgentServers { smpServers :: NonEmpty (ServerCfg 'PSMP), @@ -661,6 +661,7 @@ data ChatCommand | AddBadge BadgeCredential -- attach an issued badge credential (testing; credential from `simplex-chat badge sign`) | APIRedeemBadgeCode {userId :: UserId, code :: Text} -- redeem a badge code with the configured badge service | APIGetBadgeState {userId :: UserId} -- the user's badges, their balances and any current alert + | APIGetBadgeLedger {userId :: UserId, badgePurchaseId :: Int64} -- the purchase's ledger, oldest first -- episode is last because it is free text: it is the value that makes one occurrence of an -- alert distinct from the next, and the app returns whatever it was given | APIAckBadgeAlert {userId :: UserId, badgePurchaseId :: Int64, alertKind :: BadgeAlertKind, snooze :: Bool, episode :: Text} @@ -870,6 +871,7 @@ data ChatResponse | CRServiceReplyAccepted {user :: User, connectionId :: AgentConnId} | CRBadgeRedeemed {user :: User, redeemedBadge :: LocalBadge, newBadge :: Bool, badgeState :: Maybe BadgeState} | CRBadgeState {user :: User, badgeState :: Maybe BadgeState} + | CRBadgeLedger {user :: User, badgeLedger :: [StatementEntry]} | CRUserAcceptedGroupSent {user :: User, groupInfo :: GroupInfo, hostContact :: Maybe Contact} | CRUserDeletedMembers {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], withMessages :: Bool, msgSigned :: Bool} | CRGroupsList {user :: User, groups :: [GroupInfo]} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index c388f6115d..672bc9065b 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -61,7 +61,7 @@ import Crypto.Random (ChaChaDRG) import Simplex.Messaging.Session (SessionVar (..), withGetSessVar') import Simplex.Chat.Badges (BadgeCredential (..), BadgeInfo (..), BadgeMasterKey, BadgeType, LocalBadge (..), badgeServerCredential, mkBadgeStatus, maxSndXFTPFileSize, verifyCredential) import qualified Simplex.Chat.Badges.Ledger as L -import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeAlertKind (..), BadgeState (..)) +import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeAlertKind (..), BadgeIssueError (..), BadgeIssueFailure (..), BadgeState (..)) import Simplex.Chat.Badges.Code (badgeCodeText, parseBadgeCode) import Simplex.Chat.Badges.Service (BadgeBalance (..), BadgeServiceCommand (..), BadgeServiceErrorCode (..), BadgeServiceRequest (..), BadgeServiceResponse (..), BadgeStatement (..), StatementDebitType (..), StatementEntry (..), StatementEntryType (..), currentBadgeServiceVersion) import Simplex.Chat.Names (SimplexDomainProof (..), SimplexDomainClaim (..), claimDomain, mkDomainClaim) @@ -753,8 +753,8 @@ processChatCommand cxt nm = \case Nothing -> pure () withGroupLock "sendMessage" chatId $ do (gInfo, cmrs) <- withFastStore $ \db -> do - g <- getGroupInfo db cxt user chatId - (g,) <$> mapM (composedMessageReqMentions db user g) cms + gik@(GIK g _) <- getGroupInfoKeys db cxt user chatId + (gik,) <$> mapM (composedMessageReqMentions db user g) cms sendGroupContentMessages user gInfo gsScope asGroup live itemTTL sign cmrs APICreateChatTag (ChatTagData emoji text) -> withUser $ \user -> withFastStore' $ \db -> do _ <- createChatTag db user emoji text @@ -783,7 +783,7 @@ processChatCommand cxt nm = \case createNoteFolderContentItems user folderId (L.map composedMessageReq cms) APIReportMessage gId reportedItemId reportReason reportText -> withUser $ \user -> withGroupLock "reportMessage" gId $ do - gInfo <- withFastStore $ \db -> getGroupInfo db cxt user gId + gInfo <- withFastStore $ \db -> getGroupInfoKeys db cxt user gId let mc = MCReport reportText reportReason cm = ComposedMessage {fileSource = Nothing, quotedItemId = Just reportedItemId, msgContent = mc, mentions = M.empty} sendGroupContentMessages user gInfo (Just $ GCSMemberSupport Nothing) False False Nothing False [composedMessageReq cm] @@ -818,7 +818,7 @@ processChatCommand cxt nm = \case _ -> throwChatError CEInvalidChatItemUpdate CChatItem SMDRcv _ -> throwChatError CEInvalidChatItemUpdate CTGroup -> withGroupLock "updateChatItem" chatId $ do - gInfo@GroupInfo {groupId, membership} <- withFastStore $ \db -> getGroupInfo db cxt user chatId + g@(GIK gInfo@GroupInfo {groupId, membership} _) <- withFastStore $ \db -> getGroupInfoKeys db cxt user chatId when (isNothing scope) $ assertUserGroupRole gInfo GRAuthor let (_, ft_) = msgContentTexts mc if prohibitedSimplexLinks gInfo membership mc ft_ @@ -840,7 +840,7 @@ processChatCommand cxt nm = \case mentions' = M.map (\CIMention {memberId} -> MsgMention {memberId}) ciMentions event = XMsgUpdate itemSharedMId mc mentions' (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive) msgScope (Just showGroupAsSender) reuseSign = case msgVerified of Just (MVSigned _) -> True; _ -> False - SndMessage {msgId} <- sendGroupMessage user gInfo scope recipients reuseSign event + SndMessage {msgId} <- sendGroupMessage user g scope recipients reuseSign event ci' <- withFastStore' $ \db -> do currentTs <- liftIO getCurrentTime when changed $ @@ -886,7 +886,7 @@ processChatCommand cxt nm = \case else markDirectCIsDeleted user ct items =<< liftIO getCurrentTime pure $ CRChatItemsDeleted user deletions True False CTGroup -> withGroupLock "deleteChatItem" chatId $ do - (gInfo, items) <- getCommandGroupChatItems user chatId itemIds + (g@(GIK gInfo _), items) <- getCommandGroupChatItems user chatId itemIds -- TODO [knocking] check scope for all items? chatScopeInfo <- mapM (getChatScopeInfo cxt user) scope deletions <- case mode of @@ -899,14 +899,14 @@ processChatCommand cxt nm = \case recipients <- getGroupRecipients cxt user gInfo chatScopeInfo groupKnockingVersion assertDeletable items assertUserGroupRole gInfo GRObserver -- can still delete messages sent earlier - let signedEvents = L.nonEmpty $ mapMaybe (delEventSigned gInfo chatScopeInfo False) items - mapM_ (sendGroupSignedMessages user gInfo Nothing False recipients) signedEvents + let signedEvents = L.nonEmpty $ mapMaybe (delEventSigned g chatScopeInfo False) items + mapM_ (sendGroupSignedMessages user g Nothing False recipients) signedEvents delGroupChatItems user gInfo chatScopeInfo items False CIDMHistory -> do unless (publicGroupEditor gInfo (membership gInfo)) $ throwChatError CEInvalidChatItemDelete recipients <- getGroupRecipients cxt user gInfo chatScopeInfo groupKnockingVersion - let signedEvents = L.nonEmpty $ mapMaybe (delEventSigned gInfo chatScopeInfo True) items - mapM_ (sendGroupSignedMessages user gInfo Nothing False recipients) signedEvents + let signedEvents = L.nonEmpty $ mapMaybe (delEventSigned g chatScopeInfo True) items + mapM_ (sendGroupSignedMessages user g Nothing False recipients) signedEvents delGroupChatItems user gInfo chatScopeInfo items False pure $ CRChatItemsDeleted user deletions True False CTLocal -> do @@ -929,20 +929,20 @@ processChatCommand cxt nm = \case itemsMsgIds :: [CChatItem c] -> [SharedMsgId] itemsMsgIds = mapMaybe (\(CChatItem _ ChatItem {meta = CIMeta {itemSharedMsgId}}) -> itemSharedMsgId) -- history delete always signs (attributable owner action); self-delete signs iff the target was held signed (deniability) - delEventSigned :: GroupInfo -> Maybe GroupChatScopeInfo -> Bool -> CChatItem 'CTGroup -> Maybe (Maybe MsgSigning, ChatMsgEvent 'Json) - delEventSigned gInfo chatScopeInfo onlyHistory (CChatItem _ ChatItem {meta = CIMeta {itemSharedMsgId, msgVerified}}) = + delEventSigned :: GroupInfoKeys -> Maybe GroupChatScopeInfo -> Bool -> CChatItem 'CTGroup -> Maybe (Maybe MsgSigning, ChatMsgEvent 'Json) + delEventSigned g@(GIK gInfo _) chatScopeInfo onlyHistory (CChatItem _ ChatItem {meta = CIMeta {itemSharedMsgId, msgVerified}}) = delEvent <$> itemSharedMsgId where delEvent msgId = let evt = XMsgDel msgId Nothing (toMsgScope gInfo <$> chatScopeInfo) onlyHistory - in (groupMsgSigning (onlyHistory || itemSigned) gInfo evt, evt) + in (groupMsgSigning (onlyHistory || itemSigned) g evt, evt) itemSigned = case msgVerified of Just (MVSigned _) -> True; _ -> False APIDeleteMemberChatItem gId itemIds -> withUser $ \user -> withGroupLock "deleteChatItem" gId $ do - (gInfo, items) <- getCommandGroupChatItems user gId itemIds + (g@(GIK gInfo _), items) <- getCommandGroupChatItems user gId itemIds -- TODO [knocking] check scope is Nothing for all items? (prohibit moderation in support chats?) ms <- withFastStore' $ \db -> getGroupMembers db cxt user gInfo let recipients = filter memberCurrent ms - deletions <- delGroupChatItemsForMembers user gInfo Nothing recipients items + deletions <- delGroupChatItemsForMembers user g Nothing recipients items pure $ CRChatItemsDeleted user deletions True False APIArchiveReceivedReports gId -> withUser $ \user -> withFastStore $ \db -> do g <- getGroupInfo db cxt user gId @@ -950,7 +950,7 @@ processChatCommand cxt nm = \case ciIds <- liftIO $ markReceivedGroupReportsDeleted db user g deleteTs pure $ CRGroupChatItemsDeleted user g ciIds True (Just $ membership g) APIDeleteReceivedReports gId itemIds mode -> withUser $ \user -> withGroupLock "deleteReports" gId $ do - (gInfo, items) <- getCommandGroupChatItems user gId itemIds + (g@(GIK gInfo _), items) <- getCommandGroupChatItems user gId itemIds unless (all isRcvReport items) $ throwCmdError "some items are not received reports" -- TODO [knocking] scope can be different for each item if reports are from different members -- TODO (currently we pass Nothing as scope which is wrong) @@ -961,7 +961,7 @@ processChatCommand cxt nm = \case CIDMBroadcast -> do ms <- withFastStore' $ \db -> getGroupModerators db cxt user gInfo let recipients = filter memberCurrent ms - delGroupChatItemsForMembers user gInfo Nothing recipients items + delGroupChatItemsForMembers user g Nothing recipients items pure $ CRChatItemsDeleted user deletions True False where isRcvReport = \case @@ -990,9 +990,9 @@ processChatCommand cxt nm = \case CTGroup -> withGroupLock "chatItemReaction" chatId $ do -- TODO [knocking] check chat item scope? - (g@GroupInfo {membership}, CChatItem md ci) <- withFastStore $ \db -> do - g <- getGroupInfo db cxt user chatId - (g,) <$> getGroupCIWithReactions db user g itemId + (gik@(GIK g@GroupInfo {membership} _), CChatItem md ci) <- withFastStore $ \db -> do + gik@(GIK g _) <- getGroupInfoKeys db cxt user chatId + (gik,) <$> getGroupCIWithReactions db user g itemId chatScopeInfo <- mapM (getChatScopeInfo cxt user) scope recipients <- getGroupRecipients cxt user g chatScopeInfo groupKnockingVersion case ci of @@ -1004,7 +1004,7 @@ processChatCommand cxt nm = \case let itemMemberId = memberId' <$> chatItemMember g ci rs <- withFastStore' $ \db -> getGroupReactions db g membership itemMemberId itemSharedMId True checkReactionAllowed rs - SndMessage {msgId} <- sendGroupMessage user g scope recipients False (XMsgReact itemSharedMId itemMemberId (toMsgScope g <$> chatScopeInfo) reaction add) + SndMessage {msgId} <- sendGroupMessage user gik scope recipients False (XMsgReact itemSharedMId itemMemberId (toMsgScope g <$> chatScopeInfo) reaction add) createdAt <- liftIO getCurrentTime reactions <- withFastStore' $ \db -> do setGroupReaction db g membership itemMemberId itemSharedMId True reaction add msgId createdAt @@ -1089,7 +1089,7 @@ processChatCommand cxt nm = \case case L.nonEmpty cmrs of Just cmrs' -> withGroupLock "forwardChatItem, to group" toChatId $ do - gInfo <- withFastStore $ \db -> getGroupInfo db cxt user toChatId + gInfo <- withFastStore $ \db -> getGroupInfoKeys db cxt user toChatId sendGroupContentMessages user gInfo toScope sendAsGroup False itemTTL False cmrs' Nothing -> pure $ CRNewChatItems user [] CTLocal -> do @@ -1119,7 +1119,7 @@ processChatCommand cxt nm = \case | otherwise = displayName -- TODO [knocking] from scope? CTGroup -> withGroupLock "forwardChatItem, from group" fromChatId $ do - (gInfo, items) <- getCommandGroupChatItems user fromChatId itemIds + (GIK gInfo _, items) <- getCommandGroupChatItems user fromChatId itemIds catMaybes <$> mapM (\ci -> ciComposeMsgReq gInfo ci <$$> prepareMsgReq ci) items where ciComposeMsgReq :: GroupInfo -> CChatItem 'CTGroup -> (MsgContent, Maybe CryptoFile) -> ComposedMessageReq @@ -1226,16 +1226,16 @@ processChatCommand cxt nm = \case let ext = takeExtension fileName pure $ prefix <> formattedDate <> ext APIShareChatMsgContent (ChatRef CTGroup groupId _) toSendRef -> withUser $ \user -> do - GroupInfo {groupProfile = gp@GroupProfile {publicGroup}, membership = GroupMember {memberId, memberRole}, groupKeys} <- - withFastStore $ \db -> getGroupInfo db cxt user groupId + GIK GroupInfo {groupProfile = gp@GroupProfile {publicGroup}, membership = GroupMember {memberId, memberRole}} gks <- + withFastStore $ \db -> getGroupInfoKeys db cxt user groupId case publicGroup of Nothing -> throwCmdError "not a public group" Just PublicGroupProfile {groupLink} -> do - let signingKeys = case (memberRole, groupKeys) of - (GROwner, Just gk@GroupKeys {publicGroupKeys = Just PublicGroupKeys {groupRootKey = GRKPrivate _}}) -> Just gk + let signingKeys = case (memberRole, gks) of + (GROwner, GKPublicGroup {groupRootKey = GRKPrivate _, memberPrivKey}) -> Just memberPrivKey _ -> Nothing ownerSig <- - pure signingKeys $>>= \GroupKeys {memberPrivKey} -> + pure signingKeys $>>= \memberPrivKey -> mkLinkOwnerSig memberPrivKey groupLink (Just memberId) <$$> shareChatBinding user toSendRef let text = safeDecodeUtf8 $ strEncode groupLink pure $ CRChatMsgContent user MCChat {text, chatLink = MCLGroup groupLink gp, ownerSig} @@ -1378,7 +1378,7 @@ processChatCommand cxt nm = \case withFastStore' $ \db -> deletePendingContactConnection db userId chatId pure $ CRContactConnectionDeleted user conn CTGroup | isNothing scope -> do - gInfo@GroupInfo {membership} <- withFastStore $ \db -> getGroupInfo db cxt user chatId + g@(GIK gInfo@GroupInfo {membership} _) <- withFastStore $ \db -> getGroupInfoKeys db cxt user chatId let isOwner = memberRole' membership == GROwner canDelete = isOwner || not (memberCurrent membership) unless canDelete $ throwChatError $ CEGroupUserRole gInfo GROwner @@ -1391,7 +1391,7 @@ processChatCommand cxt nm = \case let doSendDel = memberActive membership && isOwner msgSigned <- if doSendDel - then (\SndMessage {signedMsg_} -> isJust signedMsg_) <$> sendGroupMessage' user gInfo recipients XGrpDel + then (\SndMessage {signedMsg_} -> isJust signedMsg_) <$> sendGroupMessage' user g recipients XGrpDel else pure False deleteGroupLinkIfExists user gInfo deleteMembersConnections' user members doSendDel @@ -2299,7 +2299,7 @@ processChatCommand cxt nm = \case pure $ CRStartedConnectionToContact user ct' customUserProfile CVRConnectedContact ct' -> pure $ CRContactAlreadyExists user ct' APIConnectPreparedGroup {groupId, incognito, ownerContact, msgContent_} -> withUser $ \user -> do - gInfo <- withFastStore $ \db -> getGroupInfo db cxt user groupId + g@(GIK gInfo _) <- withFastStore $ \db -> getGroupInfoKeys db cxt user groupId case gInfo of GroupInfo {preparedGroup = Nothing} -> throwCmdError "group doesn't have link to connect" GroupInfo {useRelays = BoolDef True, preparedGroup = Just PreparedGroup {connLinkToConnect}} -> do @@ -2319,9 +2319,8 @@ processChatCommand cxt nm = \case -- set group link info and incognito profile, generate and store membership keys incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing let cReqHash = contactCReqHash $ CRContactUri crData {crScheme = SSSimplex} e2e - (_, memberPrivKey) <- atomically . C.generateKeyPair =<< asks random - gInfo' <- withFastStore $ \db -> do - gInfo' <- updatePreparedRelayedGroup db cxt user gInfo mainCReq cReqHash incognitoProfile rootKey memberPrivKey publicMemberCount_ + g'@(GIK gInfo' _) <- withFastStore $ \db -> do + g'@(GIK gInfo' _) <- updatePreparedRelayedGroup db cxt user gInfo mainCReq cReqHash incognitoProfile rootKey publicMemberCount_ -- Pre-emptively create owner members with trusted keys from link data forM_ owners $ \OwnerAuth {ownerId, ownerKey} -> do let ctId_ = case ownerContact of @@ -2329,9 +2328,9 @@ processChatCommand cxt nm = \case | memberId == MemberId ownerId -> Just contactId _ -> Nothing void $ createLinkOwnerMember db cxt user gInfo' ctId_ (MemberId ownerId) ownerKey - pure gInfo' + pure g' rs <- withGroupLock "connectPreparedGroup" groupId $ - mapConcurrently (connectToRelay user gInfo') relays + mapConcurrently (connectToRelay user g') relays let relayFailed = \case (_, _, Left _) -> True; _ -> False (failed, succeeded) = partition relayFailed rs if null succeeded @@ -2373,7 +2372,7 @@ processChatCommand cxt nm = \case smId <- getSharedMsgId withFastStore' $ \db -> setRequestSharedMsgIdForGroup db groupId smId pure (smId, mc) - r <- connectViaContact user (Just $ PCEGroup gInfo hostMember) incognito connLinkToConnect welcomeSharedMsgId msg_ `catchAllErrors` \e -> do + r <- connectViaContact user (Just $ PCEGroup g hostMember) incognito connLinkToConnect welcomeSharedMsgId msg_ `catchAllErrors` \e -> do -- get updated group info, in case connection was started (connLinkPreparedConnection) - in UI it would lock ability to change -- user or incognito profile for group or business chat, in case server received request while client got network error gInfo' <- withFastStore $ \db -> getGroupInfo db cxt user groupId @@ -2705,14 +2704,14 @@ processChatCommand cxt nm = \case g <- asks random memberId <- liftIO $ MemberId <$> encodedRandomBytes g 12 (_, memberPrivKey) <- atomically $ C.generateKeyPair g - gInfo <- newGroup user incognito gProfile False memberId (Just GroupKeys {publicGroupKeys = Nothing, memberPrivKey}) Nothing + gInfo <- newGroup user incognito gProfile memberId GKGroup {memberPrivKey} Nothing createNewGroupItems user gInfo pure $ CRGroupCreated user gInfo NewGroup incognito gProfile -> withUser $ \User {userId} -> processChatCommand cxt nm $ APINewGroup userId incognito gProfile APINewPublicGroup userId incognito relayIds groupProfile -> withUserId userId $ \user -> do (gProfile', memberId, groupKeys, setupLink) <- prepareGroupLink user - gInfo <- newGroup user incognito gProfile' True memberId (Just groupKeys) (Just 1) + gInfo <- newGroup user incognito gProfile' memberId groupKeys (Just 1) (gLink, results) <- setupLink gInfo `catchAllErrors` \e -> do deleteInProgressGroup user gInfo throwError e @@ -2759,8 +2758,7 @@ processChatCommand cxt nm = \case userLinkData = UserContactLinkData UserContactData {direct = False, owners = [ownerAuth], relays = [], userData, ratchetKeys = Nothing} -- create connection with prepared link (single network call) connId <- withAgent $ \a -> createConnectionForLink a nm (aUserId user) True ccLink preparedParams userLinkData subMode - let groupKeys = GroupKeys {publicGroupKeys, memberPrivKey} - publicGroupKeys = Just PublicGroupKeys {publicGroupId = B64UrlByteString entityId, groupRootKey = GRKPrivate rootPrivKey} + let groupKeys = GKPublicGroup {groupRootKey = GRKPrivate rootPrivKey, memberPrivKey} setupLink gInfo = do -- TODO [relays] starting role should be communicated in protocol from owner to relays subRole <- asks $ channelSubscriberRole . config @@ -2810,7 +2808,7 @@ processChatCommand cxt nm = \case _ -> False APIAddMember groupId contactId memRole -> withUser $ \user -> withGroupLock "addMember" groupId $ do -- TODO for large groups: no need to load all members to determine if contact is a member - (group, contact) <- withFastStore $ \db -> (,) <$> getGroup db cxt user groupId <*> getContact db cxt user contactId + ((group, gks), contact) <- withFastStore $ \db -> (,) <$> getGroupKeys_ db cxt user groupId <*> getContact db cxt user contactId let Group gInfo members = group Contact {localDisplayName = cName} = contact when (useRelays' gInfo) $ throwCmdError "can't invite contact to channel" @@ -2820,7 +2818,7 @@ processChatCommand cxt nm = \case when (contactConnIncognito contact) $ throwChatError CEContactIncognitoCantInvite -- [incognito] forbid to invite contacts if user joined the group using an incognito profile when (incognitoMembership gInfo) $ throwChatError CEGroupIncognitoCantInvite - let sendInvitation = sendGrpInvitation user contact gInfo + let sendInvitation = sendGrpInvitation user contact (GIK gInfo gks) case contactMember contact members of Nothing -> do gVar <- asks random @@ -2843,13 +2841,13 @@ processChatCommand cxt nm = \case (invitation, ct) <- withFastStore $ \db -> do inv@ReceivedGroupInvitation {fromMember} <- getGroupInvitation db cxt user groupId (inv,) <$> getContactViaMember db cxt user fromMember - let ReceivedGroupInvitation {fromMember, connRequest, groupInfo = g@GroupInfo {membership, chatSettings}} = invitation + let ReceivedGroupInvitation {fromMember, connRequest, groupInfo = g@GroupInfo {membership, chatSettings}, groupKeys = gks} = invitation GroupMember {memberId = membershipMemId} = membership Contact {activeConn} = ct case activeConn of Just Connection {peerChatVRange} -> do subMode <- chatReadVar subscriptionMode - dm <- encodeConnInfo $ XGrpAcpt membershipMemId (groupMemberKey g) + dm <- encodeConnInfo $ XGrpAcpt membershipMemId (Just $ groupMemberKey gks) agentConnId <- case memberConn fromMember of Nothing -> do agentConnId <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True connRequest PQSupportOff @@ -2872,7 +2870,7 @@ processChatCommand cxt nm = \case pure $ CRUserAcceptedGroupSent user g {membership = membership {memberStatus = GSMemAccepted}} Nothing Nothing -> throwChatError $ CEContactNotActive ct APIAcceptMember groupId gmId role -> withUser $ \user@User {userId} -> do - (gInfo, m) <- withFastStore $ \db -> (,) <$> getGroupInfo db cxt user groupId <*> getGroupMemberById db cxt user gmId + (g@(GIK gInfo _), m) <- withFastStore $ \db -> (,) <$> getGroupInfoKeys db cxt user groupId <*> getGroupMemberById db cxt user gmId assertUserGroupRole gInfo $ max GRModerator role case memberStatus m of GSMemPendingApproval | memberCategory m == GCInviteeMember -> do -- only host can approve @@ -2881,14 +2879,14 @@ processChatCommand cxt nm = \case Just mConn -> case memberAdmission >>= review of Just MCAll -> do - introduceToModerators cxt user gInfo m + introduceToModerators cxt user g m withFastStore' $ \db -> updateGroupMemberStatus db userId m GSMemPendingReview let m' = m {memberStatus = GSMemPendingReview} pure $ CRMemberAccepted user gInfo m' Nothing -> do let msg = XGrpLinkAcpt GAAccepted role (memberId' m) void $ sendDirectMemberMessage mConn msg groupId - introduceToRemaining cxt user gInfo m {memberRole = role} + introduceToRemaining cxt user g m {memberRole = role} when (groupFeatureAllowed SGFHistory gInfo) $ sendHistory user gInfo m (m', gInfo') <- withFastStore' $ \db -> do m' <- updateGroupMemberAccepted db user m GSMemConnected role @@ -2906,13 +2904,13 @@ processChatCommand cxt nm = \case modMs <- withFastStore' $ \db -> getGroupModerators db cxt user gInfo let rcpModMs' = filter memberCurrent modMs msg = XGrpLinkAcpt GAAccepted role (memberId' m) - void $ sendGroupMessage user gInfo scope ([m] <> rcpModMs') False msg + void $ sendGroupMessage user g scope ([m] <> rcpModMs') False msg when (maxVersion (memberChatVRange m) < groupKnockingVersion) $ forM_ (memberConn m) $ \mConn -> do let msg2 = XMsgNew $ mcSimple (MCText acceptedToGroupMessage) void $ sendDirectMemberMessage mConn msg2 groupId when (memberCategory m == GCInviteeMember) $ do - introduceToRemaining cxt user gInfo m {memberRole = role} + introduceToRemaining cxt user g m {memberRole = role} when (groupFeatureAllowed SGFHistory gInfo) $ sendHistory user gInfo m (m', gInfo') <- withFastStore' $ \db -> do m' <- updateGroupMemberAccepted db user m newMemberStatus role @@ -2944,7 +2942,7 @@ processChatCommand cxt nm = \case APIMembersRole groupId memberIds newRole -> withUser $ \user -> withGroupLock "memberRole" groupId $ do -- TODO [relays] possible optimization is to read only required members + relays - g@(Group gInfo members) <- withFastStore $ \db -> getGroup db cxt user groupId + (g@(Group gInfo members), gks) <- withFastStore $ \db -> getGroupKeys_ db cxt user groupId when (selfSelected gInfo) $ throwCmdError "can't change role for self" let (invitedMems, currentMems, unchangedMems, maxRole, anyAdmin, anyPending, anyPrivilegedTarget, anyRelay, anyRosterChange, finalPrivilegedCount) = selectMembers members when (length invitedMems + length currentMems + length unchangedMems /= length memberIds) $ throwChatError CEGroupMemberNotFound @@ -2960,11 +2958,11 @@ processChatCommand cxt nm = \case throwCmdError "only the group owner can change moderator and admin roles" when (useRelays' gInfo && isRosterRole newRole && finalPrivilegedCount > maxGroupRosterSize) $ throwCmdError $ "the number of members, moderators and admins would exceed the limit of " <> show maxGroupRosterSize - (errs1, changed1) <- changeRoleInvitedMems user gInfo invitedMems + (errs1, changed1) <- changeRoleInvitedMems user (GIK gInfo gks) invitedMems let doBumpRoster = useRelays' gInfo && memberRole' (membership gInfo) == GROwner && anyRosterChange -- roster (with the change projected in) before the delta, so a relay stores the blob at this version before forwarding the delta - rosterVer <- if doBumpRoster then Just <$> broadcastRoster user gInfo (RDRoleChanged newRole currentMems) else pure Nothing - (errs2, changed2, acis, msgSigned) <- changeRoleCurrentMems user g rosterVer currentMems + rosterVer <- if doBumpRoster then Just <$> broadcastRoster user (GIK gInfo gks) (RDRoleChanged newRole currentMems) else pure Nothing + (errs2, changed2, acis, msgSigned) <- changeRoleCurrentMems user g gks rosterVer currentMems unless (null acis) $ toView $ CEvtNewChatItems user acis let errs = errs1 <> errs2 unless (null errs) $ toView $ CEvtChatErrors errs @@ -2991,8 +2989,8 @@ processChatCommand cxt nm = \case -- a current member's role actually changes here; it alters the roster iff the old or new role is on it | otherwise -> (invited, m : current, unchanged, maxRole', anyAdmin', anyPending', anyPrivTarget', anyRelay', anyRosterChange || isRosterRole newRole || isRosterRole memberRole, privCount') | otherwise = (invited, current, unchanged, maxRole, anyAdmin, anyPending, anyPrivTarget, anyRelay, anyRosterChange, if isRosterRole memberRole then privCount + 1 else privCount) - changeRoleInvitedMems :: User -> GroupInfo -> [GroupMember] -> CM ([ChatError], [GroupMember]) - changeRoleInvitedMems user gInfo memsToChange = do + changeRoleInvitedMems :: User -> GroupInfoKeys -> [GroupMember] -> CM ([ChatError], [GroupMember]) + changeRoleInvitedMems user gInfo@(GIK g _) memsToChange = do -- not batched, as we need to send different invitations to different connections anyway mems_ <- forM memsToChange $ \m -> (Right <$> changeRole m) `catchAllErrors` (pure . Left) pure $ partitionEithers mems_ @@ -3004,15 +3002,15 @@ processChatCommand cxt nm = \case sendGrpInvitation user ct gInfo (m :: GroupMember) {memberRole = newRole} cReq withFastStore' $ \db -> updateGroupMemberRole db user m newRole pure (m :: GroupMember) {memberRole = newRole} - _ -> throwChatError $ CEGroupCantResendInvitation gInfo cName - changeRoleCurrentMems :: User -> Group -> Maybe VersionRoster -> [GroupMember] -> CM ([ChatError], [GroupMember], [AChatItem], Bool) - changeRoleCurrentMems user (Group gInfo members) rosterVer memsToChange = case L.nonEmpty memsToChange of + _ -> throwChatError $ CEGroupCantResendInvitation g cName + changeRoleCurrentMems :: User -> Group -> GroupKeys -> Maybe VersionRoster -> [GroupMember] -> CM ([ChatError], [GroupMember], [AChatItem], Bool) + changeRoleCurrentMems user (Group gInfo members) gks rosterVer memsToChange = case L.nonEmpty memsToChange of Nothing -> pure ([], [], [], False) Just memsToChange' -> do let mKey m = if isJust rosterVer then MemberKey <$> memberPubKey m else Nothing events = L.map (\m@GroupMember {memberId} -> XGrpMemRole memberId newRole (mKey m) rosterVer) memsToChange' recipients = filter memberCurrent members - (msgs_, _gsr) <- sendGroupMessages user gInfo Nothing False recipients False events + (msgs_, _gsr) <- sendGroupMessages user (GIK gInfo gks) Nothing False recipients False events let signed = any (either (const False) (\SndMessage {signedMsg_} -> isJust signedMsg_)) msgs_ itemsData = zipWith (fmap . sndItemData) memsToChange (L.toList msgs_) cis_ <- saveSndChatItems user (CDGroupSnd gInfo Nothing) False itemsData Nothing False @@ -3032,7 +3030,7 @@ processChatCommand cxt nm = \case APIBlockMembersForAll groupId memberIds blockFlag -> withUser $ \user -> withGroupLock "blockForAll" groupId $ do -- TODO [relays] possible optimization is to read only required members + relays - Group gInfo members <- withFastStore $ \db -> getGroup db cxt user groupId + (Group gInfo members, gks) <- withFastStore $ \db -> getGroupKeys_ db cxt user groupId when (selfSelected gInfo) $ throwCmdError "can't block/unblock self" -- TODO [relays] consider sending restriction to all members (remove filtering), as we do in delivery jobs let (blockMems, remainingMems, maxRole, anyAdmin, anyPending) = selectMembers members @@ -3040,7 +3038,7 @@ processChatCommand cxt nm = \case when (length memberIds > 1 && anyAdmin) $ throwCmdError "can't block/unblock multiple members when admins selected" when anyPending $ throwCmdError "can't block/unblock members pending approval" assertUserGroupRole gInfo $ max GRModerator maxRole - blockMembers user gInfo blockMems remainingMems + blockMembers user (GIK gInfo gks) blockMems remainingMems where selfSelected GroupInfo {membership} = elem (groupMemberId' membership) memberIds selectMembers :: [GroupMember] -> ([GroupMember], [GroupMember], GroupMemberRole, Bool, Bool) @@ -3053,14 +3051,14 @@ processChatCommand cxt nm = \case anyPending' = anyPending || memberPending m in (m : block, remaining, maxRole', anyAdmin', anyPending') | otherwise = (block, m : remaining, maxRole, anyAdmin, anyPending) - blockMembers :: User -> GroupInfo -> [GroupMember] -> [GroupMember] -> CM ChatResponse - blockMembers user gInfo blockMems remainingMems = case L.nonEmpty blockMems of + blockMembers :: User -> GroupInfoKeys -> [GroupMember] -> [GroupMember] -> CM ChatResponse + blockMembers user g@(GIK gInfo _) blockMems remainingMems = case L.nonEmpty blockMems of Nothing -> throwCmdError "no members to block/unblock" Just blockMems' -> do let mrs = if blockFlag then MRSBlocked else MRSUnrestricted events = L.map (\GroupMember {memberId} -> XGrpMemRestrict memberId MemberRestrictions {restriction = mrs}) blockMems' recipients = filter memberCurrent remainingMems - (msgs_, _gsr) <- sendGroupMessages_ user gInfo recipients False events + (msgs_, _gsr) <- sendGroupMessages_ user g recipients False events let msgSigned = any (either (const False) (\SndMessage {signedMsg_} -> isJust signedMsg_)) msgs_ itemsData = zipWith (fmap . sndItemData) blockMems (L.toList msgs_) cis_ <- saveSndChatItems user (CDGroupSnd gInfo Nothing) False itemsData Nothing False @@ -3081,7 +3079,7 @@ processChatCommand cxt nm = \case APIRemoveMembers {groupId, groupMemberIds, withMessages} -> withUser $ \user -> withGroupLock "removeMembers" groupId $ do -- TODO [relays] possible optimization is to read only required members + relays - Group gInfo members <- withFastStore $ \db -> getGroup db cxt user groupId + (Group gInfo members, gks) <- withFastStore $ \db -> getGroupKeys_ db cxt user groupId let (count, invitedMems, pendingApprvMems, pendingRvwMems, currentMems, maxRole, anyAdmin, anyPrivilegedRemoved, anyRosterRemoved) = selectMembers gmIds members gmIds = S.fromList $ L.toList groupMemberIds memCount = length groupMemberIds @@ -3094,13 +3092,13 @@ processChatCommand cxt nm = \case let recipients = filter memberCurrent members let doBumpRoster = useRelays' gInfo && memberRole' (membership gInfo) == GROwner && anyRosterRemoved -- roster (excluding the removed members) before the delta, so a relay stores the blob at this version before forwarding the delta - rosterVer <- if doBumpRoster then Just <$> broadcastRoster user gInfo (RDRemoved currentMems) else pure Nothing - (errs2, deleted2, acis2, signed2) <- deleteMemsSend user gInfo Nothing rosterVer recipients currentMems + rosterVer <- if doBumpRoster then Just <$> broadcastRoster user (GIK gInfo gks) (RDRemoved currentMems) else pure Nothing + (errs2, deleted2, acis2, signed2) <- deleteMemsSend user (GIK gInfo gks) Nothing rosterVer recipients currentMems (errs3, deleted3, acis3, signed3) <- - foldM (\acc m -> deletePendingMember acc user gInfo [m] m) ([], [], [], False) pendingApprvMems + foldM (\acc m -> deletePendingMember acc user (GIK gInfo gks) [m] m) ([], [], [], False) pendingApprvMems let moderators = filter (\GroupMember {memberRole} -> memberRole >= GRModerator) members (errs4, deleted4, acis4, signed4) <- - foldM (\acc m -> deletePendingMember acc user gInfo (m : moderators) m) ([], [], [], False) pendingRvwMems + foldM (\acc m -> deletePendingMember acc user (GIK gInfo gks) (m : moderators) m) ([], [], [], False) pendingRvwMems let acis = acis2 <> acis3 <> acis4 errs = errs1 <> errs2 <> errs3 <> errs4 deleted = deleted1 <> deleted2 <> deleted3 <> deleted4 @@ -3108,7 +3106,7 @@ processChatCommand cxt nm = \case -- Read group info with updated membersRequireAttention and publicMemberCount gInfo' <- if useRelays' gInfo - then updatePublicGroupData user gInfo + then updatePublicGroupData user gInfo gks else withFastStore $ \db -> getGroupInfo db cxt user groupId let acis' = map (updateACIGroupInfo gInfo') acis unless (null acis') $ toView $ CEvtNewChatItems user acis' @@ -3142,18 +3140,18 @@ processChatCommand cxt nm = \case delMember db m = do deleteGroupMember db user m pure m {memberStatus = GSMemRemoved} - deletePendingMember :: ([ChatError], [GroupMember], [AChatItem], Bool) -> User -> GroupInfo -> [GroupMember] -> GroupMember -> CM ([ChatError], [GroupMember], [AChatItem], Bool) + deletePendingMember :: ([ChatError], [GroupMember], [AChatItem], Bool) -> User -> GroupInfoKeys -> [GroupMember] -> GroupMember -> CM ([ChatError], [GroupMember], [AChatItem], Bool) deletePendingMember (accErrs, accDeleted, accACIs, accSigned) user gInfo recipients m = do (m', scopeInfo) <- mkMemberSupportChatInfo m (errs, deleted, acis, signed) <- deleteMemsSend user gInfo (Just scopeInfo) Nothing recipients [m'] pure (errs <> accErrs, deleted <> accDeleted, acis <> accACIs, accSigned || signed) - deleteMemsSend :: User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe VersionRoster -> [GroupMember] -> [GroupMember] -> CM ([ChatError], [GroupMember], [AChatItem], Bool) - deleteMemsSend user gInfo chatScopeInfo rosterVer recipients memsToDelete = case L.nonEmpty memsToDelete of + deleteMemsSend :: User -> GroupInfoKeys -> Maybe GroupChatScopeInfo -> Maybe VersionRoster -> [GroupMember] -> [GroupMember] -> CM ([ChatError], [GroupMember], [AChatItem], Bool) + deleteMemsSend user g@(GIK gInfo _) chatScopeInfo rosterVer recipients memsToDelete = case L.nonEmpty memsToDelete of Nothing -> pure ([], [], [], False) Just memsToDelete' -> do let chatScope = toChatScope <$> chatScopeInfo events = L.map (\GroupMember {memberId} -> XGrpMemDel memberId withMessages rosterVer) memsToDelete' - (msgs_, _gsr) <- sendGroupMessages user gInfo chatScope False recipients False events + (msgs_, _gsr) <- sendGroupMessages user g chatScope False recipients False events let signed = any (either (const False) (\SndMessage {signedMsg_} -> isJust signedMsg_)) msgs_ itemsData_ = zipWith (fmap . sndItemData) memsToDelete (L.toList msgs_) skipUnwantedItem = \case @@ -3190,14 +3188,14 @@ processChatCommand cxt nm = \case | groupFeatureUserAllowed SGFFullDelete gInfo = deleteGroupMembersCIs user gInfo ms | otherwise = markGroupMembersCIsDeleted user gInfo ms membership APILeaveGroup groupId -> withUser $ \user@User {userId} -> do - gInfo@GroupInfo {membership} <- withFastStore $ \db -> getGroupInfo db cxt user groupId + g@(GIK gInfo@GroupInfo {membership} _) <- withFastStore $ \db -> getGroupInfoKeys db cxt user groupId filesInfo <- withFastStore' $ \db -> getGroupFileInfo db user gInfo withGroupLock "leaveGroup" groupId $ do cancelFilesInProgress user filesInfo msg <- if useRelays' gInfo && isRelay membership - then leaveChannelRelay gInfo - else leaveGroupSendMsg user gInfo + then leaveChannelRelay g + else leaveGroupSendMsg user g (gInfo', scopeInfo) <- mkLocalGroupChatScope gInfo ci <- saveSndChatItem user (CDGroupSnd gInfo' scopeInfo) msg (CISndGroupEvent SGEUserLeft) toView $ CEvtNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat gInfo' scopeInfo) ci] @@ -3212,9 +3210,9 @@ processChatCommand cxt nm = \case pure $ CRLeftMemberUser user gInfo' {membership = membership {memberStatus = GSMemLeft}, relayOwnStatus = relayOwnStatus'} where -- Relay leaving channel: create delivery job for cursor-based sending and async connection cleanup. - leaveChannelRelay gInfo = do + leaveChannelRelay g@(GIK gInfo _) = do msg@SndMessage {msgBody, signedMsg_} <- - liftEither . runIdentity =<< lift (createSndMessages $ Identity (GroupId groupId, groupMsgSigning False gInfo XGrpLeave, XGrpLeave)) + liftEither . runIdentity =<< lift (createSndMessages $ Identity (GroupId groupId, groupMsgSigning False g XGrpLeave, XGrpLeave)) let body = encodeBatchElement signedMsg_ msgBody withFastStore' $ \db -> do deleteGroupDeliveryTasks db gInfo @@ -3222,9 +3220,9 @@ processChatCommand cxt nm = \case createMsgDeliveryJob db gInfo (DJSGroup {jobSpec = DJRelayRemoved}) [] body lift . void $ getDeliveryJobWorker True (groupId, DWSGroup) pure msg - leaveGroupSendMsg user gInfo = do + leaveGroupSendMsg user g@(GIK gInfo _) = do (members, recipients) <- getRecipients user gInfo - msg <- sendGroupMessage' user gInfo recipients XGrpLeave + msg <- sendGroupMessage' user g recipients XGrpLeave deleteMembersConnections' user members True pure msg getRecipients user gInfo @@ -3284,7 +3282,7 @@ processChatCommand cxt nm = \case ct_ <- forM cName_ $ \cName -> withFastStore $ \db -> getContactByName db cxt user cName processChatCommand cxt nm $ APIListGroups userId (contactId' <$> ct_) search_ APIUpdateGroupProfile groupId p' -> withUser $ \user -> do - gInfo <- withFastStore $ \db -> getGroupInfo db cxt user groupId + gInfo <- withFastStore $ \db -> getGroupInfoKeys db cxt user groupId runUpdateGroupProfile user gInfo p' False UpdateGroupNames gName GroupProfile {displayName, fullName, shortDescr} -> updateGroupProfileByName gName $ \p -> p {displayName, fullName, shortDescr} @@ -3295,7 +3293,7 @@ processChatCommand cxt nm = \case ShowGroupDescription gName -> withUser $ \user -> CRGroupDescription user <$> withFastStore (\db -> getGroupInfoByName db cxt user gName) APISetPublicGroupAccess gId access@PublicGroupAccess {groupDomainClaim = newClaim} -> withUser $ \user -> do - gInfo@GroupInfo {groupProfile = p@GroupProfile {publicGroup}} <- withStore $ \db -> getGroupInfo db cxt user gId + gInfo@(GIK GroupInfo {groupProfile = p@GroupProfile {publicGroup}} _) <- withStore $ \db -> getGroupInfoKeys db cxt user gId case publicGroup of Just pg@PublicGroupProfile {groupLink, publicGroupAccess = existingAccess} -> do let domainChanged = (claimDomain <$> newClaim) /= (claimDomain <$> (existingAccess >>= groupDomainClaim)) @@ -3338,11 +3336,11 @@ processChatCommand cxt nm = \case gLnk <- withFastStore $ \db -> getGroupLink db user gInfo pure $ CRGroupLink user gInfo gLnk APIAddGroupShortLink groupId -> withUser $ \user -> do - (gInfo, gLink) <- withFastStore $ \db -> do - gInfo <- getGroupInfo db cxt user groupId + (g@(GIK gInfo _), gLink) <- withFastStore $ \db -> do + g@(GIK gInfo _) <- getGroupInfoKeys db cxt user groupId gLink <- getGroupLink db user gInfo - pure (gInfo, gLink) - gLink' <- setGroupLinkData nm user gInfo gLink + pure (g, gLink) + gLink' <- setGroupLinkData nm user g gLink pure $ CRGroupLink user gInfo gLink' APICreateMemberContact gId gMemberId -> withUser $ \user -> do (g, m) <- withFastStore $ \db -> (,) <$> getGroupInfo db cxt user gId <*> getGroupMember db cxt user gId gMemberId @@ -3528,10 +3526,10 @@ processChatCommand cxt nm = \case void . sendDirectContactMessage user contact $ XFileCancel sharedMsgId pure $ CRSndFileCancelled user (Just aci) ftm fts (Just (ChatRef CTGroup groupId scope), Just aci) -> do - (gInfo, sharedMsgId) <- withFastStore $ \db -> (,) <$> getGroupInfo db cxt user groupId <*> getSharedMsgIdByFileId db userId fileId + (g@(GIK gInfo _), sharedMsgId) <- withFastStore $ \db -> (,) <$> getGroupInfoKeys db cxt user groupId <*> getSharedMsgIdByFileId db userId fileId chatScopeInfo <- mapM (getChatScopeInfo cxt user) scope recipients <- getGroupRecipients cxt user gInfo chatScopeInfo groupKnockingVersion - void . sendGroupMessage user gInfo scope recipients False $ XFileCancel sharedMsgId + void . sendGroupMessage user g scope recipients False $ XFileCancel sharedMsgId pure $ CRSndFileCancelled user (Just aci) ftm fts (Just _, _) -> throwChatError $ CEFileInternal "invalid chat ref for file transfer" where @@ -3573,6 +3571,8 @@ processChatCommand cxt nm = \case -- the read also signals the worker, whose results follow as CEvtBadgeChanged lift $ startBadgeWork user CRBadgeState user <$> getUserBadgeState user + APIGetBadgeLedger userId badgePurchaseId -> withUserId userId $ \user -> + CRBadgeLedger user <$> withStore' (\db -> getBadgeLedger db user badgePurchaseId) APIAckBadgeAlert userId badgePurchaseId alertKind snooze episode -> withUserId userId $ \user -> do now <- badgeNow let snoozeUntil = if snooze then Just (addUTCTime nominalDay now) else Nothing @@ -3877,7 +3877,7 @@ processChatCommand cxt nm = \case -- relay-group joins (only via connectToRelay) carry the target relay member in preparedEntity_; -- its memberId binds the join signature so a sibling relay can't replay it relayMemberId_ = case preparedEntity_ of - Just (PCEGroup gInfo m) | useRelays' gInfo -> Just (memberId' m) + Just (PCEGroup (GIK gInfo _) m) | useRelays' gInfo -> Just (memberId' m) _ -> Nothing joinPreparedConn' xContactId_ conn@Connection {customUserProfileId} gInfo_ = do when (incognito /= isJust customUserProfileId) $ throwCmdError "incognito mode is different from prepared connection" @@ -3894,7 +3894,7 @@ processChatCommand cxt nm = \case xContactId <- mkXContactId xContactId_ -- [incognito] generate profile to send, or use membership profile for relay groups incognitoProfile_ <- case gInfo_ of - Just (Just gInfo) | useRelays' gInfo -> pure $ ExistingIncognito <$> incognitoMembershipProfile gInfo + Just (Just (GIK gInfo _)) | useRelays' gInfo -> pure $ ExistingIncognito <$> incognitoMembershipProfile gInfo _ -> if incognito then Just . NewIncognito <$> liftIO generateRandomProfile else pure Nothing let incognitoProfile = fromIncognitoProfile <$> incognitoProfile_ subMode <- chatReadVar subscriptionMode @@ -3928,8 +3928,8 @@ processChatCommand cxt nm = \case ct' <- withStore $ \db -> getContact db cxt user contactId pure $ CRSentInvitationToContact user ct' incognitoProfile _ -> throwCmdError "contact already has connection" - connectToRelay :: User -> GroupInfo -> ShortLinkContact -> CM (ShortLinkContact, GroupMember, Either ChatError ()) - connectToRelay user gInfo relayLink = do + connectToRelay :: User -> GroupInfoKeys -> ShortLinkContact -> CM (ShortLinkContact, GroupMember, Either ChatError ()) + connectToRelay user g@(GIK gInfo _) relayLink = do gVar <- asks random -- Save relayLink to re-use relay member record on retry (check by relayLink) relayMember <- withFastStore $ \db -> getCreateRelayForMember db cxt gVar user gInfo relayLink @@ -3942,7 +3942,7 @@ processChatCommand cxt nm = \case pure $ MemberId entityId _ -> throwChatError $ CEException "relay link: no relay link data or entity id" let relayLinkToConnect = CCLink cReq (Just relayLink) - void $ connectViaContact user (Just $ PCEGroup gInfo (relayMember {memberId = relayMemberId})) (incognitoMembership gInfo) relayLinkToConnect Nothing Nothing + void $ connectViaContact user (Just $ PCEGroup g (relayMember {memberId = relayMemberId})) (incognitoMembership gInfo) relayLinkToConnect Nothing Nothing relayMember' <- withFastStore $ \db -> getGroupMember db cxt user (groupId' gInfo) (groupMemberId' relayMember) pure (relayLink, relayMember', r) syncSubscriberRelays :: User -> GroupInfo -> [ShortLinkContact] -> CM () @@ -3975,21 +3975,19 @@ processChatCommand cxt nm = \case pure (connId, chatV) mkXContactId :: Maybe XContactId -> CM XContactId mkXContactId = maybe (XContactId <$> drgRandomBytes 16) pure - joinContact :: User -> Connection -> ConnReqContact -> Maybe Profile -> XContactId -> Maybe SharedMsgId -> Maybe (SharedMsgId, MsgContent) -> Maybe (Maybe GroupInfo) -> Maybe MemberId -> PQSupport -> CM Connection + joinContact :: User -> Connection -> ConnReqContact -> Maybe Profile -> XContactId -> Maybe SharedMsgId -> Maybe (SharedMsgId, MsgContent) -> Maybe (Maybe GroupInfoKeys) -> Maybe MemberId -> PQSupport -> CM Connection joinContact user conn cReq incognitoProfile xContactId welcomeSharedMsgId msg_ gInfo_ relayMemberId_ pqSup = do -- gInfo_ is Maybe (Maybe GroupInfo), where Just Nothing means "some unknown group", e.g. when joining via link without profile profileToSend <- presentUserBadge user incognitoProfile $ case gInfo_ of - Just gInfo_' -> userProfileInGroup' user gInfo_' incognitoProfile + Just gInfo_' -> userProfileInGroup' user ((\(GIK g _) -> g) <$> gInfo_') incognitoProfile Nothing -> userProfileDirect user incognitoProfile Nothing True dm <- case gInfo_ of - Just (Just gInfo) - | useRelays' gInfo -> case relayMemberId_ of + Just (Just gInfo@(GIK g gks)) + | useRelays' g -> case relayMemberId_ of Just relayMemberId -> encodeXMemberConnInfo gInfo relayMemberId profileToSend Nothing -> throwChatError $ CEInternalError "relay group join without target relay memberId" - | otherwise -> do - gInfo' <- createUserMemberKey gInfo - encodeConnInfoPQ pqSup $ XContact profileToSend (groupMemberKey gInfo') (Just xContactId) welcomeSharedMsgId msg_ + | otherwise -> encodeConnInfoPQ pqSup $ XContact profileToSend (Just $ groupMemberKey gks) (Just xContactId) welcomeSharedMsgId msg_ _ -> encodeConnInfoPQ pqSup $ XContact profileToSend Nothing (Just xContactId) welcomeSharedMsgId msg_ subMode <- chatReadVar subscriptionMode @@ -4104,8 +4102,8 @@ processChatCommand cxt nm = \case void (sendDirectContactMessage user ct' $ XInfo p Nothing) `catchAllErrors` eToView lift . when (directOrUsed ct') $ createSndFeatureItems user ct ct' pure $ CRContactPrefsUpdated user ct ct' - runUpdateGroupProfile :: User -> GroupInfo -> GroupProfile -> Bool -> CM ChatResponse - runUpdateGroupProfile user gInfo@GroupInfo {businessChat, groupProfile = p@GroupProfile {displayName = n}} p'@GroupProfile {displayName = n', image = img', memberAdmission = ma'} domainVerified = do + runUpdateGroupProfile :: User -> GroupInfoKeys -> GroupProfile -> Bool -> CM ChatResponse + runUpdateGroupProfile user (GIK gInfo@GroupInfo {businessChat, groupProfile = p@GroupProfile {displayName = n}} gks) p'@GroupProfile {displayName = n', image = img', memberAdmission = ma'} domainVerified = do assertUserGroupRole gInfo GROwner when (n /= n') $ checkValidName n' checkProfileImageSize img' @@ -4125,14 +4123,14 @@ processChatCommand cxt nm = \case withStore $ \db -> getGroupMemberByMemberId db cxt user gInfo' businessId let p'' = p' {displayName, fullName, shortDescr, image} :: GroupProfile recipients = filter memberCurrentOrPending oldMs - void $ sendGroupMessage user gInfo' Nothing recipients False (XGrpInfo p'') + void $ sendGroupMessage user (GIK gInfo' gks) Nothing recipients False (XGrpInfo p'') let ps' = fromMaybe defaultBusinessGroupPrefs $ groupPreferences p' recipients = filter memberCurrentOrPending newMs - sendGroupMessage user gInfo' Nothing recipients False $ XGrpPrefs ps' + sendGroupMessage user (GIK gInfo' gks) Nothing recipients False $ XGrpPrefs ps' Nothing -> do - void $ setGroupLinkData' nm user gInfo' + void $ setGroupLinkData' nm user (GIK gInfo' gks) recipients <- getRecipients - sendGroupMessage user gInfo' Nothing recipients False (XGrpInfo p') + sendGroupMessage user (GIK gInfo' gks) Nothing recipients False (XGrpInfo p') where getRecipients | useRelays' gInfo' = withFastStore' $ \db -> getGroupRelayMembers db cxt user gInfo' @@ -4156,13 +4154,13 @@ processChatCommand cxt nm = \case when (memberStatus membership == GSMemInvited) $ throwChatError (CEGroupNotJoined g) when (memberRemoved membership) $ throwChatError CEGroupMemberUserRemoved unless (memberActive membership) $ throwChatError CEGroupMemberNotActive - delGroupChatItemsForMembers :: User -> GroupInfo -> Maybe GroupChatScopeInfo -> [GroupMember] -> [CChatItem 'CTGroup] -> CM [ChatItemDeletion] - delGroupChatItemsForMembers user gInfo chatScopeInfo ms items = do + delGroupChatItemsForMembers :: User -> GroupInfoKeys -> Maybe GroupChatScopeInfo -> [GroupMember] -> [CChatItem 'CTGroup] -> CM [ChatItemDeletion] + delGroupChatItemsForMembers user g@(GIK gInfo _) chatScopeInfo ms items = do assertDeletable gInfo items assertUserGroupRole gInfo GRModerator let msgMemIds = itemsMsgMemIds gInfo items -- moderation deletes always sign (attributable; avoids the catch-up-moderator divergence) - signedEvents = L.nonEmpty $ map (\(msgId, memId) -> let evt = XMsgDel msgId memId (toMsgScope gInfo <$> chatScopeInfo) False in (groupMsgSigning True gInfo evt, evt)) msgMemIds + signedEvents = L.nonEmpty $ map (\(msgId, memId) -> let evt = XMsgDel msgId memId (toMsgScope gInfo <$> chatScopeInfo) False in (groupMsgSigning True g evt, evt)) msgMemIds mapM_ (sendGroupSignedMessages_ gInfo ms) signedEvents delGroupChatItems user gInfo chatScopeInfo items True where @@ -4200,10 +4198,10 @@ processChatCommand cxt nm = \case updateGroupProfileByName = updateGroupProfileByName_ Nothing updateGroupProfileByName_ :: Maybe GroupFeature -> GroupName -> (GroupProfile -> GroupProfile) -> CM ChatResponse updateGroupProfileByName_ feature_ gName update = withUser $ \user -> do - gInfo@GroupInfo {groupProfile = p} <- withStore $ \db -> - getGroupIdByName db user gName >>= getGroupInfo db cxt user + gInfo@(GIK g@GroupInfo {groupProfile = p} _) <- withStore $ \db -> + getGroupIdByName db user gName >>= getGroupInfoKeys db cxt user forM_ feature_ $ \feature -> do - let channel = useRelays' gInfo + let channel = useRelays' g applicable = if channel then groupFeatureInChannel feature else groupFeatureInRegularGroup feature unless applicable $ throwCmdError $ T.unpack (groupFeatureNameText feature) <> " is not available in " <> (if channel then "channels" else "groups") @@ -4261,29 +4259,30 @@ processChatCommand cxt nm = \case groupId <- getGroupIdByName db user gName groupMemberId <- getGroupMemberIdByName db user groupId groupMemberName pure (groupId, groupMemberId) - newGroup :: User -> IncognitoEnabled -> GroupProfile -> Bool -> MemberId -> Maybe GroupKeys -> Maybe Int64 -> CM GroupInfo - newGroup user incognito gProfile@GroupProfile {displayName, image, memberAdmission} useRelays memberId groupKeys_ publicMemberCount_ = do + newGroup :: User -> IncognitoEnabled -> GroupProfile -> MemberId -> GroupKeys -> Maybe Int64 -> CM GroupInfo + newGroup user incognito gProfile@GroupProfile {displayName, image, memberAdmission, publicGroup} memberId groupKeys publicMemberCount_ = do checkValidName displayName checkProfileImageSize image checkGroupProfileSize gProfile - when (useRelays && isJust (memberAdmission >>= review)) $ throwCmdError "Admission review is not supported in channels" + when (isPublicGroup groupKeys && isJust (memberAdmission >>= review)) $ throwCmdError "Admission review is not supported in channels" + when (not (isPublicGroup groupKeys) && isJust publicGroup) $ throwCmdError "publicGroup is not allowed in groups" -- [incognito] generate incognito profile for group membership incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing - withFastStore $ \db -> createNewGroup db cxt user gProfile incognitoProfile useRelays memberId groupKeys_ publicMemberCount_ + withFastStore $ \db -> createNewGroup db cxt user gProfile incognitoProfile memberId groupKeys publicMemberCount_ createNewGroupItems :: User -> GroupInfo -> CM () createNewGroupItems user gInfo = do let cd = CDGroupSnd gInfo Nothing createInternalChatItem user cd CIChatBanner (Just epochStart) createInternalChatItem user cd (CISndGroupE2EEInfo $ e2eInfoGroup gInfo) Nothing createGroupFeatureItems user cd CISndGroupFeature gInfo - sendGrpInvitation :: User -> Contact -> GroupInfo -> GroupMember -> ConnReqInvitation -> CM () - sendGrpInvitation user ct@Contact {contactId, localDisplayName} gInfo@GroupInfo {groupId, groupProfile, membership, businessChat} m@GroupMember {groupMemberId, memberId, memberRole = memRole} cReq = do + sendGrpInvitation :: User -> Contact -> GroupInfoKeys -> GroupMember -> ConnReqInvitation -> CM () + sendGrpInvitation user ct@Contact {contactId, localDisplayName} (GIK gInfo@GroupInfo {groupId, groupProfile, membership, businessChat} gks) m@GroupMember {groupMemberId, memberId, memberRole = memRole} cReq = do let currentMemCount = fromIntegral $ currentMembers $ groupSummary gInfo GroupMember {memberRole = userRole, memberId = userMemberId} = membership groupInv = GroupInvitation { fromMember = MemberIdRole userMemberId userRole, - fromMemberKey = groupMemberKey gInfo, + fromMemberKey = Just $ groupMemberKey gks, invitedMember = MemberIdRole memberId memRole, connRequest = cReq, groupProfile, @@ -4830,19 +4829,17 @@ processChatCommand cxt nm = \case quoteData ChatItem {content = CISndMsgContent qmc} = pure (qmc, CIQDirectSnd, True) quoteData ChatItem {content = CIRcvMsgContent qmc} = pure (qmc, CIQDirectRcv, False) quoteData _ = throwError SEInvalidQuote - sendGroupContentMessages :: User -> GroupInfo -> Maybe GroupChatScope -> ShowGroupAsSender -> Bool -> Maybe Int -> Bool -> NonEmpty ComposedMessageReq -> CM ChatResponse - sendGroupContentMessages user gInfo scope showGroupAsSender live itemTTL sign cmrs = do + sendGroupContentMessages :: User -> GroupInfoKeys -> Maybe GroupChatScope -> ShowGroupAsSender -> Bool -> Maybe Int -> Bool -> NonEmpty ComposedMessageReq -> CM ChatResponse + sendGroupContentMessages user gInfo@(GIK g _) scope showGroupAsSender live itemTTL sign cmrs = do assertMultiSendable live cmrs chatScopeInfo <- mapM (getChatScopeInfo cxt user) scope - -- the member key is created before the send, so that signatures and file badge proofs assert the same key - gInfo' <- createUserMemberKey gInfo - recipients <- getGroupRecipients cxt user gInfo' chatScopeInfo modsCompatVersion - sendGroupContentMessages_ user gInfo' scope showGroupAsSender chatScopeInfo recipients live itemTTL sign cmrs + recipients <- getGroupRecipients cxt user g chatScopeInfo modsCompatVersion + sendGroupContentMessages_ user gInfo scope showGroupAsSender chatScopeInfo recipients live itemTTL sign cmrs where hasReport = any (\(ComposedMessage {msgContent}, _, _, _) -> isReport msgContent) cmrs modsCompatVersion = if hasReport then contentReportsVersion else groupKnockingVersion - sendGroupContentMessages_ :: User -> GroupInfo -> Maybe GroupChatScope -> ShowGroupAsSender -> Maybe GroupChatScopeInfo -> [GroupMember] -> Bool -> Maybe Int -> Bool -> NonEmpty ComposedMessageReq -> CM ChatResponse - sendGroupContentMessages_ user gInfo@GroupInfo {groupId, membership} scope showGroupAsSender chatScopeInfo recipients live itemTTL sign cmrs = do + sendGroupContentMessages_ :: User -> GroupInfoKeys -> Maybe GroupChatScope -> ShowGroupAsSender -> Maybe GroupChatScopeInfo -> [GroupMember] -> Bool -> Maybe Int -> Bool -> NonEmpty ComposedMessageReq -> CM ChatResponse + sendGroupContentMessages_ user g@(GIK gInfo@GroupInfo {groupId, membership} _) scope showGroupAsSender chatScopeInfo recipients live itemTTL sign cmrs = do forM_ allowedRole $ assertUserGroupRole gInfo assertGroupContentAllowed processComposedMessages @@ -4872,7 +4869,7 @@ processChatCommand cxt nm = \case (fInvs_, ciFiles_) <- L.unzip <$> setupSndFileTransfers (length recipients) timed_ <- sndGroupCITimed live gInfo itemTTL (chatMsgEvents, quotedItems_) <- L.unzip <$> prepareMsgs (L.zip cmrs fInvs_) timed_ - (msgs_, gsr) <- sendGroupMessages user gInfo Nothing showGroupAsSender recipients signMsgs chatMsgEvents + (msgs_, gsr) <- sendGroupMessages user g Nothing showGroupAsSender recipients signMsgs chatMsgEvents let itemsData = prepareSndItemsData (L.toList cmrs) (L.toList ciFiles_) (L.toList quotedItems_) (L.toList msgs_) cis_ <- saveSndChatItems user (CDGroupSnd gInfo chatScopeInfo) showGroupAsSender itemsData timed_ live when (length cis_ /= length cmrs) $ logError "sendGroupContentMessages: cmrs and cis_ length mismatch" @@ -4996,12 +4993,12 @@ processChatCommand cxt nm = \case where getDirectCI :: DB.Connection -> ChatItemId -> IO (Either ChatError (CChatItem 'CTDirect)) getDirectCI db itemId = runExceptT . withExceptT ChatErrorStore $ getDirectChatItem db user ctId itemId - getCommandGroupChatItems :: User -> Int64 -> NonEmpty ChatItemId -> CM (GroupInfo, [CChatItem 'CTGroup]) + getCommandGroupChatItems :: User -> Int64 -> NonEmpty ChatItemId -> CM (GroupInfoKeys, [CChatItem 'CTGroup]) getCommandGroupChatItems user gId itemIds = do - gInfo <- withFastStore $ \db -> getGroupInfo db cxt user gId + g@(GIK gInfo _) <- withFastStore $ \db -> getGroupInfoKeys db cxt user gId (errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getGroupCI db gInfo) (L.toList itemIds)) unless (null errs) $ toView $ CEvtChatErrors errs - pure (gInfo, items) + pure (g, items) where getGroupCI :: DB.Connection -> GroupInfo -> ChatItemId -> IO (Either ChatError (CChatItem 'CTGroup)) getGroupCI db gInfo itemId = runExceptT . withExceptT ChatErrorStore $ getGroupCIWithReactions db user gInfo itemId @@ -5210,7 +5207,7 @@ presentUserBadgeToContacts user'@User {userId, profile = LocalProfile {localBadg Just User {userId = activeId} | activeId == userId -> Just user' active_ -> active_ lift $ withAgent' $ \a -> setUserEntitlement a (aUserId user') (badgeServerCredential localBadge) - cxt <- asks $ mkStoreCxt . config + cxt <- chatStoreCxt contacts <- withFastStore' $ \db -> getUserContacts db cxt user' withChatLock "presentUserBadge" $ forM_ contacts $ \ct -> case contactSendConn_ ct of @@ -5265,14 +5262,18 @@ redeemBadgeCode nm user@User {userId} codeText = do throwRedeemError :: BadgeRedeemError -> CM a throwRedeemError = throwChatError . CEBadgeRedeemError --- | An unknown code is reported, since the service is deployed ahead of clients, but its text is --- the service's - so it is bounded and stripped before reaching a terminal that acts on controls. badgeServiceErrorText :: BadgeServiceErrorCode -> Text -badgeServiceErrorText = \case - BSEUnknown t -> case T.filter errorCodeChar (T.take 32 t) of +badgeServiceErrorText = textEncode . boundedServiceErrorCode + +-- | An unknown code is reported and recorded, since the service is deployed ahead of clients, but +-- its text is the service's - so it is bounded and stripped before it reaches a terminal that acts +-- on controls, or a sentence the app shows the user as its own. +boundedServiceErrorCode :: BadgeServiceErrorCode -> BadgeServiceErrorCode +boundedServiceErrorCode = \case + BSEUnknown t -> BSEUnknown $ case T.filter errorCodeChar (T.take 32 t) of "" -> "unknown" t' -> t' - code -> textEncode code + code -> code where errorCodeChar c = isAsciiLower c || isDigit c || c == '_' @@ -5295,7 +5296,7 @@ getBadgeWorker User {userId} = do withGetSessVar' seq' userId ws now startWorker signalWorker where startWorker v = do - badgeWork <- newTMVarIO () + badgeWork <- newEmptyTMVarIO badgeWorkerAsync <- async $ void $ runExceptT $ runBadgeWorker userId badgeWork let w = BadgeWorker {badgeWorkerAsync, badgeWork} w <$ atomically (putTMVar (sessionVar v) w) @@ -5314,16 +5315,33 @@ runBadgeWorker userId badgeWork = do emitted <- newTVarIO Nothing ri <- asks $ badgeRetryInterval . config forever $ do - at_ <- withRetryInterval ri $ \_ loop -> do + at_ <- withRetryInterval ri $ \delay loop -> do lift waitChatStartedAndActivated now <- badgeNow - let stalled = pure $ Just $ badgeStalledInterval `addUTCTime` now - updateUserBadge userId emitted now `catchAllErrors` retryBadgeError loop stalled + updateUserBadge userId emitted now `catchAllErrors` retryBadgeError userId delay loop now <- badgeNow liftIO $ waitBadgeWake badgeWork now at_ -retryBadgeError :: CM a -> CM a -> ChatError -> CM a -retryBadgeError loop stalled e = eToView e >> if badgeErrorRetry e then loop else stalled +-- | Records the wake before waiting it out, so the state the apps hold carries the next attempt +-- however the pass ended. +retryBadgeError :: UserId -> Int64 -> CM (Maybe UTCTime) -> ChatError -> CM (Maybe UTCTime) +retryBadgeError userId delay loop e = do + eToView e + now <- badgeNow + let retrying = badgeErrorRetry e + at = (if retrying then fromIntegral delay / 1000000 else badgeStalledInterval) `addUTCTime` now + badgeNextWakeChanged userId at + if retrying then loop else pure (Just at) + +-- | Runs in the worker's error handler, so its own errors are reported and dropped: a throw here +-- would end the worker. +badgeNextWakeChanged :: UserId -> UTCTime -> CM () +badgeNextWakeChanged userId at = (`catchAllErrors` eToView) $ do + user <- withStore $ \db -> getUser db userId + written <- withStore' $ \db -> do + p_ <- getUserBadgePurchase db user + forM p_ $ \UserBadgePurchase {badgePurchaseId} -> setBadgeNextWake db badgePurchaseId (Just at) + when (isJust written) $ toView . CEvtBadgeChanged user =<< getUserBadgeState user -- | The signal is taken only by the wait that reports it - the take and the timer read are one -- transaction. now is the badge clock, so the remaining time counts down rather than re-reading it. @@ -5376,40 +5394,52 @@ updateUserBadge userId emitted now = do let issued = balanceStartTs balance' /= balanceStartTs balance -- outside the badge lock: the chat lock must not be taken under it unless retired $ presentIssuedBadge user' p' now - emitBadgeAlert user' emitted p' now balance' -- retiring and presenting both replace the badge on the record read above, so it is read again user'' <- withStore $ \db -> getUser db userId - when (retired || issued) $ toView . CEvtBadgeChanged user'' =<< getUserBadgeState user'' + emitBadgeAlert user'' emitted p' (shownBadgeCredential user'' p') now balance' -- a snooze is the one wake that is not in the ledger: nothing else brings the alert back, -- since support having ended leaves both ledger boundaries in the past - let UserBadgePurchase {alertSnoozeUntil} = p' + let UserBadgePurchase {issueError = failureBefore} = p + UserBadgePurchase {alertSnoozeUntil, issueError = failureAfter} = p' snoozeAt = find (> now) alertSnoozeUntil stalledAt = if requestDue && not issued then Just $ badgeStalledInterval `addUTCTime` now else Nothing - pure $ earliestTime [serviceAt, snoozeAt, stalledAt, badgeBoundary now (shownBadgeCredential user'' p') balance'] + wakeAt = earliestTime [serviceAt, snoozeAt, stalledAt, badgeBoundary now (shownBadgeCredential user'' p') balance'] + failed = failureAfter /= failureBefore + -- written before the event, so the state it reports carries the attempt it leads to + withStore' $ \db -> setBadgeNextWake db badgePurchaseId wakeAt + when (retired || issued || failed) $ toView . CEvtBadgeChanged user'' =<< getUserBadgeState user'' + pure wakeAt --- | Support ended is the only alert raised here: the others need subscriptions, and warning before --- a prepaid badge ends is not actionable while topping up cannot credit months without issuing. +-- | The other kinds need subscriptions, and warning before a prepaid badge ends is not actionable +-- while topping up cannot credit months without issuing. -- TODO [badges] BAPrepaidEnding belongs here, three days before paidThrough, once that exists. -derivedBadgeAlert :: UTCTime -> StatementEntry -> Maybe BadgeAlert -derivedBadgeAlert now b - | balanceMonths b == 0 && endsAt <= now = - Just BadgeAlert {kind = BASupportEnded, episode = safeDecodeUtf8 $ strEncode endsAt, date = endsAt, price = Nothing} - | otherwise = Nothing +derivedBadgeAlert :: UTCTime -> UserBadgePurchase -> Maybe BadgeCredential -> StatementEntry -> Maybe BadgeAlert +derivedBadgeAlert now p shownCred b + | endsAt <= now = Just $ alertOf BASupportEnded endsAt + | otherwise = (\BadgeIssueError {failedSince} -> alertOf BAIssueFailed failedSince) <$> shownIssueError now p shownCred where endsAt = L.paidThrough b + alertOf kind date = BadgeAlert {kind, episode = safeDecodeUtf8 $ strEncode date, date, price = Nothing} + +-- | A failure that can clear on its own is only shown once the credential lapses and contacts see it. +shownIssueError :: UTCTime -> UserBadgePurchase -> Maybe BadgeCredential -> Maybe BadgeIssueError +shownIssueError now UserBadgePurchase {issueError} shownCred = case issueError of + Just e@BadgeIssueError {reason} + | not (badgeFailureTransient reason) || maybe False ((<= now) . credentialExpiry) shownCred -> Just e + _ -> Nothing -- | Derived from state rather than kept pending: raised unless this occurrence is the one already -- answered, and raised again once a snooze that answered it lapses. -unansweredBadgeAlert :: UTCTime -> UserBadgePurchase -> StatementEntry -> Maybe BadgeAlert -unansweredBadgeAlert now UserBadgePurchase {alertAcked, alertSnoozeUntil} balance = - case derivedBadgeAlert now balance of +unansweredBadgeAlert :: UTCTime -> UserBadgePurchase -> Maybe BadgeCredential -> StatementEntry -> Maybe BadgeAlert +unansweredBadgeAlert now p@UserBadgePurchase {alertAcked, alertSnoozeUntil} shownCred balance = + case derivedBadgeAlert now p shownCred balance of Just alert@BadgeAlert {kind, episode} | alertAcked /= Just (kind, episode) || maybe False (now >=) alertSnoozeUntil -> Just alert _ -> Nothing -emitBadgeAlert :: User -> TVar (Maybe BadgeOccurrence) -> UserBadgePurchase -> UTCTime -> StatementEntry -> CM () -emitBadgeAlert user emitted p@UserBadgePurchase {alertSnoozeUntil} now balance = - forM_ (unansweredBadgeAlert now p balance) $ \alert@BadgeAlert {kind, episode} -> do +emitBadgeAlert :: User -> TVar (Maybe BadgeOccurrence) -> UserBadgePurchase -> Maybe BadgeCredential -> UTCTime -> StatementEntry -> CM () +emitBadgeAlert user emitted p@UserBadgePurchase {alertSnoozeUntil} shownCred now balance = + forM_ (unansweredBadgeAlert now p shownCred balance) $ \alert@BadgeAlert {kind, episode} -> do let occurrence = Just (kind, episode, alertSnoozeUntil) raised <- atomically $ stateTVar emitted (,occurrence) when (raised /= occurrence) $ toView $ CEvtBadgeAlert user alert @@ -5423,17 +5453,21 @@ getUserBadgeState user = do Just p@UserBadgePurchase {badgePurchaseId} -> fmap (badgeStateOf now p) <$> withStore' (`getBadgeLedgerLastEntry` badgePurchaseId) where - badgeStateOf now p@UserBadgePurchase {badgePurchaseId, badgeType, shown} balance = - BadgeState - { badgePurchaseId, - badgeType, - shown = BoolDef shown, - monthsLeft = balanceMonths balance, - paidThrough = L.paidThrough balance, - renewsAt = Nothing, - willRenew = False, - alert = unansweredBadgeAlert now p balance - } + badgeStateOf now p@UserBadgePurchase {badgePurchaseId, purchaseKey, badgeType, shown, nextWakeAt} balance = + let shownCred = shownBadgeCredential user p + in BadgeState + { badgePurchaseId, + purchaseKey, + badgeType, + shown = BoolDef shown, + monthsLeft = balanceMonths balance, + paidThrough = L.paidThrough balance, + renewsAt = Nothing, + willRenew = False, + alert = unansweredBadgeAlert now p shownCred balance, + issueError = shownIssueError now p shownCred, + nextWakeAt + } -- | How long a month that did not issue waits before it is tried again, whatever stopped it. Not -- derived from the failure, so a misclassified one cannot leave a funded badge to expire. @@ -5493,22 +5527,39 @@ earliestTime ts = case catMaybes ts of [] -> Nothing ts' -> Just $ minimum ts' +-- | temporaryOrHostError covers failing to reach the server; the service timeout is a request sent and not answered. +badgeIssueFailure :: ChatError -> BadgeIssueFailure +badgeIssueFailure e = case e of + ChatErrorAgent {agentError = AGENT (A_SERVICE ASETimeout)} -> BIFServiceTimeout + ChatErrorAgent {agentError} + | temporaryOrHostError agentError -> BIFNetwork {agentError = tshow agentError} + | otherwise -> BIFUnexpected {message = tshow agentError} + ChatError (CECommandError m) -> BIFUnexpected {message = T.pack m} + ChatError (CEInternalError m) -> BIFUnexpected {message = T.pack m} + _ -> BIFUnexpected {message = tshow e} + +-- | Whether a failure can clear on its own, which decides whether the alert waits for the shown +-- credential to lapse - and, for a thrown error, whether the pass retries it. +badgeFailureTransient :: BadgeIssueFailure -> Bool +badgeFailureTransient = \case + -- the service withholds retryAfter from internal to avoid being pressed while failing, not because it is final + BIFServiceError {code = BSEInternal} -> True + BIFServiceError {retryable} -> retryable + BIFServiceTimeout -> True + BIFNetwork {} -> True + BIFInvalidCredential -> False + BIFUnexpected {} -> False + -- | Only a failure that can clear on its own is repeated; every other throw is terminal, and -- repeating it would spin. Service errors are classified by retryAfter in requestBadgeIssue. badgeErrorRetry :: ChatError -> Bool -badgeErrorRetry = \case - ChatErrorAgent {agentError} -> retryable agentError - _ -> False - where - -- an unanswered request is the likeliest renewal failure and temporaryOrHostError does not - -- cover it: that classifies reaching the server, and this timeout is the agent's own - retryable = \case - AGENT (A_SERVICE ASETimeout) -> True - e -> temporaryOrHostError e +badgeErrorRetry = badgeFailureTransient . badgeIssueFailure --- | Ask the service for the month that is due and apply the response. A timeout writes nothing, so --- the same request is sent again on the next pass. 'Left' is a service error, already reported, and --- carries when to try again, since a service error is answered rather than thrown. +-- | Ask the service for the month that is due and apply the response. A timeout stores no ledger row, +-- so the same request is sent again on the next pass. 'Left' is a service error, already reported, and +-- carries when to try again, since a service error is answered rather than thrown. Any request that +-- ends without a credential the ledger still owes is recorded on the purchase as a failed renewal; +-- nothing else records one. requestBadgeIssue :: UserId -> UserBadgePurchase -> UTCTime -> CM (Either UTCTime StatementEntry) requestBadgeIssue userId UserBadgePurchase {badgePurchaseId, badgeType, purchaseKey, purchasePrivKey, masterKey} now = do sendTarget <- asks (badgeServiceAddress . config) >>= maybe (throwCmdError "badge service not configured") pure @@ -5521,24 +5572,43 @@ requestBadgeIssue userId UserBadgePurchase {badgePurchaseId, badgeType, purchase purchaseKey = Just purchaseKey, request = BSCIssueBadge {balance = BadgeBalance {lastEntry}} } - respData <- sendServiceRequestTo NRMBackground user sendTarget Nothing (Just purchasePrivKey) req + respData <- + sendServiceRequestTo NRMBackground user sendTarget Nothing (Just purchasePrivKey) req + `catchAllErrors` \e -> recordFailure (badgeIssueFailure e) >> throwError e case J.fromJSON (J.Object respData) of - J.Success BSPBadgeCredential {credential, statement} -> do - cred_ <- verifyIssuedCredential masterKey credential - -- TODO [badges] the statement is applied either way, so a failed verification spends the - -- month with nothing to show for it; that needs an alert, not only a line in the log + J.Success BSPBadgeCredential {credential = sentCred_, statement} -> do + verifiedCred_ <- verifyIssuedCredential masterKey sentCred_ g <- asks random -- read again: now was taken before a lock wait and an untimed request, and the check reads -- it as the client's clock against the timestamps the service put on the rows storedAt <- badgeNow - applied <- withStore' $ \db -> applyBadgeStatement db g badgePurchaseId badgeType statement cred_ storedAt - unless applied $ eToView $ ChatError $ CEInternalError "issued badge credential has no ledger row to store it against" - Right <$> (withStore' (`getBadgeLedgerLastEntry` badgePurchaseId) >>= maybe (throwCmdError "badge ledger has no balance") pure) + (applied, balance_) <- withStore' $ \db -> do + applied <- applyBadgeStatement db g badgePurchaseId badgeType statement verifiedCred_ storedAt + (applied,) <$> getBadgeLedgerLastEntry db badgePurchaseId + -- the statement is applied either way, so a month can be spent with nothing to show for it + case (sentCred_, verifiedCred_, applied) of + (Just _, Nothing, _) -> recordFailure BIFInvalidCredential + (_, _, False) -> recordUnexpected "issued badge credential has no ledger row to store it against" + -- no credential is the service saying the months ran out, which its statement then shows + (Nothing, _, _) | maybe False ((> 0) . balanceMonths) balance_ -> recordUnexpected "badge service issued no credential" + _ -> pure () + maybe (throwCmdError "badge ledger has no balance") (pure . Right) balance_ J.Success BSPError {code, retryAfter} -> do eToView $ ChatError $ CECommandError $ "badge service error: " <> T.unpack (badgeServiceErrorText code) + recordFailure BIFServiceError {code = boundedServiceErrorCode code, retryable = isJust retryAfter} ri <- asks $ badgeRetryInterval . config pure $ Left $ badgeRetryAfter ri retryAfter `addUTCTime` now - _ -> throwCmdError "unexpected badge service response" + _ -> do + recordFailure BIFUnexpected {message = unexpectedResponse} + throwCmdError $ T.unpack unexpectedResponse + where + unexpectedResponse = "unexpected badge service response" + recordUnexpected message = do + eToView $ ChatError $ CEInternalError $ T.unpack message + recordFailure BIFUnexpected {message} + recordFailure failure = do + failedAt <- badgeNow + withStore' $ \db -> setBadgeIssueError db badgePurchaseId failedAt failure -- | The signature covers the master key inside the credential, so it verifies no matter which key -- that is - the credential is stored only when that key is also this purchase's. @@ -5843,8 +5913,8 @@ runRelayGroupLinkChecks user = do where checkRelayServedGroups = do cxt <- chatStoreCxt - relayGroups <- withStore' $ \db -> getRelayServedGroups db cxt user - forM_ relayGroups $ \gInfo@GroupInfo {groupProfile = gp} -> flip catchAllErrors eToView $ do + relayGroups <- withStore $ \db -> getRelayServedGroups db cxt user + forM_ relayGroups $ \g@(GIK gInfo@GroupInfo {groupProfile = gp} _) -> flip catchAllErrors eToView $ do case publicGroup gp of Just PublicGroupProfile {groupLink = sLnk} -> do (_, ContactLinkData _ UserContactData {relays = relayLinks}, _) <- @@ -5860,7 +5930,7 @@ runRelayGroupLinkChecks user = do else void $ withStore' $ \db -> updateRelayOwnStatusFromTo db gInfo RSActive RSInactive _ -> pure () _ -> pure () - sendRelayCapIfNeeded user gInfo + sendRelayCapIfNeeded user g checkRelayInactiveGroups = do cxt <- chatStoreCxt ttl <- asks (relayInactiveTTL . config) @@ -6046,6 +6116,7 @@ chatCommandP = "/_service_request " *> (APISendServiceRequest <$> A.decimal <* A.space <*> strP <*> optional (" timeout=" *> (realToFrac <$> A.double)) <*> optional (" sign_key=" *> strP) <* A.space <*> jsonP), "/_redeem_badge_code " *> (APIRedeemBadgeCode <$> A.decimal <* A.space <*> textP), "/_badge state " *> (APIGetBadgeState <$> A.decimal), + "/_badge ledger " *> (APIGetBadgeLedger <$> A.decimal <* A.space <*> A.decimal), "/_badge ack " *> (APIAckBadgeAlert <$> A.decimal <* A.space <*> A.decimal <* A.space <*> badgeAlertKindP <* A.space <*> onOffP <* A.space <*> textP), "/_service_response " *> (APISendServiceResponse <$> A.decimal <* A.space <*> strP <* A.space <*> jsonP), "/_reject_service_request " *> (APIRejectServiceRequest <$> A.decimal <* A.space <*> strP <*> optional (A.space *> (safeDecodeUtf8 <$> A.takeByteString))), diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 441d52ffe4..e40e2466a5 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -470,9 +470,9 @@ sndBadgeProof_ User {profile = LocalProfile {localBadge}} ph = case localBadge o _ -> pure Nothing sndGroupChatBinding :: GroupInfo -> ShowGroupAsSender -> Maybe ByteString -sndGroupChatBinding GroupInfo {groupKeys, membership = GroupMember {memberId}} asGroup - | asGroup = (\PublicGroupKeys {publicGroupId} -> encodeChatBinding CBChannel $ smpEncode publicGroupId) <$> (groupKeys >>= publicGroupKeys) - | otherwise = (\GroupKeys {memberPrivKey} -> encodeChatBinding CBGroup $ groupBindingData groupKeys memberId (C.publicKey memberPrivKey)) <$> groupKeys +sndGroupChatBinding gInfo@GroupInfo {membership = GroupMember {memberId, memberPubKey}} asGroup + | asGroup = (\PublicGroupProfile {publicGroupId} -> encodeChatBinding CBChannel $ smpEncode publicGroupId) <$> publicGroup' gInfo + | otherwise = (\k -> encodeChatBinding CBGroup $ groupBindingData gInfo memberId k) <$> memberPubKey cryptoFileDigest :: CryptoFile -> CM FD.FileDigest cryptoFileDigest (CryptoFile filePath cfArgs) = do @@ -1020,11 +1020,11 @@ acceptContactRequestAsync agentAcceptContactAsync cmdId acId True cReqInvId (XInfo profileToSend Nothing) cReqPQSup subMode pure ct' -acceptGroupJoinRequestAsync :: User -> Int64 -> GroupInfo -> InvitationId -> VersionRangeChat -> Profile -> Maybe XContactId -> Maybe MemberId -> Maybe SharedMsgId -> GroupAcceptance -> GroupMemberRole -> Maybe IncognitoProfile -> Maybe MemberKey -> Maybe GroupMember -> CM GroupMember +acceptGroupJoinRequestAsync :: User -> Int64 -> GroupInfoKeys -> InvitationId -> VersionRangeChat -> Profile -> Maybe XContactId -> Maybe MemberId -> Maybe SharedMsgId -> GroupAcceptance -> GroupMemberRole -> Maybe IncognitoProfile -> Maybe MemberKey -> Maybe GroupMember -> CM GroupMember acceptGroupJoinRequestAsync user@User {userId} uclId - gInfo@GroupInfo {groupProfile, membership, businessChat} + (GIK gInfo@GroupInfo {groupProfile, membership, businessChat} gks) cReqInvId cReqChatVRange cReqProfile @@ -1058,7 +1058,7 @@ acceptGroupJoinRequestAsync GroupLinkInvitation { fromMember = MemberIdRole userMemberId userRole, fromMemberName = displayName, - fromMemberKey = groupMemberKey gInfo, + fromMemberKey = Just $ groupMemberKey gks, invitedMember = MemberIdRole memberId gLinkMemRole, groupProfile, accepted = Just gAccepted, @@ -1106,11 +1106,11 @@ acceptGroupJoinSendRejectAsync agentAcceptContactAsync cmdId acId False cReqInvId msg PQSupportOff subMode pure m -acceptBusinessJoinRequestAsync :: User -> Int64 -> GroupInfo -> GroupMember -> UserContactRequest -> CM (GroupInfo, GroupMember) +acceptBusinessJoinRequestAsync :: User -> Int64 -> GroupInfoKeys -> GroupMember -> UserContactRequest -> CM (GroupInfo, GroupMember) acceptBusinessJoinRequestAsync user uclId - gInfo@GroupInfo {membership = GroupMember {memberRole = userRole, memberId = userMemberId}} + (GIK gInfo@GroupInfo {membership = GroupMember {memberRole = userRole, memberId = userMemberId}} gks) clientMember@GroupMember {groupMemberId, memberId} UserContactRequest {agentInvitationId = AgentInvId cReqInvId, cReqChatVRange, xContactId} = do cxt <- chatStoreCxt @@ -1122,7 +1122,7 @@ acceptBusinessJoinRequestAsync GroupLinkInvitation { fromMember = MemberIdRole userMemberId userRole, fromMemberName = displayName, - fromMemberKey = groupMemberKey gInfo, + fromMemberKey = Just $ groupMemberKey gks, invitedMember = MemberIdRole memberId GRMember, groupProfile = businessGroupProfile userProfile groupPreferences, accepted = Just GAAccepted, @@ -1197,15 +1197,15 @@ businessGroupProfile :: Profile -> GroupPreferences -> GroupProfile businessGroupProfile Profile {displayName, fullName, shortDescr, description, image} groupPreferences = GroupProfile {displayName, fullName, description, shortDescr, image, publicGroup = Nothing, groupPreferences = Just groupPreferences, memberAdmission = Nothing} -introduceToModerators :: StoreCxt -> User -> GroupInfo -> GroupMember -> CM () -introduceToModerators cxt user gInfo@GroupInfo {groupId} m@GroupMember {memberRole, memberId} = do +introduceToModerators :: StoreCxt -> User -> GroupInfoKeys -> GroupMember -> CM () +introduceToModerators cxt user gInfo@(GIK g@GroupInfo {groupId} _) m@GroupMember {memberRole, memberId} = do forM_ (memberConn m) $ \mConn -> do let msg = if maxVersion (memberChatVRange m) >= groupKnockingVersion then XGrpLinkAcpt GAPendingReview memberRole memberId else XMsgNew $ mcSimple (MCText pendingReviewMessage) void $ sendDirectMemberMessage mConn msg groupId - modMs <- withStore' $ \db -> getGroupModerators db cxt user gInfo + modMs <- withStore' $ \db -> getGroupModerators db cxt user g let rcpModMs = filter shouldIntroduceToMod modMs introduceMember user gInfo m rcpModMs (Just $ MSMember $ memberId' m) where @@ -1215,15 +1215,15 @@ introduceToModerators cxt user gInfo@GroupInfo {groupId} m@GroupMember {memberRo && groupMemberId' mem /= groupMemberId' m && maxVersion (memberChatVRange mem) >= groupKnockingVersion -introduceToAll :: StoreCxt -> User -> GroupInfo -> GroupMember -> CM () -introduceToAll cxt user gInfo m = do - (members, vector) <- withStore $ \db -> liftM2 (,) (liftIO $ getGroupMembers db cxt user gInfo) (getMemberRelationsVector db m) +introduceToAll :: StoreCxt -> User -> GroupInfoKeys -> GroupMember -> CM () +introduceToAll cxt user gInfo@(GIK g _) m = do + (members, vector) <- withStore $ \db -> liftM2 (,) (liftIO $ getGroupMembers db cxt user g) (getMemberRelationsVector db m) let recipients = filter (shouldIntroduce m vector) members introduceMember user gInfo m recipients Nothing -introduceToRemaining :: StoreCxt -> User -> GroupInfo -> GroupMember -> CM () -introduceToRemaining cxt user gInfo m = do - (members, vector) <- withStore $ \db -> liftM2 (,) (liftIO $ getGroupMembers db cxt user gInfo) (getMemberRelationsVector db m) +introduceToRemaining :: StoreCxt -> User -> GroupInfoKeys -> GroupMember -> CM () +introduceToRemaining cxt user gInfo@(GIK g _) m = do + (members, vector) <- withStore $ \db -> liftM2 (,) (liftIO $ getGroupMembers db cxt user g) (getMemberRelationsVector db m) let recipients = filter (shouldIntroduce m vector) members introduceMember user gInfo m recipients Nothing @@ -1233,17 +1233,17 @@ shouldIntroduce m vec mem = && groupMemberId' mem /= groupMemberId' m && getRelation (indexInGroup mem) vec == MRNew -introduceMember :: User -> GroupInfo -> GroupMember -> [GroupMember] -> Maybe MsgScope -> CM () +introduceMember :: User -> GroupInfoKeys -> GroupMember -> [GroupMember] -> Maybe MsgScope -> CM () introduceMember _ _ GroupMember {activeConn = Nothing} _ _ = throwChatError $ CEInternalError "member connection not active" -introduceMember user gInfo toMember@GroupMember {activeConn = Just conn} introduceToMembers msgScope = do - void . sendGroupMessage' user gInfo introduceToMembers $ XGrpMemNew (memberInfo gInfo toMember) msgScope +introduceMember user gInfo@(GIK g _) toMember@GroupMember {activeConn = Just conn} introduceToMembers msgScope = do + void . sendGroupMessage' user gInfo introduceToMembers $ XGrpMemNew (memberInfo g toMember) msgScope sendIntroductions introduceToMembers where sendIntroductions reMembers = do updateToMemberVector reMembers updateReMembersVectors reMembers shuffledReMembers <- liftIO $ shuffleMembers reMembers - let events = map (memberIntroEvt gInfo) shuffledReMembers + let events = map (memberIntroEvt g) shuffledReMembers forM_ (L.nonEmpty events) $ \events' -> sendGroupMemberMessages user gInfo conn events' updateToMemberVector :: [GroupMember] -> CM () @@ -1272,11 +1272,11 @@ memberIntroEvt gInfo reMember = -- Forward the saved owner-signed roster verbatim (reusing its signed shared_msg_id), then the -- blob chunks, so the recipient verifies the owner signature. -serveRoster :: User -> GroupInfo -> GroupMember -> CM () -serveRoster user gInfo member = +serveRoster :: User -> GroupInfoKeys -> GroupMember -> CM () +serveRoster user gInfo@(GIK g _) member = when (member `supportsVersion` groupRosterVersion) $ do cxt <- chatStoreCxt - withStore' (\db -> getStoredGroupRoster db gInfo) >>= \case + withStore' (\db -> getStoredGroupRoster db g) >>= \case Just (ownerGMId, brokerTs, sm@SignedMsg {signedBody}, blob_, storedVer_) -> case J.eitherDecodeStrict' signedBody :: Either String (ChatMessage 'Json) of Left e -> logError $ "serveRoster: cannot decode saved roster message: " <> tshow e @@ -1297,24 +1297,24 @@ serveRoster user gInfo member = -- Used in groups with relays to introduce moderators and above to a new member, -- and to announce the new member to moderators and above. -- This doesn't create introduction records in db, compared to above methods. -introduceInChannel :: StoreCxt -> User -> GroupInfo -> GroupMember -> CM () +introduceInChannel :: StoreCxt -> User -> GroupInfoKeys -> GroupMember -> CM () introduceInChannel _ _ _ GroupMember {activeConn = Nothing} = throwChatError $ CEInternalError "member connection not active" -introduceInChannel cxt user gInfo subscriber@GroupMember {activeConn = Just conn, indexInGroup = subscriberIdx} = do +introduceInChannel cxt user g@(GIK gInfo _) subscriber@GroupMember {activeConn = Just conn, indexInGroup = subscriberIdx} = do (owners, adminsMods) <- withStore' $ \db -> (,) <$> getGroupOwners db cxt user gInfo <*> getGroupAdminsMods db cxt user gInfo let modMs = owners <> adminsMods - void $ sendGroupMessage' user gInfo modMs $ XGrpMemNew (memberInfo gInfo subscriber) Nothing + void $ sendGroupMessage' user g modMs $ XGrpMemNew (memberInfo gInfo subscriber) Nothing withStore' $ \db -> setMemberVectorNewRelations db subscriber [(indexInGroup m, (IDSubjectIntroduced, MRIntroduced)) | m <- modMs] -- owner intros first so the joiner has the owner profile loaded before applying the saved roster (signed by the owner) sendIntros owners - serveRoster user gInfo subscriber + serveRoster user g subscriber sendIntros adminsMods withStore' $ \db -> setMembersVectorsNewRelation db modMs subscriberIdx IDSubjectIntroduced MRIntroduced where sendIntros ms = forM_ (L.nonEmpty $ map (memberIntroEvt gInfo) ms) $ \evts -> - sendGroupMemberMessages user gInfo conn evts + sendGroupMemberMessages user g conn evts userProfileInGroup :: User -> GroupInfo -> Maybe Profile -> Profile userProfileInGroup user g = userProfileInGroup' user (Just g) @@ -1558,29 +1558,29 @@ splitFileDescr partSize lastSize rfdText = splitParts 1 rfdText then fileDescr :| [] else fileDescr <| splitParts (partNo + 1) rest -setGroupLinkData' :: NetworkRequestMode -> User -> GroupInfo -> CM (Maybe GroupLink) -setGroupLinkData' nm user gInfo = - withFastStore' (\db -> runExceptT $ getGroupLink db user gInfo) >>= \case +setGroupLinkData' :: NetworkRequestMode -> User -> GroupInfoKeys -> CM (Maybe GroupLink) +setGroupLinkData' nm user gInfo@(GIK g _) = + withFastStore' (\db -> runExceptT $ getGroupLink db user g) >>= \case Right gLink@GroupLink {shortLinkDataSet} | shortLinkDataSet -> Just <$> setGroupLinkData nm user gInfo gLink _ -> pure Nothing -setGroupLinkData :: NetworkRequestMode -> User -> GroupInfo -> GroupLink -> CM GroupLink -setGroupLinkData nm user gInfo gLink = do +setGroupLinkData :: NetworkRequestMode -> User -> GroupInfoKeys -> GroupLink -> CM GroupLink +setGroupLinkData nm user g@(GIK gInfo _) gLink = do cxt <- chatStoreCxt (conn, groupRelays) <- withFastStore $ \db -> (,) <$> getGroupLinkConnection db cxt user gInfo <*> liftIO (getPublishableGroupRelays db cxt user gInfo) - let (userLinkData, crClientData) = groupLinkData gInfo gLink groupRelays + let (userLinkData, crClientData) = groupLinkData g gLink groupRelays linkType = if useRelays' gInfo then CCTChannel else CCTGroup sLnk <- shortenShortLink' . setShortLinkType_ linkType =<< withAgent (\a -> setConnShortLink a nm (aConnId conn) SCMContact userLinkData (Just crClientData) False Nothing) withFastStore' $ \db -> setGroupLinkShortLink db gLink sLnk -setGroupLinkDataAsync :: User -> GroupInfo -> GroupLink -> CM () -setGroupLinkDataAsync user gInfo gLink = do +setGroupLinkDataAsync :: User -> GroupInfoKeys -> GroupLink -> CM () +setGroupLinkDataAsync user g@(GIK gInfo _) gLink = do cxt <- chatStoreCxt (conn, groupRelays) <- withStore $ \db -> (,) <$> getGroupLinkConnection db cxt user gInfo <*> liftIO (getPublishableGroupRelays db cxt user gInfo) - let (userLinkData, crClientData) = groupLinkData gInfo gLink groupRelays + let (userLinkData, crClientData) = groupLinkData g gLink groupRelays setAgentConnShortLinkAsync user conn userLinkData (Just crClientData) connectToRelayAsync :: User -> GroupInfo -> ShortLinkContact -> CM () @@ -1595,15 +1595,15 @@ connectToRelayAsync user gInfo relayLink = do newConnIds <- getAgentConnShortLinkAsync user CFGetRelayDataJoin Nothing relayLink withFastStore' $ \db -> createRelayMemberConnectionAsync db user gInfo relayMember relayLink newConnIds subMode -updatePublicGroupData :: User -> GroupInfo -> CM GroupInfo -updatePublicGroupData user gInfo +updatePublicGroupData :: User -> GroupInfo -> GroupKeys -> CM GroupInfo +updatePublicGroupData user gInfo gks | useRelays' gInfo && memberRole' (membership gInfo) == GROwner = do cxt <- chatStoreCxt (gInfo', gLink) <- withStore $ \db -> do gInfo' <- updatePublicMemberCount db cxt user gInfo gLink <- getGroupLink db user gInfo' pure (gInfo', gLink) - setGroupLinkDataAsync user gInfo' gLink + setGroupLinkDataAsync user (GIK gInfo' gks) gLink pure gInfo' | useRelays' gInfo && isRelay (membership gInfo) = do cxt <- chatStoreCxt @@ -1647,14 +1647,14 @@ updateContactFromLinkData user ct@Contact {profile = profile@LocalProfile {conta verifyChanged = contactDomainVerified /= Just True || claimChanged -- TODO [relays] owner: set owners on updating link data (multi-owner) -groupLinkData :: GroupInfo -> GroupLink -> [GroupRelay] -> (UserConnLinkData 'CMContact, CRClientData) -groupLinkData gInfo@GroupInfo {groupProfile, groupSummary = GroupSummary {publicMemberCount}, membership = GroupMember {memberId}, groupKeys} GroupLink {groupLinkId} groupRelays = +groupLinkData :: GroupInfoKeys -> GroupLink -> [GroupRelay] -> (UserConnLinkData 'CMContact, CRClientData) +groupLinkData (GIK gInfo@GroupInfo {groupProfile, groupSummary = GroupSummary {publicMemberCount}, membership = GroupMember {memberId}} gks) GroupLink {groupLinkId} groupRelays = let direct = not $ useRelays' gInfo relays = mapMaybe (\GroupRelay {relayLink} -> relayLink) groupRelays publicGroupData_ = PublicGroupData <$> publicMemberCount userData = encodeShortLinkData $ GroupShortLinkData {groupProfile, publicGroupData = publicGroupData_} - owners = case groupKeys of - Just GroupKeys {publicGroupKeys = Just PublicGroupKeys {groupRootKey = GRKPrivate rootPrivKey}, memberPrivKey} -> + owners = case gks of + GKPublicGroup {groupRootKey = GRKPrivate rootPrivKey, memberPrivKey} -> let ownerId = unMemberId memberId ownerKey = C.publicKey memberPrivKey authOwnerSig = C.sign' rootPrivKey (ownerId <> C.encodePubKey ownerKey) @@ -2308,18 +2308,19 @@ createSndMessages idsEvents = do encodeMessage sharedMsgId = encodeChatMessage maxEncodedMsgLength ChatMessage {chatVRange = vr, msgId = Just sharedMsgId, chatMsgEvent = evnt} -groupMsgSigning :: Bool -> GroupInfo -> ChatMsgEvent e -> Maybe MsgSigning -groupMsgSigning sign GroupInfo {membership = GroupMember {memberId}, groupKeys} evt = case groupKeys of - Just gks@GroupKeys {memberPrivKey} | shouldSign -> Just $ MsgSigning CBGroup bindingData KRMember memberPrivKey - where - tag = toCMEventTag evt - shouldSign = requiresSignature tag || (sign && signableContent tag) - bindingData = groupBindingData (Just gks) memberId (C.publicKey memberPrivKey) - _ -> Nothing - -groupBindingData :: Maybe GroupKeys -> MemberId -> C.PublicKeyEd25519 -> ByteString -groupBindingData gks memberId memberKey = case gks >>= publicGroupKeys of - Just PublicGroupKeys {publicGroupId} -> smpEncode (publicGroupId, memberId) +groupMsgSigning :: Bool -> GroupInfoKeys -> ChatMsgEvent e -> Maybe MsgSigning +groupMsgSigning sign (GIK gInfo@GroupInfo {membership = GroupMember {memberId}} gks) evt + | shouldSign = Just $ MsgSigning CBGroup bindingData KRMember memberPrivKey' + | otherwise = Nothing + where + memberPrivKey' = memberPrivKey gks + tag = toCMEventTag evt + shouldSign = requiresSignature tag || (sign && signableContent tag) + bindingData = groupBindingData gInfo memberId (C.publicKey memberPrivKey') + +groupBindingData :: GroupInfo -> MemberId -> C.PublicKeyEd25519 -> ByteString +groupBindingData gInfo memberId memberKey = case publicGroup' gInfo of + Just PublicGroupProfile {publicGroupId} -> smpEncode (publicGroupId, memberId) Nothing -> smpEncode (memberId, memberKey) type HistoryFile = (FileInvitation, RcvFileDescrText, Maybe UTCTime, Maybe BadgeProof) @@ -2330,11 +2331,11 @@ directChatBinding ct = encodeChatBinding CBDirect <$> withAgent (`getConnectionRatchetAdHash` aConnId conn) rcvGroupChatBinding :: GroupInfo -> Maybe GroupMember -> ShowGroupAsSender -> Maybe BadgeProof -> Maybe ByteString -rcvGroupChatBinding GroupInfo {groupKeys} m_ asGroup badge_ = - case (groupKeys >>= publicGroupKeys, asGroup, m_) of - (Just PublicGroupKeys {publicGroupId}, True, _) -> +rcvGroupChatBinding gInfo m_ asGroup badge_ = + case (publicGroup' gInfo, asGroup, m_) of + (Just PublicGroupProfile {publicGroupId}, True, _) -> Just $ encodeChatBinding CBChannel $ smpEncode publicGroupId - (Just PublicGroupKeys {publicGroupId}, False, Just GroupMember {memberId}) -> + (Just PublicGroupProfile {publicGroupId}, False, Just GroupMember {memberId}) -> Just $ encodeChatBinding CBGroup $ smpEncode (publicGroupId, memberId) (Nothing, False, Just GroupMember {memberId, memberPubKey}) -> (\k -> encodeChatBinding CBGroup $ smpEncode (memberId, k)) <$> (memberPubKey <|> proofMemberKey memberId badge_) @@ -2387,21 +2388,13 @@ rcvFileProhibited binding_ FileInvitation {fileSize, fileBadge} = do then Nothing else Just FileProhibited {maxSize, badgeStatus = Just st} -createUserMemberKey :: GroupInfo -> CM GroupInfo -createUserMemberKey gInfo@GroupInfo {groupId, membership, groupKeys} - | useRelays' gInfo || isJust groupKeys = pure gInfo - | otherwise = do - (_, memberPrivKey) <- atomically . C.generateKeyPair =<< asks random - withStore' $ \db -> setUserMemberKey db groupId (groupMemberId' membership) memberPrivKey - pure gInfo {groupKeys = Just GroupKeys {publicGroupKeys = Nothing, memberPrivKey}} +groupMemberKey :: GroupKeys -> MemberKey +groupMemberKey gks = MemberKey $ C.publicKey $ memberPrivKey gks -groupMemberKey :: GroupInfo -> Maybe MemberKey -groupMemberKey GroupInfo {groupKeys} = MemberKey . C.publicKey . memberPrivKey <$> groupKeys - -sendGroupMemberMessages :: forall e. MsgEncodingI e => User -> GroupInfo -> Connection -> NonEmpty (ChatMsgEvent e) -> CM () -sendGroupMemberMessages user gInfo@GroupInfo {groupId} conn events = do +sendGroupMemberMessages :: forall e. MsgEncodingI e => User -> GroupInfoKeys -> Connection -> NonEmpty (ChatMsgEvent e) -> CM () +sendGroupMemberMessages user g@(GIK gInfo@GroupInfo {groupId} _) conn events = do when (connDisabled conn) $ throwChatError (CEConnectionDisabled conn) - let idsEvts = L.map (\evt -> (GroupId groupId, groupMsgSigning False gInfo evt, evt)) events + let idsEvts = L.map (\evt -> (GroupId groupId, groupMsgSigning False g evt, evt)) events (errs, msgs) <- lift $ partitionEithers . L.toList <$> createSndMessages idsEvts unless (null errs) $ toView $ CEvtChatErrors errs forM_ (L.nonEmpty msgs) $ \msgs' -> @@ -2468,15 +2461,13 @@ encodeSignedConnInfo signing chatMsgEvent = do -- signed XMember for a relay-group join: proves the joiner holds the member key it asserts, and carries -- viaRelay = the target relay's memberId inside the signed body so a sibling relay can't accept a replay -encodeXMemberConnInfo :: GroupInfo -> MemberId -> Profile -> CM ByteString -encodeXMemberConnInfo GroupInfo {membership = GroupMember {memberId}, groupKeys} relayMemberId profileToSend = - case groupKeys of - Just gks@GroupKeys {memberPrivKey} -> - let xMemberEvt = XMember profileToSend memberId (MemberKey $ C.publicKey memberPrivKey) (Just relayMemberId) - bindingData = groupBindingData (Just gks) memberId (C.publicKey memberPrivKey) - signing = MsgSigning CBGroup bindingData KRMember memberPrivKey - in encodeSignedConnInfo signing xMemberEvt - Nothing -> throwChatError $ CEInternalError "no group keys for channel membership" +encodeXMemberConnInfo :: GroupInfoKeys -> MemberId -> Profile -> CM ByteString +encodeXMemberConnInfo (GIK gInfo@GroupInfo {membership = GroupMember {memberId}} gks) relayMemberId profileToSend = + let memberPrivKey' = memberPrivKey gks + xMemberEvt = XMember profileToSend memberId (MemberKey $ C.publicKey memberPrivKey') (Just relayMemberId) + bindingData = groupBindingData gInfo memberId (C.publicKey memberPrivKey') + signing = MsgSigning CBGroup bindingData KRMember memberPrivKey' + in encodeSignedConnInfo signing xMemberEvt deliverMessage :: Connection -> CMEventTag e -> MsgBody -> MessageId -> CM (Int64, PQEncryption) deliverMessage conn cmEventTag msgBody msgId = do @@ -2539,13 +2530,13 @@ deliverMessagesB msgReqs = do where updatePQ = updateConnPQSndEnabled db connId pqSndEnabled' -sendGroupMessage :: MsgEncodingI e => User -> GroupInfo -> Maybe GroupChatScope -> [GroupMember] -> Bool -> ChatMsgEvent e -> CM SndMessage +sendGroupMessage :: MsgEncodingI e => User -> GroupInfoKeys -> Maybe GroupChatScope -> [GroupMember] -> Bool -> ChatMsgEvent e -> CM SndMessage sendGroupMessage user gInfo gcScope members sign chatMsgEvent = do sendGroupMessages user gInfo gcScope False members sign (chatMsgEvent :| []) >>= \case ((Right msg) :| [], _) -> pure msg _ -> throwChatError $ CEInternalError "sendGroupMessage: expected 1 message" -sendGroupMessage' :: MsgEncodingI e => User -> GroupInfo -> [GroupMember] -> ChatMsgEvent e -> CM SndMessage +sendGroupMessage' :: MsgEncodingI e => User -> GroupInfoKeys -> [GroupMember] -> ChatMsgEvent e -> CM SndMessage sendGroupMessage' user gInfo members chatMsgEvent = sendGroupMessages_ user gInfo members False (chatMsgEvent :| []) >>= \case ((Right msg) :| [], _) -> pure msg @@ -2571,8 +2562,8 @@ applyRosterDelta delta current = case delta of -- advances past a version the owner hasn't recorded), then broadcast the matching blob with the change projected -- onto the served roster (so it excludes demoted/removed members). Returns the reserved version for the delta -- that follows. The blob send is best-effort - a failed send heals on the next change or on resume. -broadcastRoster :: User -> GroupInfo -> RosterDelta -> CM VersionRoster -broadcastRoster user gInfo delta = do +broadcastRoster :: User -> GroupInfoKeys -> RosterDelta -> CM VersionRoster +broadcastRoster user g@(GIK gInfo _) delta = do let rosterVer = maybe (VersionRoster 0) (\(VersionRoster n) -> VersionRoster (n + 1)) (rosterVersion gInfo) withStore' $ \db -> setGroupRosterVersion db gInfo rosterVer sendRosterBlob rosterVer `catchAllErrors` eToView @@ -2583,18 +2574,18 @@ broadcastRoster user gInfo delta = do (relays, rosterMems) <- withStore' $ \db -> (,) <$> getGroupRelayMembers db cxt user gInfo <*> getGroupRosterMembers db cxt user gInfo forM_ (L.nonEmpty relays) $ \relays' -> - sendRoster user gInfo (L.toList relays') rosterVer (buildGroupRoster $ applyRosterDelta delta rosterMems) + sendRoster user g (L.toList relays') rosterVer (buildGroupRoster $ applyRosterDelta delta rosterMems) -- Send the current roster (no version bump) to a newly added relay so it can serve joiners. -sendGroupRosterToRelay :: User -> GroupInfo -> GroupMember -> CM () -sendGroupRosterToRelay user gInfo relayMember = +sendGroupRosterToRelay :: User -> GroupInfoKeys -> GroupMember -> CM () +sendGroupRosterToRelay user g@(GIK gInfo _) relayMember = forM_ (rosterVersion gInfo) $ \rosterVer -> do cxt <- chatStoreCxt rosterMems <- withStore' $ \db -> getGroupRosterMembers db cxt user gInfo - sendRoster user gInfo [relayMember] rosterVer (buildGroupRoster rosterMems) + sendRoster user g [relayMember] rosterVer (buildGroupRoster rosterMems) -- Row-less send (no files/snd_files rows, so no send-side cleanup); redelivery is the agent's. -sendRoster :: User -> GroupInfo -> [GroupMember] -> VersionRoster -> [RosterMember] -> CM () +sendRoster :: User -> GroupInfoKeys -> [GroupMember] -> VersionRoster -> [RosterMember] -> CM () sendRoster user gInfo members rosterVer roster = do let blob = encodeRosterBlob roster fileInv = InlineFileInvitation {fileSize = fromIntegral (B.length blob), fileDigest = FD.FileDigest $ LC.sha512Hash $ LB.fromStrict blob} @@ -2602,7 +2593,7 @@ sendRoster user gInfo members rosterVer roster = do sendInlineBlobChunks user gInfo members sharedMsgId blob -- Send a binary blob as BFileChunks under a shared_msg_id to the given members (chunked by fileChunkSize). -sendInlineBlobChunks :: User -> GroupInfo -> [GroupMember] -> SharedMsgId -> ByteString -> CM () +sendInlineBlobChunks :: User -> GroupInfoKeys -> [GroupMember] -> SharedMsgId -> ByteString -> CM () sendInlineBlobChunks user gInfo members sharedMsgId blob = do chSize <- fromIntegral <$> asks (fileChunkSize . config) go chSize 1 blob @@ -2615,8 +2606,8 @@ sendInlineBlobChunks user gInfo members sharedMsgId blob = do -- Relay advertises its current web preview capability to channel owners. -- Idempotent: sends only when the configured web domain differs from what was last sent, and only to -- owners whose recorded chat version supports relayWebCapVersion (older apps can't parse XGrpRelayCap). -sendRelayCapIfNeeded :: User -> GroupInfo -> CM () -sendRelayCapIfNeeded user gInfo = do +sendRelayCapIfNeeded :: User -> GroupInfoKeys -> CM () +sendRelayCapIfNeeded user g@(GIK gInfo _) = do ChatConfig {webPreviewConfig} <- asks config let currentWebDomain = (\WebPreviewConfig {webDomain} -> webDomain) <$> webPreviewConfig sentWebDomain <- withStore' (`getRelaySentWebDomain` gInfo) @@ -2625,24 +2616,22 @@ sendRelayCapIfNeeded user gInfo = do owners <- withStore' $ \db -> getGroupOwners db cxt user gInfo let capableOwners = filter (\m -> memberCurrent m && m `supportsVersion` relayWebCapVersion) owners unless (null capableOwners) $ do - void $ sendGroupMessage' user gInfo capableOwners (XGrpRelayCap RelayCapabilities {webDomain = currentWebDomain}) + void $ sendGroupMessage' user g capableOwners (XGrpRelayCap RelayCapabilities {webDomain = currentWebDomain}) withStore' $ \db -> updateRelaySentWebDomain db gInfo currentWebDomain -sendGroupMessages :: MsgEncodingI e => User -> GroupInfo -> Maybe GroupChatScope -> ShowGroupAsSender -> [GroupMember] -> Bool -> NonEmpty (ChatMsgEvent e) -> CM (NonEmpty (Either ChatError SndMessage), GroupSndResult) -sendGroupMessages user gInfo' scope asGroup members sign events = do - gInfo <- createUserMemberKey gInfo' +sendGroupMessages :: MsgEncodingI e => User -> GroupInfoKeys -> Maybe GroupChatScope -> ShowGroupAsSender -> [GroupMember] -> Bool -> NonEmpty (ChatMsgEvent e) -> CM (NonEmpty (Either ChatError SndMessage), GroupSndResult) +sendGroupMessages user gInfo scope asGroup members sign events = do sendGroupProfileUpdate user gInfo scope asGroup members sendGroupMessages_ user gInfo members sign events -- per-item signer variant of sendGroupMessages (used for per-item delete signing); preserves the profile-update prelude -sendGroupSignedMessages :: MsgEncodingI e => User -> GroupInfo -> Maybe GroupChatScope -> ShowGroupAsSender -> [GroupMember] -> NonEmpty (Maybe MsgSigning, ChatMsgEvent e) -> CM (NonEmpty (Either ChatError SndMessage), GroupSndResult) -sendGroupSignedMessages user gInfo' scope asGroup members signedEvents = do - gInfo <- createUserMemberKey gInfo' +sendGroupSignedMessages :: MsgEncodingI e => User -> GroupInfoKeys -> Maybe GroupChatScope -> ShowGroupAsSender -> [GroupMember] -> NonEmpty (Maybe MsgSigning, ChatMsgEvent e) -> CM (NonEmpty (Either ChatError SndMessage), GroupSndResult) +sendGroupSignedMessages user gInfo@(GIK g _) scope asGroup members signedEvents = do sendGroupProfileUpdate user gInfo scope asGroup members - sendGroupSignedMessages_ gInfo members signedEvents + sendGroupSignedMessages_ g members signedEvents -sendGroupProfileUpdate :: User -> GroupInfo -> Maybe GroupChatScope -> ShowGroupAsSender -> [GroupMember] -> CM () -sendGroupProfileUpdate user gInfo scope asGroup members = +sendGroupProfileUpdate :: User -> GroupInfoKeys -> Maybe GroupChatScope -> ShowGroupAsSender -> [GroupMember] -> CM () +sendGroupProfileUpdate user g@(GIK gInfo gks) scope asGroup members = -- TODO [knocking] send current profile to pending member after approval? when shouldSendProfileUpdate $ sendProfileUpdate `catchAllErrors` eToView @@ -2661,7 +2650,7 @@ sendGroupProfileUpdate user gInfo scope asGroup members = sendProfileUpdate = do -- shouldSendProfileUpdate excludes incognito membership, so the badge is presented profileUpdate <- presentUserBadge user Nothing $ redactedMemberProfile gInfo (membership gInfo) $ fromLocalProfile p - void $ sendGroupMessage' user gInfo members $ XInfo profileUpdate (groupMemberKey gInfo) + void $ sendGroupMessage' user g members $ XInfo profileUpdate (Just $ groupMemberKey gks) currentTs <- liftIO getCurrentTime withStore' $ \db -> updateUserMemberProfileSentAt db user gInfo currentTs @@ -2671,9 +2660,9 @@ data GroupSndResult = GroupSndResult forwarded :: [GroupMember] } -sendGroupMessages_ :: MsgEncodingI e => User -> GroupInfo -> [GroupMember] -> Bool -> NonEmpty (ChatMsgEvent e) -> CM (NonEmpty (Either ChatError SndMessage), GroupSndResult) -sendGroupMessages_ _user gInfo recipientMembers sign events = - sendGroupSignedMessages_ gInfo recipientMembers $ L.map (\evt -> (groupMsgSigning sign gInfo evt, evt)) events +sendGroupMessages_ :: MsgEncodingI e => User -> GroupInfoKeys -> [GroupMember] -> Bool -> NonEmpty (ChatMsgEvent e) -> CM (NonEmpty (Either ChatError SndMessage), GroupSndResult) +sendGroupMessages_ _user gInfo@(GIK g _) recipientMembers sign events = + sendGroupSignedMessages_ g recipientMembers $ L.map (\evt -> (groupMsgSigning sign gInfo evt, evt)) events sendGroupSignedMessages_ :: MsgEncodingI e => GroupInfo -> [GroupMember] -> NonEmpty (Maybe MsgSigning, ChatMsgEvent e) -> CM (NonEmpty (Either ChatError SndMessage), GroupSndResult) sendGroupSignedMessages_ gInfo@GroupInfo {groupId} recipientMembers signedEvents = do @@ -3046,10 +3035,10 @@ joinAgentConnectionAsync :: CommandId -> Bool -> ConnId -> Bool -> ConnectionReq joinAgentConnectionAsync cmdId updateConn connId enableNtfs cReqUri cInfo subMode = withAgent $ \a -> joinConnectionAsync a (aCorrId cmdId) updateConn connId enableNtfs cReqUri cInfo PQSupportOff subMode -allowAgentConnectionAsync :: MsgEncodingI e => User -> Connection -> ConfirmationId -> Maybe GroupInfo -> ChatMsgEvent e -> CM () +allowAgentConnectionAsync :: MsgEncodingI e => User -> Connection -> ConfirmationId -> Maybe GroupInfoKeys -> ChatMsgEvent e -> CM () allowAgentConnectionAsync user conn@Connection {pqSupport} confId gInfo_ msg = do let signing_ = case gInfo_ of - Just gInfo | useRelays' gInfo || maxVersion (peerChatVRange conn) >= relayWebCapVersion -> groupMsgSigning False gInfo msg + Just gInfo@(GIK g _) | useRelays' g || maxVersion (peerChatVRange conn) >= relayWebCapVersion -> groupMsgSigning False gInfo msg _ -> Nothing dm <- case signing_ of Just signing -> encodeSignedConnInfo signing msg @@ -3292,7 +3281,7 @@ createChatItems :: createChatItems user itemTs_ dirsCIContents = do createdAt <- liftIO getCurrentTime let itemTs = fromMaybe createdAt itemTs_ - cxt <- chatStoreCxt' + cxt <- asks storeCxt void . withStoreBatch' $ \db -> map (updateChat db cxt createdAt) dirsCIContents withStoreBatch' $ \db -> concatMap (createACIs db itemTs createdAt) dirsCIContents where @@ -3390,13 +3379,9 @@ waitChatStartedAndActivated = do unless (isJust started && activated) retry chatStoreCxt :: CM StoreCxt -chatStoreCxt = lift chatStoreCxt' +chatStoreCxt = asks storeCxt {-# INLINE chatStoreCxt #-} -chatStoreCxt' :: CM' StoreCxt -chatStoreCxt' = mkStoreCxt <$> asks config -{-# INLINE chatStoreCxt' #-} - chatVersionRange :: CM VersionRangeChat chatVersionRange = lift chatVersionRange' {-# INLINE chatVersionRange #-} diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 245a9e1c2e..5493aa006c 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -114,9 +114,9 @@ smallGroupsRcptsMemLimit = 20 -- Verifies member signatures over CBGroup <> (publicGroupId, memberId) or (memberId, pubKey) <> signedBody under the given key. -- signatures is NonEmpty so the verification can't be vacuously true. -verifyGroupSig :: C.PublicKeyEd25519 -> Maybe GroupKeys -> MemberId -> NonEmpty MsgSignature -> ByteString -> Bool -verifyGroupSig key gks memberId signatures signedBody = - let prefix = encodeChatBinding CBGroup $ groupBindingData gks memberId key +verifyGroupSig :: C.PublicKeyEd25519 -> GroupInfo -> MemberId -> NonEmpty MsgSignature -> ByteString -> Bool +verifyGroupSig key gInfo memberId signatures signedBody = + let prefix = encodeChatBinding CBGroup $ groupBindingData gInfo memberId key in all (\case (MsgSignature KRMember sig) -> C.verify (C.APublicVerifyKey C.SEd25519 key) sig (prefix <> signedBody)) signatures processAgentMessage :: ACorrId -> ConnId -> AEvent 'AEConn -> CM () @@ -137,13 +137,18 @@ processAgentMessage corrId connId msg = do -- Missing connection/entity errors here will be sent to the view but not shown as CRITICAL alert, -- as in this case no need to ACK message - we can't process messages for this connection anyway. critical connId (withStore $ getUserEntity cxt) >>= \case - Just (user, entity) -> processAgentMessageConn cxt user entity corrId connId msg `catchAllErrors` eToView + Just (user, entity, gks_) -> processAgentMessageConn cxt user entity gks_ corrId connId msg `catchAllErrors` eToView _ -> throwChatError $ CENoConnectionUser (AgentConnId connId) where - getUserEntity :: StoreCxt -> DB.Connection -> ExceptT StoreError IO (Maybe (User, ConnectionEntity)) + getUserEntity :: StoreCxt -> DB.Connection -> ExceptT StoreError IO (Maybe (User, ConnectionEntity, Maybe GroupKeys)) getUserEntity cxt db = liftIO (getUserByAConnId db $ AgentConnId connId) - >>= mapM (\user -> (user,) <$> (getConnectionEntity db cxt user (AgentConnId connId) >>= liftIO . updateConnStatus db)) + >>= mapM (\user -> do + (entity, groupKeysData_) <- getConnectionEntityKeys db cxt user (AgentConnId connId) + gks_ <- case entity of + RcvGroupMsgConnection _ gInfo _ -> mapM (mkGroupKeys db cxt gInfo) groupKeysData_ + _ -> pure Nothing + (user,,gks_) <$> liftIO (updateConnStatus db entity)) updateConnStatus :: DB.Connection -> ConnectionEntity -> IO ConnectionEntity updateConnStatus db acEntity = case agentMsgConnStatus (entityConnection acEntity) msg of @@ -430,8 +435,8 @@ processAgentMsgRcvFile _corrId aFileId msg = do type ShouldDeleteGroupConns = Bool -processAgentMessageConn :: StoreCxt -> User -> ConnectionEntity -> ACorrId -> ConnId -> AEvent 'AEConn -> CM () -processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMessage = +processAgentMessageConn :: StoreCxt -> User -> ConnectionEntity -> Maybe GroupKeys -> ACorrId -> ConnId -> AEvent 'AEConn -> CM () +processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId agentMessage = case agentMessage of END -> case entity of RcvDirectMsgConnection _ (Just ct) -> toView $ CEvtContactAnotherClient user ct @@ -440,8 +445,9 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe _ -> case entity of RcvDirectMsgConnection conn contact_ -> processDirectMessage agentMessage entity conn contact_ - RcvGroupMsgConnection conn gInfo m -> - processGroupMessage agentMessage entity conn gInfo m + RcvGroupMsgConnection conn gInfo m -> case gks_ of + Just gks -> processGroupMessage agentMessage entity conn (GIK gInfo gks) m + Nothing -> throwChatError $ CEInternalError "group connection entity without group keys" UserContactConnection conn uc -> processContactConnMessage agentMessage entity conn uc where @@ -496,10 +502,10 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId) profileToSend <- presentUserBadge user incognitoProfile $ case gInfo_ of - Just gInfo -> userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) + Just (GIK gInfo _) -> userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) Nothing -> userProfileDirect user (fromLocalProfile <$> incognitoProfile) Nothing True -- [async agent commands] no continuation needed, but command should be asynchronous for stability - allowAgentConnectionAsync user conn'' confId gInfo_ $ XInfo profileToSend (groupMemberKey =<< gInfo_) + allowAgentConnectionAsync user conn'' confId gInfo_ $ XInfo profileToSend ((\(GIK _ gks) -> groupMemberKey gks) <$> gInfo_) INFO pqSupport connInfo -> do processINFOpqSupport conn pqSupport void $ saveConnInfo conn connInfo @@ -619,6 +625,7 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe void $ withStore' $ \db -> resetMemberContactFields db ct' XGrpLinkInv glInv -> do -- XGrpLinkInv here means we are connecting via business contact card, so we replace contact with group + when (isPublicGroupInv glInv) $ throwChatError $ CEInvalidChatMessage conn'' Nothing (safeDecodeUtf8 connInfo) "x.grp.link.inv: publicGroup not allowed in p2p groups" memberKeys <- atomically . C.generateKeyPair =<< asks random (gInfo, host) <- withStore $ \db -> do liftIO $ deleteContactCardKeepConn db connId ct @@ -627,7 +634,8 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe -- [incognito] send saved profile incognitoProfile <- forM customUserProfileId $ \pId -> withStore (\db -> getProfileById db userId pId) profileToSend <- presentUserBadge user incognitoProfile $ userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) - allowAgentConnectionAsync user conn'' confId (Just gInfo) $ XInfo profileToSend (groupMemberKey gInfo) + let gks = GKGroup {memberPrivKey = snd memberKeys} + allowAgentConnectionAsync user conn'' confId (Just $ GIK gInfo gks) $ XInfo profileToSend (Just $ groupMemberKey gks) toView $ CEvtBusinessLinkConnecting user gInfo host ct _ -> messageError "CONF for existing contact must have x.grp.mem.info or x.info" INFO pqSupport connInfo -> do @@ -753,8 +761,8 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndMsgContent mc) toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDSnd (DirectChat ct) ci] - processGroupMessage :: AEvent e -> ConnectionEntity -> Connection -> GroupInfo -> GroupMember -> CM () - processGroupMessage agentMsg connEntity conn@Connection {connId, customUserProfileId, connectionCode} gInfo@GroupInfo {groupId, groupProfile, membership, chatSettings} m = case agentMsg of + processGroupMessage :: AEvent e -> ConnectionEntity -> Connection -> GroupInfoKeys -> GroupMember -> CM () + processGroupMessage agentMsg connEntity conn@Connection {connId, customUserProfileId, connectionCode} g@(GIK gInfo@GroupInfo {groupId, groupProfile, membership, chatSettings} gks) m = case agentMsg of INV (ACR _ cReq) -> withCompletedCommand conn agentMsg $ \CommandData {cmdFunction} -> case cReq of @@ -779,7 +787,7 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe withStore $ \db -> liftIO $ updateGroupMemberStatus db userId m GSMemAccepted forM_ mKey $ \(MemberKey k) -> withStore' $ \db -> setMemberPubKey db (groupMemberId' m) k -- [async agent commands] no continuation needed, but command should be asynchronous for stability - allowAgentConnectionAsync user conn' confId (Just gInfo) XOk + allowAgentConnectionAsync user conn' confId (Just g) XOk | otherwise -> messageError "x.grp.acpt: memberId is different from expected" XGrpRelayAcpt relayLink relayCap | memberRole' membership == GROwner && isRelay m -> do @@ -799,7 +807,7 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe liftIO $ updateGroupMemberStatus db userId m GSMemLeft pure (relay', m {memberStatus = GSMemLeft}) -- complete the contact handshake so the relay receives INFO and cleans up its transient bookkeeping - allowAgentConnectionAsync user conn' confId (Just gInfo) XOk + allowAgentConnectionAsync user conn' confId (Just g) XOk toView $ CEvtGroupRelayUpdated user gInfo m' relay' toViewTE $ TERelayRejected user gInfo reason | otherwise -> messageError "x.grp.relay.reject: only owner should receive relay rejection" @@ -811,12 +819,11 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe pgId = fmap (\PublicGroupProfile {publicGroupId} -> publicGroupId), useRelays' gInfo == isJust rcvPG && pgId rcvPG == pgId curPG -> do -- XGrpLinkInv here means we are connecting via prepared group, and we have to update user and host member records - (gInfo'', m') <- withStore $ \db -> updatePreparedUserAndHostMembersInvited db cxt user gInfo m glInv - gInfo' <- createUserMemberKey gInfo'' + (gInfo', m') <- withStore $ \db -> updatePreparedUserAndHostMembersInvited db cxt user gInfo m glInv -- [incognito] send saved profile incognitoProfile <- forM customUserProfileId $ \pId -> withStore (\db -> getProfileById db userId pId) profileToSend <- presentUserBadge user incognitoProfile $ userProfileInGroup user gInfo' (fromLocalProfile <$> incognitoProfile) - allowAgentConnectionAsync user conn' confId (Just gInfo') $ XInfo profileToSend (groupMemberKey gInfo') + allowAgentConnectionAsync user conn' confId (Just $ GIK gInfo' gks) $ XInfo profileToSend (Just $ groupMemberKey gks) toView $ CEvtGroupLinkConnecting user gInfo' m' | otherwise -> messageError "x.grp.link.inv: publicGroupId mismatch" XGrpLinkReject glRjct@GroupLinkRejection {rejectionReason} -> do @@ -832,7 +839,7 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe membershipProfile <- presentUserBadge user (incognitoMembershipProfile gInfo) $ redactedMemberProfile gInfo membership $ fromLocalProfile $ memberProfile membership -- TODO update member profile -- [async agent commands] no continuation needed, but command should be asynchronous for stability - allowAgentConnectionAsync user conn' confId (Just gInfo) $ XGrpMemInfo membershipMemId membershipProfile + allowAgentConnectionAsync user conn' confId (Just g) $ XGrpMemInfo membershipMemId membershipProfile | otherwise -> messageError "x.grp.mem.info: memberId is different from expected" _ -> messageError "CONF from member must have x.grp.mem.info" INFO _pqSupport connInfo -> do @@ -913,12 +920,12 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe Nothing -> do withStore' $ \db -> setGroupRosterVersion db gInfo (VersionRoster 0) pure gInfo {rosterVersion = Just (VersionRoster 0)} - sendGroupRosterToRelay user gInfo' m + sendGroupRosterToRelay user (GIK gInfo' gks) m else do -- a relay below groupRosterVersion can't ack a roster; publish it on connect as before -- the handshake (getPublishableGroupRelays and the LINK handler include/activate it by version) gLink <- withStore $ \db -> getGroupLink db user gInfo - setGroupLinkDataAsync user gInfo gLink + setGroupLinkDataAsync user g gLink | otherwise -> do (gInfo', mStatus) <- if not (memberPending m) @@ -939,26 +946,25 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe when (isJust viaUserContactLink && isNothing (memberContactId m')) $ sendXGrpLinkMem gInfo'' m' if useRelays' gInfo'' then do - introduceInChannel cxt user gInfo'' m' + introduceInChannel cxt user (GIK gInfo'' gks) m' case mStatus of GSMemPendingApproval -> pure () GSMemPendingReview -> pure () _ -> when (groupFeatureAllowed SGFHistory gInfo'') $ sendHistory user gInfo'' m' else case mStatus of GSMemPendingApproval -> pure () - GSMemPendingReview -> introduceToModerators cxt user gInfo'' m' + GSMemPendingReview -> introduceToModerators cxt user (GIK gInfo'' gks) m' _ -> do - introduceToAll cxt user gInfo'' m' + introduceToAll cxt user (GIK gInfo'' gks) m' let memberIsCustomer = case businessChat gInfo'' of Just BusinessChatInfo {chatType = BCCustomer, customerId} -> memberId' m' == customerId _ -> False when (groupFeatureAllowed SGFHistory gInfo'' && not memberIsCustomer) $ sendHistory user gInfo'' m' where - sendXGrpLinkMem gInfo''' m' = do - gInfo'' <- createUserMemberKey gInfo''' + sendXGrpLinkMem gInfo'' m' = do let incognitoProfile = ExistingIncognito <$> incognitoMembershipProfile gInfo'' profileToSend <- presentUserBadge user incognitoProfile $ userProfileInGroup user gInfo'' (fromIncognitoProfile <$> incognitoProfile) - sendGroupMemberMessages user gInfo'' conn [XGrpLinkMem profileToSend (groupMemberKey gInfo'')] + sendGroupMemberMessages user (GIK gInfo'' gks) conn [XGrpLinkMem profileToSend (Just $ groupMemberKey gks)] _ -> do unless (memberPending m) $ withStore' $ \db -> updateGroupMemberStatus db userId m GSMemConnected notifyMemberConnected gInfo m Nothing @@ -1022,7 +1028,7 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe case fwd_ of Just fwd | SJson <- enc -> do logInfo $ "group fwd=" <> tshow tag <> " " <> eInfo - xGrpMsgForward gInfo' scopeInfo m' fwd parsedMsg brokerTs + xGrpMsgForward (GIK gInfo' gks) scopeInfo m' fwd parsedMsg brokerTs `catchAllErrors` \e -> eToView e pure newDeliveryTasks -- direct JSON and binary messages; binary events don't produce delivery tasks @@ -1073,36 +1079,36 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe XFileAcptInv sharedMsgId fileConnReq_ fName -> Nothing <$ xFileAcptInvGroup gInfo' m'' sharedMsgId fileConnReq_ fName XInfo p mKey -> fmap ctx <$> xInfoMember gInfo' m'' p mKey msg brokerTs XGrpLinkMem p mKey -> Nothing <$ xGrpLinkMem gInfo' m'' conn' p mKey msg - XGrpLinkAcpt acceptance role memberId -> Nothing <$ xGrpLinkAcpt gInfo' m'' acceptance role memberId msg brokerTs + XGrpLinkAcpt acceptance role memberId -> Nothing <$ xGrpLinkAcpt (GIK gInfo' gks) m'' acceptance role memberId msg brokerTs XGrpRelayNew rl -> fmap ctx <$> xGrpRelayNew gInfo' m'' rl XGrpRelayCap relayCap | memberRole' membership == GROwner && isRelay m'' -> Nothing <$ withStore' (\db -> updateRelayCapabilities db m'' relayCap) | otherwise -> Nothing <$ messageWarning "x.grp.relay.cap: only owner should receive relay capabilities" - XGrpMemNew memInfo msgScope -> fmap ctx <$> xGrpMemNew gInfo' m'' memInfo msgScope msg brokerTs + XGrpMemNew memInfo msgScope -> fmap ctx <$> xGrpMemNew (GIK gInfo' gks) m'' memInfo msgScope msg brokerTs XGrpMemIntro memInfo memRestrictions_ -> Nothing <$ xGrpMemIntro gInfo' m'' memInfo memRestrictions_ XGrpMemInv memId introInv -> Nothing <$ xGrpMemInv gInfo' m'' memId introInv XGrpMemFwd memInfo introInv -> Nothing <$ xGrpMemFwd gInfo' m'' memInfo introInv - XGrpMemRole memId memRole memberKey rosterVer -> fmap ctx <$> xGrpMemRole gInfo' Nothing m'' memId memRole memberKey rosterVer msg brokerTs + XGrpMemRole memId memRole memberKey rosterVer -> fmap ctx <$> xGrpMemRole (GIK gInfo' gks) Nothing m'' memId memRole memberKey rosterVer msg brokerTs XGrpMemRestrict memId memRestrictions -> fmap ctx <$> xGrpMemRestrict gInfo' m'' memId memRestrictions msg brokerTs XGrpMemCon memId -> Nothing <$ xGrpMemCon gInfo' m'' memId XGrpMemDel memId withMessages rosterVer -> case encoding @e of - SJson -> fmap ctx <$> xGrpMemDel gInfo' Nothing m'' memId withMessages rosterVer verifiedMsg msg brokerTs False + SJson -> fmap ctx <$> xGrpMemDel (GIK gInfo' gks) Nothing m'' memId withMessages rosterVer verifiedMsg msg brokerTs False SBinary -> pure Nothing - XGrpLeave -> fmap ctx <$> xGrpLeave gInfo' m'' msg brokerTs + XGrpLeave -> fmap ctx <$> xGrpLeave (GIK gInfo' gks) m'' msg brokerTs XGrpDel -> Just (DeliveryTaskContext (DJSGroup {jobSpec = DJRelayRemoved}) False) <$ xGrpDel gInfo' m'' msg brokerTs - XGrpInfo p' -> fmap ctx <$> xGrpInfo gInfo' m'' p' msg brokerTs + XGrpInfo p' -> fmap ctx <$> xGrpInfo (GIK gInfo' gks) m'' p' msg brokerTs XGrpPrefs ps' -> fmap ctx <$> xGrpPrefs gInfo' m'' ps' msg XGrpRoster gr -> fmap ctx <$> xGrpRoster gInfo' m'' m'' gr verifiedMsg sharedMsgId_ brokerTs - XGrpRosterAck ackVer ackErr -> Nothing <$ xGrpRosterAck gInfo' m'' ackVer ackErr - XGrpRosterRequest reqVer -> Nothing <$ xGrpRosterRequest gInfo' m'' reqVer + XGrpRosterAck ackVer ackErr -> Nothing <$ xGrpRosterAck (GIK gInfo' gks) m'' ackVer ackErr + XGrpRosterRequest reqVer -> Nothing <$ xGrpRosterRequest (GIK gInfo' gks) m'' reqVer -- TODO [knocking] why don't we forward these messages? XGrpDirectInv connReq mContent_ msgScope -> memberCanSend (Just m'') msgScope $ Nothing <$ xGrpDirectInv gInfo' m'' conn' connReq mContent_ msg brokerTs - XGrpMsgForward fwd msg' -> Nothing <$ xGrpMsgForward gInfo' Nothing m'' fwd (ParsedMsg Nothing Nothing msg') brokerTs + XGrpMsgForward fwd msg' -> Nothing <$ xGrpMsgForward (GIK gInfo' gks) Nothing m'' fwd (ParsedMsg Nothing Nothing msg') brokerTs XInfoProbe probe -> Nothing <$ xInfoProbe (COMGroupMember m'') probe XInfoProbeCheck probeHash -> Nothing <$ xInfoProbeCheck (COMGroupMember m'') probeHash XInfoProbeOk probe -> Nothing <$ xInfoProbeOk (COMGroupMember m'') probe - BFileChunk sharedMsgId chunk -> Nothing <$ bFileChunkGroup gInfo' m'' sharedMsgId chunk msgMeta + BFileChunk sharedMsgId chunk -> Nothing <$ bFileChunkGroup (GIK gInfo' gks) m'' sharedMsgId chunk msgMeta _ -> Nothing <$ messageError ("unsupported message: " <> tshow event) forM deliveryTaskContext_ $ \taskContext -> do let contentChanged :: CM () @@ -1169,7 +1175,7 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe mapM_ toView fileEvent_ unless (null acis) $ toView $ CEvtChatItemsStatusesUpdated user acis when continued $ do - when (isUserGrpFwdRelay gInfo) $ serveRoster user gInfo m -- roster ahead of the resumed backlog + when (isUserGrpFwdRelay gInfo) $ serveRoster user g m -- roster ahead of the resumed backlog sendPendingGroupMessages user gInfo m conn SWITCH qd phase cStats -> do toView $ CEvtGroupMemberSwitch user gInfo m (SwitchProgress qd phase cStats) @@ -1240,7 +1246,7 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe withStore' $ \db -> updateConnLinkData db user conn cReq cReqHash groupLinkId chatV pqSup let incognitoProfile = fromLocalProfile <$> incognitoMembershipProfile gInfo profileToSend <- presentUserBadge user incognitoProfile $ userProfileInGroup user gInfo incognitoProfile - dm <- encodeXMemberConnInfo gInfo relayMemberId profileToSend + dm <- encodeXMemberConnInfo g relayMemberId profileToSend subMode <- chatReadVar subscriptionMode (cmdId, connId') <- prepareAgentJoin user (Just conn) True cReq joinAgentConnectionAsync cmdId True connId' True cReq dm subMode @@ -1256,7 +1262,7 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe liftIO $ updateGroupMemberStatus db userId m GSMemAccepted (m', relay) <- setRelayLinkAccepted db cxt user m (MemberKey relayKey) relayProfile pure (confId, m', relay) - allowAgentConnectionAsync user conn confId (Just gInfo) XOk + allowAgentConnectionAsync user conn confId (Just g) XOk toView $ CEvtGroupRelayUpdated user gInfo m' relay else -- TODO [relays] owner: TBC failed RelayStatus? @@ -1265,7 +1271,7 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe QCONT -> do continued <- continueSending connEntity conn when continued $ do - when (isUserGrpFwdRelay gInfo) $ serveRoster user gInfo m -- roster ahead of the resumed backlog + when (isUserGrpFwdRelay gInfo) $ serveRoster user g m -- roster ahead of the resumed backlog sendPendingGroupMessages user gInfo m conn MWARN msgId err -> do withStore' $ \db -> updateGroupItemsErrorStatus db msgId (groupMemberId' m) (GSSWarning $ agentSndError err) @@ -1310,9 +1316,9 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe _ -> pure Nothing sendGroupAutoReply mc = \case Just UserContactRequest {welcomeSharedMsgId = Just smId} -> - void $ sendGroupMessage' user gInfo [m] $ XMsgUpdate smId mc M.empty Nothing Nothing Nothing Nothing + void $ sendGroupMessage' user g [m] $ XMsgUpdate smId mc M.empty Nothing Nothing Nothing Nothing _ -> do - msg <- sendGroupMessage' user gInfo [m] $ XMsgNew $ mcSimple mc + msg <- sendGroupMessage' user g [m] $ XMsgNew $ mcSimple mc ci <- saveSndChatItem user (CDGroupSnd gInfo Nothing) msg (CISndMsgContent mc) withStore' $ \db -> createGroupSndStatus db (chatItemId' ci) (groupMemberId' m) GSSNew toView $ CEvtNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat gInfo Nothing) ci] @@ -1339,7 +1345,7 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe r n'' = Just (ci, CIRcvDecryptionError mde n'') mdeUpdatedCI _ _ = Nothing - receiveFileChunk :: Maybe GroupInfo -> RcvFileTransfer -> Maybe Connection -> MsgMeta -> FileChunk -> CM () + receiveFileChunk :: Maybe GroupInfoKeys -> RcvFileTransfer -> Maybe Connection -> MsgMeta -> FileChunk -> CM () receiveFileChunk gInfo_ ft@RcvFileTransfer {fileId, fileType, chunkSize} conn_ MsgMeta {recipient = (msgId, _), integrity} = \case FileChunkCancel -> case fileType of -- cancel only this source's transfer; other relays' in-flight transfers are independent @@ -1409,13 +1415,13 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe CFSetShortLink -> case (ucGroupId_, auData) of (Just groupId, UserContactLinkData UserContactData {relays = relayLinks}) -> do - (gInfo, gLink, relays, relaysChanged, newlyActiveLinks) <- withStore $ \db -> do - gInfo <- getGroupInfo db cxt user groupId + (g@(GIK gInfo _), gLink, relays, relaysChanged, newlyActiveLinks) <- withStore $ \db -> do + g@(GIK gInfo _) <- getGroupInfoKeys db cxt user groupId gLink <- getGroupLink db user gInfo relays <- liftIO $ getGroupRelays db gInfo (relays', changed, newlyActiveLinks) <- liftIO $ foldrM (updateRelay db) ([], False, []) relays liftIO $ setGroupInProgressDone db gInfo - pure (gInfo, gLink, relays', changed, newlyActiveLinks) + pure (g, gLink, relays', changed, newlyActiveLinks) toView $ CEvtGroupLinkDataUpdated user gInfo gLink relays relaysChanged let GroupSummary {publicMemberCount} = groupSummary gInfo -- Owner is counted in publicMemberCount; > 1 means at least one subscriber. @@ -1431,7 +1437,7 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe allRelayMembers events = XGrpRelayNew <$> newlyActive unless (null recipients) $ - void $ sendGroupMessages user gInfo Nothing False recipients False events + void $ sendGroupMessages user g Nothing False recipients False events where updateRelay :: DB.Connection -> GroupRelay -> ([GroupRelay], Bool, [ShortLinkContact]) -> IO ([GroupRelay], Bool, [ShortLinkContact]) updateRelay db relay@GroupRelay {relayLink, relayStatus} (acc, changed, newlyActiveLinks) = @@ -1480,7 +1486,7 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe REContact ct -> -- TODO [short links] update request msg toView $ CEvtContactRequestAlreadyAccepted user ct - REBusinessChat gInfo _clientMember -> + REBusinessChat (GIK gInfo _) _clientMember -> -- TODO [short links] update request msg toView $ CEvtBusinessRequestAlreadyAccepted user gInfo RSCurrentRequest prevUcr_ ucr@UserContactRequest {welcomeSharedMsgId} re_ -> case re_ of @@ -1514,8 +1520,8 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe else pure Nothing ct' <- acceptContactRequestAsync user uclId ct ucr incognitoProfile toView $ CEvtAcceptingContactRequest user ct' - Just (REBusinessChat gInfo clientMember) -> do - (_gInfo', _clientMember') <- acceptBusinessJoinRequestAsync user uclId gInfo clientMember ucr + Just (REBusinessChat g@(GIK gInfo _) clientMember) -> do + (_gInfo', _clientMember') <- acceptBusinessJoinRequestAsync user uclId g clientMember ucr let cd = CDGroupRcv gInfo Nothing clientMember void $ case prevUcr_ of Just UserContactRequest {requestSharedMsgId = prevSharedMsgId_} -> @@ -1607,7 +1613,7 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe -- ##### Group link join requests (don't create contact requests) ##### Just gli@GroupLinkInfo {groupId, memberRole = gLinkMemRole} -> do -- TODO [short links] deduplicate request by xContactId? - gInfo <- withStore $ \db -> getGroupInfo db cxt user groupId + g@(GIK gInfo _) <- withStore $ \db -> getGroupInfoKeys db cxt user groupId if | useRelays' gInfo -> messageWarning $ "processContactConnMessage (group " <> groupName' gInfo <> "): ignored direct join request from " <> displayName <> " (group uses relays)" @@ -1618,7 +1624,7 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe maybe (pure $ Right (GAAccepted, gLinkMemRole)) (\am -> liftIO $ am gInfo gli p) acceptMember_ >>= \case Right (acceptance, useRole) -> do let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo - mem <- acceptGroupJoinRequestAsync user uclId gInfo invId chatVRange p xContactId_ Nothing welcomeMsgId_ acceptance useRole profileMode memberKey_ Nothing + mem <- acceptGroupJoinRequestAsync user uclId g invId chatVRange p xContactId_ Nothing welcomeMsgId_ acceptance useRole profileMode memberKey_ Nothing (gInfo', mem', scopeInfo) <- mkGroupChatScope gInfo mem createInternalChatItem user (CDGroupRcv gInfo' scopeInfo mem') (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing toView $ CEvtAcceptingGroupJoinRequestMember user gInfo' mem' @@ -1662,7 +1668,7 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe (_ucl, gLinkInfo_) <- withStore $ \db -> getUserContactLinkById db userId uclId case gLinkInfo_ of Just GroupLinkInfo {groupId, memberRole = gLinkMemRole} -> do - gInfo <- withStore $ \db -> getGroupInfo db cxt user groupId + g@(GIK gInfo _) <- withStore $ \db -> getGroupInfoKeys db cxt user groupId existing_ <- withStore' $ \db -> eitherToMaybe <$> runExceptT (getGroupMemberByMemberId db cxt user gInfo joiningMemberId) case existing_ of Just rosterMem @@ -1670,21 +1676,21 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe -- possession of that exact key, otherwise this is an attempt to impersonate it | isRosterRole (memberRole' rosterMem) -> if verifyKey gInfo rosterMem - then acceptJoin gInfo (Just rosterMem) (memberRole' rosterMem) + then acceptJoin g (Just rosterMem) (memberRole' rosterMem) else messageError "memberJoinRequestViaRelay: rejected join claiming privileged memberId (key mismatch or invalid signature)" - _ -> acceptJoin gInfo Nothing gLinkMemRole + _ -> acceptJoin g Nothing gLinkMemRole Nothing -> messageError "memberJoinRequestViaRelay: no group link info for relay link" where -- replay defense: the viaRelay == own memberId check (viaRelay is in the signed body); without it a sibling relay could replay a privileged member's signed join - verifyKey gInfo rosterMem = case (signedMsg_, groupKeys gInfo) of - (Just SignedMsg {chatBinding = CBGroup, signatures, signedBody}, Just gks) -> + verifyKey gInfo rosterMem = case signedMsg_ of + Just SignedMsg {chatBinding = CBGroup, signatures, signedBody} -> memberPubKey rosterMem == Just joiningKey - && verifyGroupSig joiningKey (Just gks) joiningMemberId signatures signedBody + && verifyGroupSig joiningKey gInfo joiningMemberId signatures signedBody && viaRelay == Just (memberId' (membership gInfo)) _ -> False - acceptJoin gInfo existingMem_ acceptRole = do - mem <- acceptGroupJoinRequestAsync user uclId gInfo invId chatVRange p Nothing (Just joiningMemberId) Nothing GAAccepted acceptRole Nothing (Just joiningMemberKey) existingMem_ + acceptJoin g@(GIK gInfo _) existingMem_ acceptRole = do + mem <- acceptGroupJoinRequestAsync user uclId g invId chatVRange p Nothing (Just joiningMemberId) Nothing GAAccepted acceptRole Nothing (Just joiningMemberKey) existingMem_ (gInfo', mem', scopeInfo) <- mkGroupChatScope gInfo mem createInternalChatItem user (CDGroupRcv gInfo' scopeInfo mem') (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing toView $ CEvtAcceptingGroupJoinRequestMember user gInfo' mem' @@ -2593,8 +2599,8 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe -- A group BFileChunk is a normal inline file chunk or a roster blob chunk, both located by -- (group_id, shared_msg_id). A chunk matching no in-flight transfer (an orphaned re-served roster -- chunk, or a missing normal file) is ignored; the outer withAckMessage acks it. - bFileChunkGroup :: GroupInfo -> GroupMember -> SharedMsgId -> FileChunk -> MsgMeta -> CM () - bFileChunkGroup gInfo@GroupInfo {groupId} fromMember sharedMsgId chunk meta = do + bFileChunkGroup :: GroupInfoKeys -> GroupMember -> SharedMsgId -> FileChunk -> MsgMeta -> CM () + bFileChunkGroup gInfo@(GIK GroupInfo {groupId} _) fromMember sharedMsgId chunk meta = do fileId_ <- withStore' $ \db -> getGroupRcvFileId db userId groupId (groupMemberId' fromMember) sharedMsgId forM_ fileId_ $ \fileId -> do ft <- withStore $ \db -> getRcvFileTransfer db user fileId @@ -2614,7 +2620,7 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe -- A roster re-serve re-sends the blob from chunk 1; discard any partial first, else chunk 1 over a -- partial is out-of-order (RcvChunkError) and appending after the stale prefix corrupts the blob. - receiveRosterChunk :: GroupInfo -> RcvFileTransfer -> MsgMeta -> FileChunk -> CM () + receiveRosterChunk :: GroupInfoKeys -> RcvFileTransfer -> MsgMeta -> FileChunk -> CM () receiveRosterChunk gInfo ft meta chunk = do case chunk of FileChunk {chunkNo} | chunkNo == 1 -> do @@ -2679,14 +2685,14 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe when (fromMemId == memId) $ throwChatError CEGroupDuplicateMemberId -- [incognito] if direct connection with host is incognito, create membership using the same incognito profile memberKeys <- atomically . C.generateKeyPair =<< asks random - (gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership}, hostId) <- withStore $ \db -> createGroupInvitation db cxt user ct inv customUserProfileId memberKeys + (GIK gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership} gks, hostId) <- withStore $ \db -> createGroupInvitation db cxt user ct inv customUserProfileId memberKeys void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing Nothing (Just epochStart) let GroupMember {groupMemberId, memberId = membershipMemId} = membership -- hostContact is only reported for group links, where the client replaces -- the transient host connection view with the group and removes its chat joinGroupAsync hostContact_ sameLink = do subMode <- chatReadVar subscriptionMode - dm <- encodeConnInfo $ XGrpAcpt membershipMemId (groupMemberKey gInfo) + dm <- encodeConnInfo $ XGrpAcpt membershipMemId (Just $ groupMemberKey gks) connIds@(cmdId, acId) <- prepareAgentJoin user Nothing True connRequest withStore' $ \db -> do when sameLink $ setViaGroupLinkUri db groupId connId @@ -2816,11 +2822,11 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe | otherwise -> messageError "member key not signed by that key, ignored" where signed = case signedMsg_ of - Just SignedMsg {chatBinding = CBGroup, signatures, signedBody} -> verifyGroupSig k (groupKeys gInfo) memberId signatures signedBody + Just SignedMsg {chatBinding = CBGroup, signatures, signedBody} -> verifyGroupSig k gInfo memberId signatures signedBody _ -> False - xGrpLinkAcpt :: GroupInfo -> GroupMember -> GroupAcceptance -> GroupMemberRole -> MemberId -> RcvMessage -> UTCTime -> CM () - xGrpLinkAcpt gInfo@GroupInfo {membership} m acceptance role memberId msg brokerTs + xGrpLinkAcpt :: GroupInfoKeys -> GroupMember -> GroupAcceptance -> GroupMemberRole -> MemberId -> RcvMessage -> UTCTime -> CM () + xGrpLinkAcpt g@(GIK gInfo@GroupInfo {membership} _) m acceptance role memberId msg brokerTs | memberRole' m < GRModerator || memberRole' m < role = messageError "x.grp.link.acpt with insufficient member permissions" | sameMemberId memberId membership = processUserAccepted @@ -2869,7 +2875,7 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe GAPendingApproval -> messageWarning "x.grp.link.acpt: unexpected group acceptance - pending approval" introduceToRemainingMembers acceptedMember = do - introduceToRemaining cxt user gInfo acceptedMember + introduceToRemaining cxt user g acceptedMember when (groupFeatureAllowed SGFHistory gInfo) $ sendHistory user gInfo acceptedMember maybeCreateGroupDescrLocal :: GroupInfo -> GroupMember -> CM () @@ -3145,7 +3151,7 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe toView $ CEvtContactAndMemberAssociated user c2 g m1 c2' pure c2' - saveConnInfo :: Connection -> ConnInfo -> CM (Connection, Maybe GroupInfo) + saveConnInfo :: Connection -> ConnInfo -> CM (Connection, Maybe GroupInfoKeys) saveConnInfo activeConn connInfo = do ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage activeConn connInfo conn' <- updatePeerChatVRange activeConn chatVRange @@ -3155,21 +3161,25 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe toView $ CEvtContactConnecting user ct pure (conn', Nothing) XGrpLinkInv glInv -> do + when (isPublicGroupInv glInv) $ throwChatError $ CEInvalidChatMessage conn' Nothing (safeDecodeUtf8 connInfo) "x.grp.link.inv: publicGroup not allowed in p2p groups" memberKeys <- atomically . C.generateKeyPair =<< asks random (gInfo, host) <- withStore $ \db -> createGroupInvitedViaLink db cxt user conn' memberKeys glInv toView $ CEvtGroupLinkConnecting user gInfo host - pure (conn', Just gInfo) + pure (conn', Just $ GIK gInfo GKGroup {memberPrivKey = snd memberKeys}) XGrpLinkReject glRjct@GroupLinkRejection {rejectionReason} -> do memberKeys <- atomically . C.generateKeyPair =<< asks random (gInfo, host) <- withStore $ \db -> createGroupRejectedViaLink db cxt user conn' memberKeys glRjct toView $ CEvtGroupLinkConnecting user gInfo host toViewTE $ TEGroupLinkRejected user gInfo rejectionReason - pure (conn', Just gInfo) + pure (conn', Just $ GIK gInfo GKGroup {memberPrivKey = snd memberKeys}) -- TODO show/log error, other events in SMP confirmation _ -> pure (conn', Nothing) - xGrpMemNew :: GroupInfo -> GroupMember -> MemberInfo -> Maybe MsgScope -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) - xGrpMemNew gInfo m memInfo@(MemberInfo memId memRole _ _ assertedKey_) msgScope_ msg brokerTs = do + isPublicGroupInv :: GroupLinkInvitation -> Bool + isPublicGroupInv GroupLinkInvitation {groupProfile = GroupProfile {publicGroup}} = isJust publicGroup + + xGrpMemNew :: GroupInfoKeys -> GroupMember -> MemberInfo -> Maybe MsgScope -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) + xGrpMemNew (GIK gInfo gks) m memInfo@(MemberInfo memId memRole _ _ assertedKey_) msgScope_ msg brokerTs = do unless (useRelays' gInfo) $ checkHostRole m memRole if sameMemberId memId (membership gInfo) then pure Nothing @@ -3189,7 +3199,7 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe messageWarning $ "x.grp.mem.new: relay asserted key differs from roster-established key, keeping roster key, memberId=" <> safeDecodeUtf8 (strEncode memId) updatedMember <- withStore $ \db -> updateRosterMemberAnnounced db cxt user m unknownMember memInfo initialStatus -- roster members can't be pending, so no members-require-attention update - gInfo' <- updatePublicGroupData user gInfo + gInfo' <- updatePublicGroupData user gInfo gks toView $ CEvtUnknownMemberAnnounced user gInfo' m unknownMember updatedMember memberAnnouncedToView updatedMember gInfo' pure $ deliveryJobScope updatedMember @@ -3204,7 +3214,7 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe then liftIO $ increaseGroupMembersRequireAttention db user gInfo else pure gInfo pure (updatedMember, gInfo') - gInfo'' <- updatePublicGroupData user gInfo' + gInfo'' <- updatePublicGroupData user gInfo' gks toView $ CEvtUnknownMemberAnnounced user gInfo'' m unknownMember updatedMember memberAnnouncedToView updatedMember gInfo'' pure $ deliveryJobScope updatedMember @@ -3222,7 +3232,7 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe then liftIO $ increaseGroupMembersRequireAttention db user gInfo else pure gInfo pure (newMember, gInfo') - gInfo'' <- updatePublicGroupData user gInfo' + gInfo'' <- updatePublicGroupData user gInfo' gks memberAnnouncedToView newMember gInfo'' pure $ deliveryJobScope newMember where @@ -3342,8 +3352,8 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe -- batch), then advance it in the same transaction; a strictly lower version is a replay and is ignored. -- Only an owner sender may advance it: a non-owner signed event is rejected by the action that follows, -- but must not bump roster_version first, or every later owner roster at a lower version is dropped. - applyAtRosterVersion :: GroupInfo -> Maybe GroupMember -> GroupMember -> Maybe VersionRoster -> CM (Maybe DeliveryJobScope) -> CM (Maybe DeliveryJobScope) - applyAtRosterVersion gInfo fwdRelay_ sender rosterVer_ action + applyAtRosterVersion :: GroupInfoKeys -> Maybe GroupMember -> GroupMember -> Maybe VersionRoster -> CM (Maybe DeliveryJobScope) -> CM (Maybe DeliveryJobScope) + applyAtRosterVersion g@(GIK gInfo _) fwdRelay_ sender rosterVer_ action | not (useRelays' gInfo) = action | otherwise = case rosterVer_ of Nothing -> action @@ -3381,19 +3391,19 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe | otherwise = case fwdRelay_ of Just relay | gap, relay `supportsVersion` groupRosterVersion -> - void $ sendGroupMessage' user gInfo [relay] (XGrpRosterRequest prevComplete) + void $ sendGroupMessage' user g [relay] (XGrpRosterRequest prevComplete) _ -> pure () where gap = v > nextCompleteVersion prevComplete - xGrpMemRole :: GroupInfo -> Maybe GroupMember -> GroupMember -> MemberId -> GroupMemberRole -> Maybe MemberKey -> Maybe VersionRoster -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) - xGrpMemRole gInfo@GroupInfo {membership} fwdRelay_ m@GroupMember {memberRole = senderRole} memId memRole memberKey_ rosterVer_ msg@RcvMessage {msgSigned} brokerTs + xGrpMemRole :: GroupInfoKeys -> Maybe GroupMember -> GroupMember -> MemberId -> GroupMemberRole -> Maybe MemberKey -> Maybe VersionRoster -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) + xGrpMemRole g@(GIK gInfo@GroupInfo {membership} _) fwdRelay_ m@GroupMember {memberRole = senderRole} memId memRole memberKey_ rosterVer_ msg@RcvMessage {msgSigned} brokerTs | memRole == GRRelay = messageError "x.grp.mem.role: relay role can't be assigned" $> Nothing | membershipMemId == memId = - applyAtRosterVersion gInfo fwdRelay_ m rosterVer_ $ + applyAtRosterVersion g fwdRelay_ m rosterVer_ $ let gInfo' = gInfo {membership = membership {memberRole = memRole}} in changeMemberRole gInfo' membership False (\db -> updateGroupMemberRole db user membership memRole) (RGEUserRole memRole) True - | otherwise = applyAtRosterVersion gInfo fwdRelay_ m rosterVer_ $ do + | otherwise = applyAtRosterVersion g fwdRelay_ m rosterVer_ $ do defaultRole <- unknownMemberRole gInfo -- an owner-signed event with a key TOFU-creates an unknown member only for a roster role; else a plain lookup let allowCreate = useRelays' gInfo && senderRole == GROwner && isRosterRole memRole && isJust memberKey_ @@ -3487,8 +3497,8 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe -- Blob arrived: verify the owner-attested digest over the plaintext and guard against -- downgrade before applying; on a relay, ack the owner and re-serve to members. - rosterCompletion :: GroupInfo -> RcvFileTransfer -> CM () - rosterCompletion gInfo RcvFileTransfer {fileId, fileStatus} = + rosterCompletion :: GroupInfoKeys -> RcvFileTransfer -> CM () + rosterCompletion g@(GIK gInfo _) RcvFileTransfer {fileId, fileStatus} = withStore' (\db -> getRosterTransfer db fileId) >>= \case -- defensive: the file always has its transfer (created together, deleted together) Nothing -> lift (closeFileHandle fileId rcvFiles) >> forM_ (rosterFilePath fileStatus) removeFsFile @@ -3498,7 +3508,7 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe let isRelay' = isUserGrpFwdRelay gInfo ackErr err = do cleanupRosterTransferById transferId - when isRelay' $ forM_ owner_ $ \owner -> sendRosterAck gInfo owner pendingVer (Just err) + when isRelay' $ forM_ owner_ $ \owner -> sendRosterAck g owner pendingVer (Just err) if FD.FileDigest (LC.sha512Hash (LB.fromStrict blob)) /= pendingDigest then ackErr "relay could not verify the roster blob" else case parseAll rosterBlobP blob of @@ -3523,7 +3533,7 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe emitRosterResults gInfo author rosterBrokerTs results -- ack while setting up (own status accepted/acknowledged); a serving (active) relay must not ack broadcasts. when (isRelay' && (relayOwnStatus gInfo == Just RSAccepted || relayOwnStatus gInfo == Just RSAcknowledgedRoster)) $ do - sendRosterAck gInfo author pendingVer Nothing + sendRosterAck g author pendingVer Nothing withStore' $ \db -> void $ updateRelayOwnStatusFromTo db gInfo RSAccepted RSAcknowledgedRoster where rosterFilePath = \case @@ -3589,11 +3599,11 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe else pure (gInfo, author) toView CEvtMemberRole {user, groupInfo = gInfo', byMember = author', member, fromRole, toRole, msgSigned = Just MSSVerified} - sendRosterAck :: GroupInfo -> GroupMember -> VersionRoster -> Maybe Text -> CM () + sendRosterAck :: GroupInfoKeys -> GroupMember -> VersionRoster -> Maybe Text -> CM () sendRosterAck gInfo owner ackVer err = void $ sendGroupMessage' user gInfo [owner] (XGrpRosterAck ackVer err) - xGrpRosterAck :: GroupInfo -> GroupMember -> VersionRoster -> Maybe Text -> CM () - xGrpRosterAck gInfo m ackVer err = do + xGrpRosterAck :: GroupInfoKeys -> GroupMember -> VersionRoster -> Maybe Text -> CM () + xGrpRosterAck g@(GIK gInfo _) m ackVer err = do relay_ <- withStore' $ \db -> eitherToMaybe <$> runExceptT (getGroupRelayByGMId db (groupMemberId' m)) case relay_ of Just relay@GroupRelay {relayStatus = RSAccepted} -> case err of @@ -3603,7 +3613,7 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe relay' <- liftIO $ updateRelayStatus db relay RSAcknowledgedRoster gLink <- getGroupLink db user gInfo pure (relay', gLink) - setGroupLinkDataAsync user gInfo gLink + setGroupLinkDataAsync user g gLink toView $ CEvtGroupRelayUpdated user gInfo m relay' | otherwise -> messageWarning "x.grp.roster.ack: stale version, awaiting ack for the current roster" Just e -> do @@ -3617,13 +3627,13 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe -- - the latter bounds reflected amplification (a member can't re-trigger a full serve). Gating on the stored -- blob (not roster_version, the gate) means the relay serves only a blob the requester will accept. -- serveRoster records the served version (on all serve paths) and is a no-op without a roster. - xGrpRosterRequest :: GroupInfo -> GroupMember -> Maybe VersionRoster -> CM () - xGrpRosterRequest gInfo m reqVer_ = + xGrpRosterRequest :: GroupInfoKeys -> GroupMember -> Maybe VersionRoster -> CM () + xGrpRosterRequest g@(GIK gInfo _) m reqVer_ = when (isUserGrpFwdRelay gInfo) $ do (stored_, served_) <- withStore' $ \db -> (,) <$> getStoredRosterVersion db gInfo <*> getMemberRosterServedVersion db m forM_ stored_ $ \stored -> - when (maybe True (stored >) reqVer_ && maybe True (stored >) served_) $ serveRoster user gInfo m + when (maybe True (stored >) reqVer_ && maybe True (stored >) served_) $ serveRoster user g m checkHostRole :: GroupMember -> GroupMemberRole -> CM () checkHostRole GroupMember {memberRole, localDisplayName} memRole = @@ -3669,11 +3679,11 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe withStore $ \db -> setMemberVectorRelationConnected db sendingMem refMem MRSubjectConnected withStore $ \db -> setMemberVectorRelationConnected db refMem sendingMem MRReferencedConnected - xGrpMemDel :: GroupInfo -> Maybe GroupMember -> GroupMember -> MemberId -> Bool -> Maybe VersionRoster -> VerifiedMsg 'Json -> RcvMessage -> UTCTime -> Bool -> CM (Maybe DeliveryJobScope) - xGrpMemDel gInfo@GroupInfo {membership} fwdRelay_ m@GroupMember {memberRole = senderRole} memId withMessages rosterVer_ verifiedMsg msg@RcvMessage {msgSigned} brokerTs forwarded = do + xGrpMemDel :: GroupInfoKeys -> Maybe GroupMember -> GroupMember -> MemberId -> Bool -> Maybe VersionRoster -> VerifiedMsg 'Json -> RcvMessage -> UTCTime -> Bool -> CM (Maybe DeliveryJobScope) + xGrpMemDel g@(GIK gInfo@GroupInfo {membership} gks) fwdRelay_ m@GroupMember {memberRole = senderRole} memId withMessages rosterVer_ verifiedMsg msg@RcvMessage {msgSigned} brokerTs forwarded = do let GroupMember {memberId = membershipMemId} = membership if membershipMemId == memId - then applyAtRosterVersion gInfo fwdRelay_ m rosterVer_ $ checkRole membership $ do + then applyAtRosterVersion g fwdRelay_ m rosterVer_ $ checkRole membership $ do deleteGroupLinkIfExists user gInfo -- TODO [relays] possible improvement is to immediately delete rcv queues if isUserGrpFwdRelay unless (isUserGrpFwdRelay gInfo) $ deleteGroupConnections user gInfo False @@ -3685,7 +3695,7 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe deleteMemberItem msg gInfo RGEUserDeleted toView $ CEvtDeletedMemberUser user gInfo {membership = membership'} m withMessages msgSigned pure $ Just DJSGroup {jobSpec = DJRelayRemoved} - else applyAtRosterVersion gInfo fwdRelay_ m rosterVer_ $ + else applyAtRosterVersion g fwdRelay_ m rosterVer_ $ withStore' (\db -> runExceptT $ getGroupMemberByMemberId db cxt user gInfo memId) >>= \case Left _ -> do messageError "x.grp.mem.del with unknown member ID" @@ -3711,7 +3721,7 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe fullyDeleteMemberRecord user gInfo deletedMember -- Undeleted "member connected" chat item will prevent deletion of member record. | otherwise -> deleteOrUpdateMemberRecord user gInfo deletedMember - gInfo'' <- updatePublicGroupData user gInfo' + gInfo'' <- updatePublicGroupData user gInfo' gks let wasDeleted = memberStatus == GSMemRemoved || memberStatus == GSMemLeft -- Clear forwardedByMember if it references the deleted member, -- as the member record was already deleted above. @@ -3753,12 +3763,12 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe | useRelays' gInfo = asks $ channelSubscriberRole . config | otherwise = pure GRAuthor - xGrpLeave :: GroupInfo -> GroupMember -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) - xGrpLeave gInfo m msg@RcvMessage {msgSigned} brokerTs = do + xGrpLeave :: GroupInfoKeys -> GroupMember -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) + xGrpLeave (GIK gInfo gks) m msg@RcvMessage {msgSigned} brokerTs = do deleteMemberConnection m -- member record is not deleted to allow creation of "member left" chat item gInfo' <- updateMemberRecordDeleted user gInfo m GSMemLeft - gInfo'' <- updatePublicGroupData user gInfo' + gInfo'' <- updatePublicGroupData user gInfo' gks unless (muteEventInChannel gInfo'' m) $ do (gInfo''', m', scopeInfo) <- mkGroupChatScope gInfo'' m (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo''' scopeInfo m') msg brokerTs (CIRcvGroupEvent RGEMemberLeft) @@ -3778,8 +3788,8 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe groupMsgToView cInfo ci toView $ CEvtGroupDeleted user gInfo'' {membership = membership {memberStatus = GSMemGroupDeleted}} m' msgSigned - xGrpInfo :: GroupInfo -> GroupMember -> GroupProfile -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) - xGrpInfo g@GroupInfo {groupProfile = p@GroupProfile {publicGroup = pg}, businessChat} m@GroupMember {memberRole} p'@GroupProfile {publicGroup = pg'} msg@RcvMessage {msgSigned} brokerTs + xGrpInfo :: GroupInfoKeys -> GroupMember -> GroupProfile -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) + xGrpInfo gik@(GIK g@GroupInfo {groupProfile = p@GroupProfile {publicGroup = pg}, businessChat} gks) m@GroupMember {memberRole} p'@GroupProfile {publicGroup = pg'} msg@RcvMessage {msgSigned} brokerTs | memberRole < GROwner = messageError "x.grp.info with insufficient member permissions" $> Nothing | let pgId = fmap (\PublicGroupProfile {publicGroupId} -> publicGroupId), useRelays' g && (isNothing pg' || pgId pg' /= pgId pg) = messageError "x.grp.info: publicGroupId mismatch for channel" $> Nothing @@ -3799,10 +3809,10 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe -- other owners receiving the update do not refresh the same link ChatConfig {updateGroupLinksFromApp} <- asks config unless (useRelays' g'' || updateGroupLinksFromApp) $ - void $ forkIO $ void $ setGroupLinkData' NRMBackground user g'' + void $ forkIO $ void $ setGroupLinkData' NRMBackground user (GIK g'' gks) Just _ -> updateGroupPrefs_ msgSigned g m $ fromMaybe defaultBusinessGroupPrefs $ groupPreferences p' -- relay advertises its web capability now that the owner's version is known (bumped by saveGroupRcvMsg) - when (isRelay (membership g)) $ sendRelayCapIfNeeded user g + when (isRelay (membership g)) $ sendRelayCapIfNeeded user gik pure $ Just DJSGroup {jobSpec = DJDeliveryJob {includePending = True}} xGrpPrefs :: GroupInfo -> GroupMember -> GroupPreferences -> RcvMessage -> CM (Maybe DeliveryJobScope) @@ -3917,8 +3927,8 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe toViewTE $ TEContactVerificationReset user ct createInternalChatItem user (CDDirectRcv ct) (CIRcvConnEvent RCEVerificationCodeReset) Nothing - xGrpMsgForward :: GroupInfo -> Maybe GroupChatScopeInfo -> GroupMember -> GrpMsgForward -> ParsedMsg 'Json -> UTCTime -> CM () - xGrpMsgForward gInfo scopeInfo m@GroupMember {localDisplayName} GrpMsgForward {fwdSender, fwdBrokerTs = msgTs} parsedMsg@(ParsedMsg _ _ chatMsg@ChatMessage {chatMsgEvent}) brokerTs = do + xGrpMsgForward :: GroupInfoKeys -> Maybe GroupChatScopeInfo -> GroupMember -> GrpMsgForward -> ParsedMsg 'Json -> UTCTime -> CM () + xGrpMsgForward g@(GIK gInfo _) scopeInfo m@GroupMember {localDisplayName} GrpMsgForward {fwdSender, fwdBrokerTs = msgTs} parsedMsg@(ParsedMsg _ _ chatMsg@ChatMessage {chatMsgEvent}) brokerTs = do unless (isMemberGrpFwdRelay gInfo m) $ throwChatError (CEGroupContactRole localDisplayName) case fwdSender of FwdMember memberId memberName -> do @@ -3962,13 +3972,13 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe XFileCancel sharedMsgId -> void $ xFileCancelGroup gInfo author_ sharedMsgId XInfo p mKey -> withAuthor XInfo_ $ \author -> void $ xInfoMember gInfo author p mKey rcvMsg msgTs XGrpRelayNew rl -> withAuthor XGrpRelayNew_ $ \author -> void $ xGrpRelayNew gInfo author rl - XGrpMemNew memInfo msgScope -> withAuthor XGrpMemNew_ $ \author -> void $ xGrpMemNew gInfo author memInfo msgScope rcvMsg msgTs - XGrpMemRole memId memRole memberKey rosterVer -> withAuthor XGrpMemRole_ $ \author -> void $ xGrpMemRole gInfo (Just m) author memId memRole memberKey rosterVer rcvMsg msgTs + XGrpMemNew memInfo msgScope -> withAuthor XGrpMemNew_ $ \author -> void $ xGrpMemNew g author memInfo msgScope rcvMsg msgTs + XGrpMemRole memId memRole memberKey rosterVer -> withAuthor XGrpMemRole_ $ \author -> void $ xGrpMemRole g (Just m) author memId memRole memberKey rosterVer rcvMsg msgTs XGrpMemRestrict memId memRestrictions -> withAuthor XGrpMemRestrict_ $ \author -> void $ xGrpMemRestrict gInfo author memId memRestrictions rcvMsg msgTs - XGrpMemDel memId withMessages rosterVer -> withAuthor XGrpMemDel_ $ \author -> void $ xGrpMemDel gInfo (Just m) author memId withMessages rosterVer verifiedMsg rcvMsg msgTs True - XGrpLeave -> withAuthor XGrpLeave_ $ \author -> void $ xGrpLeave gInfo author rcvMsg msgTs + XGrpMemDel memId withMessages rosterVer -> withAuthor XGrpMemDel_ $ \author -> void $ xGrpMemDel g (Just m) author memId withMessages rosterVer verifiedMsg rcvMsg msgTs True + XGrpLeave -> withAuthor XGrpLeave_ $ \author -> void $ xGrpLeave g author rcvMsg msgTs XGrpDel -> withAuthor XGrpDel_ $ \author -> void $ xGrpDel gInfo author rcvMsg msgTs - XGrpInfo p' -> withAuthor XGrpInfo_ $ \author -> void $ xGrpInfo gInfo author p' rcvMsg msgTs + XGrpInfo p' -> withAuthor XGrpInfo_ $ \author -> void $ xGrpInfo g author p' rcvMsg msgTs XGrpPrefs ps' -> withAuthor XGrpPrefs_ $ \author -> void $ xGrpPrefs gInfo author ps' rcvMsg XGrpRoster gr -> withAuthor XGrpRoster_ $ \author -> void $ xGrpRoster gInfo m author gr verifiedMsg sharedMsgId_ msgTs _ -> messageError $ "x.grp.msg.forward: unsupported forwarded event " <> T.pack (show $ toCMEventTag event) @@ -3979,7 +3989,7 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe Nothing -> messageError $ "x.grp.msg.forward: event " <> tshow tag <> " requires author" withVerifiedMsg :: GroupInfo -> Maybe GroupChatScopeInfo -> GroupMember -> ParsedMsg e -> UTCTime -> (VerifiedMsg e -> CM a) -> CM (Maybe a) - withVerifiedMsg gInfo@GroupInfo {membership, groupKeys} scopeInfo member@GroupMember {memberPubKey, memberId} (ParsedMsg _ signedMsg_ chatMsg@ChatMessage {chatMsgEvent}) ts action = + withVerifiedMsg gInfo@GroupInfo {membership} scopeInfo member@GroupMember {memberPubKey, memberId} (ParsedMsg _ signedMsg_ chatMsg@ChatMessage {chatMsgEvent}) ts action = case verified of Just verifiedMsg -> Just <$> action verifiedMsg Nothing -> do @@ -3989,7 +3999,7 @@ processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMe verified = case signedMsg_ of Just sm@SignedMsg {chatBinding, signatures, signedBody} -> case memberPubKey of Just pubKey -> case chatBinding of - CBGroup -> signed MSSVerified <$ guard (verifyGroupSig pubKey groupKeys memberId signatures signedBody) + CBGroup -> signed MSSVerified <$ guard (verifyGroupSig pubKey gInfo memberId signatures signedBody) _ -> signed MSSSignedNoKey <$ guard signatureOptional Nothing -> signed MSSSignedNoKey <$ guard (signatureOptional || unverifiedAllowed membership member tag) where @@ -4477,10 +4487,10 @@ runRelayRequestWorker a Worker {doWork} = do eToView e processRelayRequest :: GroupId -> RelayRequestData -> CM () processRelayRequest groupId rrd = do - (gInfo, groupLink_) <- withStore $ \db -> do - gInfo <- getGroupInfo db cxt user groupId + (g@(GIK gInfo _), groupLink_) <- withStore $ \db -> do + g@(GIK gInfo _) <- getGroupInfoKeys db cxt user groupId groupLink_ <- liftIO $ runExceptT $ getGroupLink db user gInfo - pure (gInfo, groupLink_) + pure (g, groupLink_) -- Check if relay link already exists (recovery case) case groupLink_ of Right GroupLink {connLinkContact = CCLink _ sLnk_} -> @@ -4488,11 +4498,14 @@ runRelayRequestWorker a Worker {doWork} = do Just sLnk -> acceptOwnerConnection rrd gInfo sLnk Nothing -> throwChatError $ CEException "processRelayRequest: relay link doesn't have short link" Left _ -> do - (gInfo', sLnk) <- getLinkDataCreateRelayLink rrd gInfo + (gInfo', sLnk) <- getLinkDataCreateRelayLink rrd g acceptOwnerConnection rrd gInfo' sLnk where - getLinkDataCreateRelayLink :: RelayRequestData -> GroupInfo -> CM (GroupInfo, ShortLinkContact) - getLinkDataCreateRelayLink RelayRequestData {reqGroupLink} gInfo = do + getLinkDataCreateRelayLink :: RelayRequestData -> GroupInfoKeys -> CM (GroupInfo, ShortLinkContact) + getLinkDataCreateRelayLink RelayRequestData {reqGroupLink} (GIK gInfo gks) = do + memberPrivKey' <- case gks of + GKRelayRequest {memberPrivKey} -> pure memberPrivKey + _ -> throwChatError $ CEException "getLinkDataCreateRelayLink: group is not a relay request" (FixedLinkData {linkEntityId, rootKey}, cData@(ContactLinkData _ UserContactData {owners}), _) <- getShortLinkConnReq' NRMBackground user reqGroupLink liftIO (decodeLinkUserData cData) >>= \case Nothing -> throwChatError $ CEException "getLinkDataCreateRelayLink: no group link data" @@ -4502,10 +4515,10 @@ runRelayRequestWorker a Worker {doWork} = do | B64UrlByteString entityId == publicGroupId -> pure pg _ -> throwChatError $ CEException "getLinkDataCreateRelayLink: linkEntityId does not match profile publicGroupId" validateGroupProfile gp - ((_, memberPrivKey), sLnk) <- createRelayLink gInfo + sLnk <- createRelayLink gInfo (C.publicKey memberPrivKey', memberPrivKey') gInfo' <- withStore $ \db -> do void $ updateGroupProfile db user gInfo gp - updateRelayGroupKeys db user gInfo pg rootKey memberPrivKey owners + updateRelayGroupKeys db user gInfo pg rootKey owners getGroupInfo db cxt user groupId pure (gInfo', sLnk) where @@ -4513,14 +4526,13 @@ runRelayRequestWorker a Worker {doWork} = do validateGroupProfile _groupProfile = do -- TODO [relays] relay: validate group profile, verify owner's signature pure () - createRelayLink :: GroupInfo -> CM (C.KeyPairEd25519, ShortLinkContact) - createRelayLink gi = do + createRelayLink :: GroupInfo -> C.KeyPairEd25519 -> CM ShortLinkContact + createRelayLink gi sigKeys = do let GroupInfo {membership} = gi GroupMember {memberId = MemberId relayMemId, memberProfile = p} = membership gVar <- asks random groupLinkId <- GroupLinkId <$> drgRandomBytes 16 subMode <- chatReadVar subscriptionMode - sigKeys <- atomically $ C.generateKeyPair gVar let crClientData = encodeJSON $ CRDataGroup groupLinkId -- prepare link with relayMemId as linkEntityId (no server request) (ccLink, preparedParams) <- withAgent $ \a' -> prepareConnectionLink a' (aUserId user) sigKeys relayMemId True (Just crClientData) CR.IKPQOff False Nothing @@ -4535,7 +4547,7 @@ runRelayRequestWorker a Worker {doWork} = do -- TODO [relays] starting role should be communicated in protocol from owner to relays subRole <- asks $ channelSubscriberRole . config void $ withFastStore $ \db -> createGroupLink db gVar user gi connId ccLink' groupLinkId subRole subMode - pure (sigKeys, sLnk) + pure sLnk acceptOwnerConnection :: RelayRequestData -> GroupInfo -> ShortLinkContact -> CM () acceptOwnerConnection RelayRequestData {relayInvId, reqChatVRange} gi relayLink = do ownerMember <- withStore $ \db -> getHostMember db cxt user groupId diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 60cd75c4a8..807152f826 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -12,6 +12,7 @@ module Simplex.Chat.Mobile where import Control.Concurrent.STM import Control.Exception (SomeException, catch) +import Control.Monad import Control.Monad.Except import Control.Monad.Reader import Data.Aeson (ToJSON (..)) @@ -35,6 +36,7 @@ import Foreign.Ptr import Foreign.StablePtr import Foreign.Storable (poke) import GHC.IO.Encoding (setFileSystemEncoding, setForeignEncoding, setLocaleEncoding) +import Numeric.Natural (Natural) import Simplex.Chat import Simplex.Chat.Badges.Code (badgeCodeText, parseBadgeCode) import Simplex.Chat.Controller @@ -73,6 +75,7 @@ import qualified Simplex.Messaging.Agent.Store.DB as DB data DBMigrationResult = DBMOk | DBMInvalidConfirmation + | DBMInvalidQueueSize | DBMErrorNotADatabase {dbFile :: String} | DBMErrorMigration {dbFile :: String, migrationError :: MigrationError} | DBMErrorSQL {dbFile :: String, migrationSQLError :: String} @@ -113,6 +116,8 @@ foreign export ccall "chat_migrate_init" cChatMigrateInit :: CString -> CString foreign export ccall "chat_migrate_init_key" cChatMigrateInitKey :: CString -> CString -> CInt -> CString -> CInt -> Ptr (StablePtr ChatController) -> IO CJSONString +foreign export ccall "chat_migrate_init_queue" cChatMigrateInitQueue :: CString -> CString -> CString -> CInt -> Ptr (StablePtr ChatController) -> IO CJSONString + foreign export ccall "chat_close_store" cChatCloseStore :: StablePtr ChatController -> IO CString foreign export ccall "chat_reopen_store" cChatReopenStore :: StablePtr ChatController -> IO CString @@ -166,7 +171,14 @@ cChatMigrateInit fp key conf = cChatMigrateInitKey fp key 0 conf 0 -- For postgres first param is schema prefix, second param is database connection string. cChatMigrateInitKey :: CString -> CString -> CInt -> CString -> CInt -> Ptr (StablePtr ChatController) -> IO CJSONString -cChatMigrateInitKey fp key keepKey conf background ctrl = do +cChatMigrateInitKey fp key keepKey conf background = cChatMigrateInit_ fp key (keepKey /= 0) conf (background /= 0) mobileQueueSize + +-- | queueSize is the size of internal queues, same as terminal option --queue-size +cChatMigrateInitQueue :: CString -> CString -> CString -> CInt -> Ptr (StablePtr ChatController) -> IO CJSONString +cChatMigrateInitQueue fp key conf queueSize = cChatMigrateInit_ fp key False conf False (fromIntegral queueSize) + +cChatMigrateInit_ :: CString -> CString -> Bool -> CString -> Bool -> Int -> Ptr (StablePtr ChatController) -> IO CJSONString +cChatMigrateInit_ fp key keepKey conf background queueSize ctrl = do -- ensure we are set to UTF-8; iOS does not have locale, and will default to -- US-ASCII all the time. setLocaleEncoding utf8 @@ -176,7 +188,7 @@ cChatMigrateInitKey fp key keepKey conf background ctrl = do chatDbOpts <- mobileDbOpts fp key confirm <- peekCAString conf r <- - chatMigrateInitKey chatDbOpts (keepKey /= 0) confirm (background /= 0) >>= \case + chatMigrateInitKey chatDbOpts keepKey confirm background queueSize >>= \case Right cc -> (newStablePtr cc >>= poke ctrl) $> DBMOk Left e -> pure e newCStringFromLazyBS $ J.encode r @@ -254,8 +266,11 @@ cChatParseBadgeCode cCode = do cChatJsonLength :: CString -> IO CInt cChatJsonLength s = fromIntegral . subtract 2 . LB.length . J.encode . safeDecodeUtf8 <$> B.packCString s -mobileChatOpts :: ChatDbOpts -> ChatOpts -mobileChatOpts dbOptions = +mobileQueueSize :: Int +mobileQueueSize = 4096 + +mobileChatOpts :: ChatDbOpts -> Natural -> ChatOpts +mobileChatOpts dbOptions tbqSize = ChatOpts { coreOptions = CoreChatOpts @@ -268,7 +283,7 @@ mobileChatOpts dbOptions = logServerHosts = True, logAgent = Nothing, logFile = Nothing, - tbqSize = 4096, + tbqSize, maxChats = 5000, deviceName = Nothing, chatRelay = False, @@ -314,18 +329,19 @@ getActiveUser_ st = find activeUser <$> withTransaction st getUsers chatMigrateInit :: String -> ScrubbedBytes -> String -> IO (Either DBMigrationResult ChatController) chatMigrateInit dbFilePrefix dbKey confirm = do let chatDBOpts = ChatDbOpts {dbFilePrefix, dbKey, trackQueries = DB.TQSlow 5000, vacuumOnMigration = True} - chatMigrateInitKey chatDBOpts False confirm False + chatMigrateInitKey chatDBOpts False confirm False mobileQueueSize #endif -chatMigrateInitKey :: ChatDbOpts -> Bool -> String -> Bool -> IO (Either DBMigrationResult ChatController) -chatMigrateInitKey chatDbOpts keepKey confirm backgroundMode = runExceptT $ do +chatMigrateInitKey :: ChatDbOpts -> Bool -> String -> Bool -> Int -> IO (Either DBMigrationResult ChatController) +chatMigrateInitKey chatDbOpts keepKey confirm backgroundMode queueSize = runExceptT $ do + unless (queueSize > 0) $ throwError DBMInvalidQueueSize confirmMigrations <- liftEitherWith (const DBMInvalidConfirmation) $ strDecode $ B.pack confirm let migrationConfig = MigrationConfig confirmMigrations (Just "") chatStore <- migrate createChatStore (toDBOpts chatDbOpts chatSuffix keepKey chatDBFunctions) migrationConfig agentStore <- migrate createAgentStore (toDBOpts chatDbOpts agentSuffix keepKey []) migrationConfig ExceptT $ initialize chatStore ChatDatabase {chatStore, agentStore} where - opts = mobileChatOpts $ removeDbKey chatDbOpts + opts = mobileChatOpts (removeDbKey chatDbOpts) (fromIntegral queueSize) initialize st db = do user_ <- liftIO $ getActiveUser_ st first DBMAgentError <$> newChatController db user_ defaultMobileConfig opts backgroundMode diff --git a/src/Simplex/Chat/Store/Badges.hs b/src/Simplex/Chat/Store/Badges.hs index c17bd5f360..19d6d2efef 100644 --- a/src/Simplex/Chat/Store/Badges.hs +++ b/src/Simplex/Chat/Store/Badges.hs @@ -4,6 +4,7 @@ {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} +{-# LANGUAGE TypeOperators #-} module Simplex.Chat.Store.Badges ( BadgeCodeRedemption (..), @@ -12,6 +13,8 @@ module Simplex.Chat.Store.Badges getBadgePurchase, userHasBadge, setBadgeAlertAcked, + setBadgeIssueError, + setBadgeNextWake, clearShownBadge, getBadgeCodeRedemption, createBadgeCodeRedemption, @@ -22,6 +25,7 @@ module Simplex.Chat.Store.Badges getLatestIssuedCredential, storeBadgeStatement, getBadgeLedgerLastEntry, + getBadgeLedger, getBadgeLedgerEntryId, ) where @@ -31,13 +35,13 @@ import Crypto.Random (ChaChaDRG) import qualified Data.Aeson as J import qualified Data.ByteString.Lazy.Char8 as LB import Data.Int (Int64) -import Data.Maybe (isJust) +import Data.Maybe (isJust, mapMaybe) import Data.Text (Text) import Data.Time.Clock (UTCTime) import Simplex.Chat.Badges import Simplex.Chat.Badges.Ledger import Simplex.Chat.Badges.Service (StatementCreditType (..), StatementDebitType (..), StatementEntry (..), StatementEntryType (..)) -import Simplex.Chat.Badges.Types (BadgeAlertKind, BadgePurchaseStatus (..)) +import Simplex.Chat.Badges.Types (BadgeAlertKind, BadgeIssueError (..), BadgeIssueFailure, BadgePurchaseStatus (..)) import Simplex.Chat.Store.Shared (insertedRowId) import Simplex.Chat.Types import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..)) @@ -144,6 +148,11 @@ storeBadgeIssuance db g badgePurchaseId entryId credential now = ON CONFLICT (badge_purchase_id, entry_id) DO NOTHING |] ((issuanceId, badgePurchaseId, entryId, badgeType) :. (periodStart, periodEnd, badgeExpiry, Binary (LB.toStrict $ J.encode credential), now)) + -- a month stored ends the run of failures + DB.execute + db + "UPDATE badge_purchases SET issue_failed_since = NULL, issue_error_at = NULL, issue_error = NULL WHERE badge_purchase_id = ?" + (Only badgePurchaseId) pure True where BadgeCredential {badgeInfo = BadgeInfo {badgeType, badgeExpiry}} = credential @@ -198,7 +207,9 @@ data UserBadgePurchase = UserBadgePurchase badgeType :: BadgeType, shown :: Bool, alertAcked :: Maybe (BadgeAlertKind, Text), - alertSnoozeUntil :: Maybe UTCTime + alertSnoozeUntil :: Maybe UTCTime, + issueError :: Maybe BadgeIssueError, + nextWakeAt :: Maybe UTCTime } -- | Newest, not the one shown_badge_id points at - retirement clears that, and the support ended @@ -227,14 +238,15 @@ getBadgePurchase db purchaseId = [sql| SELECT p.badge_purchase_id, p.purchase_key, p.purchase_priv_key, p.master_key, p.current_badge_type, (CASE WHEN u.shown_badge_id = p.badge_purchase_id THEN 1 ELSE 0 END), - p.alert_acked_kind, p.alert_acked_episode, p.alert_snooze_until + p.alert_acked_kind, p.alert_acked_episode, p.alert_snooze_until, + p.issue_failed_since, p.issue_error_at, p.issue_error, p.next_wake_at FROM badge_purchases p JOIN users u ON u.user_id = p.user_id WHERE p.badge_purchase_id = ? AND p.purchase_priv_key IS NOT NULL |] (Only purchaseId) where - toPurchase (badgePurchaseId, purchaseKey, purchasePrivKey, Binary mk, badgeType, shown_, ackedKind_, ackedEpisode_, alertSnoozeUntil) = + toPurchase ((badgePurchaseId, purchaseKey, purchasePrivKey, Binary mk, badgeType, shown_, ackedKind_, ackedEpisode_, alertSnoozeUntil) :. (failedSince_, lastAttemptAt_, reason_, nextWakeAt)) = UserBadgePurchase { badgePurchaseId, purchaseKey, @@ -243,7 +255,9 @@ getBadgePurchase db purchaseId = badgeType, shown = unBI shown_, alertAcked = (,) <$> ackedKind_ <*> ackedEpisode_, - alertSnoozeUntil + alertSnoozeUntil, + issueError = BadgeIssueError <$> failedSince_ <*> lastAttemptAt_ <*> reason_, + nextWakeAt } -- | Whether a badge is on the profile now: set when a redemption stores one, cleared when it is @@ -265,6 +279,24 @@ setBadgeAlertAcked db User {userId} badgePurchaseId kind episode snoozeUntil = "UPDATE badge_purchases SET alert_acked_kind = ?, alert_acked_episode = ?, alert_snooze_until = ? WHERE badge_purchase_id = ? AND user_id = ?" (kind, episode, snoozeUntil, badgePurchaseId, userId) +-- | Record a renewal that ended without a credential. The COALESCE keeps the start of the current +-- run of failures, which is the alert's episode and must survive a restart. +setBadgeIssueError :: DB.Connection -> Int64 -> UTCTime -> BadgeIssueFailure -> IO () +setBadgeIssueError db badgePurchaseId now failure = + DB.execute + db + [sql| + UPDATE badge_purchases + SET issue_failed_since = COALESCE(issue_failed_since, ?), issue_error_at = ?, issue_error = ? + WHERE badge_purchase_id = ? + |] + (now, now, failure, badgePurchaseId) + +-- | The wake the worker is about to wait for, so the state the apps hold says when it will try again. +setBadgeNextWake :: DB.Connection -> Int64 -> Maybe UTCTime -> IO () +setBadgeNextWake db badgePurchaseId at_ = + DB.execute db "UPDATE badge_purchases SET next_wake_at = ? WHERE badge_purchase_id = ?" (at_, badgePurchaseId) + -- | Stop showing a badge that has expired unrenewed; the profile update is broadcast by the caller. clearShownBadge :: DB.Connection -> User -> Int64 -> IO () clearShownBadge db User {userId} badgePurchaseId = @@ -306,7 +338,7 @@ storeBadgeStatement db badgePurchaseId badgeType tip entries now = -- | The balance is the last row; nothing derives it by summing the history. getBadgeLedgerLastEntry :: DB.Connection -> Int64 -> IO (Maybe StatementEntry) getBadgeLedgerLastEntry db badgePurchaseId = - maybeFirstRow' Nothing toEntry $ + maybeFirstRow' Nothing toStatementEntry $ DB.query db [sql| @@ -318,10 +350,27 @@ getBadgeLedgerLastEntry db badgePurchaseId = LIMIT 1 |] (Only badgePurchaseId) - where - toEntry ((entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType) :. (wasPausedSince, createdAt, entryType_, credit_, debit_, value_)) = - (\entryType -> StatementEntry {entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType, wasPausedSince, createdAt, entryType}) - <$> maybe (entryTypeFromColumns entryType_ credit_ debit_) (entryTypeFromValue entryType_) value_ + +-- | Oldest first. A row whose type this version cannot rebuild is left out, as it is from the tip. +getBadgeLedger :: DB.Connection -> User -> Int64 -> IO [StatementEntry] +getBadgeLedger db User {userId} badgePurchaseId = + mapMaybe toStatementEntry + <$> DB.query + db + [sql| + SELECT l.entry_uuid, l.change_months, l.balance_months, l.balance_start_ts, l.balance_anchor_ts, l.balance_badge_type, + l.was_paused_since, l.service_created_at, l.entry_type, l.entry_credit_type, l.entry_debit_type, l.entry_type_value + FROM badge_ledger l + JOIN badge_purchases p ON p.badge_purchase_id = l.badge_purchase_id + WHERE l.badge_purchase_id = ? AND p.user_id = ? + ORDER BY l.entry_id + |] + (badgePurchaseId, userId) + +toStatementEntry :: (Text, Int, Int, UTCTime, UTCTime, BadgeType) :. (Maybe UTCTime, UTCTime, Text, Maybe Text, Maybe Text, Maybe Text) -> Maybe StatementEntry +toStatementEntry ((entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType) :. (wasPausedSince, createdAt, entryType_, credit_, debit_, value_)) = + (\entryType -> StatementEntry {entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType, wasPausedSince, createdAt, entryType}) + <$> maybe (entryTypeFromColumns entryType_ credit_ debit_) (entryTypeFromValue entryType_) value_ -- | Decodes the stored JSON rather than rebuilding from the tag, so a version that has since -- learnt the type reads it with its fields, and one that has not still gets it back verbatim. diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index fe7539fad7..1057a71a02 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -12,6 +12,7 @@ module Simplex.Chat.Store.Connections ( getChatLockEntity, getConnectionEntity, + getConnectionEntityKeys, getConnectionEntityByConnReq, getConnectionEntityViaShortLink, getContactConnEntityByConnReqHash, @@ -76,18 +77,23 @@ getChatLockEntity db agentConnId = do -- - from receiving: getConnectionEntity, getContactConnEntityByConnReqHash -- - from subscribing: getContactConnsToSub, getUCLConnsToSub, getMemberConnsToSub, getPendingConnsToSub getConnectionEntity :: DB.Connection -> StoreCxt -> User -> AgentConnId -> ExceptT StoreError IO ConnectionEntity -getConnectionEntity db cxt user@User {userId, userContactId} agentConnId = do +getConnectionEntity db cxt user agentConnId = fst <$> getConnectionEntityKeys db cxt user agentConnId + +getConnectionEntityKeys :: DB.Connection -> StoreCxt -> User -> AgentConnId -> ExceptT StoreError IO (ConnectionEntity, Maybe GroupKeysRow) +getConnectionEntityKeys db cxt user@User {userId, userContactId} agentConnId = do c@Connection {connType, entityId} <- getConnection_ case entityId of Nothing -> if connType == ConnContact - then pure $ RcvDirectMsgConnection c Nothing + then pure (RcvDirectMsgConnection c Nothing, Nothing) else throwError $ SEInternalError $ "connection " <> show connType <> " without entity" Just entId -> case connType of - ConnMember -> uncurry (RcvGroupMsgConnection c) <$> getGroupAndMember_ entId c - ConnContact -> RcvDirectMsgConnection c . Just <$> getContactRec_ entId c - ConnUserContact -> UserContactConnection c <$> getUserContact_ entId + ConnMember -> do + ((gInfo, keysData), m) <- getGroupAndMember_ entId c + pure (RcvGroupMsgConnection c gInfo m, Just keysData) + ConnContact -> (,Nothing) . RcvDirectMsgConnection c . Just <$> getContactRec_ entId c + ConnUserContact -> (,Nothing) . UserContactConnection c <$> getUserContact_ entId where getConnection_ :: ExceptT StoreError IO Connection getConnection_ = ExceptT $ do @@ -134,7 +140,7 @@ getConnectionEntity db cxt user@User {userId, userContactId} agentConnId = do contactRequest = UserContactRequestRef <$> contactRequestId <*> (unBI <$> rejectionSupported_) groupDirectInv = toGroupDirectInvitation groupDirectInvRow in Contact {contactId, localDisplayName, profile, activeConn, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, preparedContact, contactRequestId, contactRequest, contactGroupMemberId, contactGrpInvSent, groupDirectInv, chatTags, chatItemTTL, uiThemes, chatDeleted, customData} - getGroupAndMember_ :: Int64 -> Connection -> ExceptT StoreError IO (GroupInfo, GroupMember) + getGroupAndMember_ :: Int64 -> Connection -> ExceptT StoreError IO ((GroupInfo, GroupKeysRow), GroupMember) getGroupAndMember_ groupMemberId c = do currentTs <- liftIO getCurrentTime gm <- @@ -178,8 +184,8 @@ getConnectionEntity db cxt user@User {userId, userContactId} agentConnId = do AND mu.member_status NOT IN (?,?,?) |] (groupMemberId, userId, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted) - liftIO $ bitraverse (addGroupChatTags db) pure gm - toGroupAndMember :: UTCTime -> Connection -> GroupInfoRow :. GroupMemberRow -> (GroupInfo, GroupMember) + liftIO $ bitraverse (\(g, keysData) -> (,keysData) <$> addGroupChatTags db g) pure gm + toGroupAndMember :: UTCTime -> Connection -> GroupInfoRow :. GroupMemberRow -> ((GroupInfo, GroupKeysRow), GroupMember) toGroupAndMember currentTs c (groupInfoRow :. memberRow) = let groupInfo = toGroupInfo currentTs cxt userContactId [] groupInfoRow member = toGroupMember currentTs userContactId memberRow diff --git a/src/Simplex/Chat/Store/ContactRequest.hs b/src/Simplex/Chat/Store/ContactRequest.hs index 146f957947..f87a1e242a 100644 --- a/src/Simplex/Chat/Store/ContactRequest.hs +++ b/src/Simplex/Chat/Store/ContactRequest.hs @@ -91,11 +91,12 @@ createOrUpdateContactRequest pure $ RSAcceptedRequest cr (REContact ct) Nothing -> liftIO (getAcceptedBusinessChat xContactId) >>= \case - Just gInfo@GroupInfo {businessChat = Just BusinessChatInfo {customerId}} -> do + Just (gInfo@GroupInfo {businessChat = Just BusinessChatInfo {customerId}}, keysData) -> do clientMember <- getGroupMemberByMemberId db cxt user gInfo customerId cr <- liftIO $ getContactRequestByXContactId xContactId - pure $ RSAcceptedRequest cr (REBusinessChat gInfo clientMember) - Just GroupInfo {businessChat = Nothing} -> throwError SEInvalidBusinessChatContactRequest + gks <- mkGroupKeys db cxt gInfo keysData + pure $ RSAcceptedRequest cr (REBusinessChat (GIK gInfo gks) clientMember) + Just (GroupInfo {businessChat = Nothing}, _) -> throwError SEInvalidBusinessChatContactRequest -- 2) if no legacy accepted contact or business chat was found, next we try to find an existing request Nothing -> liftIO (getContactRequestByXContactId xContactId) >>= \case @@ -131,7 +132,7 @@ createOrUpdateContactRequest |] (userId, xContactId) mapM (addDirectChatTags db) ct_ - getAcceptedBusinessChat :: XContactId -> IO (Maybe GroupInfo) + getAcceptedBusinessChat :: XContactId -> IO (Maybe (GroupInfo, GroupKeysRow)) getAcceptedBusinessChat xContactId = do currentTs <- getCurrentTime g_ <- @@ -140,7 +141,7 @@ createOrUpdateContactRequest db (groupInfoQuery <> " WHERE g.business_xcontact_id = ? AND g.user_id = ? AND mu.contact_id = ?") (xContactId, userId, userContactId) - mapM (addGroupChatTags db) g_ + forM g_ $ \(g, keysData) -> (,keysData) <$> addGroupChatTags db g getContactRequestByXContactId :: XContactId -> IO (Maybe UserContactRequest) getContactRequestByXContactId xContactId = do currentTs <- getCurrentTime @@ -214,7 +215,7 @@ createOrUpdateContactRequest pure $ RSCurrentRequest Nothing ucr (Just $ REContact ct) createBusinessChat = do let groupPreferences = maybe defaultBusinessGroupPrefs businessGroupPrefs $ preferences' user - (gInfo@GroupInfo {groupId}, clientMember) <- + (gInfo@(GIK GroupInfo {groupId} _), clientMember) <- createBusinessRequestGroup db cxt gVar user cReqChatVRange profile profileId ldn groupPreferences liftIO $ DB.execute @@ -302,11 +303,12 @@ createOrUpdateContactRequest ct <- getContact db cxt user contactId pure $ Just (REContact ct) (Nothing, Just businessGroupId) -> do - gInfo <- getGroupInfo db cxt user businessGroupId + (gInfo, keysData) <- getGroupInfoRow db cxt user businessGroupId case gInfo of GroupInfo {businessChat = Just BusinessChatInfo {customerId}} -> do clientMember <- getGroupMemberByMemberId db cxt user gInfo customerId - pure $ Just (REBusinessChat gInfo clientMember) + gks <- mkGroupKeys db cxt gInfo keysData + pure $ Just (REBusinessChat (GIK gInfo gks) clientMember) _ -> throwError SEInvalidBusinessChatContactRequest (Nothing, Nothing) -> pure Nothing _ -> throwError $ SEInvalidContactRequestEntity contactRequestId diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 53d5cd619e..a87d266b03 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -188,7 +188,7 @@ createConnReqConnection db userId acId preparedEntity_ cReq cReqHash sLnk xConta connId <- insertedRowId db case preparedEntity_ of -- For relay groups, setPreparedGroupLinkInfo_ is called via updatePreparedRelayedGroup before the relay loop - Just (PCEGroup gInfo _) | not (useRelays' gInfo) -> + Just (PCEGroup (GIK gInfo _) _) | not (useRelays' gInfo) -> setPreparedGroupLinkInfo_ db gInfo cReq cReqHash customUserProfileId Nothing currentTs _ -> pure () pure diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 84a58fb222..2c0b2dc080 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -40,6 +40,7 @@ module Simplex.Chat.Store.Groups createGroupRejectedViaLink, setGroupInvitationChatItemId, getGroup, + getGroupKeys_, getGroupInfoByUserContactLinkConnReq, getGroupInfoViaUserTarget, getGroupViaShortLinkToConnect, @@ -144,7 +145,7 @@ module Simplex.Chat.Store.Groups updatePreparedRelayedGroup, updatePublicMemberCount, setPublicMemberCount, - updateGroupMemberKeys, + setGroupRootKey, updateRelayGroupKeys, updateGroupMemberStatus, updateGroupMemberStatusById, @@ -376,26 +377,24 @@ setGroupLinkShortLink db gLnk@GroupLink {userContactLinkId, connLinkContact = CC pure gLnk {connLinkContact = CCLink connFullLink (Just shortLink), shortLinkDataSet = True, shortLinkLargeDataSet = BoolDef True} -- | creates completely new group with a single member - the current user -createNewGroup :: DB.Connection -> StoreCxt -> User -> GroupProfile -> Maybe Profile -> Bool -> MemberId -> Maybe GroupKeys -> Maybe Int64 -> ExceptT StoreError IO GroupInfo -createNewGroup db cxt user@User {userId} groupProfile incognitoProfile useRelays memberId groupKeys publicMemberCount_ = ExceptT $ do +createNewGroup :: DB.Connection -> StoreCxt -> User -> GroupProfile -> Maybe Profile -> MemberId -> GroupKeys -> Maybe Int64 -> ExceptT StoreError IO GroupInfo +createNewGroup db cxt user@User {userId} groupProfile incognitoProfile memberId groupKeys publicMemberCount_ = ExceptT $ do let GroupProfile {displayName, fullName, shortDescr, description, image, publicGroup, groupPreferences, memberAdmission} = groupProfile (groupType_, groupLink_, publicGroupId_) = case publicGroup of Just PublicGroupProfile {groupType, groupLink, publicGroupId} -> (Just groupType, Just groupLink, Just publicGroupId) Nothing -> (Nothing, Nothing, Nothing) fullGroupPreferences = mergeGroupPreferences groupPreferences + useRelays = isPublicGroup groupKeys rosterVersion0 = if useRelays then Just (VersionRoster 0) else Nothing currentTs <- getCurrentTime customUserProfileId <- mapM (createIncognitoProfile_ db userId currentTs) incognitoProfile withLocalDisplayName db userId displayName $ \ldn -> runExceptT $ do - let (rootPrivKey_, rootPubKey_, memberPrivKey_) = case groupKeys of - Nothing -> (Nothing, Nothing, Nothing) - Just GroupKeys {publicGroupKeys, memberPrivKey} -> - let (rpk, rpub) = case publicGroupKeys of - Just PublicGroupKeys {groupRootKey} -> case groupRootKey of - GRKPrivate pk -> (Just pk, Nothing) - GRKPublic k -> (Nothing, Just k) - Nothing -> (Nothing, Nothing) - in (rpk, rpub, Just memberPrivKey) + let (rootPrivKey_, rootPubKey_) = case groupKeys of + GKPublicGroup {groupRootKey} -> case groupRootKey of + GRKPrivate pk -> (Just pk, Nothing) + GRKPublic k -> (Nothing, Just k) + _ -> (Nothing, Nothing) + memberPrivKey_ = Just $ memberPrivKey groupKeys groupId <- liftIO $ do DB.execute db @@ -423,7 +422,7 @@ createNewGroup db cxt user@User {userId} groupProfile incognitoProfile useRelays :. (rootPrivKey_, rootPubKey_, memberPrivKey_, publicMemberCount_, rosterVersion0) ) insertedRowId db - let memberPubKey = C.publicKey . memberPrivKey <$> groupKeys + let memberPubKey = Just $ C.publicKey $ memberPrivKey groupKeys membership <- createContactMemberInv_ db user groupId Nothing user (MemberIdRole memberId GROwner) GCUserMember GSMemCreator IBUser customUserProfileId memberPubKey currentTs (vr cxt) let chatSettings = ChatSettings {enableNtfs = MFAll, sendRcpts = Nothing, favorite = False} pure @@ -451,18 +450,17 @@ createNewGroup db cxt user@User {userId} groupProfile incognitoProfile useRelays customData = Nothing, membersRequireAttention = 0, viaGroupLinkUri = Nothing, - groupKeys, groupDomainVerified = Nothing } -- | creates a new group record for the group the current user was invited to, or returns an existing one -createGroupInvitation :: DB.Connection -> StoreCxt -> User -> Contact -> GroupInvitation -> Maybe ProfileId -> C.KeyPairEd25519 -> ExceptT StoreError IO (GroupInfo, GroupMemberId) +createGroupInvitation :: DB.Connection -> StoreCxt -> User -> Contact -> GroupInvitation -> Maybe ProfileId -> C.KeyPairEd25519 -> ExceptT StoreError IO (GroupInfoKeys, GroupMemberId) createGroupInvitation _ _ _ Contact {localDisplayName, activeConn = Nothing} _ _ _ = throwError $ SEContactNotReady localDisplayName createGroupInvitation db cxt user@User {userId} contact@Contact {contactId, activeConn = Just Connection {peerChatVRange}} GroupInvitation {fromMember, fromMemberKey, invitedMember, connRequest, groupProfile, business} incognitoProfileId memberKeys = do liftIO getInvitationGroupId_ >>= \case Nothing -> createGroupInvitation_ Just gId -> do - gInfo@GroupInfo {membership, groupProfile = p'} <- getGroupInfo db cxt user gId + GIK gInfo@GroupInfo {membership, groupProfile = p'} gks <- getGroupInfoKeys db cxt user gId hostId <- getHostMemberId_ db user gId let GroupMember {groupMemberId, memberId, memberRole} = membership MemberIdRole {memberId = invMemberId, memberRole = invMemberRole} = invitedMember @@ -472,13 +470,13 @@ createGroupInvitation db cxt user@User {userId} contact@Contact {contactId, acti if p' == groupProfile then pure gInfo else updateGroupProfile db user gInfo groupProfile - pure (gInfo', hostId) + pure (GIK gInfo' gks, hostId) where getInvitationGroupId_ :: IO (Maybe Int64) getInvitationGroupId_ = maybeFirstRow fromOnly $ DB.query db "SELECT group_id FROM groups WHERE inv_queue_info = ? AND user_id = ? LIMIT 1" (connRequest, userId) - createGroupInvitation_ :: ExceptT StoreError IO (GroupInfo, GroupMemberId) + createGroupInvitation_ :: ExceptT StoreError IO (GroupInfoKeys, GroupMemberId) createGroupInvitation_ = do let GroupProfile {displayName, fullName, shortDescr, description, image, groupPreferences, memberAdmission} = groupProfile fullGroupPreferences = mergeGroupPreferences groupPreferences @@ -530,9 +528,9 @@ createGroupInvitation db cxt user@User {userId} contact@Contact {contactId, acti customData = Nothing, membersRequireAttention = 0, viaGroupLinkUri = Nothing, - groupKeys = Just GroupKeys {publicGroupKeys = Nothing, memberPrivKey = snd memberKeys}, groupDomainVerified = Nothing - }, + } + `GIK` GKGroup {memberPrivKey = snd memberKeys}, groupMemberId ) @@ -946,10 +944,13 @@ setGroupInvitationChatItemId db User {userId} groupId chatItemId = do -- TODO return the last connection that is ready, not any last connection -- requires updating connection status getGroup :: DB.Connection -> StoreCxt -> User -> GroupId -> ExceptT StoreError IO Group -getGroup db cxt user groupId = do - gInfo <- getGroupInfo db cxt user groupId +getGroup db cxt user groupId = fst <$> getGroupKeys_ db cxt user groupId + +getGroupKeys_ :: DB.Connection -> StoreCxt -> User -> GroupId -> ExceptT StoreError IO (Group, GroupKeys) +getGroupKeys_ db cxt user groupId = do + GIK gInfo gks <- getGroupInfoKeys db cxt user groupId members <- liftIO $ getGroupMembers db cxt user gInfo - pure $ Group gInfo members + pure (Group gInfo members, gks) deleteGroupChatItems :: DB.Connection -> User -> GroupInfo -> IO () deleteGroupChatItems db User {userId} GroupInfo {groupId} = @@ -1054,7 +1055,7 @@ getInProgressGroups db cxt user@User {userId} createdAtCutoff = do getBaseGroupDetails :: DB.Connection -> StoreCxt -> User -> Maybe ContactId -> Maybe Text -> IO [GroupInfo] getBaseGroupDetails db cxt User {userId, userContactId} _contactId_ search_ = do currentTs <- getCurrentTime - map (toGroupInfo currentTs cxt userContactId []) + map (toGroupInfo_ currentTs cxt userContactId []) <$> DB.query db (groupInfoQuery <> " " <> condition) (userId, userContactId, search, search, search, search) where condition = @@ -1361,11 +1362,11 @@ getGroupInvitation :: DB.Connection -> StoreCxt -> User -> GroupId -> ExceptT St getGroupInvitation db cxt user groupId = getConnRec_ user >>= \case Just connRequest -> do - groupInfo@GroupInfo {membership} <- getGroupInfo db cxt user groupId + GIK groupInfo@GroupInfo {membership} groupKeys <- getGroupInfoKeys db cxt user groupId when (memberStatus membership /= GSMemInvited) $ throwError SEGroupAlreadyJoined hostId <- getHostMemberId_ db user groupId fromMember <- getGroupMember db cxt user groupId hostId - pure ReceivedGroupInvitation {fromMember, connRequest, groupInfo} + pure ReceivedGroupInvitation {fromMember, connRequest, groupInfo, groupKeys} _ -> throwError SEGroupInvitationNotFound where getConnRec_ :: User -> ExceptT StoreError IO (Maybe ConnReqInvitation) @@ -1695,12 +1696,6 @@ setGroupMemberKeyRole db GroupMember {groupMemberId} pubKey role = do currentTs <- getCurrentTime DB.execute db "UPDATE group_members SET member_pub_key = ?, member_role = ?, updated_at = ? WHERE group_member_id = ?" (pubKey, role, currentTs, groupMemberId) -setUserMemberKey :: DB.Connection -> GroupId -> GroupMemberId -> C.PrivateKeyEd25519 -> IO () -setUserMemberKey db groupId membershipId memberPrivKey = do - currentTs <- getCurrentTime - DB.execute db "UPDATE groups SET member_priv_key = ?, updated_at = ? WHERE group_id = ?" (memberPrivKey, currentTs, groupId) - DB.execute db "UPDATE group_members SET member_pub_key = ?, updated_at = ? WHERE group_member_id = ?" (C.publicKey memberPrivKey, currentTs, membershipId) - setMemberPubKey :: DB.Connection -> GroupMemberId -> C.PublicKeyEd25519 -> IO () setMemberPubKey db groupMemberId pubKey = do currentTs <- getCurrentTime @@ -1914,13 +1909,13 @@ createRelayRequestGroup db cxt user@User {userId} GroupRelayInvitation {fromMemb groupPreferences = Nothing, memberAdmission = Nothing } - (groupId, _groupLDN) <- createGroup_ db userId placeholderProfile Nothing Nothing True (Just relayStatus) Nothing Nothing currentTs + (_, memberPrivKey) <- atomically $ C.generateKeyPair (drg cxt) + (groupId, _groupLDN) <- createGroup_ db userId placeholderProfile Nothing Nothing True (Just relayStatus) Nothing (Just memberPrivKey) currentTs -- Store relay request data for recovery liftIO $ setRelayRequestData_ groupId currentTs ownerMemberId <- insertOwner_ currentTs groupId let relayMember = MemberIdRole relayMemberId GRRelay - -- TODO [member keys] should relays use member keys? - _membership <- createContactMemberInv_ db user groupId (Just ownerMemberId) user relayMember GCUserMember memberStatus IBUnknown Nothing Nothing currentTs (vr cxt) + _membership <- createContactMemberInv_ db user groupId (Just ownerMemberId) user relayMember GCUserMember memberStatus IBUnknown Nothing (Just $ C.publicKey memberPrivKey) currentTs (vr cxt) ownerMember <- getGroupMember db cxt user groupId ownerMemberId g <- getGroupInfo db cxt user groupId pure (g, ownerMember) @@ -2015,16 +2010,17 @@ isRelayGroupRejected db User {userId} groupLink = (userId, groupLink, RSRejected) ) -getRelayServedGroups :: DB.Connection -> StoreCxt -> User -> IO [GroupInfo] +getRelayServedGroups :: DB.Connection -> StoreCxt -> User -> ExceptT StoreError IO [GroupInfoKeys] getRelayServedGroups db cxt User {userId, userContactId} = do - currentTs <- getCurrentTime - map (toGroupInfo currentTs cxt userContactId []) + currentTs <- liftIO getCurrentTime + rows <- liftIO $ map (toGroupInfo currentTs cxt userContactId []) <$> DB.query db ( groupInfoQuery <> " WHERE g.user_id = ? AND mu.contact_id = ? AND g.relay_own_status IN (?, ?, ?)" ) (userId, userContactId, RSAccepted, RSAcknowledgedRoster, RSActive) + forM rows $ \(g, keysData) -> GIK g <$> mkGroupKeys db cxt g keysData getRelayPublishableGroups :: DB.Connection -> User -> IO [(Int64, B64UrlByteString, Maybe PublicGroupAccess)] getRelayPublishableGroups db User {userId, userContactId} = @@ -2062,7 +2058,7 @@ getRelayInactiveGroups :: DB.Connection -> StoreCxt -> User -> NominalDiffTime - getRelayInactiveGroups db cxt User {userId, userContactId} ttl = do currentTs <- getCurrentTime let cutoffTs = addUTCTime (- ttl) currentTs - map (toGroupInfo currentTs cxt userContactId []) + map (toGroupInfo_ currentTs cxt userContactId []) <$> DB.query db ( groupInfoQuery @@ -2149,7 +2145,7 @@ createJoiningMemberConnection Connection {connId} <- createConnection_ db userId ConnMember (Just groupMemberId) agentConnId ConnNew chatV cReqChatVRange Nothing (Just uclId) Nothing 0 createdAt subMode PQSupportOff setCommandConnId db user cmdId connId -createBusinessRequestGroup :: DB.Connection -> StoreCxt -> TVar ChaChaDRG -> User -> VersionRangeChat -> Profile -> Int64 -> Text -> GroupPreferences -> ExceptT StoreError IO (GroupInfo, GroupMember) +createBusinessRequestGroup :: DB.Connection -> StoreCxt -> TVar ChaChaDRG -> User -> VersionRangeChat -> Profile -> Int64 -> Text -> GroupPreferences -> ExceptT StoreError IO (GroupInfoKeys, GroupMember) createBusinessRequestGroup db cxt @@ -2164,7 +2160,7 @@ createBusinessRequestGroup (groupId, membership@GroupMember {memberId = userMemberId}) <- insertGroup_ currentTs (groupMemberId, memberId) <- insertClientMember_ currentTs groupId membership liftIO $ DB.execute db "UPDATE groups SET business_member_id = ?, customer_member_id = ? WHERE group_id = ?" (userMemberId, memberId, groupId) - groupInfo <- getGroupInfo db cxt user groupId + groupInfo <- getGroupInfoKeys db cxt user groupId clientMember <- getGroupMemberById db cxt user groupMemberId pure (groupInfo, clientMember) where @@ -2250,14 +2246,14 @@ createMemberConnectionAsync db user@User {userId} groupMemberId (cmdId, agentCon -- which is used in single-connection flows. updatePreparedRelayedGroup :: DB.Connection -> StoreCxt -> User -> GroupInfo -> ConnReqContact -> ConnReqUriHash -> Maybe Profile -> - C.PublicKeyEd25519 -> C.PrivateKeyEd25519 -> Maybe Int64 -> - ExceptT StoreError IO GroupInfo -updatePreparedRelayedGroup db cxt user@User {userId} gInfo cReq cReqHash incognitoProfile rootPubKey memberPrivKey publicMemberCount_ = do + C.PublicKeyEd25519 -> Maybe Int64 -> + ExceptT StoreError IO GroupInfoKeys +updatePreparedRelayedGroup db cxt user@User {userId} gInfo cReq cReqHash incognitoProfile rootPubKey publicMemberCount_ = do currentTs <- liftIO getCurrentTime customUserProfileId <- liftIO $ mapM (createIncognitoProfile_ db userId currentTs) incognitoProfile liftIO $ setPreparedGroupLinkInfo_ db gInfo cReq cReqHash customUserProfileId publicMemberCount_ currentTs - liftIO $ updateGroupMemberKeys db (groupId' gInfo) rootPubKey memberPrivKey (groupMemberId' $ membership gInfo) - getGroupInfo db cxt user (groupId' gInfo) + liftIO $ setGroupRootKey db (groupId' gInfo) rootPubKey + getGroupInfoKeys db cxt user (groupId' gInfo) updatePublicMemberCount :: DB.Connection -> StoreCxt -> User -> GroupInfo -> ExceptT StoreError IO GroupInfo updatePublicMemberCount db cxt user GroupInfo {groupId} = do @@ -2284,23 +2280,15 @@ setPublicMemberCount db cxt user GroupInfo {groupId} publicCount = do liftIO $ DB.execute db "UPDATE groups SET public_member_count = ?, updated_at = ? WHERE group_id = ?" (publicCount, currentTs, groupId) getGroupInfo db cxt user groupId -updateGroupMemberKeys :: DB.Connection -> GroupId -> C.PublicKeyEd25519 -> C.PrivateKeyEd25519 -> GroupMemberId -> IO () -updateGroupMemberKeys db groupId rootPubKey memberPrivKey membershipGMId = do +setGroupRootKey :: DB.Connection -> GroupId -> C.PublicKeyEd25519 -> IO () +setGroupRootKey db groupId rootPubKey = do currentTs <- getCurrentTime - DB.execute - db - "UPDATE groups SET root_pub_key = ?, member_priv_key = ?, updated_at = ? WHERE group_id = ?" - (rootPubKey, memberPrivKey, currentTs, groupId) - DB.execute - db - "UPDATE group_members SET member_pub_key = ?, updated_at = ? WHERE group_member_id = ?" - (C.publicKey memberPrivKey, currentTs, membershipGMId) + DB.execute db "UPDATE groups SET root_pub_key = ?, updated_at = ? WHERE group_id = ?" (rootPubKey, currentTs, groupId) -updateRelayGroupKeys :: DB.Connection -> User -> GroupInfo -> PublicGroupProfile -> C.PublicKeyEd25519 -> C.PrivateKeyEd25519 -> [OwnerAuth] -> ExceptT StoreError IO () -updateRelayGroupKeys db user@User {userId} gInfo PublicGroupProfile {groupType, groupLink, publicGroupId} rootPubKey memberPrivKey owners = do +updateRelayGroupKeys :: DB.Connection -> User -> GroupInfo -> PublicGroupProfile -> C.PublicKeyEd25519 -> [OwnerAuth] -> ExceptT StoreError IO () +updateRelayGroupKeys db user@User {userId} gInfo PublicGroupProfile {groupType, groupLink, publicGroupId} rootPubKey owners = do currentTs <- liftIO getCurrentTime - let membershipGMId = groupMemberId' $ membership gInfo - groupId = groupId' gInfo + let groupId = groupId' gInfo liftIO $ do DB.execute db @@ -2309,14 +2297,7 @@ updateRelayGroupKeys db user@User {userId} gInfo PublicGroupProfile {groupType, WHERE group_profile_id IN (SELECT group_profile_id FROM groups WHERE user_id = ? AND group_id = ?) |] (groupType, groupLink, publicGroupId, currentTs, userId, groupId) - DB.execute - db - "UPDATE groups SET root_pub_key = ?, member_priv_key = ?, updated_at = ? WHERE group_id = ?" - (rootPubKey, memberPrivKey, currentTs, groupId) - DB.execute - db - "UPDATE group_members SET member_pub_key = ?, updated_at = ? WHERE group_member_id = ?" - (C.publicKey memberPrivKey, currentTs, membershipGMId) + setGroupRootKey db groupId rootPubKey -- TODO [relays] relay: if not found, create owner record (multi-owner) forM_ owners $ \OwnerAuth {ownerId, ownerKey} -> do ownerGMId <- getGroupMemberIdViaMemberId db user gInfo (MemberId ownerId) diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index 9e5a1aa5aa..40c13527d9 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -51,6 +51,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260822_forward_link import Simplex.Chat.Store.Postgres.Migrations.M20260828_file_expiry import Simplex.Chat.Store.Postgres.Migrations.M20260904_file_badges import Simplex.Chat.Store.Postgres.Migrations.M20260915_user_badges +import Simplex.Chat.Store.Postgres.Migrations.M20260918_badge_issue_errors import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -101,7 +102,8 @@ schemaMigrations = ("20260822_forward_link", m20260822_forward_link, Just down_m20260822_forward_link), ("20260828_file_expiry", m20260828_file_expiry, Just down_m20260828_file_expiry), ("20260904_file_badges", m20260904_file_badges, Just down_m20260904_file_badges), - ("20260915_user_badges", m20260915_user_badges, Just down_m20260915_user_badges) + ("20260915_user_badges", m20260915_user_badges, Just down_m20260915_user_badges), + ("20260918_badge_issue_errors", m20260918_badge_issue_errors, Just down_m20260918_badge_issue_errors) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260918_badge_issue_errors.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260918_badge_issue_errors.hs new file mode 100644 index 0000000000..3edfc5b9ef --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260918_badge_issue_errors.hs @@ -0,0 +1,31 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20260918_badge_issue_errors where + +import Data.Text (Text) +import Text.RawString.QQ (r) + +m20260918_badge_issue_errors :: Text +m20260918_badge_issue_errors = + [r| +ALTER TABLE badge_purchases ADD COLUMN issue_failed_since TIMESTAMPTZ; + +ALTER TABLE badge_purchases ADD COLUMN issue_error_at TIMESTAMPTZ; + +ALTER TABLE badge_purchases ADD COLUMN issue_error TEXT; + +ALTER TABLE badge_purchases ADD COLUMN next_wake_at TIMESTAMPTZ; +|] + +down_m20260918_badge_issue_errors :: Text +down_m20260918_badge_issue_errors = + [r| +ALTER TABLE badge_purchases DROP COLUMN issue_failed_since; + +ALTER TABLE badge_purchases DROP COLUMN issue_error_at; + +ALTER TABLE badge_purchases DROP COLUMN issue_error; + +ALTER TABLE badge_purchases DROP COLUMN next_wake_at; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index 1e2a26d74e..0c7bf85cb6 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -275,7 +275,11 @@ CREATE TABLE test_chat_schema.badge_purchases ( alert_acked_kind text, alert_acked_episode text, alert_snooze_until timestamp with time zone, - badge_code_redemption_id bigint + badge_code_redemption_id bigint, + issue_failed_since timestamp with time zone, + issue_error_at timestamp with time zone, + issue_error text, + next_wake_at timestamp with time zone ); diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 0bbd7c06b6..f49495168e 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -174,6 +174,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260822_forward_link import Simplex.Chat.Store.SQLite.Migrations.M20260828_file_expiry import Simplex.Chat.Store.SQLite.Migrations.M20260904_file_badges import Simplex.Chat.Store.SQLite.Migrations.M20260915_user_badges +import Simplex.Chat.Store.SQLite.Migrations.M20260918_badge_issue_errors import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -347,7 +348,8 @@ schemaMigrations = ("20260822_forward_link", m20260822_forward_link, Just down_m20260822_forward_link), ("20260828_file_expiry", m20260828_file_expiry, Just down_m20260828_file_expiry), ("20260904_file_badges", m20260904_file_badges, Just down_m20260904_file_badges), - ("20260915_user_badges", m20260915_user_badges, Just down_m20260915_user_badges) + ("20260915_user_badges", m20260915_user_badges, Just down_m20260915_user_badges), + ("20260918_badge_issue_errors", m20260918_badge_issue_errors, Just down_m20260918_badge_issue_errors) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260918_badge_issue_errors.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260918_badge_issue_errors.hs new file mode 100644 index 0000000000..ed50354bf9 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260918_badge_issue_errors.hs @@ -0,0 +1,30 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20260918_badge_issue_errors where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20260918_badge_issue_errors :: Query +m20260918_badge_issue_errors = + [sql| +ALTER TABLE badge_purchases ADD COLUMN issue_failed_since TEXT; + +ALTER TABLE badge_purchases ADD COLUMN issue_error_at TEXT; + +ALTER TABLE badge_purchases ADD COLUMN issue_error TEXT; + +ALTER TABLE badge_purchases ADD COLUMN next_wake_at TEXT; +|] + +down_m20260918_badge_issue_errors :: Query +down_m20260918_badge_issue_errors = + [sql| +ALTER TABLE badge_purchases DROP COLUMN issue_failed_since; + +ALTER TABLE badge_purchases DROP COLUMN issue_error_at; + +ALTER TABLE badge_purchases DROP COLUMN issue_error; + +ALTER TABLE badge_purchases DROP COLUMN next_wake_at; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index 10cf2b93c2..4062a1e8f6 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -1870,6 +1870,15 @@ Query: Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE groups + SET member_priv_key = COALESCE(member_priv_key, ?), updated_at = ? + WHERE group_id = ? + RETURNING member_priv_key + +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE groups SET relay_request_inv_id = ?, @@ -4024,6 +4033,18 @@ Query: Plan: SCAN group_members +Query: + SELECT l.entry_uuid, l.change_months, l.balance_months, l.balance_start_ts, l.balance_anchor_ts, l.balance_badge_type, + l.was_paused_since, l.service_created_at, l.entry_type, l.entry_credit_type, l.entry_debit_type, l.entry_type_value + FROM badge_ledger l + JOIN badge_purchases p ON p.badge_purchase_id = l.badge_purchase_id + WHERE l.badge_purchase_id = ? AND p.user_id = ? + ORDER BY l.entry_id + +Plan: +SEARCH p USING COVERING INDEX idx_badge_purchases_user (user_id=? AND rowid=?) +SEARCH l USING INDEX idx_badge_ledger_purchase (badge_purchase_id=?) + Query: SELECT m.group_member_id FROM group_members m @@ -4040,7 +4061,8 @@ SEARCH p USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT p.badge_purchase_id, p.purchase_key, p.purchase_priv_key, p.master_key, p.current_badge_type, (CASE WHEN u.shown_badge_id = p.badge_purchase_id THEN 1 ELSE 0 END), - p.alert_acked_kind, p.alert_acked_episode, p.alert_snooze_until + p.alert_acked_kind, p.alert_acked_episode, p.alert_snooze_until, + p.issue_failed_since, p.issue_error_at, p.issue_error, p.next_wake_at FROM badge_purchases p JOIN users u ON u.user_id = p.user_id WHERE p.badge_purchase_id = ? AND p.purchase_priv_key IS NOT NULL @@ -5131,6 +5153,14 @@ SEARCH f USING INTEGER PRIMARY KEY (rowid=?) SEARCH s USING COVERING INDEX idx_snd_files_file_id (file_id=?) LEFT-JOIN SEARCH r USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +Query: + UPDATE badge_purchases + SET issue_failed_since = COALESCE(issue_failed_since, ?), issue_error_at = ?, issue_error = ? + WHERE badge_purchase_id = ? + +Plan: +SEARCH badge_purchases USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE chat_items SET item_content = ?, item_text = ?, item_status = ?, item_deleted = ?, item_deleted_ts = ?, item_edited = ?, item_live = ?, updated_at = ?, timed_ttl = ?, timed_delete_at = ? @@ -7555,6 +7585,10 @@ Query: SELECT image FROM contact_profiles WHERE display_name = ? LIMIT 1 Plan: SEARCH contact_profiles USING INDEX contact_profiles_index (display_name=?) +Query: SELECT issue_failed_since, issue_error FROM badge_purchases +Plan: +SCAN badge_purchases + Query: SELECT last_insert_rowid() Plan: SCAN CONSTANT ROW @@ -7575,10 +7609,22 @@ Query: SELECT member_id FROM group_members WHERE member_role = ? LIMIT 1 Plan: SCAN group_members +Query: SELECT member_priv_key FROM groups +Plan: +SCAN groups + Query: SELECT member_pub_key FROM group_members WHERE local_display_name = ? Plan: SCAN group_members +Query: SELECT member_pub_key FROM group_members WHERE member_category = 'host' +Plan: +SCAN group_members + +Query: SELECT member_pub_key FROM group_members WHERE member_category = 'user' +Plan: +SCAN group_members + Query: SELECT member_pub_key FROM group_members WHERE member_role = 'moderator' Plan: SCAN group_members @@ -7679,6 +7725,10 @@ Query: SELECT sent_inv_queue_info FROM group_members WHERE group_member_id = ? A Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +Query: SELECT service_created_at, balance_start_ts FROM badge_ledger ORDER BY entry_id +Plan: +SCAN badge_ledger + Query: SELECT shared_msg_id FROM chat_items WHERE shared_msg_id IS NOT NULL ORDER BY chat_item_id DESC LIMIT 1 Plan: SCAN chat_items @@ -7727,6 +7777,14 @@ Query: UPDATE badge_purchases SET alert_acked_kind = ?, alert_acked_episode = ?, Plan: SEARCH badge_purchases USING INTEGER PRIMARY KEY (rowid=?) +Query: UPDATE badge_purchases SET issue_failed_since = NULL, issue_error_at = NULL, issue_error = NULL WHERE badge_purchase_id = ? +Plan: +SEARCH badge_purchases USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE badge_purchases SET next_wake_at = ? WHERE badge_purchase_id = ? +Plan: +SEARCH badge_purchases USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE chat_items SET item_msg_body = ?, item_chat_binding = ?, item_signatures = ?, item_signed_by_group_member_id = ? WHERE chat_item_id = ? AND include_in_history = 1 Plan: SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) @@ -7951,6 +8009,14 @@ Query: UPDATE group_members SET member_pub_key = ?, updated_at = ? WHERE group_m Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +Query: UPDATE group_members SET member_pub_key = NULL WHERE member_category = 'host' +Plan: +SCAN group_members + +Query: UPDATE group_members SET member_pub_key = NULL WHERE member_category = 'user' +Plan: +SCAN group_members + Query: UPDATE group_members SET member_relations_vector = set_member_vector_new_relation(member_relations_vector, ?, ?, ?), updated_at = ? WHERE group_member_id = ? Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) @@ -8043,6 +8109,10 @@ Query: UPDATE groups SET local_display_name = ?, updated_at = ? WHERE user_id = Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) +Query: UPDATE groups SET member_priv_key = NULL +Plan: +SCAN groups + Query: UPDATE groups SET members_require_attention=1 WHERE group_id=? Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) @@ -8063,7 +8133,7 @@ Query: UPDATE groups SET request_shared_msg_id = ? WHERE group_id = ? Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) -Query: UPDATE groups SET root_pub_key = ?, member_priv_key = ?, updated_at = ? WHERE group_id = ? +Query: UPDATE groups SET root_pub_key = ?, updated_at = ? WHERE group_id = ? Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 090b6332ab..b6eddbaf0f 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -932,6 +932,10 @@ CREATE TABLE badge_purchases( alert_acked_episode TEXT, alert_snooze_until TEXT, badge_code_redemption_id INTEGER REFERENCES badge_code_redemptions, + issue_failed_since TEXT, + issue_error_at TEXT, + issue_error TEXT, + next_wake_at TEXT, UNIQUE(purchase_key) ) STRICT; CREATE TABLE badge_ledger( diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index c98609a065..8eeefa328f 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -694,18 +694,21 @@ type GroupMemberRow = (GroupMemberId, GroupId, Int64, MemberId, VersionChat, Ver type ProfileRow = (ProfileId, ContactName, Text, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, Maybe Preferences) :. BadgeRow :. ContactDomainRow -toGroupInfo :: UTCTime -> StoreCxt -> Int64 -> [ChatTagId] -> GroupInfoRow -> GroupInfo +toGroupInfo :: UTCTime -> StoreCxt -> Int64 -> [ChatTagId] -> GroupInfoRow -> (GroupInfo, GroupKeysRow) toGroupInfo now cxt userContactId chatTags ((groupId, localDisplayName, displayName, fullName, shortDescr, localAlias, description, image, groupType_, groupLink_, publicGroupId_) :. accessRow :. (enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. preparedGroupRow :. businessRow :. (BI useRelays, relayOwnStatus, uiThemes, currentMembers, publicMemberCount, rosterVersion, customData, chatItemTTL, membersRequireAttention, viaGroupLinkUri, groupDomainVerified) :. groupKeysRow :. userMemberRow) = let membership = (toGroupMember now userContactId userMemberRow) {memberChatVRange = vr cxt} chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} fullGroupPreferences = mergeGroupPreferences groupPreferences publicGroup = toPublicGroupProfile groupType_ groupLink_ publicGroupId_ (toPublicGroupAccess accessRow) - groupKeys = toGroupKeys publicGroupId_ groupKeysRow groupProfile = GroupProfile {displayName, fullName, shortDescr, description, image, publicGroup, groupPreferences, memberAdmission} businessChat = toBusinessChatInfo (toPublicGroupAccess accessRow >>= groupDomainClaim) businessRow preparedGroup = toPreparedGroup preparedGroupRow groupSummary = GroupSummary {currentMembers, publicMemberCount} - in GroupInfo {groupId, useRelays = BoolDef useRelays, relayOwnStatus, localDisplayName, groupProfile, localAlias, businessChat, fullGroupPreferences, membership, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, preparedGroup, chatTags, chatItemTTL, uiThemes, groupSummary, rosterVersion, customData, membersRequireAttention, viaGroupLinkUri, groupKeys, groupDomainVerified = unBI <$> groupDomainVerified} + gInfo = GroupInfo {groupId, useRelays = BoolDef useRelays, relayOwnStatus, localDisplayName, groupProfile, localAlias, businessChat, fullGroupPreferences, membership, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, preparedGroup, chatTags, chatItemTTL, uiThemes, groupSummary, rosterVersion, customData, membersRequireAttention, viaGroupLinkUri, groupDomainVerified = unBI <$> groupDomainVerified} + in (gInfo, groupKeysRow) + +toGroupInfo_ :: UTCTime -> StoreCxt -> Int64 -> [ChatTagId] -> GroupInfoRow -> GroupInfo +toGroupInfo_ now cxt userContactId chatTags row = fst $ toGroupInfo now cxt userContactId chatTags row toPreparedGroup :: PreparedGroupRow -> Maybe PreparedGroup toPreparedGroup = \case @@ -733,13 +736,35 @@ toPublicGroupAccess (groupWebPage, groupDomain_, domainWebPage_, allowEmbedding_ domainWebPage = maybe False unBI domainWebPage_ allowEmbedding = maybe False unBI allowEmbedding_ -toGroupKeys :: Maybe B64UrlByteString -> GroupKeysRow -> Maybe GroupKeys -toGroupKeys publicGroupId_ (rootPrivKey, rootPubKey, memberPrivKey) = - let publicGroupKeys = case (publicGroupId_, GRKPrivate <$> rootPrivKey <|> GRKPublic <$> rootPubKey) of - (Just publicGroupId, Just groupRootKey) -> Just $ Just PublicGroupKeys {publicGroupId, groupRootKey} - (Nothing, Nothing) -> Just Nothing - _ -> Nothing -- invalid state, in which case messages won't be signed even if memberPrivKey is present - in GroupKeys <$> publicGroupKeys <*> memberPrivKey +mkGroupKeys :: DB.Connection -> StoreCxt -> GroupInfo -> GroupKeysRow -> ExceptT StoreError IO GroupKeys +mkGroupKeys db cxt g@GroupInfo {groupId, groupProfile = GroupProfile {publicGroup}, membership} (rootPrivKey, rootPubKey, memberPrivKey_) = do + memberPrivKey <- case memberPrivKey_ of + Just k -> pure k + Nothing -> do + (_, k) <- atomically $ C.generateKeyPair (drg cxt) + setUserMemberKey db groupId (groupMemberId' membership) k + pure $ case (useRelays' g, isJust publicGroup, GRKPrivate <$> rootPrivKey <|> GRKPublic <$> rootPubKey) of + (False, _, _) -> GKGroup {memberPrivKey} + (True, True, Just groupRootKey) -> GKPublicGroup {groupRootKey, memberPrivKey} + (True, True, Nothing) -> GKPreparedPublicGroup {memberPrivKey} + (True, False, _) -> GKRelayRequest {memberPrivKey} + +setUserMemberKey :: DB.Connection -> GroupId -> GroupMemberId -> C.PrivateKeyEd25519 -> ExceptT StoreError IO C.PrivateKeyEd25519 +setUserMemberKey db groupId membershipId newKey = do + currentTs <- liftIO getCurrentTime + memberPrivKey <- + ExceptT . firstRow fromOnly (SEGroupNotFound groupId) $ + DB.query + db + [sql| + UPDATE groups + SET member_priv_key = COALESCE(member_priv_key, ?), updated_at = ? + WHERE group_id = ? + RETURNING member_priv_key + |] + (newKey, currentTs, groupId) + liftIO $ DB.execute db "UPDATE group_members SET member_pub_key = ?, updated_at = ? WHERE group_member_id = ?" (C.publicKey memberPrivKey, currentTs, membershipId) + pure memberPrivKey toGroupMember :: UTCTime -> Int64 -> GroupMemberRow -> GroupMember toGroupMember now userContactId ((groupMemberId, groupId, indexInGroup, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, BI showMessages, memberRestriction_) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. profileRow :. (createdAt, updatedAt) :. (supportChatTs_, supportChatUnread, supportChatMemberAttention, supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey, relayLink, memberCode_, memberCodeVerifiedAt_)) = @@ -901,8 +926,18 @@ addGroupChatTags db g@GroupInfo {groupId} = do chatTags <- getGroupChatTags db groupId pure (g :: GroupInfo) {chatTags} +getGroupInfoKeys :: DB.Connection -> StoreCxt -> User -> Int64 -> ExceptT StoreError IO GroupInfoKeys +getGroupInfoKeys db cxt user groupId = do + (g@GroupInfo {membership}, keysData) <- getGroupInfoRow db cxt user groupId + gks <- mkGroupKeys db cxt g keysData + let membership' = membership {memberPubKey = Just $ C.publicKey $ memberPrivKey gks} :: GroupMember + pure $ GIK (g :: GroupInfo) {membership = membership'} gks + getGroupInfo :: DB.Connection -> StoreCxt -> User -> Int64 -> ExceptT StoreError IO GroupInfo -getGroupInfo db cxt User {userId, userContactId} groupId = ExceptT $ do +getGroupInfo db cxt user groupId = fst <$> getGroupInfoRow db cxt user groupId + +getGroupInfoRow :: DB.Connection -> StoreCxt -> User -> Int64 -> ExceptT StoreError IO (GroupInfo, GroupKeysRow) +getGroupInfoRow db cxt User {userId, userContactId} groupId = ExceptT $ do currentTs <- getCurrentTime chatTags <- getGroupChatTags db groupId firstRow (toGroupInfo currentTs cxt userContactId chatTags) (SEGroupNotFound groupId) $ diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 7bfd2f299d..282d6b2fda 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -30,7 +30,9 @@ module Simplex.Chat.Types where import Control.Applicative ((<|>)) +import Control.Concurrent.STM (TVar) import Crypto.Number.Serialize (os2ip) +import Crypto.Random (ChaChaDRG) import Data.Aeson (FromJSON (..), ToJSON (..)) import qualified Data.Aeson as J import qualified Data.Aeson.Encoding as JE @@ -437,7 +439,7 @@ instance ToJSON ConnReqUriHash where data RequestEntity = REContact Contact - | REBusinessChat GroupInfo GroupMember + | REBusinessChat GroupInfoKeys GroupMember type RepeatRequest = Bool @@ -479,17 +481,30 @@ groupRootPubKey :: GroupRootKey -> C.PublicKeyEd25519 groupRootPubKey (GRKPrivate pk) = C.publicKey pk groupRootPubKey (GRKPublic pk) = pk -data GroupKeys = GroupKeys - { publicGroupKeys :: Maybe PublicGroupKeys, - memberPrivKey :: C.PrivateKeyEd25519 - } +data GroupKeys + = GKGroup + { memberPrivKey :: C.PrivateKeyEd25519 + } + | GKPublicGroup + { groupRootKey :: GroupRootKey, + memberPrivKey :: C.PrivateKeyEd25519 + } + | GKRelayRequest + { memberPrivKey :: C.PrivateKeyEd25519 + } + | GKPreparedPublicGroup + { memberPrivKey :: C.PrivateKeyEd25519 + } deriving (Eq, Show) -data PublicGroupKeys = PublicGroupKeys - { publicGroupId :: B64UrlByteString, - groupRootKey :: GroupRootKey - } - deriving (Eq, Show) +isPublicGroup :: GroupKeys -> Bool +isPublicGroup = \case + GKGroup {} -> False + GKPublicGroup {} -> True + GKRelayRequest {} -> True + GKPreparedPublicGroup {} -> True + +data GroupInfoKeys = GIK GroupInfo GroupKeys data GroupInfo = GroupInfo { groupId :: GroupId, @@ -515,7 +530,6 @@ data GroupInfo = GroupInfo rosterVersion :: Maybe VersionRoster, membersRequireAttention :: Int, viaGroupLinkUri :: Maybe ConnReqContact, - groupKeys :: Maybe GroupKeys, groupDomainVerified :: Maybe Bool } deriving (Eq, Show) @@ -523,6 +537,9 @@ data GroupInfo = GroupInfo useRelays' :: GroupInfo -> Bool useRelays' GroupInfo {useRelays} = isTrue useRelays +publicGroup' :: GroupInfo -> Maybe PublicGroupProfile +publicGroup' g@GroupInfo {groupProfile = GroupProfile {publicGroup}} = if useRelays' g then publicGroup else Nothing + relayServesGroup :: GroupInfo -> Bool relayServesGroup GroupInfo {relayOwnStatus} = case relayOwnStatus of Just RSInactive -> False @@ -595,7 +612,7 @@ data GroupLink = GroupLink data ContactOrGroup = CGContact Contact | CGGroup GroupInfo [GroupMember] -data PreparedChatEntity = PCEContact Contact | PCEGroup {groupInfo :: GroupInfo, hostMember :: GroupMember} +data PreparedChatEntity = PCEContact Contact | PCEGroup {groupInfo :: GroupInfoKeys, hostMember :: GroupMember} contactAndGroupIds :: ContactOrGroup -> (Maybe ContactId, Maybe GroupId) contactAndGroupIds = \case @@ -1145,7 +1162,8 @@ memberRestrictions m data ReceivedGroupInvitation = ReceivedGroupInvitation { fromMember :: GroupMember, connRequest :: ConnReqInvitation, - groupInfo :: GroupInfo + groupInfo :: GroupInfo, + groupKeys :: GroupKeys } deriving (Eq, Show) @@ -2240,8 +2258,8 @@ type VersionChat = Version ChatVersion type VersionRangeChat = VersionRange ChatVersion -- | Store-wide context passed to store functions in place of the bare `vr` --- parameter. Built from config by mkStoreCxt; more fields are added here over time. -data StoreCxt = StoreCxt {vr :: VersionRangeChat, badgeKeys :: Map Int BBSPublicKey} +-- parameter. Built from config by storeCxt; more fields are added here over time. +data StoreCxt = StoreCxt {vr :: VersionRangeChat, badgeKeys :: Map Int BBSPublicKey, drg :: TVar ChaChaDRG} pattern VersionChat :: Word16 -> VersionChat pattern VersionChat v = Version v @@ -2347,12 +2365,6 @@ instance FromJSON GroupSummary where parseJSON = $(JQ.mkParseJSON defaultJSON ''GroupSummary) omittedField = Just GroupSummary {currentMembers = 0, publicMemberCount = Nothing} -$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "GRK") ''GroupRootKey) - -$(JQ.deriveJSON defaultJSON ''PublicGroupKeys) - -$(JQ.deriveJSON defaultJSON ''GroupKeys) - $(JQ.deriveJSON defaultJSON ''GroupInfo) $(JQ.deriveJSON defaultJSON ''Group) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 007dc8af0b..1c62bc267b 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -44,7 +44,9 @@ import Simplex.Chat.Help import Simplex.Chat.Library.Commands (badgeServiceErrorText, maxImageSize) import Simplex.Chat.Markdown import Simplex.Chat.Badges (BadgeInfo (..), BadgeStatus (..), BadgeType (..), LocalBadge, localBadgeInfo, localBadgeStatus) -import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeState (..)) +import Simplex.Chat.Badges.Ledger (creditTypeTag, debitTypeTag) +import Simplex.Chat.Badges.Service (StatementEntry (..), StatementEntryType (..)) +import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeIssueError (..), BadgeState (..)) import Simplex.Chat.Messages hiding (NewChatItem (..)) import Simplex.Chat.Messages.CIContent import Simplex.Chat.Operators @@ -192,6 +194,7 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, showFullLinks, te -- the badge is only shown when it is the one now on the profile; a replayed code's badge may not be CRBadgeRedeemed u badge newBadge _ -> ttyUser u $ if newBadge then "badge redeemed" : viewContactBadge (Just badge) else ["badge already redeemed"] CRBadgeState u st -> ttyUser u $ viewUserBadgeState st + CRBadgeLedger u entries -> ttyUser u $ viewBadgeLedger entries CRGroupCreated u g -> ttyUser u $ viewGroupCreated g testView CRPublicGroupCreated u g _groupLink _relays -> ttyUser u $ viewGroupCreated g testView CRPublicGroupCreationFailed u results -> ttyUser u $ viewPublicGroupCreationFailed results @@ -1839,7 +1842,7 @@ viewContactBadge = maybe [] $ \lb -> viewUserBadgeState :: Maybe BadgeState -> [StyledString] viewUserBadgeState = maybe [] viewBadge where - viewBadge BadgeState {badgePurchaseId, badgeType, monthsLeft, paidThrough, alert} = + viewBadge BadgeState {badgePurchaseId, badgeType, monthsLeft, paidThrough, alert, issueError, nextWakeAt} = plain ( tshow badgePurchaseId <> ": " @@ -1848,15 +1851,35 @@ viewUserBadgeState = maybe [] viewBadge <> tshow monthsLeft <> " months left, paid through " <> day paidThrough + <> maybe "" ((", next check " <>) . dayTime) nextWakeAt ) - : maybe [] viewBadgeAlert alert + : maybe [] viewBadgeIssueError issueError + <> maybe [] viewBadgeAlert alert + +viewBadgeIssueError :: BadgeIssueError -> [StyledString] +viewBadgeIssueError BadgeIssueError {failedSince, lastAttemptAt, reason} = + [plain $ "renewal failing since " <> day failedSince <> ", last " <> dayTime lastAttemptAt <> ": " <> safeDecodeUtf8 (strEncode reason)] viewBadgeAlert :: BadgeAlert -> [StyledString] viewBadgeAlert BadgeAlert {kind, date} = [plain $ "badge alert: " <> textEncode kind <> " " <> day date] +viewBadgeLedger :: [StatementEntry] -> [StyledString] +viewBadgeLedger [] = ["no ledger entries"] +viewBadgeLedger entries = map viewEntry entries + where + viewEntry StatementEntry {createdAt, entryType, changeMonths, balanceMonths, balanceStartTs} = + plain $ day createdAt <> " " <> entryKind entryType <> " " <> withSign changeMonths <> " -> " <> tshow balanceMonths <> ", from " <> day balanceStartTs + entryKind = \case + SECredit c -> creditTypeTag c + SEDebit d -> debitTypeTag d + withSign n = (if n >= 0 then "+" else "") <> tshow n + day :: UTCTime -> Text day = T.pack . formatTime defaultTimeLocale "%Y-%m-%d" +dayTime :: UTCTime -> Text +dayTime = T.pack . formatTime defaultTimeLocale "%Y-%m-%d %H:%M" + viewContactInfo :: Contact -> Maybe ConnectionStats -> Maybe Profile -> [StyledString] viewContactInfo ct@Contact {contactId, profile = LocalProfile {localAlias, contactLink, localBadge, contactDomain, contactDomainVerified, description}, activeConn, uiThemes, customData} stats incognitoProfile = ["contact ID: " <> sShow contactId] diff --git a/src/Simplex/Chat/Web.hs b/src/Simplex/Chat/Web.hs index 9720116d91..30d674d55f 100644 --- a/src/Simplex/Chat/Web.hs +++ b/src/Simplex/Chat/Web.hs @@ -42,7 +42,7 @@ import Data.Text (Text) import qualified Data.Text as T import qualified Data.Text.IO as TIO import Data.Time.Clock (UTCTime, getCurrentTime) -import Simplex.Chat.Controller (ChatController (..), CorsOrigin (..), PublishableGroup (..), WebPreviewConfig (..), WebPreviewState (..), mkStoreCxt) +import Simplex.Chat.Controller (ChatController (..), CorsOrigin (..), PublishableGroup (..), WebPreviewConfig (..), WebPreviewState (..), storeCxt) import Simplex.Chat.Markdown (FormattedText (..), MarkdownList, parseMaybeMarkdownList) import Simplex.Chat.Messages ( CChatItem (..), @@ -137,7 +137,7 @@ webPreviewWorker cfg@WebPreviewConfig {webJsonDir, webCorsFile, webUpdateInterva seedRoutinePending wps forever $ workerLoop wps `catchOwn` \e -> logError ("web preview worker error: " <> tshow e) where - cxt = mkStoreCxt (config cc) + cxt = storeCxt cc workerLoop wps@WebPreviewState {priorityRender, filesToRemove, corsNeeded, routinePending, wakeSignal} = do drainRemovals @@ -262,7 +262,7 @@ renderGroupPreview WebPreviewConfig {webJsonDir, webPreviewItemCount} cc user gI pure $ corsEntry publicGroupId <$> publicGroupAccess Nothing -> pure Nothing where - cxt = mkStoreCxt (config cc) + cxt = storeCxt cc channelContentChanged :: ChatController -> Int64 -> STM () channelContentChanged cc gId = diff --git a/tests/BadgeTests.hs b/tests/BadgeTests.hs index 06c5ccfe86..bd8f62d3fd 100644 --- a/tests/BadgeTests.hs +++ b/tests/BadgeTests.hs @@ -27,14 +27,16 @@ import Simplex.Chat.Badges import Simplex.Chat.Badges.Code import Simplex.Chat.Badges.Ledger import Simplex.Chat.Badges.Service +import Simplex.Chat.Badges.Types (BadgeIssueFailure (..)) import Simplex.Chat (defaultChatConfig) import Simplex.Chat.Controller (ChatError (..), ChatErrorType (..), badgeRetryInterval, chatErrorAgent) -import Simplex.Chat.Library.Commands (badgeErrorRetry, badgeRetryAfter, badgeStalledInterval) +import Simplex.Chat.Library.Commands (badgeErrorRetry, badgeFailureTransient, badgeIssueFailure, badgeRetryAfter, badgeServiceErrorText, badgeStalledInterval) import Simplex.Messaging.Agent.Protocol (AgentErrorType (..), AgentServiceError (..), SMPAgentError (..)) import Simplex.Messaging.Agent.RetryInterval (RetryInterval (..), nextRetryDelay) import Simplex.Messaging.Crypto.BBS import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Protocol (BrokerErrorType (..), NetworkError (..)) +import Simplex.Messaging.Protocol (BrokerErrorType (..), ErrorType (AUTH), NetworkError (..)) +import Simplex.Messaging.Util (tshow) import Simplex.Messaging.Version.Internal (Version (..)) import Test.Hspec @@ -83,6 +85,11 @@ badgeTests = do it "backs off to the cap" testRetryBackoff it "floors the wait a service asks for, and honours anything above it" testServiceRetryFloor it "sends retryAfter with the transient service codes and no other" testServiceRetryAfter + describe "recording a failed renewal" $ do + it "records the agent error a request failed with, not the chat error around it" testIssueFailureClassification + it "waits for the credential to lapse on internal, as on a refusal the service marks transient" testServiceErrorTransience + it "stores every failure so that it reads back, whatever the service called its code" testIssueFailureEncoding + it "bounds and strips a code this version does not know" testServiceErrorCodeBounded describe "service protocol JSON" $ do it "redeemBadgeCode request matches the schema" testRedeemRequestJSON it "badgeCredential response matches the schema" testCredentialResponseJSON @@ -677,6 +684,66 @@ testServiceRetryAfter = do (\code -> badgeErrorRetryAfter code `shouldBe` Nothing) [BSEBadRequest, BSEUnsupportedVersion, BSEUnknownPurchaseKey, BSECodeInvalid, BSECodeUsed, BSECodeExpired, BSEUnknown "future_code"] +-- The app shows the recorded failure in a sentence, so an agent error is stored as the agent +-- error and not as the chat error wrapping it, whether or not it can clear on its own. +testIssueFailureClassification :: IO () +testIssueFailureClassification = do + let failureFor = badgeIssueFailure . chatErrorAgent + timeout = BROKER "localhost" TIMEOUT + auth = SMP "localhost" AUTH + failureFor (AGENT (A_SERVICE ASETimeout)) `shouldBe` BIFServiceTimeout + failureFor timeout `shouldBe` BIFNetwork {agentError = tshow timeout} + failureFor auth `shouldBe` BIFUnexpected {message = tshow auth} + badgeIssueFailure (ChatError (CECommandError "unexpected badge service response")) `shouldBe` BIFUnexpected {message = "unexpected badge service response"} + +-- A refusal the service marks transient is not worth a word before the credential lapses. Internal +-- comes without retryAfter so that a failing service is not pressed, not because the fault is +-- final, so it waits the same way; any other refusal is told at once. +testServiceErrorTransience :: IO () +testServiceErrorTransience = do + badgeFailureTransient BIFServiceError {code = BSERateLimited, retryable = True} `shouldBe` True + badgeFailureTransient BIFServiceError {code = BSEInternal, retryable = False} `shouldBe` True + badgeFailureTransient BIFServiceError {code = BSEUnknownPurchaseKey, retryable = False} `shouldBe` False + badgeFailureTransient BIFServiceError {code = BSEUnknown "future_code", retryable = False} `shouldBe` False + badgeFailureTransient BIFServiceError {code = BSEUnknown "future_code", retryable = True} `shouldBe` True + +-- The failure is stored as text and read back by getBadgePurchase, so every value this version +-- writes must read back as itself, and any other value must still read: a row it cannot parse +-- would otherwise fail every read of the purchase. +testIssueFailureEncoding :: IO () +testIssueFailureEncoding = do + mapM_ + (\f -> strDecode (strEncode f) `shouldBe` Right f) + [ BIFServiceError {code = BSEUnknownPurchaseKey, retryable = False}, + BIFServiceError {code = BSERateLimited, retryable = True}, + BIFServiceError {code = BSEUnknown "future_code", retryable = True}, + BIFServiceError {code = BSEUnknown "code with spaces", retryable = False}, + BIFServiceError {code = BSEUnknown "", retryable = False}, + BIFServiceTimeout, + BIFNetwork {agentError = "BROKER \"smp://x\" NETWORK"}, + BIFInvalidCredential, + BIFUnexpected {message = "unexpected badge service response"}, + BIFUnexpected {message = "several words and : punctuation"} + ] + -- the row the CLI prints, and what a reader of the database sees + strEncode BIFServiceError {code = BSECodeUsed, retryable = False} `shouldBe` "service_error final code_used" + strEncode BIFServiceError {code = BSERateLimited, retryable = True} `shouldBe` "service_error retry rate_limited" + strEncode BIFServiceTimeout `shouldBe` "service_timeout" + -- a row a later version wrote, or one edited by hand, still reads - as the text it holds + strDecode "future_failure with text" `shouldBe` Right BIFUnexpected {message = "future_failure with text"} + strDecode "service_timeout trailing" `shouldBe` Right BIFUnexpected {message = "service_timeout trailing"} + strDecode "service_error maybe code_used" `shouldBe` Right BIFUnexpected {message = "service_error maybe code_used"} + +-- The text of a code this version does not know is chosen by the service, and the app shows it in +-- a sentence of its own - so it is bounded and stripped of anything but a code before it is stored. +testServiceErrorCodeBounded :: IO () +testServiceErrorCodeBounded = do + badgeServiceErrorText BSECodeUsed `shouldBe` "code_used" + badgeServiceErrorText (BSEUnknown "future_code") `shouldBe` "future_code" + badgeServiceErrorText (BSEUnknown "two words") `shouldBe` "twowords" + badgeServiceErrorText (BSEUnknown (T.replicate 100 "a")) `shouldBe` T.replicate 32 "a" + badgeServiceErrorText (BSEUnknown "Visit evil.example.com!") `shouldBe` "isitevilexamplecom" + -- The client replicates entry_credit_type / entry_debit_type verbatim, so a stored tag that -- disagreed with the wire tag would put a different row on each side. testEntryTypeColumns :: IO () diff --git a/tests/Bots/BadgeServiceTests.hs b/tests/Bots/BadgeServiceTests.hs index 640d0ba533..a5e3cb82c8 100644 --- a/tests/Bots/BadgeServiceTests.hs +++ b/tests/Bots/BadgeServiceTests.hs @@ -17,9 +17,10 @@ import ChatTests.DBUtils import ChatTests.Utils import Control.Concurrent (forkIO, killThread, threadDelay) import Control.Concurrent.STM (atomically, readTMVar) -import Control.Monad (void, when) +import Control.Monad (forM_, void, when) import Control.Exception (finally) import qualified Data.Aeson as J +import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import Data.Char (toLower) import Data.Either (isLeft, isRight) @@ -32,6 +33,7 @@ import System.Timeout (timeout) import Data.Text (Text) import qualified Data.Text as T import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime, diffUTCTime, getCurrentTime, nominalDay) +import Data.Time.Format (defaultTimeLocale, formatTime) import Simplex.Chat.Badges (BadgeCredential (..), BadgeInfo (..), BadgeMasterKey, BadgeType (..), generateMasterKey) import Simplex.Chat.Badges.Code (BadgeCode, badgeCodeText, formatBadgeCode, parseBadgeCode, randomBadgeCode) import Simplex.Chat.Badges.Ledger (addMonths, creditTypeTag, debitTypeTag, endOfMondayAfter) @@ -40,8 +42,10 @@ import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), ChatRespon import Simplex.Chat.Core (sendChatCmdStr) import Simplex.Chat.Options (CoreChatOpts (..)) import Simplex.Chat.Options.DB +import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..)) +import Simplex.Messaging.Agent.RetryInterval (RetryInterval (..)) import Simplex.Messaging.Agent.Store.Common (withTransaction) -import Simplex.Messaging.Agent.Store.DB (BoolInt (..)) +import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Chat.Types (ChatPeerType (..), Profile (..)) import qualified Simplex.Messaging.Crypto as C @@ -82,6 +86,11 @@ badgeServiceTests = do it "should retire when entitlement ends, not when the credential expires" testRetiresWhenEntitlementEnds it "should alert that support ended, survive a restart, and go silent once acknowledged" testEndedAlert it "should raise a snoozed alert once more when the snooze lapses" testSnoozedAlertReturns + it "should alert that renewal failed when the service refuses it" testIssueFailedAlert + it "should wait for the shown credential to lapse before alerting on a failure that can clear" testIssueFailedWaitsForExpiry + it "should silence an acknowledged run of failures, and alert again on the next run" testIssueFailedAckAndNewRun + it "should record no failure when the service issues nothing because the months ran out" testNoCredentialMonthsRanOut + it "should record a failure when the service issues nothing though months are left" testNoCredentialMonthsLeft it "should renew a badge on a profile that is not active, without switching to it" testRenewalKeepsActiveProfile it "should broadcast the current profile when a renewal presents a badge" testRenewalKeepsProfileEdits it "should present the month already issued when a previous pass did not" testPresentationCatchesUp @@ -142,6 +151,11 @@ data BadgeServiceEnv = BadgeServiceEnv bsController :: ChatController } +-- | Stop the service for good: requests sent after it go unanswered until they time out. Stopping +-- chat unsubscribes its queues, where killing the thread could still let a request arrive mid-teardown. +stopBadgeService :: ChatController -> IO () +stopBadgeService cc = void $ sendChatCmdStr cc "/_stop" + -- | Start the badge service on a fresh issuer key, and hand the test body what depends on it: -- the client config trusting that key and addressing the service, the address, and the controller. withBadgeService :: HasCallStack => TestParams -> (ChatConfig -> String -> ChatController -> IO ()) -> IO () @@ -504,6 +518,12 @@ ledgerRows ChatController {chatStore} table = <> table <> " ORDER BY entry_id" +-- the two dates the CLI prints for each row +ledgerTimes :: ChatController -> IO [(UTCTime, UTCTime)] +ledgerTimes ChatController {chatStore} = + withTransaction chatStore $ \db -> + DB.query_ db "SELECT service_created_at, balance_start_ts FROM badge_ledger ORDER BY entry_id" + -- | The client's verdict on each row, in ledger order. The service has no such column: it computes -- the rows rather than checking what someone else computed. balanceChecks :: ChatController -> IO [Maybe Bool] @@ -539,6 +559,18 @@ testClientReplicatesLedger ps = -- nor a second issuance for the one month issued: the replay names a month already stored expiries <- issuedExpiries (chatController alice) length expiries `shouldBe` 1 + -- the CLI lists the rows oldest first, with the dates they carry + times <- ledgerTimes (chatController alice) + alice ##> "/_badge ledger 1 1" + forM_ (zip times [("code", "+3", "3"), ("badge", "-1", "2")]) $ \((createdAt, from), (kind, change, balance)) -> + alice <## (day createdAt <> " " <> kind <> " " <> change <> " -> " <> balance <> ", from " <> day from) + -- and nothing for a purchase that is another profile's + alice ##> "/create user alisa" + showActiveUser alice "alisa" + alice ##> "/_badge ledger 2 1" + alice <## "no ledger entries" + where + day = formatTime defaultTimeLocale "%Y-%m-%d" -- the balance start of the last row, which is when the next month falls due dueAtOf :: [ReplicatedRow] -> UTCTime @@ -912,6 +944,236 @@ testEndedAlert ps = alice <## "user profile: alice (Alice)" alice <## "use /p [] to change it" +-- A refusal the service will not take back is worth telling the user at once: the badge is still +-- worn and will start showing as expired. +testIssueFailedAlert :: HasCallStack => TestParams -> IO () +testIssueFailedAlert ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClock, bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + code <- issueCode cc BTSupporter 3 + redeemFirstBadge alice code + rows <- ledgerRows (chatController alice) "badge_ledger" + -- the service no longer knows this purchase, so it refuses with no retryAfter: terminal + setServicePurchaseKey cc "not the purchase key" + setClockAt bsClock $ fst $ renewalMoments rows + alice ##> "/_app activate" + alice <## "ok" + alice <## badgeServiceRefused + alice <##. "badge alert: issue_failed " + -- the state reports the failure, when the next attempt is due, and the alert it raised + alice <##. "1: supporter" + alice <##. "renewal failing since " + alice <##. "badge alert: issue_failed " + issueErrorRow (chatController alice) >>= \(_, reason) -> + reason `shouldBe` Just "service_error final unknown_purchase_key" + +-- A failure that can clear on its own is not worth a word while contacts still see the badge as +-- valid: neither the alert nor the state shows it until the shown credential lapses, which is when +-- they stop. It is recorded from the first attempt, so the run's start is not lost. +testIssueFailedWaitsForExpiry :: HasCallStack => TestParams -> IO () +testIssueFailedWaitsForExpiry ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClock, bsClientCfg, bsController = cc} -> do + -- the redemption runs against the service, so it keeps the ordinary request timeout + (requestAt, presentAt) <- withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + code <- issueCode cc BTSupporter 3 + redeemFirstBadge alice code + renewalMoments <$> ledgerRows (chatController alice) "badge_ledger" + stopBadgeService cc + let cfg = failingServiceCfg bsClientCfg + -- the request goes unanswered and times out, which is a failure that can clear on its own + setClockAt bsClock requestAt + failedSince <- withTestChatCfg ps cfg "alice" $ \alice -> do + alice <##. "1: supporter" + -- the state carries no failure and no alert: /p prints only its own output + alice ##> "/p" + alice <## "user profile: alice (Alice, * supporter)" + alice <## "use /p [] to change it" + (since, reason) <- issueErrorRow (chatController alice) + reason `shouldBe` Just "service_timeout" + pure since + -- the shown credential lapses: from here contacts see the badge as expired, and it is worth a word + setClockAt bsClock presentAt + withTestChatCfg ps cfg "alice" $ \alice -> do + alice <##. "1: supporter" + alice <##. "renewal failing since " + alice <##. "badge alert: issue_failed " + -- still the one run: the alert's episode is when it started, not this pass + issueErrorRow (chatController alice) >>= \(since, _) -> since `shouldBe` failedSince + +-- Acknowledging answers the run that is failing, not the failure: the error stays on the badge +-- screen while the alert goes quiet, and a later run raises it again under a new episode. +testIssueFailedAckAndNewRun :: HasCallStack => TestParams -> IO () +testIssueFailedAckAndNewRun ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClock, bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + code <- issueCode cc BTSupporter 3 + redeemFirstBadge alice code + rows <- ledgerRows (chatController alice) "badge_ledger" + purchaseKey <- servicePurchaseKey cc + setServicePurchaseKey cc "not the purchase key" + let (requestAt, presentAt) = renewalMoments rows + setClockAt bsClock requestAt + alice ##> "/_app activate" + alice <## "ok" + alice <## badgeServiceRefused + alice <##. "badge alert: issue_failed " + alice <##. "1: supporter" + alice <##. "renewal failing since " + alice <##. "badge alert: issue_failed " + episode <- episodeOf . fst <$> issueErrorRow (chatController alice) + alice ##> ("/_badge ack 1 1 issue_failed off " <> T.unpack episode) + -- the state still carries the error, and no alert with it + alice <##. "1: supporter" + alice <##. "renewal failing since " + -- the ack signalled the worker, and the pass it ran failed again and raised nothing + alice <## badgeServiceRefused + alice <##. "1: supporter" + alice <##. "renewal failing since " + ackedEpisode (chatController alice) `shouldReturn` (Just "issue_failed", Just episode) + -- the service is back: the month it owes is issued, which ends the run + setServicePurchaseKey cc purchaseKey + alice ##> "/_app activate" + alice <## "ok" + renewed <- waitLedgerRows (chatController alice) 3 + alice <##. "1: supporter" + waitIssueErrorCleared (chatController alice) + -- the month issued is presented when the one on the profile lapses, and only then is the + -- next renewal asked for - so the run that fails next is a new one + setClockAt bsClock presentAt + alice ##> "/_app activate" + alice <## "ok" + waitShownIssued (chatController alice) + setServicePurchaseKey cc "not the purchase key" + setClockAt bsClock $ fst $ renewalMoments renewed + alice ##> "/_app activate" + alice <## "ok" + alice <## badgeServiceRefused + alice <##. "badge alert: issue_failed " + alice <##. "1: supporter" + alice <##. "renewal failing since " + alice <##. "badge alert: issue_failed " + -- a new run, so acknowledging the first one does not silence this one + newEpisode <- episodeOf . fst <$> issueErrorRow (chatController alice) + newEpisode `shouldNotBe` episode + +-- The service issues nothing when the months it holds have run out - here through a debit the +-- client had not seen. That is support ending, not a failed renewal: the statement brings the +-- balance to nothing and the usual alert follows, where a recorded failure would name a +-- credential that was never issued. +testNoCredentialMonthsRanOut :: HasCallStack => TestParams -> IO () +testNoCredentialMonthsRanOut ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClock, bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + code <- issueCode cc BTSupporter 3 + redeemFirstBadge alice code + rows <- ledgerRows (chatController alice) "badge_ledger" + insertServiceSupportDebit cc + -- at the credential's expiry the issued period has ended, so the service holds no current credential either + setClockAt bsClock $ snd $ renewalMoments rows + alice ##> "/_app activate" + alice <## "ok" + alice <##. "badge alert: support_ended " + issueErrorRow (chatController alice) `shouldReturn` (Nothing, Nothing) + -- the debit is the client's now, and the next pass retires the badge on it + rows' <- ledgerRows (chatController alice) "badge_ledger" + map (\(_, ch, m, _, _, t) -> (ch, m, t)) rows' `shouldBe` [(3, 3, Just "code"), (-1, 2, Just "badge"), (-2, 0, Just "support")] + alice ##> "/_app activate" + alice <## "ok" + alice <##. "1: supporter" + alice <##. "badge alert: support_ended " + waitShownBadge (chatController alice) Nothing + +-- The service issuing nothing while the ledger still owes a month is a fault the client cannot +-- resolve, so it is recorded and told at once. +testNoCredentialMonthsLeft :: HasCallStack => TestParams -> IO () +testNoCredentialMonthsLeft ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClock, bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + code <- issueCode cc BTSupporter 3 + redeemFirstBadge alice code + rows <- ledgerRows (chatController alice) "badge_ledger" + zeroServiceLedgerTip cc + setClockAt bsClock $ snd $ renewalMoments rows + alice ##> "/_app activate" + alice <## "ok" + alice <## "internal chat error: badge service issued no credential" + alice <##. "badge alert: issue_failed " + alice <##. "1: supporter" + alice <##. "renewal failing since " + alice <##. "badge alert: issue_failed " + (_, reason) <- issueErrorRow (chatController alice) + reason `shouldBe` Just "unexpected badge service issued no credential" + +-- | A debit the service wrote on its own, taking back the months the purchase had: copied from +-- the tip so that it follows it in every column the client checks. +insertServiceSupportDebit :: ChatController -> IO () +insertServiceSupportDebit ChatController {chatStore} = + withTransaction chatStore $ \db -> + DB.execute_ db . fromString $ + "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_debit_type)" + <> " SELECT 'support-debit', badge_purchase_id, -balance_months, 0, balance_start_ts, balance_anchor_ts," + <> " balance_badge_type, service_created_at, created_at, 'debit', 'support'" + <> " FROM sx_badge_service_badge_ledger ORDER BY entry_id DESC LIMIT 1" + +-- | The service's tip with its months struck out: nothing to issue, and no row restating the ledger. +zeroServiceLedgerTip :: ChatController -> IO () +zeroServiceLedgerTip ChatController {chatStore} = + withTransaction chatStore $ \db -> + DB.execute_ db "UPDATE sx_badge_service_badge_ledger SET balance_months = 0 WHERE entry_id = (SELECT MAX(entry_id) FROM sx_badge_service_badge_ledger)" + +badgeServiceRefused :: String +badgeServiceRefused = "bad chat command: badge service error: unknown_purchase_key" + +-- | The alert's episode is the start of the run of failures, as the ack command spells it. +episodeOf :: HasCallStack => Maybe UTCTime -> Text +episodeOf = maybe (error "no failure recorded") (safeDecodeUtf8 . strEncode) + +-- | A client that gives up on an unanswered service request in seconds rather than half a minute, +-- and does not retry a failed pass on its own - so every pass is one the test asked for. +failingServiceCfg :: ChatConfig -> ChatConfig +failingServiceCfg cfg = + cfg + { agentConfig = (agentConfig cfg) {serviceRequestTimeout = 2}, + badgeRetryInterval = RetryInterval {initialInterval = 3600000000, increaseAfter = 0, maxInterval = 3600000000} + } + +-- | The service reaches a purchase by the verified signer's key and no other way, so changing the +-- key it holds makes it answer unknown_purchase_key, and putting it back makes renewals work again. +servicePurchaseKey :: HasCallStack => ChatController -> IO ByteString +servicePurchaseKey ChatController {chatStore} = do + rows :: [(Binary ByteString, Int64)] <- + withTransaction chatStore $ \db -> + DB.query_ db "SELECT purchase_key, badge_purchase_id FROM sx_badge_service_badge_purchases" + pure $ case rows of + [(Binary k, _)] -> k + _ -> error $ "expected one service purchase, got " <> show (length rows) + +setServicePurchaseKey :: ChatController -> ByteString -> IO () +setServicePurchaseKey ChatController {chatStore} k = + withTransaction chatStore $ \db -> + DB.execute db "UPDATE sx_badge_service_badge_purchases SET purchase_key = ?" (Only (Binary k)) + +-- the run of failed renewals the purchase carries: when it started, and the last failure as stored +issueErrorRow :: HasCallStack => ChatController -> IO (Maybe UTCTime, Maybe Text) +issueErrorRow ChatController {chatStore} = do + rows :: [(Maybe UTCTime, Maybe Text)] <- + withTransaction chatStore $ \db -> + DB.query_ db "SELECT issue_failed_since, issue_error FROM badge_purchases" + pure $ case rows of + [r] -> r + _ -> error $ "expected one badge purchase, got " <> show (length rows) + +-- the clearing is written by the pass that stored the issuance, which the test waits for +waitIssueErrorCleared :: HasCallStack => ChatController -> IO () +waitIssueErrorCleared cc = loop (100 :: Int) + where + loop 0 = issueErrorRow cc >>= (`shouldBe` (Nothing, Nothing)) + loop i = + issueErrorRow cc >>= \r -> + if r == (Nothing, Nothing) then pure () else threadDelay 50000 >> loop (i - 1) + -- A worker runs for every profile, not only the one in use, so presenting a renewed badge must not -- make its profile active - the next message would then be sent from the wrong identity. testRenewalKeepsActiveProfile :: HasCallStack => TestParams -> IO () diff --git a/tests/ChatTests/Files.hs b/tests/ChatTests/Files.hs index 55766e905a..14851444a7 100644 --- a/tests/ChatTests/Files.hs +++ b/tests/ChatTests/Files.hs @@ -920,8 +920,8 @@ testFileBadgeProofStatus ps = do withNewTestChatCfg ps (badgeFileCfg pk) "alice" aliceProfile $ \alice -> do now <- getCurrentTime let ph = PHFileInv {chatBinding = "Dalice-binding", fileSize = 272376} - otherBinding = (ph :: ProofPresHeader) {chatBinding = "Dbob-binding"} - otherSize = (ph :: ProofPresHeader) {fileSize = 1} + otherBinding = PHFileInv {chatBinding = "Dbob-binding", fileSize = 272376} + otherSize = PHFileInv {chatBinding = "Dalice-binding", fileSize = 1} proofFor expiry = do cred <- issueTestBadge sk expiry Right badge <- badgeProof pk cred ph diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index e17de3a766..66deaae225 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -114,6 +114,7 @@ chatGroupTests = do it "shared batch body reference across binary and json members" testGroupSharedBatchBodyMixedModes it "shared batch body reused across binary and json members" testSharedBatchBodyMixed it "all old members group upgrades to current version" testGroupAllOldThenUpgrade + it "member key is generated at the first read of a group created without one" testGroupMemberKeyGenerated describe "async group connections" $ do xit "create and join group when clients go offline" testGroupAsync describe "group links" $ do @@ -2594,6 +2595,60 @@ testGroupAllOldThenUpgrade ps = where oldCfg = testCfg {chatVRange = mkVersionRange (VersionChat 9) (VersionChat 17)} +testGroupMemberKeyGenerated :: HasCallStack => TestParams -> IO () +testGroupMemberKeyGenerated = + testChat2 aliceProfile bobProfile $ \alice bob -> do + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + alice <## "bob (Bob): accepting request to join group #team..." + concurrentlyN_ + [ alice <## "#team: bob joined the group", + do + bob <## "#team: joining the group..." + bob <## "#team: you joined the group" + ] + alice #> "#team hi0" + bob <# "#team alice> hi0" + void $ withCCTransaction alice $ \db -> do + DB.execute_ db "UPDATE groups SET member_priv_key = NULL" + DB.execute_ db "UPDATE group_members SET member_pub_key = NULL WHERE member_category = 'user'" + void $ withCCTransaction bob $ \db -> + DB.execute_ db "UPDATE group_members SET member_pub_key = NULL WHERE member_category = 'host'" + alice ##> "/p alisa" + alice <## "user profile is changed to alisa (your 0 contacts are notified)" + alice #> "#team hi1" + bob <# "#team alisa> hi1" + bob ##> "/_get chat #1 count=100" + r <- chat <$> getTermLine bob + r `shouldContain` [(0, "updated profile (signed, no key to verify)")] + privKey1 <- alicePrivKey alice + pubKey1 <- alicePubKey alice + bobKnownKey <- withCCTransaction bob $ \db -> + DB.query_ db "SELECT member_pub_key FROM group_members WHERE member_category = 'host'" :: IO [Only (Maybe C.PublicKeyEd25519)] + (C.publicKey <$> privKey1) `shouldBe` pubKey1 + bobKnownKey `shouldBe` [Only pubKey1] + alice ##> "/p alisa2" + alice <## "user profile is changed to alisa2 (your 0 contacts are notified)" + alice #> "#team hi2" + bob <# "#team alisa2> hi2" + bob ##> "/_get chat #1 count=100" + r' <- chat <$> getTermLine bob + r' `shouldContain` [(0, "updated profile (signed)")] + privKey2 <- alicePrivKey alice + privKey2 `shouldBe` privKey1 + where + alicePrivKey alice = do + [Only k] <- withCCTransaction alice $ \db -> DB.query_ db "SELECT member_priv_key FROM groups" :: IO [Only (Maybe C.PrivateKeyEd25519)] + pure k + alicePubKey alice = do + [Only k] <- withCCTransaction alice $ \db -> DB.query_ db "SELECT member_pub_key FROM group_members WHERE member_category = 'user'" :: IO [Only (Maybe C.PublicKeyEd25519)] + pure k + testGroupAsync :: HasCallStack => TestParams -> IO () testGroupAsync ps = do withNewTestChat ps "alice" aliceProfile $ \alice -> do diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 743bd5be52..10cb6e45ff 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -24,7 +24,7 @@ import Data.Time.Clock.POSIX (posixSecondsToUTCTime) import Data.Time.Format (defaultTimeLocale, formatTime) import qualified Data.Map.Strict as M import Simplex.Chat.Badges (BadgeCredential, BadgeInfo (..), BadgePurchase (..), BadgeRequest (..), BadgeType (..), generateMasterKey, issueBadge, verifyPayment) -import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), ChatHooks (..), defaultChatHooks, mkStoreCxt) +import Simplex.Chat.Controller (ChatConfig (..), ChatHooks (..), defaultChatHooks, storeCxt) import Simplex.Chat.Options (ChatOpts (..), CoreChatOpts (..)) import Simplex.Chat.Protocol (LinkOwnerSig, MsgChatLink (..), MsgContent (..)) import Simplex.Chat.Store.Shared (createContact) @@ -1611,13 +1611,13 @@ testPlanAddressContactViaAddress = Left _ -> error "error parsing contact link" Right cReq -> do let profile = aliceProfile {contactLink = Just cReq} - void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> let TestCC {chatController = ChatController {config}} = bob in runExceptT $ createContact db (mkStoreCxt config) user profile + void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> runExceptT $ createContact db (storeCxt $ chatController bob) user profile bob @@@ [("@alice", "")] bob ##> "/delete @alice" bob <## "alice: contact is deleted" - void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> let TestCC {chatController = ChatController {config}} = bob in runExceptT $ createContact db (mkStoreCxt config) user profile + void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> runExceptT $ createContact db (storeCxt $ chatController bob) user profile bob @@@ [("@alice", "")] bob ##> ("/_connect plan 1 " <> cLink) @@ -1632,7 +1632,7 @@ testPlanAddressContactViaAddress = alice ##> "/delete @bob" alice <## "bob: contact is deleted" - void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> let TestCC {chatController = ChatController {config}} = bob in runExceptT $ createContact db (mkStoreCxt config) user profile + void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> runExceptT $ createContact db (storeCxt $ chatController bob) user profile bob @@@ [("@alice", "")] -- GUI api @@ -1673,13 +1673,13 @@ testPlanAddressContactViaShortAddress = Left _ -> error "error parsing contact link" Right shortLink -> do let profile = aliceProfile {contactLink = Just shortLink} - void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> let TestCC {chatController = ChatController {config}} = bob in runExceptT $ createContact db (mkStoreCxt config) user profile + void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> runExceptT $ createContact db (storeCxt $ chatController bob) user profile bob @@@ [("@alice", "")] bob ##> "/delete @alice" bob <## "alice: contact is deleted" - void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> let TestCC {chatController = ChatController {config}} = bob in runExceptT $ createContact db (mkStoreCxt config) user profile + void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> runExceptT $ createContact db (storeCxt $ chatController bob) user profile bob @@@ [("@alice", "")] bob ##> ("/_connect plan 1 " <> sLink) @@ -1694,7 +1694,7 @@ testPlanAddressContactViaShortAddress = alice ##> "/delete @bob" alice <## "bob: contact is deleted" - void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> let TestCC {chatController = ChatController {config}} = bob in runExceptT $ createContact db (mkStoreCxt config) user profile + void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> runExceptT $ createContact db (storeCxt $ chatController bob) user profile bob @@@ [("@alice", "")] -- GUI api diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index f16b3ac090..305207fef6 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -23,7 +23,7 @@ import Data.List (isPrefixOf, isSuffixOf) import Data.Maybe (fromMaybe) import Data.String import qualified Data.Text as T -import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), mkStoreCxt) +import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), storeCxt) import Simplex.Chat.Library.Commands (maxProfileImageSize) import Simplex.Chat.Markdown (viewName) import Simplex.Chat.Messages.CIContent (e2eInfoNoPQText, e2eInfoPQText) @@ -709,10 +709,10 @@ getCtConn cc contactId = getTestCCContact cc contactId >>= maybe (fail "no conne getTestCCContact :: TestCC -> ContactId -> IO Contact getTestCCContact cc contactId = do - let TestCC {chatController = ChatController {config}} = cc + let TestCC {chatController} = cc withCCTransaction cc $ \db -> withCCUser cc $ \user -> - runExceptT (getContact db (mkStoreCxt config) user contactId) >>= either (fail . show) pure + runExceptT (getContact db (storeCxt chatController) user contactId) >>= either (fail . show) pure lastItemId :: HasCallStack => TestCC -> IO String lastItemId cc = do diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index e6aecfd295..b93e1c8c8c 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -25,7 +25,7 @@ import qualified Data.ByteString.Lazy.Char8 as LB import Data.Time.Clock (getCurrentTime) import Data.Word (Word8, Word32) import Foreign.C -import Foreign.Marshal.Alloc (mallocBytes) +import Foreign.Marshal.Alloc (alloca, mallocBytes) import Foreign.Marshal.Utils (copyBytes) import Foreign.Ptr import Foreign.StablePtr @@ -34,7 +34,7 @@ import GHC.IO.Encoding (setLocaleEncoding, setFileSystemEncoding, setForeignEnco import JSONFixtures import Simplex.Chat import Simplex.Chat.Badges (BadgeInfo (..), BadgeRequest (..), BadgeType (..), generateMasterKey, verifyCredential) -import Simplex.Chat.Controller (ChatController (..), ChatDatabase (..)) +import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), ChatDatabase (..)) import Simplex.Chat.Mobile hiding (error) import Simplex.Chat.Mobile.Badges hiding (error) import Simplex.Chat.Mobile.File @@ -44,6 +44,8 @@ import Simplex.Chat.Options.DB import Simplex.Chat.Store import Simplex.Chat.Store.Profiles import Simplex.Chat.Types (AgentUserId (..), Profile (..)) +import Simplex.Messaging.Agent.Client (AgentClient (..)) +import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), Env (..)) import Simplex.Messaging.Agent.Store.Shared (MigrationConfig (..), MigrationConfirmation (..)) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Crypto as C @@ -65,6 +67,7 @@ mobileTests = do setForeignEncoding utf8 it "start new chat without user" testChatApiNoUser it "start new chat with existing user" testChatApi + it "should set queue size via C API" testChatMigrateInitQueueCApi it "should encrypt/decrypt WebRTC frames" testMediaApi it "should encrypt/decrypt WebRTC frames via C API" testMediaCApi describe "should read/write encrypted files via C API" $ do @@ -165,6 +168,22 @@ testChatApi ps = do chatParseMarkdown "hello" `shouldBe` "{}" chatParseMarkdown "*hello*" `shouldBe` parsedMarkdown +testChatMigrateInitQueueCApi :: TestParams -> IO () +testChatMigrateInitQueueCApi ps = do + cPath <- newCString $ tmpPath ps "1" + cKey <- newCString "" + cConfirm <- newCString "yesUp" + alloca $ \ctrlPtr -> do + let migrateInit queueSize = peekCAString =<< cChatMigrateInitQueue cPath cKey cConfirm queueSize ctrlPtr + migrateInit 0 `shouldReturn` jsonStr DBMInvalidQueueSize + migrateInit (-1) `shouldReturn` jsonStr DBMInvalidQueueSize + migrateInit 65536 `shouldReturn` jsonStr DBMOk + ChatController {config = ChatConfig {tbqSize}, smpAgent = AgentClient {agentEnv = Env {config = AgentConfig {tbqSize = agentQSize}}}} <- deRefStablePtr =<< peek ctrlPtr + tbqSize `shouldBe` 65536 + agentQSize `shouldBe` 65536 + where + jsonStr = LB.unpack . J.encode + testMediaApi :: HasCallStack => TestParams -> IO () testMediaApi ps = do let tmp = tmpPath ps diff --git a/website/package.json b/website/package.json index 6ad5deab6b..5e4e947bcf 100644 --- a/website/package.json +++ b/website/package.json @@ -7,7 +7,7 @@ "build": "npm run build:js && npm run build:eleventy && npm run build:tailwind", "start": "npx eleventy --serve", "test": "echo \"Error: no test specified\" && exit 1", - "build:js": "cp ./node_modules/qrcode/build/qrcode.js ./src/js/ && ./copy_call.sh", + "build:js": "cp ./node_modules/qrcode/build/qrcode.js ./src/js/ && cp ./node_modules/@plausible-analytics/tracker/plausible.js ./src/js/page.js && ./copy_call.sh", "build:eleventy": "eleventy", "build:tailwind": "npx tailwindcss -i tailwind.css -o _site/css/tailwind.css", "watch:tailwind": "npx tailwindcss -i tailwind.css -o _site/css/tailwind.css --watch" @@ -29,6 +29,7 @@ "tailwindcss": "3.3.1" }, "dependencies": { + "@plausible-analytics/tracker": "0.4.6", "@simplex-chat/xftp-web": "^0.3.0", "eleventy-plugin-i18n": "^0.1.3", "fs": "^0.0.1-security", diff --git a/website/src/_includes/blog_previews/20260919.html b/website/src/_includes/blog_previews/20260919.html new file mode 100644 index 0000000000..d6333c3c62 --- /dev/null +++ b/website/src/_includes/blog_previews/20260919.html @@ -0,0 +1,5 @@ +

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

+ +

Investors in our equity crowdfunding receive badges as perks.

+ +

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

diff --git a/website/src/crowdfunding.html b/website/src/crowdfunding.html index 53d23ab6c4..177f722097 100644 --- a/website/src/crowdfunding.html +++ b/website/src/crowdfunding.html @@ -64,7 +64,7 @@ templateEngineOverride: njk --cf-area-off: calc((var(--sec-h) - 100svh) / 2); } - @media screen and (max-width: 959px) { + @media screen and (max-width: 959px) and (orientation: portrait) { :root { --cf-grad-light: linear-gradient(30deg, #e8f3ff 0%, #c0e2ff 40%, #e9ffff 80%, #ffefd6 90%); --cf-grad-dark: linear-gradient(30deg, #000000 0%, #131d49 52%, #3f5598 65%, #c3faff 85%, #fff6e0 90%); @@ -193,11 +193,12 @@ templateEngineOverride: njk margin-top: calc(var(--sec-vhu) * 3); max-width: calc(var(--sec-vwu) * 40); } + .cf-hero .lead-short { display: none; } .cf-stats { display: flex; gap: calc(var(--sec-vwu) * 5); - margin-top: calc(var(--sec-vhu) * 6); + margin-top: calc(var(--sec-vhu) * 4.67); position: relative; top: calc(var(--sec-vhu) * 2); } @@ -218,7 +219,61 @@ templateEngineOverride: njk color: #dfeaff; } - .cf-hero .cf-live-link { display: inline-block; margin-top: calc(var(--sec-vhu) * 5); font-size: calc(var(--sec-vwu) * 2.2); position: relative; top: calc(var(--sec-vhu) * 1.5); } + .cf-hero-offer { + position: relative; + margin-top: calc(var(--sec-vhu) * 6.3); + width: calc(var(--sec-vwu) * 56); + } + .cf-hero-offer p { font-family: "Manrope", sans-serif; } + .cf-hero-offer .cf-offer-lead { + font-weight: 600; + font-size: calc(var(--sec-vwu) * 2.1); + line-height: 1.4; + color: #ffffff; + font-variant-numeric: tabular-nums; + } + .cf-hero-offer .cf-offer-lead b { + font-weight: 700; + background: linear-gradient(90deg, #019bfe 0%, #64fdff 58%, #c8feff 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + color: transparent; + } + .cf-hero-offer .cf-offer-lead span { white-space: nowrap; } + .cf-hero-offer .cf-offer-terms { + font-weight: 300; + font-size: calc(var(--sec-vwu) * 1.55); + line-height: 1.55; + color: #dfeaff; + margin-top: calc(var(--sec-vhu) * 0.4); + } + .cf-hero-offer .cf-offer-terms a { position: relative; color: inherit; text-decoration: none; } + .cf-hero-offer .cf-offer-terms abbr { text-decoration: underline dotted; text-underline-offset: 3px; cursor: pointer; } + .cf-hero-offer .cf-offer-terms a::after { + content: attr(data-tip); + position: absolute; + left: calc(100% + 12px); + top: 50%; + transform: translateY(-50%); + white-space: nowrap; + padding: 7px 12px; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, .18); + background: rgba(5, 9, 32, .96); + color: #dfeaff; + font-family: "Manrope", sans-serif; + font-weight: 400; + font-size: calc(var(--sec-vwu) * 1.1); + line-height: 1.4; + opacity: 0; + visibility: hidden; + transition: opacity .12s ease; + pointer-events: none; + } + .cf-hero-offer .cf-offer-terms a:hover::after, + .cf-hero-offer .cf-offer-terms a:focus-visible::after { opacity: 1; visibility: visible; } + /* ---- close: same left-aligned layout as the hero ---- */ .cf-close .area { position: relative; display: flex; align-items: flex-end; padding: 0 calc(var(--sec-vwu) * 7.5) calc(var(--sec-vhu) * 22) 0; } @@ -261,7 +316,6 @@ templateEngineOverride: njk width: fit-content; } .cf-close .cf-hero-cta { margin-top: calc(var(--sec-vhu) * 7); } - .cf-close .cf-live-link { display: inline-block; margin-top: calc(var(--sec-vhu) * 4.5); font-size: calc(var(--sec-vwu) * 2.2); position: relative; top: 0; } .cf-phone { position: absolute; @@ -315,10 +369,9 @@ templateEngineOverride: njk background: linear-gradient(180deg, #00F0FF 0%, #00C7FF 38%, #008FFF 100%); color: #ffffff; } - .cf-hero-invest-link { + .cf-cta-link { display: flex; align-items: center; - gap: calc(var(--sec-vwu) * 0.7); font-family: "Manrope", sans-serif; font-weight: 400; font-size: calc(var(--sec-vwu) * 1.5); @@ -329,10 +382,8 @@ templateEngineOverride: njk -webkit-text-fill-color: transparent; background-clip: text; color: transparent; - --wf-capital-nav-logo-fill: #64fdff; } - .cf-hero-invest-link svg { height: calc(var(--sec-vwu) * 2.1); width: auto; display: block; transform: translateY(calc(var(--sec-vwu) * -0.25)); } - .cf-hero-invest-link:focus-visible { outline: 2px solid #ffffff; outline-offset: 4px; border-radius: 4px; } + .cf-cta-link:focus-visible { outline: 2px solid #ffffff; outline-offset: 4px; border-radius: 4px; } /* the same link twice: beside the button (desktop), in the hero's top right corner (mobile). Only one of the two is ever displayed. */ .cf-hero-corner { display: none; } @@ -370,7 +421,6 @@ templateEngineOverride: njk .cf-pill:active { transform: translateY(1px); } .cf-top { top: 30px; } - .cf-bottom { bottom: 30px; } .cf-primary { background: linear-gradient(90deg, #019bfe 0%, #2e3fa0 100%); @@ -380,42 +430,19 @@ templateEngineOverride: njk .cf-primary:hover { filter: brightness(1.07); } .cf-primary:focus-visible { outline-color: #ffffff; } - .cf-secondary { - background: #ffffff; - color: #006cd7; - border: 1px solid rgba(2, 55, 137, .15); - --wf-capital-nav-logo-fill: #006cd7; - } - .cf-secondary:hover { box-shadow: 0 0 0 1px #80b6eb; } - .cf-pill svg { height: 30px; width: auto; display: block; transform: translateY(-3.5px); } - @media screen and (min-width: 960px) { + @media screen and (min-width: 960px), screen and (max-width: 959px) and (orientation: landscape) { .cf-pill { width: 252px; justify-content: center; } } - .cf-live-link { - font-family: "Manrope", sans-serif; - font-weight: 300; - font-size: calc(var(--sec-vwu) * 1.9); - background: linear-gradient(90deg, #019bfe 0%, #64fdff 58%, #c8feff 100%); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; - color: transparent; - text-decoration: none; - } - .cf-live-link:focus-visible { outline: 2px solid #019bfe; outline-offset: 2px; } - .cf-pill, - .cf-live-link, - .cf-hero-invest-link, + .cf-cta-link, .cf .text-container a.gradient-text { transition: none; } - .cf-live-link:hover, - .cf-hero-invest-link:hover, + .cf-cta-link:hover, .cf .text-container a.gradient-text:hover { filter: brightness(1.12); } - @media screen and (min-width: 960px) { + @media screen and (min-width: 960px), screen and (max-width: 959px) and (orientation: landscape) { .cf-network .text-container h2 { max-width: calc(var(--sec-vwu) * 23.5) !important; } .cf-network .text-container p { max-width: calc(var(--sec-vwu) * 31) !important; } .cf-network .text-container a { max-width: calc(var(--sec-vwu) * 31) !important; } @@ -455,7 +482,7 @@ templateEngineOverride: njk #navbar.on-light > a.logo.logo-light { display: flex; } #navbar.on-light > a.logo.logo-dark { display: none; } - @media screen and (min-width: 960px) { + @media screen and (min-width: 960px), screen and (max-width: 959px) and (orientation: landscape) { header#navbar { height: 74px; } header#navbar > a.logo { top: 0; @@ -475,15 +502,17 @@ templateEngineOverride: njk opacity: 1 !important; transform: none !important; background: transparent !important; - height: 54px !important; } + } + @media screen and (max-width: 959px) and (orientation: portrait) { + header#navbar { height: 54px !important; } header#navbar > a.logo { top: 0 !important; height: 100% !important; align-items: center; transform: translateY(var(--cf-nav-shift)); } [dir="ltr"] header#navbar > a.logo { padding-left: var(--cf-text-pad); } header#navbar > a.logo img { height: 31.5px; } } /* ---- mobile: portrait art on top, text at the bottom (home page system) ---- */ - @media screen and (max-width: 959px) { + @media screen and (max-width: 959px) and (orientation: portrait) { .cf .text-container h2 { font-size: calc(var(--sec-vwu) * 9.4); } .cf .text-container { margin-left: 0 !important; @@ -599,10 +628,8 @@ templateEngineOverride: njk .cf-hero h1, .cf-hero .lead, .cf-hero .cf-stats, - .cf-hero .cf-live-link, .cf-close h1, - .cf-close .cf-close-call, - .cf-close .cf-live-link { + .cf-close .cf-close-call { padding-left: var(--cf-text-inset); padding-right: var(--cf-text-inset); } @@ -627,12 +654,12 @@ templateEngineOverride: njk --cf-phone-h: calc(var(--cf-phone-w) / 0.4914); --cf-phone-part: 0.55; /* bottom of the hero text block, measured from the section top */ - --cf-hero-text-b: calc(var(--cf-top-base) + max(0px, calc(-1 * var(--cf-area-off))) + 78.5vw); + --cf-hero-text-b: calc(var(--cf-top-base) + max(0px, calc(-1 * var(--cf-area-off))) + 76.7vw); /* the arc crest starts 0.2678 down the cover image, which is 2.349 phone widths tall and sits 0.4034 phone heights above the phone top: the phone may rise no higher than this */ --cf-phone-ceiling: calc(var(--cf-hero-text-b) + var(--cf-phone-w) * 0.1917); - --cf-phone-show: max(calc(var(--cf-phone-h) * 0.15), min(calc(var(--cf-phone-h) * var(--cf-phone-part)), calc(100svh - var(--cf-phone-ceiling)))); + --cf-phone-show: max(52vw, min(calc(var(--cf-phone-h) * var(--cf-phone-part)), calc(100svh - var(--cf-phone-ceiling)))); --cf-phone-top: calc(100svh - var(--cf-phone-show) + var(--cf-area-off)); --cf-fade-ext: max(0px, calc(110lvh - 100svh)); /* dvh so the fade rides with the button as the chrome moves */ @@ -653,10 +680,11 @@ templateEngineOverride: njk .cf-close .cf-hero-text { display: contents; } .cf-hero h1, .cf-hero .lead, - .cf-hero .cf-stats, - .cf-hero .cf-live-link { position: relative; z-index: 4; } - .cf-hero h1 { font-size: calc(var(--sec-vwu) * 9.7); } + .cf-hero .cf-stats { position: relative; z-index: 4; } + .cf-hero h1 { font-size: calc(var(--sec-vwu) * 8.54); } .cf-hero .lead { font-size: calc(var(--sec-vwu) * 3.55); max-width: none; margin-top: calc(var(--sec-vhu) * 2); } + .cf-hero .lead:not(.lead-short) { display: none; } + .cf-hero .lead-short { display: block; font-size: calc(var(--sec-vwu) * 4.5); white-space: nowrap; } .cf-close .area { padding: calc(var(--cf-top) + var(--sec-vhu) * 14) var(--cf-gutter) 0; } /* crest a fixed 78px from the section top */ section.page.cf.cf-close { @@ -664,8 +692,7 @@ templateEngineOverride: njk } /* centred like the home page, the two heading lines set apart */ .cf-close h1, - .cf-close .cf-close-call, - .cf-close .cf-live-link { text-align: center; } + .cf-close .cf-close-call { text-align: center; } /* the heading rides up with the arc; nothing below it moves */ .cf-close h1 { font-size: calc(var(--sec-vwu) * 8.7); font-weight: 700; position: relative; top: -32px; } .cf-close-l1 { font-size: calc(var(--sec-vwu) * 11.745); } @@ -684,12 +711,23 @@ templateEngineOverride: njk padding: calc(var(--sec-vhu) * 2.5) calc(var(--sec-vwu) * 5); } .cf-close-offer p { font-size: calc(var(--sec-vwu) * 3.6); } - .cf-close .cf-live-link { display: block; font-size: calc(var(--sec-vwu) * 5); margin-top: calc(var(--sec-vhu) * 2.5); top: 0; } .cf-stats { top: 0; gap: calc(var(--sec-vwu) * 8); margin-top: calc(var(--sec-vhu) * 3); } + .cf-hero .cf-hero-offer { + position: relative; + top: 0; + width: auto; + z-index: 4; + margin-top: calc(var(--sec-vhu) * 2); + padding: 10px var(--cf-text-pad) 12px; + margin-left: calc(-1 * var(--cf-gutter)); + margin-right: calc(-1 * var(--cf-gutter)); + background: linear-gradient(to bottom, rgba(2, 7, 29, 0) 0%, rgba(2, 7, 29, var(--cf-offer-veil, 0)) 22%, rgba(2, 7, 29, var(--cf-offer-veil, 0)) 78%, rgba(2, 7, 29, 0) 100%); + } + .cf-hero-offer .cf-offer-lead { font-size: calc(var(--sec-vwu) * 4.2); line-height: 1.3; white-space: nowrap; } + .cf-hero-offer .cf-offer-terms { font-size: calc(var(--sec-vwu) * 4); margin-top: calc(var(--sec-vhu) * 0.8); } .cf-stat img { width: calc(var(--sec-vwu) * 8); } .cf-stat b { font-size: calc(var(--sec-vwu) * 6.5); } .cf-stat span { font-size: calc(var(--sec-vwu) * 3.4); } - .cf-hero .cf-live-link { margin-top: calc(var(--sec-vhu) * 2); top: 0; font-size: calc(var(--sec-vwu) * 5); } /* the pill sits on the bottom edge and rides with the chrome: dvh tracks the visible bottom, svh the box the area is sized from */ .cf-hero .cf-hero-cta, @@ -702,17 +740,16 @@ templateEngineOverride: njk } /* over the phone and the gradient covering it */ .cf-hero .cf-hero-cta { z-index: 5; } - .cf-hero-cta .cf-pill { width: 100%; height: 48px; font-size: 16px; } + .cf-hero-cta .cf-pill { width: 100%; height: 48px; font-size: 16px; gap: 9px; } + .cf-hero-cta .cf-pill svg { height: 30px; transform: translateY(-3.5px); } /* one link, two placements: the top right corner of the hero, and its own button-sized target above the pill on the closing section */ - .cf-hero-invest-link { + .cf-cta-link { height: 48px; font-size: calc(var(--sec-vwu) * 4.6); - gap: calc(var(--sec-vwu) * 1.9); } - .cf-hero-invest-link svg { height: calc(var(--sec-vwu) * 6.5); transform: translateY(calc(var(--sec-vwu) * -0.7)); } - .cf-close .cf-hero-invest-link { justify-content: center; } - .cf-hero .cf-hero-cta .cf-hero-invest-link { display: none; } + .cf-close .cf-cta-link { justify-content: center; } + .cf-hero .cf-hero-cta .cf-cta-link { display: none; } /* a child of the section, so its reference is the section's top edge, which the band raises by --cf-band-shift, as with the arc. z-index clears .area, which is 11 and covers this corner. */ @@ -752,9 +789,7 @@ templateEngineOverride: njk } .cf-pill { font-size: 16px; } - .cf-bottom { top: calc(5px + var(--cf-nav-shift)); right: 16px; bottom: auto; padding: 0 18px; } .cf-top { top: auto; left: 16px; right: 16px; bottom: var(--cf-cta-bottom); height: 48px; justify-content: center; } - .cf-live-link { font-size: calc(var(--sec-vwu) * 4.2); } .cf .text-container a.gradient-text { font-weight: 400; } } @@ -781,24 +816,22 @@ templateEngineOverride: njk .cf-tag .n { font-weight: 700; font-size: calc(var(--sec-vwu) * 1.5); line-height: 1.35; } .cf-tag .v { font-weight: 500; font-size: calc(var(--sec-vwu) * 1.2); line-height: 1.45; } .cf-tag .v b { font-weight: 700; } - @media screen and (min-width: 960px) { + @media screen and (min-width: 960px), screen and (max-width: 959px) and (orientation: landscape) { .cf-tags-art, .cf-art { display: none; } } - @media screen and (max-width: 959px) { + @media screen and (max-width: 959px) and (orientation: portrait) { .cf-tag .n { font-size: 3.54cqh; line-height: 1.3; } } /* 320pt phones: keep the headline at three lines */ - @media screen and (max-width: 330px) { - .cf-hero h1 { font-size: calc(var(--sec-vwu) * 9.3); } + @media screen and (max-width: 330px) and (orientation: portrait) { + .cf-hero h1 { font-size: calc(var(--sec-vwu) * 8.18); } } - /* narrow phones: keep the corner link and the pill clear of the logo */ - @media screen and (max-width: 389px) { + /* narrow phones: keep the corner link clear of the logo */ + @media screen and (max-width: 389px) and (orientation: portrait) { header#navbar > a.logo img { height: 24px; } - .cf-bottom { font-size: 12px; padding: 0 12px; height: 40px; } - .cf-bottom svg { height: 22px; transform: translateY(-2.5px); } .cf-hero-corner { height: 40px; } .cf-tag .v { font-size: 2.96cqh; line-height: 1.4; } } @@ -827,7 +860,7 @@ templateEngineOverride: njk .cf-fine-links a { color: #9fb2d8; text-decoration: none; } .cf-fine-links a:hover { color: #ffffff; } - @media screen and (max-width: 959px) { + @media screen and (max-width: 959px) and (orientation: portrait) { /* padding-bottom comes from .safari-ios section.page, which a fit-content footer does not need */ section.page.cf.cf-footer { height: fit-content; min-height: fit-content; padding-bottom: 0; } @@ -957,11 +990,73 @@ templateEngineOverride: njk .cf-modal-panel h2 { font-size: 1.4rem; } } /* 320pt phones: the card's text column is narrower than the submit label */ - @media screen and (max-width: 389px) { + @media screen and (max-width: 389px) and (orientation: portrait) { .cf-modal { padding: 24px 12px; } .cf-modal-panel { padding: 28px 20px; } .cf-form-submit { padding: 0 12px; font-size: 15px; } } + @media screen and (max-width: 959px) and (orientation: landscape) { + :root { + --cover-bg-w: calc(min((3200 / 1920) * 100vw, (3200 / 1080) * 100svh)); + --cover-bg-h: calc(min((1800 / 1920) * 100vw, (1800 / 1080) * 100svh)); + --sec-w: calc(min((1920 / 1920) * 100vw, (1920 / 1080) * 100svh)); + --sec-h: calc(min((1080 / 1920) * 100vw, (1080 / 1080) * 100svh)); + --sec-vwu: calc(var(--sec-w) / 100); + --sec-vhu: calc(var(--sec-h) / 100); + } + html { scroll-snap-type: none; } + section.page.cf:not(.cf-footer), + html.safari-ios section.page.cf:not(.cf-footer), + html.chrome-ios section.page.cf:not(.cf-footer), + html.chrome-android section.page.cf:not(.cf-footer), + html.safari-ios section.page.cf.cf-current:not(.cf-footer), + html.chrome-ios section.page.cf.cf-current:not(.cf-footer) { + height: auto; + min-height: 100dvh; + margin-top: 0; + padding: 74px 0 0; + align-items: flex-start; + } + section.page.cf .area { + height: auto; + min-height: calc(100dvh - 74px); + } + .page .text-container { + height: auto; + min-height: calc(100dvh - 74px); + padding-top: calc(var(--sec-vhu) * 3); + padding-bottom: calc(var(--sec-vhu) * 3); + } + .page .text-container { + justify-content: center; + gap: calc(var(--sec-vhu) * 3.25); + margin-right: calc(var(--sec-vwu) * 7.5) !important; + margin-left: calc(var(--sec-vwu) * 7.5) !important; + padding-bottom: 0; + max-width: none !important; + } + .cf-right .text-container { margin-left: auto !important; } + .cf-left .text-container { margin-right: auto !important; } + .page .text-container h2 { font-size: calc(var(--sec-vwu) * 4.94); line-height: 1.05; } + .page .text-container p { font-weight: 200; font-size: calc(var(--sec-vwu) * 1.62); } + .page .text-container p span { font-weight: 500; } + .page .text-container a { font-weight: 200; font-size: calc(var(--sec-vwu) * 1.62); } + .cf-hero h1 { font-size: calc(var(--sec-vwu) * 4.4); } + .cf-hero .lead:not(.lead-short) { display: none; } + .cf-hero .lead-short { display: block; margin-top: calc(var(--sec-vhu) * 2); } + .cf-hero .cf-hero-cta { margin-top: calc(var(--sec-vhu) * 3); } + .cf-hero .cf-stats { margin-top: calc(var(--sec-vhu) * 3); } + .cf-hero .area { + align-items: center; + padding: calc(var(--sec-vhu) * 2) 0 calc(var(--sec-vhu) * 6); + } + .cf-phone { top: calc(var(--sec-vhu) * 27.1); bottom: auto; } + section.page.cf.cf-hero, + section.page.cf.cf-close { + background-position: calc(50% + var(--sec-vwu) * 40.3) calc(74px + var(--sec-h) / 2 + var(--sec-vhu) * 2.2 - var(--cover-bg-h) / 2); + } + .cf-close .area { padding-bottom: calc(var(--sec-vhu) * 8); } + } @@ -985,11 +1080,12 @@ templateEngineOverride: njk

The first and the only messaging network without any user IDs

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

+

No phone numbers, emails, or accounts.

@@ -998,33 +1094,34 @@ templateEngineOverride: njk
-
480KMonthly users
+
520KMonthly users
- Join the livestream and Q&A on Sep 15 +
+

SimpleX name for 7 years

+

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

+
- - Invest on {{ wfLogo() }} - + Subscribe for updates -
+
-

480,000+ users with zero marketing spend

-

Hundreds of user posts, podcasts and videos and $650,000 in user donations, before any paid features.

+

520,000
monthly users

+

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

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

-
+

Every other network can identify you

@@ -1036,13 +1133,13 @@ templateEngineOverride: njk
-
+
-
+
@@ -1089,7 +1186,7 @@ templateEngineOverride: njk
-
+

Design advantage that can't be copied

@@ -1100,7 +1197,7 @@ templateEngineOverride: njk
-
+

A network others build on

@@ -1112,18 +1209,17 @@ templateEngineOverride: njk
-
+

Get a stake
in SimpleX Chat

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

- Join the livestream and Q&A on Sep 15

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

@@ -1132,7 +1228,7 @@ templateEngineOverride: njk
-