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 4ae103d746..eadca9f71e 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -704,6 +704,7 @@ enum ChatCommand: ChatCmdProtocol { case .subscriptionEnded: "subscription_ended" case .prepaidEnding: "prepaid_ending" case .supportEnded: "support_ended" + case .issueFailed: "issue_failed" } } diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 1bf711ca5f..6a5b1de353 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -383,6 +383,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? @@ -463,6 +470,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 d4de1205c7..742ef20688 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -2203,12 +2203,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") 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/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/BadgesView.swift b/apps/ios/Shared/Views/Badges/BadgesView.swift index 1960d25bf0..df12094a6b 100644 --- a/apps/ios/Shared/Views/Badges/BadgesView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesView.swift @@ -33,3 +33,23 @@ struct BadgesView: View { .animation(.default, value: shownBadge != nil) } } + +var supportSimpleXAlertAction: UIAlertAction { + UIAlertAction(title: NSLocalizedString("Support SimpleX", comment: "alert button"), style: .default) { _ in + openBadgesView() + } +} + +func openBadgesView() { + showAppSheet { + NavigationView { + BadgesView(showsAsSheet: true) + .modifier(ThemedBackground()) + } + } +} + +// false until the badge state is loaded for the current profile, so a supporter is never pitched to +func noShownBadge() -> Bool { + BadgeModel.shared.badgeState?.shown != true && BadgeModel.shared.userId == ChatModel.shared.currentUser?.userId +} diff --git a/apps/ios/Shared/Views/Badges/BadgesYourBadgeView.swift b/apps/ios/Shared/Views/Badges/BadgesYourBadgeView.swift index 59eeba7eac..60c6a0ab77 100644 --- a/apps/ios/Shared/Views/Badges/BadgesYourBadgeView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesYourBadgeView.swift @@ -12,6 +12,7 @@ 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 @@ -48,7 +49,32 @@ 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") } } } @@ -56,10 +82,16 @@ struct BadgesYourBadgeView: View { Section(header: Text("Credential").foregroundColor(theme.colors.secondary)) { if let badge = chatModel.currentUser?.profile.localBadge { infoRow("Status", badge.status.rawValue) - infoRow("Expires", DateFormatter.localizedString(from: badge.badge.badgeExpiry, dateStyle: .medium, timeStyle: .short)) + infoRow("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 } @@ -77,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/Chat/ChatItemInfoView.swift b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift index 15effaeea6..ffcf525300 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift @@ -164,6 +164,15 @@ struct ChatItemInfoView: View { } if let file = ci.file, let fileExpires = file.fileExpires { infoRow(file.expired ? "File was available until" : "File available until", localTimestamp(fileExpires)) + if noShownBadge() { + Button { + openBadgesView() + } label: { + Text("Support SimpleX to send larger files that stay available longer") + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + } + } } if meta.msgVerified?.verified == true { let signedText: LocalizedStringKey = ci.chatDir.sent ? "Signed" : "Signed & verified" diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index caa4e2a501..a1570ba4a1 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -338,6 +338,18 @@ enum UploadContent: Equatable { } } +// A badge only helps below the largest badge's limit, and not in incognito chats, so outside that +// the alert is informational as before. +func showLargeFileAlert(_ fileSize: Int64, incognito: Bool, senderProfile: LocalProfile?) { + let title = NSLocalizedString("Large file!", comment: "file alert title") + let message = largeFileMessage(fileSize, incognito: incognito, badgeIssue: expiredBadgeReason(fileSize, senderProfile)) + if !incognito && fileSize <= MAX_FILE_SIZE_XFTP_LEGEND && noShownBadge() { + showAlert(title, message: message) { [supportSimpleXAlertAction, okAlertAction] } + } else { + showAlert(title, message: message) + } +} + // Spec: spec/client/compose.md#ComposeView struct ComposeView: View { @EnvironmentObject var chatModel: ChatModel @@ -668,10 +680,7 @@ struct ComposeView: View { fileSize <= maxFileSize { composeState = composeState.copy(preview: .filePreview(fileName: fileURL.lastPathComponent, file: fileURL)) } else { - showAlert( - NSLocalizedString("Large file!", comment: "file alert title"), - message: largeFileMessage(Int64(fileSize ?? 0), incognito: sendIncognito, badgeIssue: expiredBadgeReason(Int64(fileSize ?? 0), sendProfile)) - ) + showLargeFileAlert(Int64(fileSize ?? 0), incognito: sendIncognito, senderProfile: sendProfile) } } catch { logger.error("ComposeView fileImporter error \(error.localizedDescription)") diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 61f29485a7..5c5021b79f 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 = "" @@ -176,6 +177,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 @@ -220,6 +224,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) { @@ -375,19 +382,19 @@ 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")) { + private func showBadgeAlertDismissAlert(_ title: String) { + showAlert(title) { [ UIAlertAction(title: NSLocalizedString("Remind me later", comment: "alert button"), style: .default) { _ in Task { await ackBadgeAlert(snooze: true) } @@ -425,14 +432,27 @@ struct ChatListView: View { @ViewBuilder private var chatList: some View { if shouldShowOnboarding { - ConnectOnboardingView() - .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) - .modifier(ThemedBackground()) + VStack(spacing: 0) { + ConnectOnboardingView() + if 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()) } else { chatListContent } } + private func openGetStake() { + getStakeBannerTapped = true + showGetStakeSheet = true + } + private var chatListContent: some View { let cs = filteredChats() return ZStack { @@ -464,19 +484,40 @@ 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 } + // noShownBadge is false until the badge state loads: the pitch must not lock the slot and then + // be hidden by a supporter's badge arriving a moment later, leaving the slot empty for the session + } 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) @@ -484,6 +525,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/Helpers/AppSheet.swift b/apps/ios/Shared/Views/Helpers/AppSheet.swift index 17fe95a058..51ae63f07e 100644 --- a/apps/ios/Shared/Views/Helpers/AppSheet.swift +++ b/apps/ios/Shared/Views/Helpers/AppSheet.swift @@ -29,6 +29,18 @@ private struct PrivacySensitive: ViewModifier { } } +// Presented from the top view controller instead of a .sheet on a parent view, so an alert button or +// a view that is itself in a sheet can open it. +func showAppSheet(@ViewBuilder content: () -> Content) { + if let topController = getTopViewController() { + let v = content() + .modifier(PrivacySensitive()) + .environmentObject(ChatModel.shared) + .environmentObject(AppTheme.shared) + topController.present(UIHostingController(rootView: v), animated: true) + } +} + extension View { func appSheet( isPresented: Binding, diff --git a/apps/ios/Shared/Views/Helpers/NameBadge.swift b/apps/ios/Shared/Views/Helpers/NameBadge.swift index 26d9821158..085d9be0be 100644 --- a/apps/ios/Shared/Views/Helpers/NameBadge.swift +++ b/apps/ios/Shared/Views/Helpers/NameBadge.swift @@ -166,8 +166,13 @@ func showBadgeInfoAlert(_ name: String, _ badge: LocalBadge) { } else { String.localizedStringWithFormat(NSLocalizedString("%@ supports SimpleX Chat.", comment: "badge alert"), name) } - let v7 = NSLocalizedString("You can support SimpleX starting from v7 of the app.", comment: "badge alert") - showAlert(title, message: supports + "\n\n" + v7) + if noShownBadge() { + showAlert(title, message: supports) { + [ supportSimpleXAlertAction, okAlertAction ] + } + } else { + showAlert(title, message: supports) + } } } } 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 767acade71..c1d1e5b111 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -57,6 +57,9 @@ let DEFAULT_CHAT_ITEM_TAIL = "chatItemTail" let DEFAULT_ONE_HAND_UI_CARD_SHOWN = "oneHandUICardShown" let DEFAULT_ADDRESS_CREATION_CARD_SHOWN = "addressCreationCardShown" let DEFAULT_SUPPORTER_BANNER_SHOWN = "supporterBannerShown" +let DEFAULT_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" @@ -119,6 +122,9 @@ let appDefaults: [String: Any] = [ DEFAULT_ONE_HAND_UI_CARD_SHOWN: false, DEFAULT_ADDRESS_CREATION_CARD_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, @@ -151,6 +157,9 @@ let hintDefaults = [ DEFAULT_ONE_HAND_UI_CARD_SHOWN, DEFAULT_ADDRESS_CREATION_CARD_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, @@ -403,7 +412,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 505552647b..e0a634a713 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -165,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 */; }; @@ -192,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 */; }; @@ -556,6 +557,7 @@ 646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAuthenticationUtils.swift; sourceTree = ""; }; 647B15E72F4C8D2500EB431E /* AddChannelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddChannelView.swift; sourceTree = ""; }; 647B15E92F4C8D5100EB431E /* ChatRelayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatRelayView.swift; sourceTree = ""; }; + 647C9E283061362A0032110E /* GetStakeBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetStakeBanner.swift; sourceTree = ""; }; 647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMemberInfoView.swift; sourceTree = ""; }; 648010AA281ADD15009009B9 /* CIFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIFileView.swift; sourceTree = ""; }; 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = ""; }; @@ -584,8 +586,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 = ""; }; @@ -758,8 +760,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; @@ -846,8 +848,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 = ""; @@ -1052,6 +1054,7 @@ 5CB9250B27A942F300ACCCDD /* ChatList */ = { isa = PBXGroup; children = ( + 647C9E283061362A0032110E /* GetStakeBanner.swift */, 5C2E260A27A30CFA00F70299 /* ChatListView.swift */, 5C5346A727B59A6A004DF848 /* ChatHelp.swift */, 5CB9250C27A9432000ACCCDD /* ChatListNavLink.swift */, @@ -1736,6 +1739,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 */, @@ -2135,7 +2139,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; @@ -2185,7 +2189,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; @@ -2227,7 +2231,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; @@ -2247,7 +2251,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; @@ -2272,7 +2276,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; @@ -2309,7 +2313,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; @@ -2346,7 +2350,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; @@ -2397,7 +2401,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; @@ -2451,7 +2455,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; @@ -2485,7 +2489,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 d7b8623ab3..75d66ebb6f 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -902,6 +902,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 ae3c8d662b..cf520a65c7 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -324,7 +324,8 @@ 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 @@ -334,10 +335,51 @@ 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 @@ -519,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) @@ -2457,7 +2500,7 @@ public struct Contact: Identifiable, Decodable, NamedChat, Hashable { } public var isContactCard: Bool { - (activeConn == nil || activeConn?.connStatus == .prepared) && profile.contactLink != nil && active && preparedContact == nil && contactRequestId == nil + (activeConn == nil || activeConn?.connStatus == .prepared) && profile.contactLink != nil && active && preparedContact == nil && contactRequestId == nil && groupDirectInv == nil } @inline(__always) 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/client/chat-view.md b/apps/ios/spec/client/chat-view.md index afe656ed04..c509662c9e 100644 --- a/apps/ios/spec/client/chat-view.md +++ b/apps/ios/spec/client/chat-view.md @@ -279,6 +279,7 @@ Detailed message information sheet (accessed via long-press menu "Info"): - Edit history (all previous versions of edited messages) - Forward chain info - Message timestamps (created, updated, deleted) +- When the file expires, and below it, for a user without a badge, a button opening the badges sheet --- 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 5bbb41e4dd..9d65606a9c 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 @@ -130,6 +130,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 * */ @@ -199,6 +201,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( @@ -1923,7 +1931,7 @@ data class Contact( } val isContactCard: Boolean get() = - (activeConn == null || activeConn.connStatus == ConnStatus.Prepared) && profile.contactLink != null && active && preparedContact == null && contactRequestId == null + (activeConn == null || activeConn.connStatus == ConnStatus.Prepared) && profile.contactLink != null && active && preparedContact == null && contactRequestId == null && groupDirectInv == null val isBot: Boolean get() = profile.peerType == ChatPeerType.Bot @@ -2232,11 +2240,47 @@ data class BadgeState( 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, @@ -2387,7 +2431,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 966a931208..9100b5ecea 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) } @@ -4539,6 +4541,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 @@ -7381,6 +7384,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/BadgesRedeemCodeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt index a9d2a4d19c..9ebaf9bcdc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt @@ -58,7 +58,7 @@ private fun formatBadgeCodeInput(s: String): String { } @Composable -fun BadgesRedeemCodeView() { +fun BadgesRedeemCodeView(modalManager: ModalManager) { val rhId = remember { chatModel.remoteHostId() } val supporterBannerShown = remember { appPrefs.supporterBannerShown } val code = remember { mutableStateOf(TextFieldValue("")) } @@ -96,7 +96,7 @@ fun BadgesRedeemCodeView() { ) } else { supporterBannerShown.set(true) - ModalManager.start.closeModal() + modalManager.closeModal() } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesSupportSimplexView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesSupportSimplexView.kt index 48ef36ece1..4f1cc2ed66 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesSupportSimplexView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesSupportSimplexView.kt @@ -32,7 +32,7 @@ import chat.simplex.common.views.onboarding.TextButtonBelowOnboardingButton import chat.simplex.res.MR @Composable -fun BadgesSupportSimplexView() { +fun BadgesSupportSimplexView(modalManager: ModalManager) { ColumnWithScrollBar( Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp), verticalArrangement = Arrangement.spacedBy(16.dp), @@ -57,7 +57,7 @@ fun BadgesSupportSimplexView() { // TODO [badges] restore WhyBuiltButton() when in-app purchase lands: the level screen // returns to the flow and HowItWorksButton() moves there, leaving this one alone here. - HowItWorksButton() + HowItWorksButton(modalManager) Spacer(Modifier.weight(1f)) @@ -66,7 +66,7 @@ fun BadgesSupportSimplexView() { Spacer(Modifier.weight(1f)) Column(horizontalAlignment = Alignment.CenterHorizontally) { - RedeemCodeButton() + RedeemCodeButton(modalManager) GetCodeButton() } } @@ -74,22 +74,22 @@ fun BadgesSupportSimplexView() { // the in-app purchase path, kept compiling and uncalled until payments return after the MVP @Composable -private fun ChooseLevelButton() { +private fun ChooseLevelButton(modalManager: ModalManager) { OnboardingActionButton( modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), labelId = MR.strings.badges_choose_your_level, onboarding = null, onclick = { - ModalManager.start.showModal { BadgesYourLevelView() } + modalManager.showModal { BadgesYourLevelView(modalManager) } } ) } @Composable -private fun WhyBuiltButton() { +private fun WhyBuiltButton(modalManager: ModalManager) { val primary = MaterialTheme.colors.primary TextButton({ - ModalManager.start.showModal { HowItWorks(user = chatModel.currentUser.value, onboardingStage = null, titleColor = primary) } + modalManager.showModal { HowItWorks(user = chatModel.currentUser.value, onboardingStage = null, titleColor = primary) } }) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { Icon(painterResource(MR.images.ic_info), null, tint = MaterialTheme.colors.primary) @@ -99,9 +99,9 @@ private fun WhyBuiltButton() { } @Composable -private fun HowItWorksButton() { +private fun HowItWorksButton(modalManager: ModalManager) { TextButton({ - ModalManager.start.showModal { BadgesHowItWorksView() } + modalManager.showModal { BadgesHowItWorksView() } }) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { Icon(painterResource(MR.images.ic_info), null, tint = MaterialTheme.colors.primary) @@ -111,13 +111,13 @@ private fun HowItWorksButton() { } @Composable -private fun RedeemCodeButton() { +private fun RedeemCodeButton(modalManager: ModalManager) { OnboardingActionButton( modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), labelId = MR.strings.badges_redeem_code_button, onboarding = null, onclick = { - ModalManager.start.showModal { BadgesRedeemCodeView() } + modalManager.showModal { BadgesRedeemCodeView(modalManager) } } ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesView.kt index 0cb128c902..ff2660497e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesView.kt @@ -5,11 +5,12 @@ import androidx.compose.runtime.Composable import chat.simplex.common.model.BadgeModel import chat.simplex.common.model.BadgeState import chat.simplex.common.platform.chatModel +import chat.simplex.common.views.helpers.ModalManager import chat.simplex.common.views.helpers.ModalView @OptIn(ExperimentalAnimationApi::class) @Composable -fun BadgesView(close: () -> Unit) { +fun BadgesView(modalManager: ModalManager, close: () -> Unit) { val shownBadge: BadgeState? = run { if (!BadgeModel.isCurrent(chatModel.remoteHostId(), chatModel.currentUser.value?.userId)) return@run null val badgeState = BadgeModel.badgeState.value @@ -20,10 +21,15 @@ fun BadgesView(close: () -> Unit) { ModalView(close, cardScreen = shownBadge != null) { AnimatedContent(targetState = shownBadge, transitionSpec = { fadeIn() with fadeOut() }, contentKey = { it != null }) { badgeState -> if (badgeState != null) { - BadgesYourBadgeView(badgeState) + BadgesYourBadgeView(badgeState, modalManager) } else { - BadgesSupportSimplexView() + BadgesSupportSimplexView(modalManager) } } } } + +// ModalManager.end, not start: every caller is in the chat, which on desktop is the right pane +fun openBadgesView() { + ModalManager.end.showCustomModal { close -> BadgesView(ModalManager.end, close) } +} 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 035eb64846..c2ca7d7f10 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 @@ -12,8 +12,10 @@ 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 @@ -29,11 +31,13 @@ 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 -fun BadgesYourBadgeView(badgeState: BadgeState) { +fun BadgesYourBadgeView(badgeState: BadgeState, modalManager: ModalManager) { ColumnWithScrollBar { AppBarTitle(stringResource(MR.strings.badges_your_badge)) @@ -50,10 +54,30 @@ fun BadgesYourBadgeView(badgeState: BadgeState) { SettingsActionItem( painterResource(MR.images.ic_info), stringResource(MR.strings.badges_how_it_works_button), - { ModalManager.start.showModal { BadgesHowItWorksView() } }, + { modalManager.showModal { BadgesHowItWorksView() } }, ) } 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)) { @@ -64,10 +88,17 @@ fun BadgesYourBadgeView(badgeState: BadgeState) { } 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) } }) { + SectionItemView({ modalManager.showCustomModal { close -> BadgesLedgerView(badgeState, close) } }) { Text(stringResource(MR.strings.badges_ledger)) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourLevelView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourLevelView.kt index c77e4dd61e..161702704b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourLevelView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourLevelView.kt @@ -54,7 +54,7 @@ enum class BadgeLevel { } @Composable -fun BadgesYourLevelView() { +fun BadgesYourLevelView(modalManager: ModalManager) { var selectedLevel by remember { mutableStateOf(BadgeLevel.Supporter) } LaunchedEffect(Unit) { BadgeStore.load() } @@ -99,11 +99,11 @@ fun BadgesYourLevelView() { // Nested Column with no spacing so the TextButtonBelowOnboardingButton sits directly under // the action button (matches onboarding pattern where its own 7.5dp top padding is the gap). Column(horizontalAlignment = Alignment.CenterHorizontally) { - ContinueButton(selectedLevel) + ContinueButton(selectedLevel, modalManager) TextButtonBelowOnboardingButton( text = stringResource(MR.strings.badges_how_it_works_button), icon = painterResource(MR.images.ic_info), - onClick = { ModalManager.start.showModal { BadgesHowItWorksView() } } + onClick = { modalManager.showModal { BadgesHowItWorksView() } } ) } } @@ -140,13 +140,13 @@ private fun LevelCard(level: BadgeLevel, selectedLevel: BadgeLevel, modifier: Mo } @Composable -private fun ContinueButton(selectedLevel: BadgeLevel) { +private fun ContinueButton(selectedLevel: BadgeLevel, modalManager: ModalManager) { OnboardingActionButton( modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), labelId = MR.strings.badges_continue, onboarding = null, onclick = { - ModalManager.start.showModal { BadgesPayView(selectedLevel) } + modalManager.showModal { BadgesPayView(selectedLevel) } } ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/SupportSimpleXBanner.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/SupportSimpleXBanner.kt 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/ChatItemInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt index 554d79ebae..50f5e35462 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt @@ -29,7 +29,9 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.group.MemberProfileImage import chat.simplex.common.views.chat.item.* +import chat.simplex.common.views.badges.openBadgesView import chat.simplex.common.views.chatlist.* +import chat.simplex.common.views.newchat.noShownBadge import chat.simplex.common.views.usersettings.networkAndServers.serverHostname import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource @@ -277,6 +279,11 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools if (file?.fileExpires != null) { val expiresRes = if (file.expired) MR.strings.info_row_file_expired else MR.strings.info_row_file_expires InfoRow(stringResource(expiresRes), localTimestamp(file.fileExpires)) + if (noShownBadge()) { + SectionItemView(::openBadgesView) { + Text(stringResource(MR.strings.badges_larger_files_longer), color = MaterialTheme.colors.primary) + } + } } if (ci.meta.msgVerified?.verified == true) { val signedRes = if (sent) MR.strings.info_row_signed else MR.strings.info_row_signed_verified diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index cb36d86230..7e43db2e98 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -502,7 +502,7 @@ fun ChatView( groupMembersJob = scope.launch(Dispatchers.Default) { val r = chatModel.controller.apiGroupMemberInfo(chatRh, groupInfo.groupId, member.groupMemberId) val stats = r?.second - val (updatedMember, code) = if (member.memberActive) { + val (updatedMember, code) = if ((member.memberActive || (groupInfo.useRelays && member.memberCurrent)) && member.memberRole != GroupMemberRole.Relay) { val memCode = chatModel.controller.apiGetGroupMemberCode(chatRh, groupInfo.apiId, member.groupMemberId) (memCode?.first ?: r?.first ?: member) to memCode?.second } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index a03cb63525..c81149fcb1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -33,12 +33,14 @@ import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.model.ChatModel.filesToDelete import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.badges.openBadgesView import chat.simplex.common.views.chat.group.hostFromRelayLink import chat.simplex.common.views.chat.group.relayConnStatus import chat.simplex.common.views.chat.item.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.RelayProgressIndicator import chat.simplex.common.views.newchat.RelayStatusIndicator +import chat.simplex.common.views.newchat.noShownBadge import chat.simplex.common.views.newchat.relayDisplayName import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource @@ -320,6 +322,24 @@ private fun isVideoUri(uri: URI): Boolean { private fun isWebmUri(uri: URI): Boolean = getFileName(uri)?.lowercase()?.endsWith(".webm") == true +// A badge only helps below the largest badge's limit, and not in incognito chats, so outside that +// the alert is informational as before. +fun showLargeFileAlert(fileSize: Long, incognito: Boolean, senderProfile: LocalProfile?) { + val title = generalGetString(MR.strings.large_file) + val text = largeFileMessage(fileSize, incognito, expiredBadgeReason(fileSize, senderProfile)) + if (!incognito && fileSize <= MAX_FILE_SIZE_XFTP_LEGEND && noShownBadge()) { + AlertManager.shared.showAlertDialog( + title = title, + text = text, + confirmText = generalGetString(MR.strings.ok), + dismissText = generalGetString(MR.strings.badges_support_simplex_title), + onDismiss = ::openBadgesView + ) + } else { + AlertManager.shared.showAlertMsg(title, text) + } +} + fun MutableState.processPickedFile(uri: URI?, text: String?) { if (uri != null) { val maxFileSize = value.maxFileSize @@ -330,10 +350,7 @@ fun MutableState.processPickedFile(uri: URI?, text: String?) { value = value.copy(message = if (text != null) ComposeMessage(text) else value.message, preview = ComposePreview.FilePreview(fileName, uri)) } } else if (fileSize != null) { - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.large_file), - largeFileMessage(fileSize, value.sendIncognito, expiredBadgeReason(fileSize, chatModel.currentUser.value?.profile)) - ) + showLargeFileAlert(fileSize, value.sendIncognito, chatModel.currentUser.value?.profile) } else { showWrongUriAlert() } @@ -359,10 +376,7 @@ suspend fun MutableState.processPickedMedia(uris: List, text: UploadContent.AnimatedImage(uri) } else { bitmap = null - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.large_file), - largeFileMessage(fileSize ?: 0, value.sendIncognito, expiredBadgeReason(fileSize ?: 0, chatModel.currentUser.value?.profile)) - ) + showLargeFileAlert(fileSize ?: 0, value.sendIncognito, chatModel.currentUser.value?.profile) null } } else if (bitmap != null) { 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..fae1b7c155 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 + onTap = { ModalManager.start.showCustomModal { close -> BadgesView(ModalManager.start, close) } }, + 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( - onTap = { ModalManager.start.showCustomModal { close -> BadgesView(close) } }, + title = stringResource(MR.strings.badges_renewal_failed), + subtitle = stringResource(MR.strings.badges_tap_for_details), + warning = true, + onTap = { ModalManager.start.showCustomModal { close -> BadgesView(ModalManager.start, 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(ModalManager.start, 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/helpers/ChatInfoImage.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt index fcae2e6b47..91919ff0cf 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt @@ -36,6 +36,8 @@ import chat.simplex.common.model.LocalBadge import chat.simplex.common.model.localDate import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.badges.openBadgesView +import chat.simplex.common.views.newchat.noShownBadge import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource import kotlin.math.max @@ -244,10 +246,17 @@ fun showBadgeInfoAlert(name: String, badge: LocalBadge, uriHandler: UriHandler) String.format(generalGetString(MR.strings.badge_supported_simplex), name, localDate(badge.badge.badgeExpiry)) else String.format(generalGetString(MR.strings.badge_supports_simplex), name) - AlertManager.shared.showAlertMsg( - title = title, - text = supports + "\n\n" + generalGetString(MR.strings.badge_support_from_v7) - ) + if (noShownBadge()) { + AlertManager.shared.showAlertDialog( + title = title, + text = supports, + confirmText = generalGetString(MR.strings.ok), + dismissText = generalGetString(MR.strings.badges_support_simplex_title), + onDismiss = ::openBadgesView + ) + } else { + AlertManager.shared.showAlertMsg(title = title, text = supports) + } } } } 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..c49caf7cfa 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 @@ -107,7 +107,7 @@ fun SettingsLayout( SectionView { // Direct showModal (no settings / cardScreen flags) — settings-style card chrome would render // a gray top bar / back button that badges views don't want (they have their own inline titles). - SectionItemView(click = { ModalManager.start.showCustomModal { close -> BadgesView(close) } }) { + SectionItemView(click = { ModalManager.start.showCustomModal { close -> BadgesView(ModalManager.start, close) } }) { val badgeType = chatModel.currentUser.value?.profile?.localBadge?.badge?.badgeType ?: BadgeType.Supporter Image(painterResource(badgeImage(badgeType)), stringResource(MR.strings.supporter_perks), Modifier.size(24.dp)) TextIconSpaced() @@ -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 4a3f7870fe..cd4e0a89d1 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml @@ -2762,7 +2762,6 @@ شارة غير متحقق منها لا تدعم خوادم ترحيل الدردشة المستعملة صفحات الويب. رمز صفحة الويب - يمكنك دعم SimpleX بدءًا من الإصدار 7 من التطبيق. خطأ في حفظ الاسم اسمح للناس بالتواصل معك عبر الاسم المسجَّل في عنوان SimpleX الخاص بك. اسمح للأشخاص بالانضمام باستخدام الاسم المسجَّل عبر رابط هذه القناة. 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 e310834fda..42aecc9cfe 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -2678,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 📢 @@ -3132,7 +3134,6 @@ Runs in background to receive messages %s supports SimpleX Chat. %1$s supported SimpleX Chat. The badge expired on %2$s. - You can support SimpleX starting from v7 of the app. %s invested in SimpleX Chat crowdfunding. Unverified badge This badge could not be verified and may not be genuine. @@ -3159,50 +3160,54 @@ 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. + Support SimpleX to send larger files that stay available longer Purchase successful Purchase pending The purchase is awaiting approval. This build does not deliver purchases approved later. Purchase error Get your code Redeem code - Paste the code from your receipt. + 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 @@ -3224,6 +3229,17 @@ 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/de/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml index 6ea5d7686d..ccc7732a18 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml @@ -2858,7 +2858,6 @@ Abzeichen nicht verifiziert Die verwendeten Chat‑Relais unterstützen keine Webseiten. Webseiten-Code - Sie können SimpleX ab der App-Version v7 unterstützen. Fehler beim Speichern des Namens Lassen Sie sich über den mit Ihrer SimpleX‑Adresse registrierten Namen verbinden. Ermöglichen Sie Beitritte über den mit diesem Kanal‑Link registrierten Namen. 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 59b99534cd..43e516561a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml @@ -2785,7 +2785,6 @@ Este grupo requiere una versión más reciente de la aplicación. Por favor, actualizala para unirte. Código web Los servidores usados no admiten páginas web. - Puedes apoyar SimpleX desde la versión 7. Insignia sin verificar Conectar con %s Error al guardar el nombre 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 51cfa90ff1..baa98ebf0b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml @@ -2833,7 +2833,6 @@ Fonctionne en arrière-plan pour recevoir les messages %s soutient SimpleX Chat. %1$s a soutenu SimpleX Chat. Le badge a expiré le %2$s. - Vous pouvez soutenir SimpleX à partir de la version 7 de l’application. %s a investi dans le financement participatif de SimpleX Chat. Badge non vérifié Ce badge n’a pas pu être vérifié et peut ne pas être authentique. 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 8bc886865d..447dabcf3d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml @@ -2751,7 +2751,6 @@ visszaigazolt névsor %s támogatja a SimpleX Chatet. %1$s támogatta a SimpleX Chatet. A kitűző lejárt ekkor: %2$s. - A SimpleXet az alkalmazás v7-es verziójától kezdve támogathatja. %s befektetett a SimpleX Chat közösségi finanszírozásába. Ellenőrizetlen kitűző Nem sikerült ellenőrizni ezt a kitűzőt, és lehet, hogy nem eredeti. 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 afaac45347..9b4e775685 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml @@ -2789,7 +2789,6 @@ Targhetta non verificata I relay di chat usati non supportano le pagine web. Codice pagina web - Puoi sostenere SimpleX dalla versione 7 dell\'app. Errore di salvataggio del nome Consenti alle persone di collegarsi tramite il nome registrato con il tuo indirizzo SimpleX. Consenti alle persone di entrare attraverso il nome registrato con questo link del canale. 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 e5c425e7d7..0789cdcc69 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml @@ -2747,7 +2747,6 @@ メッセージを受信するためにバックグラウンドで実行します %s は SimpleX Chat をサポートしています。 %1$s は SimpleX Chat をサポートしました。バッジは %2$s に期限切れになりました。 - アプリのv7から SimpleX をサポートできます。 %s は SimpleX Chat のクラウドファンディングに出資しました。 未検証のバッジ このバッジは検証できず、本物ではない可能性があります。 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 01239d6049..c353ad2a2f 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 @@ -2316,7 +2316,6 @@ %1$d inscrito %1$d inscritos %1$s apoiou o SimpleX Chat. O selo expirou em %2$s. - Você pode apoiar o SimpleX a partir da versão 7 do aplicativo. Aceitar solicitação de contato Aceitar solicitação de contato aceito 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 ba571e4d0a..6f43e046d8 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml @@ -2812,7 +2812,6 @@ Не удалось проверить подлинность этого значка. Возможно, он не является подлинным. Неподтвержденный значок %s инвестировал(а) в краудфандинг SimpleX Chat. - Вы можете поддержать SimpleX начиная с версии приложения v7. %s поддерживает SimpleX Chat. Работает в фоновом режиме для получения сообщений Нет серверов для разрешения имён. 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 88148f022f..30414e244c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml @@ -2788,7 +2788,6 @@ sen Abonesiniz Bir bağlantı veya QR kodu paylaşabilirsiniz; böylece kanala herkes katılabilir. - Uygulamanın v7 sürümünden itibaren SimpleX\'i destekleyebilirsiniz. Bu aktarım bağlantısı (relay link) üzerinden kanala bağlandınız. Kanalınız Kanalınız 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 804316d18c..75e67cca37 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 @@ -2768,7 +2768,6 @@ 未验证的徽章 所用的聊天中继不支持网页。 网页代码 - 从 v7 版本起您可以支持 SimpleX。 高级选项 高级设置 允许任何人嵌入 diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml index 357099ddef..5827cead70 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 @@ -2772,7 +2772,6 @@ 在背景執行以接收訊息 %s 支持 SimpleX Chat。 %1$s 曾支持 SimpleX Chat。此徽章已於 %2$s 過期。 - 你可以從應用程式 v7 開始支持 SimpleX。 %s 投資了 SimpleX Chat 眾籌。 未驗證徽章 無法驗證此徽章,可能並非真實。 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/client/chat-view.md b/apps/multiplatform/spec/client/chat-view.md index a6691c2878..adf8ad0224 100644 --- a/apps/multiplatform/spec/client/chat-view.md +++ b/apps/multiplatform/spec/client/chat-view.md @@ -271,7 +271,7 @@ Key sections: group profile, group link, member list with roles, group preferenc |---|---| | `ChatView.kt` | Main chat view, ChatLayout, ChatItemsList, ChatInfoToolbar | | `ChatInfoView.kt` | Contact info modal | -| `ChatItemInfoView.kt` | Individual message delivery/read info | +| `ChatItemInfoView.kt` | Individual message delivery/read info; below a file's expiry, for a user without a badge, a button opening the badges view | | `ChatItemsLoader.kt` | Pagination and message loading logic | | `ChatItemsMerger.kt` | MergedItems grouping of consecutive events | | `CommandsMenuView.kt` | Bot `/command` menu popup | 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/blog/20260919-simplex-supporter-badges.md b/blog/20260919-simplex-supporter-badges.md new file mode 100644 index 0000000000..7aa2b2e4ac --- /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 November 22, you will also receive [a public SimpleX name](https://simplex.domains?utm_source=blog) for 5 years[^name]. + +Learn more and invest on Wefunder: [https://wefunder.com/simplex.chat](https://wefunder.com/simplex.chat?utm_source=blog) + +[^name]: Investors of $500 or more by September 22 receive a name for 7 years, and after November 22 for 3 years — 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 5f49b26653..f90e8e08da 100644 --- a/bots/api/COMMANDS.md +++ b/bots/api/COMMANDS.md @@ -1537,15 +1537,15 @@ Connect via prepared SimpleX link. The link can be 1-time invitation link, conta **Syntax**: ``` -/_connect [ ] +/_connect [ incognito=on][ ] ``` ```javascript -'/_connect ' + userId + (preparedLink_ ? ' ' + CreatedConnLink.cmdString(preparedLink_) : '') // JavaScript +'/_connect ' + userId + (incognito ? ' incognito=on' : '') + (preparedLink_ ? ' ' + CreatedConnLink.cmdString(preparedLink_) : '') // JavaScript ``` ```python -'/_connect ' + str(userId) + ((' ' + CreatedConnLink_cmd_string(preparedLink_)) if preparedLink_ is not None else '') # Python +'/_connect ' + str(userId) + (' incognito=on' if incognito else '') + ((' ' + CreatedConnLink_cmd_string(preparedLink_)) if preparedLink_ is not None else '') # Python ``` **Responses**: diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 14ff04b08c..f1122979a9 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -466,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" --- diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index eb4804fa45..4ffba7bfbe 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") diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index 58298c44fb..ac5992770e 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -20,7 +20,7 @@ import Data.Char (isUpper, toLower, toUpper) import Data.List (find, mapAccumL, sortOn) import qualified Data.List.NonEmpty as L import qualified Data.Map.Strict as M -import Data.Maybe (fromMaybe) +import Data.Maybe (fromMaybe, mapMaybe) import qualified Data.Set as S import Data.Text (Text) import GHC.Generics @@ -177,11 +177,15 @@ normalizeConsName pfx consName -- making chatDir optional because clients use CIDirection? instead of CIQDirection (the type is replaced in Types.hs) ciQuoteType :: SumTypeInfo -ciQuoteType = - let st@(STI _ records) = sti @(CIQuote 'CTDirect) - optChatDir f@(FieldInfo n t) = if n == "chatDir" then FieldInfo n (TIOptional t) else f - updateRecord (RecordTypeInfo name fields) = RecordTypeInfo name $ map optChatDir fields - in st {recordTypes = map updateRecord records} -- need to map even though there is one constructor in this type +ciQuoteType = updateFields mkOptional $ sti @(CIQuote 'CTDirect) + where + mkOptional = map (\f@(FieldInfo n t) -> if n == "chatDir" then FieldInfo n (TIOptional t) else f) + +removeField :: String -> SumTypeInfo -> SumTypeInfo +removeField n = updateFields $ mapMaybe (\f@(FieldInfo n' _) -> if n == n' then Nothing else Just f) + +updateFields :: ([FieldInfo] -> [FieldInfo]) -> SumTypeInfo -> SumTypeInfo +updateFields f st@(STI _ records) = st {recordTypes = map (\(RecordTypeInfo name fields) -> RecordTypeInfo name $ f fields) records} -- type info, JSON encoding, constructor prefix, removed constructors, string encoding for commands, description chatTypesDocsData :: [(SumTypeInfo, SumTypeJsonEncoding, String, [ConsName], Expr, Text)] @@ -217,7 +221,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, "", [], "", ""), @@ -302,7 +306,7 @@ chatTypesDocsData = (sti @GroupMemberSettings, STRecord, "", [], "", ""), (sti @GroupMemberStatus, STEnum' ((\case "group_deleted" -> "deleted"; "intro_invited" -> "intro-inv"; s -> s) . consSep "GSMem" '_'), "", [], "", ""), (sti @GroupPreference, STRecord, "", [], "", ""), - (sti @GroupPreferences, STRecord, "", [], "", ""), + (removeField "_json" $ sti @GroupPreferences, STRecord, "", [], "", ""), (sti @GroupProfile, STRecord, "", [], "", ""), (sti @GroupRelay, STRecord, "", [], "", ""), (sti @GroupShortLinkData, STRecord, "", [], "", ""), @@ -338,7 +342,7 @@ chatTypesDocsData = (sti @PendingContactConnection, STRecord, "", [], "", ""), (sti @PlanResolveMode, STEnum, "PRM", [], "", ""), (sti @PrefEnabled, STRecord, "", [], "", ""), - (sti @Preferences, STRecord, "", [], "", ""), + (removeField "_json" $ sti @Preferences, STRecord, "", [], "", ""), (sti @PreparedContact, STRecord, "", [], "", ""), (sti @GroupDirectInvitation, STRecord, "", [], "", ""), (sti @PreparedGroup, STRecord, "", [], "", ""), diff --git a/cabal.project b/cabal.project index 81a7dc7e6f..b8c73b14cf 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: 5294b7d8b7285a887b849b738e29bec207e5dde8 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 fbde8ce9ac..c217ef944c 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 3bf3fdc831..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 { 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 fd4f561c3d..e6155c9ce7 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 6a8fd96c87..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"] 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-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..a77e1d0c8c 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"."5294b7d8b7285a887b849b738e29bec207e5dde8" = "0adjvg6mcyk936qzyl3a4daq68kslzpqhpdh369cf8wwcfqkiqg4"; "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 576ccf128e..153bef02ea 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,8 @@ 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 + Simplex.Chat.Store.Postgres.Migrations.M20260923_preferences_json else exposed-modules: Simplex.Chat.Archive @@ -341,6 +343,8 @@ 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 + Simplex.Chat.Store.SQLite.Migrations.M20260923_preferences_json 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 cc205458c7..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,6 +225,44 @@ data BadgeAlertPrice = BadgeAlertPrice } 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) + +-- | 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 @@ -227,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) @@ -263,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) @@ -273,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/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 96f760dea9..4632fe727e 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) @@ -1721,7 +1721,7 @@ processChatCommand cxt nm = \case Just _ -> do let chatV = initialChatVersion subMode <- chatReadVar subscriptionMode - connId <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True cReq PQSupportOff + (connId, _) <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True cReq PQSupportOff conn@Connection {connId = testCId} <- withFastStore $ \db -> createRelayTestConnection db cxt user connId ConnPrepared chatV subMode challenge <- drgRandomBytes 32 @@ -2426,7 +2426,8 @@ processChatCommand cxt nm = \case g' <- withFastStore' $ \db -> setGroupDomainVerified db user g verified pure $ CRGroupDomainVerified user g' reason APIConnectContactViaAddress userId incognito contactId -> withUserId userId $ \user -> do - ct@Contact {profile = LocalProfile {contactLink}} <- withFastStore $ \db -> getContact db cxt user contactId + ct@Contact {profile = LocalProfile {contactLink}, groupDirectInv} <- withFastStore $ \db -> getContact db cxt user contactId + when (isJust groupDirectInv) $ throwCmdError "contact is a member contact request" ccLink <- case contactLink of Just (CLFull cReq) -> pure $ CCLink cReq Nothing Just (CLShort sLnk) -> do @@ -2844,7 +2845,7 @@ processChatCommand cxt nm = \case dm <- encodeConnInfo $ XGrpAcpt membershipMemId (Just $ groupMemberKey gks) agentConnId <- case memberConn fromMember of Nothing -> do - agentConnId <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True connRequest PQSupportOff + (agentConnId, _) <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True connRequest PQSupportOff let chatV = vr cxt `peerConnChatVersion` peerChatVRange void $ withFastStore' $ \db -> createMemberConnection db userId fromMember agentConnId chatV peerChatVRange subMode pure agentConnId @@ -3398,7 +3399,7 @@ processChatCommand cxt nm = \case -- possible improvement: use agent connRequestAgentVersion to determine pqSupport here; -- for joinPreparedConn below - same + encodeConnInfoPQ; -- same for auto-accept on xGrpDirectInv - acId <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True cReq PQSupportOff + (acId, _) <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True cReq PQSupportOff conn <- withStore $ \db -> do connId <- liftIO $ createMemberContactConn db user acId Nothing gInfo mConn ConnPrepared contactId subMode getConnectionById db cxt user connId @@ -3757,7 +3758,7 @@ processChatCommand cxt nm = \case withMemberName gName mName cmd = withUser $ \user -> getGroupAndMemberId user gName mName >>= processChatCommand cxt nm . uncurry cmd getConnectionCode :: ConnId -> CM Text - getConnectionCode connId = verificationCode <$> withAgent (`getConnectionRatchetAdHash` connId) + getConnectionCode connId = verificationCode . codeAD <$> withAgent (`getConnectionVerifyCodes` connId) getChannelMemberCode :: GroupInfo -> GroupMember -> CM Text getChannelMemberCode GroupInfo {membership} m = case (memberPubKey membership, memberPubKey m) of @@ -3818,7 +3819,7 @@ processChatCommand cxt nm = \case joinNewConn chatV = do -- [incognito] generate profile to send incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing - connId <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True cReq pqSup' + (connId, _) <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True cReq pqSup' let ccLink = CCLink cReq $ serverShortLink <$> sLnk_ conn <- withFastStore' $ \db -> createDirectConnection' db userId connId ccLink contactId_ ConnPrepared incognitoProfile subMode chatV pqSup' joinPreparedConn conn incognitoProfile @@ -3873,13 +3874,13 @@ processChatCommand cxt nm = \case relayMemberId_ = case preparedEntity_ of Just (PCEGroup (GIK gInfo _) m) | useRelays' gInfo -> Just (memberId' m) _ -> Nothing - joinPreparedConn' xContactId_ conn@Connection {customUserProfileId} gInfo_ = do + joinPreparedConn' xContactId_ conn@Connection {connId, customUserProfileId} gInfo_ = do when (incognito /= isJust customUserProfileId) $ throwCmdError "incognito mode is different from prepared connection" -- TODO [relays] member: refactor joinContact and up avoiding parallel ifs, xContactId is not used xContactId <- mkXContactId xContactId_ - localIncognitoProfile <- forM customUserProfileId $ \pId -> withFastStore $ \db -> getProfileById db userId pId + (cReq', localIncognitoProfile) <- withFastStore $ \db -> (,) <$> getConnReqContact db connId <*> forM customUserProfileId (getProfileById db userId) let incognitoProfile = fromLocalProfile <$> localIncognitoProfile - conn' <- joinContact user conn cReq incognitoProfile xContactId welcomeSharedMsgId msg_ gInfo_ relayMemberId_ PQSupportOn + conn' <- joinContact user conn cReq' incognitoProfile xContactId welcomeSharedMsgId msg_ gInfo_ relayMemberId_ PQSupportOn pure $ CVRSentInvitation conn' incognitoProfile connect' groupLinkId xContactId_ gInfo_ = do let inGroup = isJust groupLinkId @@ -3912,13 +3913,13 @@ processChatCommand cxt nm = \case void $ joinContact user conn cReq incognitoProfile newXContactId Nothing Nothing Nothing Nothing pqSup ct' <- withStore $ \db -> getContact db cxt user contactId pure $ CRSentInvitationToContact user ct' incognitoProfile - Just conn@Connection {connStatus, xContactId = xContactId_, customUserProfileId} -> case connStatus of + Just conn@Connection {connId, connStatus, xContactId = xContactId_, customUserProfileId} -> case connStatus of ConnPrepared -> do when (incognito /= isJust customUserProfileId) $ throwCmdError "incognito mode is different from prepared connection" xContactId <- mkXContactId xContactId_ - localIncognitoProfile <- forM customUserProfileId $ \pId -> withFastStore $ \db -> getProfileById db userId pId + (cReq', localIncognitoProfile) <- withFastStore $ \db -> (,) <$> getConnReqContact db connId <*> forM customUserProfileId (getProfileById db userId) let incognitoProfile = fromLocalProfile <$> localIncognitoProfile - void $ joinContact user conn cReq incognitoProfile xContactId Nothing Nothing Nothing Nothing PQSupportOn + void $ joinContact user conn cReq' incognitoProfile xContactId Nothing Nothing Nothing Nothing PQSupportOn ct' <- withStore $ \db -> getContact db cxt user contactId pure $ CRSentInvitationToContact user ct' incognitoProfile _ -> throwCmdError "contact already has connection" @@ -3965,7 +3966,7 @@ processChatCommand cxt nm = \case Nothing -> throwChatError CEInvalidConnReq Just _ -> do let chatV = initialChatVersion - connId <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True cReq pqSup + (connId, _) <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True cReq pqSup pure (connId, chatV) mkXContactId :: Maybe XContactId -> CM XContactId mkXContactId = maybe (XContactId <$> drgRandomBytes 16) pure @@ -4307,7 +4308,7 @@ processChatCommand cxt nm = \case let chatV = initialChatVersion gVar <- asks random subMode <- chatReadVar subscriptionMode - connId <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True cReq PQSupportOff + (connId, _) <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True cReq PQSupportOff (relayMember, conn, groupRelay) <- withFastStore $ \db -> do relayMember <- createRelayForOwner db cxt gVar user gInfo relay groupRelay <- createGroupRelayRecord db gInfo relayMember relay @@ -4710,7 +4711,7 @@ processChatCommand cxt nm = \case SRDirect contactId -> do ct <- withFastStore $ \db -> getContact db cxt u contactId forM (contactConn ct) $ \conn -> - (CBDirect,) <$> withAgent (`getConnectionRatchetAdHash` aConnId conn) + (CBDirect,) . codeAD <$> withAgent (`getConnectionVerifyCodes` aConnId conn) SRGroup toGroupId _ asGroup -> do GroupInfo {groupProfile = GroupProfile {publicGroup}, membership = m} <- withFastStore $ \db -> getGroupInfo db cxt u toGroupId pure $ mkBinding m <$> publicGroup @@ -5259,14 +5260,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 == '_' @@ -5289,7 +5294,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) @@ -5308,16 +5313,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. @@ -5370,40 +5392,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 @@ -5417,18 +5451,21 @@ getUserBadgeState user = do Just p@UserBadgePurchase {badgePurchaseId} -> fmap (badgeStateOf now p) <$> withStore' (`getBadgeLedgerLastEntry` badgePurchaseId) where - badgeStateOf now p@UserBadgePurchase {badgePurchaseId, purchaseKey, badgeType, shown} balance = - BadgeState - { badgePurchaseId, - purchaseKey, - badgeType, - shown = BoolDef shown, - monthsLeft = balanceMonths balance, - paidThrough = L.paidThrough balance, - renewsAt = Nothing, - willRenew = False, - alert = unansweredBadgeAlert now p balance - } + 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. @@ -5488,22 +5525,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 @@ -5516,24 +5570,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. diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index cf30993382..796ec0ef93 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -977,7 +977,7 @@ acceptContactRequest nm user@User {userId} UserContactRequest {agentInvitationId (ct, conn, incognitoProfile) <- case contactId_ of Nothing -> do incognitoProfile <- if incognito then Just . NewIncognito <$> liftIO generateRandomProfile else pure Nothing - connId <- withAgent $ \a -> prepareConnectionToAccept a (aUserId user) True invId pqSup' + (connId, _) <- withAgent $ \a -> prepareConnectionToAccept a (aUserId user) True invId pqSup' (ct, conn) <- withStore' $ \db -> createContactFromRequest db user userContactLinkId_ connId chatV cReqChatVRange cName profileId cp xContactId incognitoProfile subMode pqSup' False pure (ct, conn, incognitoProfile) @@ -986,7 +986,7 @@ acceptContactRequest nm user@User {userId} UserContactRequest {agentInvitationId case contactConn ct of Nothing -> do incognitoProfile <- if incognito then Just . NewIncognito <$> liftIO generateRandomProfile else pure Nothing - connId <- withAgent $ \a -> prepareConnectionToAccept a (aUserId user) True invId pqSup' + (connId, _) <- withAgent $ \a -> prepareConnectionToAccept a (aUserId user) True invId pqSup' currentTs <- liftIO getCurrentTime conn <- withStore' $ \db -> do forM_ xContactId $ \xcId -> setContactAcceptedXContactId db ct xcId @@ -2329,7 +2329,7 @@ type HistoryFile = (FileInvitation, RcvFileDescrText, Maybe UTCTime, Maybe Badge directChatBinding :: Contact -> CM (Maybe ByteString) directChatBinding ct = forM (contactConn ct) $ \conn -> - encodeChatBinding CBDirect <$> withAgent (`getConnectionRatchetAdHash` aConnId conn) + encodeChatBinding CBDirect . codeAD <$> withAgent (`getConnectionVerifyCodes` aConnId conn) rcvGroupChatBinding :: GroupInfo -> Maybe GroupMember -> ShowGroupAsSender -> Maybe BadgeProof -> Maybe ByteString rcvGroupChatBinding gInfo m_ asGroup badge_ = @@ -3032,7 +3032,7 @@ prepareAgentJoin user conn_ enableNtfs cReqUri = do cmdId <- withStore' $ \db -> createCommand db user (dbConnId <$> conn_) CFJoinConn connId <- case conn_ of Just conn -> pure $ aConnId conn - Nothing -> withAgent $ \a -> prepareConnectionToJoin a (aUserId user) enableNtfs cReqUri PQSupportOff + Nothing -> fst <$> withAgent (\a -> prepareConnectionToJoin a (aUserId user) enableNtfs cReqUri PQSupportOff) pure (cmdId, connId) joinAgentConnectionAsync :: CommandId -> Bool -> ConnId -> Bool -> ConnectionRequestUri c -> ConnInfo -> SubscriptionMode -> CM () @@ -3058,7 +3058,7 @@ allowAgentConnectionInfo user conn@Connection {connId} confId dm = do prepareAgentAccept :: User -> Bool -> InvitationId -> PQSupport -> CM (CommandId, ConnId) prepareAgentAccept user enableNtfs invId pqSup = do cmdId <- withStore' $ \db -> createCommand db user Nothing CFAcceptContact - connId <- withAgent $ \a -> prepareConnectionToAccept a (aUserId user) enableNtfs invId pqSup + (connId, _) <- withAgent $ \a -> prepareConnectionToAccept a (aUserId user) enableNtfs invId pqSup pure (cmdId, connId) agentAcceptContactAsync :: MsgEncodingI e => CommandId -> ConnId -> Bool -> InvitationId -> ChatMsgEvent e -> PQSupport -> SubscriptionMode -> CM () diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 995bc79fcf..6022114a52 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -1393,7 +1393,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag processContactConnMessage :: AEvent e -> ConnectionEntity -> Connection -> UserContact -> CM () processContactConnMessage agentMsg connEntity conn UserContact {userContactLinkId = uclId, groupId = ucGroupId_} = case agentMsg of - REQ invId pqSupport _ connInfo rejectionSupported -> do + REQ invId pqSupport _ connInfo _ rejectionSupported -> do (signedMsg_, ChatMessage {chatVRange, chatMsgEvent}) <- parseChatMessage' conn connInfo case chatMsgEvent of XContact p memberKey_ xContactId_ welcomeMsgId_ requestMsg_ -> profileContactRequest invId chatVRange p memberKey_ xContactId_ welcomeMsgId_ requestMsg_ pqSupport rejectionSupported @@ -1911,7 +1911,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag keepSig <- case contactConn ct of Nothing -> pure False Just conn -> do - adHash <- withAgent (`getConnectionRatchetAdHash` aConnId conn) + adHash <- codeAD <$> withAgent (`getConnectionVerifyCodes` aConnId conn) pure $ encodeChatBinding CBDirect adHash == binding pure $ if keepSig then c else MCChat {text, chatLink, ownerSig = Nothing} _ -> pure c @@ -3898,7 +3898,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag securityCodeChanged mCt' createItems mCt' m | otherwise = do - acId <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True connReq PQSupportOff + (acId, _) <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True connReq PQSupportOff mCt' <- withStore $ \db -> do updateMemberContactInvited db user mCt groupDirectInv void $ liftIO $ createMemberContactConn db user acId Nothing g mConn ConnPrepared mContactId subMode @@ -3919,7 +3919,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag createInternalChatItem user (CDDirectSnd mCt) CIChatBanner (Just epochStart) createItems mCt m' | otherwise = do - acId <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True connReq PQSupportOff + (acId, _) <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True connReq PQSupportOff (mCt, m') <- withStore $ \db -> do (mContactId, m') <- liftIO $ createMemberContactInvited db user g m groupDirectInv void $ liftIO $ createMemberContactConn db user acId Nothing g mConn ConnPrepared mContactId subMode diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index d2e1d8460d..085df84956 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -12,6 +12,7 @@ module Simplex.Chat.Mobile where import Control.Concurrent.STM import Control.Exception (SomeException, catch) +import Control.Monad import Control.Monad.Except import Control.Monad.Reader import Data.Aeson (ToJSON (..)) @@ -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, @@ -313,18 +328,19 @@ getActiveUser_ st = find activeUser <$> withTransaction st getUsers chatMigrateInit :: String -> ScrubbedBytes -> String -> IO (Either DBMigrationResult ChatController) chatMigrateInit dbFilePrefix dbKey confirm = do let chatDBOpts = ChatDbOpts {dbFilePrefix, dbKey, trackQueries = DB.TQSlow 5000, vacuumOnMigration = True} - chatMigrateInitKey chatDBOpts False confirm False + chatMigrateInitKey chatDBOpts False confirm False mobileQueueSize #endif -chatMigrateInitKey :: ChatDbOpts -> Bool -> String -> Bool -> IO (Either DBMigrationResult ChatController) -chatMigrateInitKey chatDbOpts keepKey confirm backgroundMode = runExceptT $ do +chatMigrateInitKey :: ChatDbOpts -> Bool -> String -> Bool -> Int -> IO (Either DBMigrationResult ChatController) +chatMigrateInitKey chatDbOpts keepKey confirm backgroundMode queueSize = runExceptT $ do + unless (queueSize > 0) $ throwError DBMInvalidQueueSize confirmMigrations <- liftEitherWith (const DBMInvalidConfirmation) $ strDecode $ B.pack confirm let migrationConfig = MigrationConfig confirmMigrations (Just "") chatStore <- migrate createChatStore (toDBOpts chatDbOpts chatSuffix keepKey chatDBFunctions) migrationConfig agentStore <- migrate createAgentStore (toDBOpts chatDbOpts agentSuffix keepKey []) migrationConfig ExceptT $ initialize chatStore ChatDatabase {chatStore, agentStore} where - opts = mobileChatOpts $ removeDbKey chatDbOpts + opts = mobileChatOpts (removeDbKey chatDbOpts) (fromIntegral queueSize) initialize st db = do user_ <- liftIO $ getActiveUser_ st first DBMAgentError <$> newChatController db user_ defaultMobileConfig opts backgroundMode diff --git a/src/Simplex/Chat/Store/Badges.hs b/src/Simplex/Chat/Store/Badges.hs index d581150df8..19d6d2efef 100644 --- a/src/Simplex/Chat/Store/Badges.hs +++ b/src/Simplex/Chat/Store/Badges.hs @@ -13,6 +13,8 @@ module Simplex.Chat.Store.Badges getBadgePurchase, userHasBadge, setBadgeAlertAcked, + setBadgeIssueError, + setBadgeNextWake, clearShownBadge, getBadgeCodeRedemption, createBadgeCodeRedemption, @@ -39,7 +41,7 @@ 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 (..)) @@ -146,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 @@ -200,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 @@ -229,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, @@ -245,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 @@ -267,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 = diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 1057a71a02..0867118cb6 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -37,6 +37,7 @@ import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Groups import Simplex.Chat.Store.Shared import Simplex.Chat.Types +import Simplex.Chat.Types.Preferences import Simplex.Messaging.Agent.Protocol (ConnId) import Simplex.Messaging.Agent.Store.AgentStore (firstRow, firstRow', fromOnlyBI, maybeFirstRow) import Simplex.Messaging.Agent.Store.DB (BoolInt (..)) @@ -119,7 +120,7 @@ getConnectionEntityKeys db cxt user@User {userId, userContactId} agentConnId = d [sql| SELECT c.contact_profile_id, c.local_display_name, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, c.contact_used, c.contact_status, c.enable_ntfs, c.send_rcpts, c.favorite, - p.preferences, c.user_preferences, c.created_at, c.updated_at, c.chat_ts, c.conn_full_link_to_connect, c.conn_short_link_to_connect, c.welcome_shared_msg_id, c.request_shared_msg_id, c.contact_request_id, cr2.rejection_supported, + p.preferences, p.preferences_json, c.user_preferences, c.created_at, c.updated_at, c.chat_ts, c.conn_full_link_to_connect, c.conn_short_link_to_connect, c.welcome_shared_msg_id, c.request_shared_msg_id, c.contact_request_id, cr2.rejection_supported, c.contact_group_member_id, c.contact_grp_inv_sent, c.grp_direct_inv_link, c.grp_direct_inv_from_group_id, c.grp_direct_inv_from_group_member_id, c.grp_direct_inv_from_member_conn_id, c.grp_direct_inv_started_connection, c.ui_themes, c.chat_deleted, c.custom_data, c.chat_item_ttl, p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, @@ -131,8 +132,9 @@ getConnectionEntityKeys db cxt user@User {userId, userContactId} agentConnId = d |] (userId, contactId, CSActive) toContact' :: UTCTime -> Int64 -> Connection -> [ChatTagId] -> ContactRow' -> Contact - toContact' currentTs contactId conn chatTags ((profileId, localDisplayName, displayName, fullName, shortDescr, description, image, contactLink, peerType, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, rejectionSupported_, contactGroupMemberId, BI contactGrpInvSent) :. groupDirectInvRow :. (uiThemes, BI chatDeleted, customData, chatItemTTL) :. badgeRow :. domainRow) = - let profile = LocalProfile {profileId, displayName, fullName, shortDescr, description, image, contactLink, contactDomain = rowToContactDomain domainRow, contactDomainVerified = rowToDomainVerified domainRow, peerType, localBadge = rowToBadge currentTs badgeRow, preferences, localAlias} + toContact' currentTs contactId conn chatTags ((profileId, localDisplayName, displayName, fullName, shortDescr, description, image, contactLink, peerType, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, encodedPrefs, receivedPrefs, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, rejectionSupported_, contactGroupMemberId, BI contactGrpInvSent) :. groupDirectInvRow :. (uiThemes, BI chatDeleted, customData, chatItemTTL) :. badgeRow :. domainRow) = + let preferences = chatPrefsFromRow encodedPrefs receivedPrefs + profile = LocalProfile {profileId, displayName, fullName, shortDescr, description, image, contactLink, contactDomain = rowToContactDomain domainRow, contactDomainVerified = rowToDomainVerified domainRow, peerType, localBadge = rowToBadge currentTs badgeRow, preferences, localAlias} chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito conn activeConn = Just conn @@ -153,7 +155,7 @@ getConnectionEntityKeys db cxt user@User {userId, userContactId} agentConnId = d -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, gp.group_type, gp.group_link, gp.public_group_id, gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding, gp.group_domain_proof, - g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, + g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.preferences_json, gp.member_admission, g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, @@ -164,13 +166,13 @@ getConnectionEntityKeys db cxt user@User {userId, userContactId} agentConnId = d mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} - pu.display_name, pu.full_name, pu.short_descr, pu.description, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, + pu.display_name, pu.full_name, pu.short_descr, pu.description, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, pu.preferences_json, pu.badge_proof, pu.badge_pres_header, pu.badge_expiry, pu.badge_type, pu.badge_verified, pu.badge_extra, pu.badge_master_key, pu.badge_signature, pu.badge_key_idx, pu.contact_domain, pu.contact_domain_proof, pu.contact_domain_verified, mu.created_at, mu.updated_at, mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key, mu.relay_link, mu.member_security_code, mu.member_security_code_verified_at, -- from GroupMember m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, - m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, p.preferences_json, p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, m.member_security_code, m.member_security_code_verified_at diff --git a/src/Simplex/Chat/Store/ContactRequest.hs b/src/Simplex/Chat/Store/ContactRequest.hs index 5bcbaad380..518d988106 100644 --- a/src/Simplex/Chat/Store/ContactRequest.hs +++ b/src/Simplex/Chat/Store/ContactRequest.hs @@ -116,7 +116,7 @@ createOrUpdateContactRequest SELECT -- Contact ct.contact_id, ct.contact_profile_id, ct.local_display_name, cp.display_name, cp.full_name, cp.short_descr, cp.description, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, - cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, cr2.rejection_supported, + cp.preferences, cp.preferences_json, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, cr2.rejection_supported, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx, cp.contact_domain, cp.contact_domain_proof, cp.contact_domain_verified, @@ -153,7 +153,7 @@ createOrUpdateContactRequest cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.business_group_id, cr.user_contact_link_id, cr.rejection_supported, cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, cr.xcontact_id, - cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, + cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, p.preferences_json, cr.created_at, cr.updated_at, cr.peer_chat_min_version, cr.peer_chat_max_version, p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified @@ -172,8 +172,8 @@ createOrUpdateContactRequest liftIO $ DB.execute db - "INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, contact_link, user_id, local_alias, preferences, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" - ((displayName, fullName, shortDescr, description, image, contactLink, userId) :. ("" :: LocalAlias, preferences, currentTs, currentTs) :. badgeToRow badge badgeVerified) + "INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, contact_link, user_id, local_alias, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx, preferences, preferences_json) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" + ((displayName, fullName, shortDescr, description, image, contactLink, userId) :. ("" :: LocalAlias, currentTs, currentTs) :. badgeToRow badge badgeVerified :. prefsToRow preferences) profileId <- liftIO $ insertedRowId db liftIO $ DB.execute diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 9f0812d357..20e9a37eed 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -322,7 +322,7 @@ getContactByConnReqHash db cxt user@User {userId} cReqHash1 cReqHash2 = do SELECT -- Contact ct.contact_id, ct.contact_profile_id, ct.local_display_name, cp.display_name, cp.full_name, cp.short_descr, cp.description, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, - cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, cr2.rejection_supported, + cp.preferences, cp.preferences_json, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, cr2.rejection_supported, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx, @@ -737,12 +737,13 @@ updateContactProfile_' db userId profileId Profile {displayName, fullName, short db [sql| UPDATE contact_profiles - SET display_name = ?, full_name = ?, short_descr = ?, description = ?, image = ?, contact_link = ?, preferences = ?, chat_peer_type = ?, updated_at = ?, + SET preferences = ?, preferences_json = ?, + display_name = ?, full_name = ?, short_descr = ?, description = ?, image = ?, contact_link = ?, chat_peer_type = ?, updated_at = ?, badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ?, contact_domain = ?, contact_domain_proof = ? WHERE user_id = ? AND contact_profile_id = ? |] - ((displayName, fullName, shortDescr, description, image, contactLink, preferences, peerType, updatedAt) :. badgeToRow badge badgeVerified :. contactDomainToRow contactDomain :. (userId, profileId)) + (prefsToRow preferences :. (displayName, fullName, shortDescr, description, image, contactLink, peerType, updatedAt) :. badgeToRow badge badgeVerified :. contactDomainToRow contactDomain :. (userId, profileId)) -- update only member profile fields (when member doesn't have associated contact - we can reset contactLink and prefs) updateMemberContactProfileReset_ :: DB.Connection -> UserId -> ProfileId -> Profile -> Maybe Bool -> IO () @@ -756,7 +757,7 @@ updateMemberContactProfileReset_' db userId profileId Profile {displayName, full db [sql| UPDATE contact_profiles - SET display_name = ?, full_name = ?, short_descr = ?, description = ?, image = ?, contact_link = NULL, preferences = NULL, updated_at = ?, + SET display_name = ?, full_name = ?, short_descr = ?, description = ?, image = ?, contact_link = NULL, preferences = NULL, preferences_json = NULL, updated_at = ?, badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ?, contact_domain = ?, contact_domain_proof = ? WHERE user_id = ? AND contact_profile_id = ? @@ -851,7 +852,7 @@ contactRequestQuery = cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.business_group_id, cr.user_contact_link_id, cr.rejection_supported, cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, cr.xcontact_id, - cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, + cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, p.preferences_json, cr.created_at, cr.updated_at, cr.peer_chat_min_version, cr.peer_chat_max_version, p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, @@ -973,7 +974,7 @@ getContact_ db cxt user@User {userId} contactId deleted = do SELECT -- Contact ct.contact_id, ct.contact_profile_id, ct.local_display_name, cp.display_name, cp.full_name, cp.short_descr, cp.description, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, - cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, cr2.rejection_supported, + cp.preferences, cp.preferences_json, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, cr2.rejection_supported, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx, diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 1e3dcb6d0e..99cbd379a6 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -216,7 +216,7 @@ import Control.Monad import Control.Monad.Except import Control.Monad.IO.Class import Crypto.Random (ChaChaDRG) -import Data.Bifunctor (first, second) +import Data.Bifunctor (first) import Data.ByteString (ByteString) import qualified Data.ByteString as B import Data.Char (toLower) @@ -263,11 +263,11 @@ import Database.SQLite.Simple (Only (..), Query, (:.) (..)) import Database.SQLite.Simple.QQ (sql) #endif -type MaybeGroupMemberRow = (Maybe GroupMemberId, Maybe GroupId, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId) :. ((Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe LocalAlias, Maybe Preferences) :. BadgeRow :. ContactDomainRow) :. (Maybe UTCTime, Maybe UTCTime) :. (Maybe UTCTime, Maybe Int64, Maybe Int64, Maybe Int64, Maybe UTCTime, Maybe C.PublicKeyEd25519, Maybe ShortLinkContact, Maybe Text, Maybe UTCTime) +type MaybeGroupMemberRow = (Maybe GroupMemberId, Maybe GroupId, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId) :. ((Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe LocalAlias, Maybe Text, Maybe Text) :. BadgeRow :. ContactDomainRow) :. (Maybe UTCTime, Maybe UTCTime) :. (Maybe UTCTime, Maybe Int64, Maybe Int64, Maybe Int64, Maybe UTCTime, Maybe C.PublicKeyEd25519, Maybe ShortLinkContact, Maybe Text, Maybe UTCTime) toMaybeGroupMember :: UTCTime -> Int64 -> MaybeGroupMemberRow -> Maybe GroupMember -toMaybeGroupMember now userContactId ((Just groupMemberId, Just groupId, Just indexInGroup, Just memberId, Just minVer, Just maxVer, Just memberRole, Just memberCategory, Just memberStatus, Just showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, Just localDisplayName, memberContactId, Just memberContactProfileId) :. ((Just profileId, Just displayName, Just fullName, shortDescr, description, image, contactLink, peerType, Just localAlias, contactPreferences) :. badgeRow :. domainRow) :. (Just createdAt, Just updatedAt) :. (supportChatTs, Just supportChatUnread, Just supportChatUnanswered, Just supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey, relayLink, memberCode_, memberCodeVerifiedAt_)) = - Just $ toGroupMember now userContactId ((groupMemberId, groupId, indexInGroup, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. ((profileId, displayName, fullName, shortDescr, description, image, contactLink, peerType, localAlias, contactPreferences) :. badgeRow :. domainRow) :. (createdAt, updatedAt) :. (supportChatTs, supportChatUnread, supportChatUnanswered, supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey, relayLink, memberCode_, memberCodeVerifiedAt_)) +toMaybeGroupMember now userContactId ((Just groupMemberId, Just groupId, Just indexInGroup, Just memberId, Just minVer, Just maxVer, Just memberRole, Just memberCategory, Just memberStatus, Just showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, Just localDisplayName, memberContactId, Just memberContactProfileId) :. ((Just profileId, Just displayName, Just fullName, shortDescr, description, image, contactLink, peerType, Just localAlias, encodedPrefs, receivedPrefs) :. badgeRow :. domainRow) :. (Just createdAt, Just updatedAt) :. (supportChatTs, Just supportChatUnread, Just supportChatUnanswered, Just supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey, relayLink, memberCode_, memberCodeVerifiedAt_)) = + Just $ toGroupMember now userContactId ((groupMemberId, groupId, indexInGroup, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. ((profileId, displayName, fullName, shortDescr, description, image, contactLink, peerType, localAlias, encodedPrefs, receivedPrefs) :. badgeRow :. domainRow) :. (createdAt, updatedAt) :. (supportChatTs, supportChatUnread, supportChatUnanswered, supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey, relayLink, memberCode_, memberCodeVerifiedAt_)) toMaybeGroupMember _ _ _ = Nothing createGroupLink :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> ConnId -> CreatedLinkContact -> GroupLinkId -> GroupMemberRole -> SubscriptionMode -> ExceptT StoreError IO GroupLink @@ -403,11 +403,11 @@ createNewGroup db cxt user@User {userId} groupProfile incognitoProfile memberId (display_name, full_name, short_descr, description, image, group_type, group_link, public_group_id, group_web_page, group_domain, domain_web_page, allow_embedding, group_domain_proof, - user_id, preferences, member_admission, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + user_id, member_admission, created_at, updated_at, preferences, preferences_json) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] ((displayName, fullName, shortDescr, description, image, groupType_, groupLink_, publicGroupId_) :. publicGroupAccessRow publicGroup - :. (userId, groupPreferences, memberAdmission, currentTs, currentTs)) + :. (userId, memberAdmission, currentTs, currentTs) :. prefsToRow groupPreferences) profileId <- insertedRowId db DB.execute db @@ -486,8 +486,8 @@ createGroupInvitation db cxt user@User {userId} contact@Contact {contactId, acti groupId <- liftIO $ do DB.execute db - "INSERT INTO group_profiles (display_name, full_name, short_descr, description, image, user_id, preferences, member_admission, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)" - (displayName, fullName, shortDescr, description, image, userId, groupPreferences, memberAdmission, currentTs, currentTs) + "INSERT INTO group_profiles (display_name, full_name, short_descr, description, image, user_id, member_admission, created_at, updated_at, preferences, preferences_json) VALUES (?,?,?,?,?,?,?,?,?,?,?)" + ((displayName, fullName, shortDescr, description, image, userId, memberAdmission, currentTs, currentTs) :. prefsToRow groupPreferences) profileId <- insertedRowId db DB.execute db @@ -917,11 +917,11 @@ createGroup_ db userId groupProfile prepared business useRelays relayOwnStatus p (display_name, full_name, short_descr, description, image, group_type, group_link, public_group_id, group_web_page, group_domain, domain_web_page, allow_embedding, group_domain_proof, - user_id, preferences, member_admission, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + user_id, member_admission, created_at, updated_at, preferences, preferences_json) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] ((displayName, fullName, shortDescr, description, image, groupType_, groupLink_, publicGroupId_) :. publicGroupAccessRow publicGroup - :. (userId, groupPreferences, memberAdmission, currentTs, currentTs)) + :. (userId, memberAdmission, currentTs, currentTs) :. prefsToRow groupPreferences) profileId <- insertedRowId db DB.execute db @@ -1071,11 +1071,11 @@ getBaseGroupDetails db cxt User {userId, userContactId} _contactId_ search_ = do getContactGroupPreferences :: DB.Connection -> User -> Contact -> IO [(GroupMemberRole, FullGroupPreferences)] getContactGroupPreferences db User {userId} Contact {contactId} = do - map (second mergeGroupPreferences) + map (\(role, encodedPrefs, receivedPrefs) -> (role, mergeGroupPreferences $ groupPrefsFromRow encodedPrefs receivedPrefs)) <$> DB.query db [sql| - SELECT m.member_role, gp.preferences + SELECT m.member_role, gp.preferences, gp.preferences_json FROM groups g JOIN group_profiles gp USING (group_profile_id) JOIN group_members m USING (group_id) @@ -2088,8 +2088,8 @@ createJoiningMember liftIO $ DB.execute db - "INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, contact_link, user_id, preferences, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" - ((displayName, fullName, shortDescr, description, image, contactLink, userId, preferences, currentTs, currentTs) :. badgeToRow badge badgeVerified) + "INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, contact_link, user_id, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx, preferences, preferences_json) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" + ((displayName, fullName, shortDescr, description, image, contactLink, userId, currentTs, currentTs) :. badgeToRow badge badgeVerified :. prefsToRow preferences) profileId <- liftIO $ insertedRowId db case cReqMemberId_ of Just memberId -> do @@ -2170,8 +2170,8 @@ createBusinessRequestGroup liftIO $ DB.execute db - "INSERT INTO group_profiles (display_name, full_name, short_descr, image, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)" - (displayName, fullName, shortDescr, image, userId, groupPreferences, currentTs, currentTs) + "INSERT INTO group_profiles (display_name, full_name, short_descr, image, user_id, created_at, updated_at, preferences, preferences_json) VALUES (?,?,?,?,?,?,?,?,?)" + ((displayName, fullName, shortDescr, image, userId, currentTs, currentTs) :. prefsToRow (Just groupPreferences)) groupProfileId <- liftIO $ insertedRowId db liftIO $ DB.execute @@ -2441,8 +2441,8 @@ createNewMemberProfile_ db cxt User {userId} Profile {displayName, fullName, sho badgeVerified <- verifyBadge_ (boundProof binding_) (badgeKeys cxt) badge DB.execute db - "INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, contact_link, user_id, preferences, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" - ((displayName, fullName, shortDescr, description, image, contactLink, userId, preferences, createdAt, createdAt) :. badgeToRow badge badgeVerified) + "INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, contact_link, user_id, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx, preferences, preferences_json) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" + ((displayName, fullName, shortDescr, description, image, contactLink, userId, createdAt, createdAt) :. badgeToRow badge badgeVerified :. prefsToRow preferences) profileId <- insertedRowId db pure $ Right (ldn, profileId, badgeVerified) @@ -2721,19 +2721,21 @@ updateGroupProfile db user@User {userId} g@GroupInfo {groupId, localDisplayName, db [sql| UPDATE group_profiles - SET display_name = ?, full_name = ?, short_descr = ?, description = ?, image = ?, + SET preferences = ?, preferences_json = ?, + display_name = ?, full_name = ?, short_descr = ?, description = ?, image = ?, group_type = ?, group_link = ?, group_web_page = ?, group_domain = CASE WHEN ? THEN ? ELSE group_domain END, domain_web_page = ?, allow_embedding = ?, group_domain_proof = ?, - preferences = ?, member_admission = ?, updated_at = ? + member_admission = ?, updated_at = ? WHERE group_profile_id IN ( SELECT group_profile_id FROM groups WHERE user_id = ? AND group_id = ? ) |] - ( (newName, fullName, shortDescr, description, image, groupType_, groupLink_) + ( prefsToRow groupPreferences + :. (newName, fullName, shortDescr, description, image, groupType_, groupLink_) :. (groupWebPage_, isJust publicGroup, groupDomain_, domainWebPage_, allowEmbedding_, groupDomainProof_) - :. (groupPreferences, memberAdmission, currentTs, userId, groupId) + :. (memberAdmission, currentTs, userId, groupId) ) updateGroup_ ldn currentTs = do DB.execute @@ -2770,14 +2772,14 @@ updateGroupPreferences db User {userId} g@GroupInfo {groupId, groupProfile = p} db [sql| UPDATE group_profiles - SET preferences = ?, updated_at = ? + SET preferences = ?, preferences_json = ?, updated_at = ? WHERE group_profile_id IN ( SELECT group_profile_id FROM groups WHERE user_id = ? AND group_id = ? ) |] - (ps, currentTs, userId, groupId) + (prefsToRow (Just ps) :. (currentTs, userId, groupId)) pure (g :: GroupInfo) {groupProfile = p {groupPreferences = Just ps}, fullGroupPreferences = mergeGroupPreferences $ Just ps} updateGroupProfileFromMember :: DB.Connection -> User -> GroupInfo -> Profile -> ExceptT StoreError IO GroupInfo @@ -2797,15 +2799,16 @@ getGroupProfileById db groupId = SELECT gp.display_name, gp.full_name, gp.short_descr, gp.description, gp.image, gp.group_type, gp.group_link, gp.public_group_id, gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding, gp.group_domain_proof, - gp.preferences, gp.member_admission + gp.preferences, gp.preferences_json, gp.member_admission FROM group_profiles gp JOIN groups g ON gp.group_profile_id = g.group_profile_id WHERE g.group_id = ? |] (Only groupId) where - toGroupProfile ((displayName, fullName, shortDescr, description, image, groupType_, groupLink_, publicGroupId_) :. accessRow :. (groupPreferences, memberAdmission)) = - let publicGroupAccess = toPublicGroupAccess accessRow + toGroupProfile ((displayName, fullName, shortDescr, description, image, groupType_, groupLink_, publicGroupId_) :. accessRow :. (encodedPrefs, receivedPrefs, memberAdmission)) = + let groupPreferences = groupPrefsFromRow encodedPrefs receivedPrefs + publicGroupAccess = toPublicGroupAccess accessRow in GroupProfile {displayName, fullName, shortDescr, description, image, publicGroup = toPublicGroupProfile groupType_ groupLink_ publicGroupId_ publicGroupAccess, groupPreferences, memberAdmission} getGroupInfoByUserContactLinkConnReq :: DB.Connection -> StoreCxt -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe GroupInfo) diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 3998e1f249..b8909da1b3 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -725,7 +725,7 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe -- GroupMember m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, - p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, p.preferences_json, p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, m.member_security_code, m.member_security_code_verified_at @@ -1148,7 +1148,7 @@ getContactRequestChatPreviews_ db User {userId} pagination clq = do cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.business_group_id, cr.user_contact_link_id, cr.rejection_supported, cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, cr.xcontact_id, - cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, + cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, p.preferences_json, cr.created_at, cr.updated_at, cr.peer_chat_min_version, cr.peer_chat_max_version, p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified @@ -3110,7 +3110,7 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do -- GroupMember m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, - p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, p.preferences_json, p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, m.member_security_code, m.member_security_code_verified_at, @@ -3119,14 +3119,14 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do -- quoted GroupMember rm.group_member_id, rm.group_id, rm.index_in_group, rm.member_id, rm.peer_chat_min_version, rm.peer_chat_max_version, rm.member_role, rm.member_category, rm.member_status, rm.show_messages, rm.member_restriction, rm.invited_by, rm.invited_by_group_member_id, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, - rp.display_name, rp.full_name, rp.short_descr, rp.description, rp.image, rp.contact_link, rp.chat_peer_type, rp.local_alias, rp.preferences, + rp.display_name, rp.full_name, rp.short_descr, rp.description, rp.image, rp.contact_link, rp.chat_peer_type, rp.local_alias, rp.preferences, rp.preferences_json, rp.badge_proof, rp.badge_pres_header, rp.badge_expiry, rp.badge_type, rp.badge_verified, rp.badge_extra, rp.badge_master_key, rp.badge_signature, rp.badge_key_idx, rp.contact_domain, rp.contact_domain_proof, rp.contact_domain_verified, rm.created_at, rm.updated_at, rm.support_chat_ts, rm.support_chat_items_unread, rm.support_chat_items_member_attention, rm.support_chat_items_mentions, rm.support_chat_last_msg_from_member_ts, rm.member_pub_key, rm.relay_link, rm.member_security_code, rm.member_security_code_verified_at, -- deleted by GroupMember dbm.group_member_id, dbm.group_id, dbm.index_in_group, dbm.member_id, dbm.peer_chat_min_version, dbm.peer_chat_max_version, dbm.member_role, dbm.member_category, dbm.member_status, dbm.show_messages, dbm.member_restriction, dbm.invited_by, dbm.invited_by_group_member_id, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, - dbp.display_name, dbp.full_name, dbp.short_descr, dbp.description, dbp.image, dbp.contact_link, dbp.chat_peer_type, dbp.local_alias, dbp.preferences, + dbp.display_name, dbp.full_name, dbp.short_descr, dbp.description, dbp.image, dbp.contact_link, dbp.chat_peer_type, dbp.local_alias, dbp.preferences, dbp.preferences_json, dbp.badge_proof, dbp.badge_pres_header, dbp.badge_expiry, dbp.badge_type, dbp.badge_verified, dbp.badge_extra, dbp.badge_master_key, dbp.badge_signature, dbp.badge_key_idx, dbp.contact_domain, dbp.contact_domain_proof, dbp.contact_domain_verified, dbm.created_at, dbm.updated_at, dbm.support_chat_ts, dbm.support_chat_items_unread, dbm.support_chat_items_member_attention, dbm.support_chat_items_mentions, dbm.support_chat_last_msg_from_member_ts, dbm.member_pub_key, dbm.relay_link, dbm.member_security_code, dbm.member_security_code_verified_at diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index 9e5a1aa5aa..24ed45c9a9 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -51,6 +51,8 @@ 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.Chat.Store.Postgres.Migrations.M20260923_preferences_json import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -101,7 +103,9 @@ 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), + ("20260923_preferences_json", m20260923_preferences_json, Just down_m20260923_preferences_json) ] -- | 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/M20260923_preferences_json.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260923_preferences_json.hs new file mode 100644 index 0000000000..81039139e8 --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260923_preferences_json.hs @@ -0,0 +1,21 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20260923_preferences_json where + +import Data.Text (Text) +import Text.RawString.QQ (r) + +m20260923_preferences_json :: Text +m20260923_preferences_json = + [r| +ALTER TABLE contact_profiles ADD COLUMN preferences_json TEXT; +ALTER TABLE group_profiles ADD COLUMN preferences_json TEXT; +|] + +down_m20260923_preferences_json :: Text +down_m20260923_preferences_json = + [r| +ALTER TABLE group_profiles DROP COLUMN preferences_json; +ALTER TABLE contact_profiles DROP COLUMN preferences_json; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index 1e2a26d74e..ad975874db 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 ); @@ -676,7 +680,8 @@ CREATE TABLE test_chat_schema.contact_profiles ( contact_domain text, contact_domain_proof text, contact_domain_verified smallint, - description text + description text, + preferences_json text ); @@ -1038,7 +1043,8 @@ CREATE TABLE test_chat_schema.group_profiles ( group_domain text, domain_web_page bigint, allow_embedding bigint, - group_domain_proof text + group_domain_proof text, + preferences_json text ); diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index 20e05009a5..6498279f77 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -375,10 +375,11 @@ updateUserProfileFields_' db userId profileId Profile {displayName, fullName, sh db [sql| UPDATE contact_profiles - SET display_name = ?, full_name = ?, short_descr = ?, description = ?, image = ?, contact_link = ?, preferences = ?, chat_peer_type = ?, updated_at = ? + SET preferences = ?, preferences_json = ?, + display_name = ?, full_name = ?, short_descr = ?, description = ?, image = ?, contact_link = ?, chat_peer_type = ?, updated_at = ? WHERE user_id = ? AND contact_profile_id = ? |] - ((displayName, fullName, shortDescr, description, image, contactLink, preferences, peerType, updatedAt) :. (userId, profileId)) + (prefsToRow preferences :. (displayName, fullName, shortDescr, description, image, contactLink, peerType, updatedAt) :. (userId, profileId)) -- store the user's own badge credential; touches only the badge columns. -- bumps user_member_profile_updated_at so groups receive the updated profile (with the badge) on the next message. @@ -429,14 +430,14 @@ getUserContactProfiles db User {userId} = <$> DB.query db [sql| - SELECT display_name, full_name, short_descr, description, image, contact_link, chat_peer_type, contact_domain, preferences + SELECT display_name, full_name, short_descr, description, image, contact_link, chat_peer_type, contact_domain, preferences, preferences_json FROM contact_profiles WHERE user_id = ? |] (Only userId) where - toContactProfile :: (ContactName, Text, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe SimplexDomain, Maybe Preferences) -> Profile - toContactProfile (displayName, fullName, shortDescr, description, image, contactLink, peerType, domain_, preferences) = Profile {displayName, fullName, shortDescr, description, image, contactLink, contactDomain = mkDomainClaim <$> domain_, peerType, preferences, badge = Nothing} + toContactProfile :: (ContactName, Text, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe SimplexDomain, Maybe Text, Maybe Text) -> Profile + toContactProfile (displayName, fullName, shortDescr, description, image, contactLink, peerType, domain_, encodedPrefs, receivedPrefs) = Profile {displayName, fullName, shortDescr, description, image, contactLink, contactDomain = mkDomainClaim <$> domain_, peerType, preferences = chatPrefsFromRow encodedPrefs receivedPrefs, badge = Nothing} createUserContactLink :: DB.Connection -> User -> ConnId -> CreatedLinkContact -> SubscriptionMode -> C.PrivateKeyEd25519 -> ExceptT StoreError IO () createUserContactLink db User {userId} agentConnId (CCLink cReq shortLink) subMode linkPrivSigKey = diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 0bbd7c06b6..4dcefa1598 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -174,6 +174,8 @@ 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.Chat.Store.SQLite.Migrations.M20260923_preferences_json import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -347,7 +349,9 @@ 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), + ("20260923_preferences_json", m20260923_preferences_json, Just down_m20260923_preferences_json) ] -- | 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/M20260923_preferences_json.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260923_preferences_json.hs new file mode 100644 index 0000000000..4ee4dbc1e2 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260923_preferences_json.hs @@ -0,0 +1,20 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20260923_preferences_json where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20260923_preferences_json :: Query +m20260923_preferences_json = + [sql| +ALTER TABLE contact_profiles ADD COLUMN preferences_json TEXT; +ALTER TABLE group_profiles ADD COLUMN preferences_json TEXT; +|] + +down_m20260923_preferences_json :: Query +down_m20260923_preferences_json = + [sql| +ALTER TABLE group_profiles DROP COLUMN preferences_json; +ALTER TABLE contact_profiles DROP COLUMN preferences_json; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt index 04d1caeb1a..aec4d76287 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt @@ -637,9 +637,11 @@ SEARCH snd_message_deliveries USING COVERING INDEX idx_snd_message_deliveries_co Query: INSERT INTO ratchets - (conn_id, ratchet_state, x3dh_pub_key_1, x3dh_pub_key_2, pq_pub_kem) VALUES (?, ?, ?, ?, ?) + (conn_id, ratchet_state, rc_verify_code_ad, rc_verify_code_pq, x3dh_pub_key_1, x3dh_pub_key_2, pq_pub_kem) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (conn_id) DO UPDATE SET ratchet_state = EXCLUDED.ratchet_state, + rc_verify_code_ad = EXCLUDED.rc_verify_code_ad, + rc_verify_code_pq = EXCLUDED.rc_verify_code_pq, x3dh_priv_key_1 = NULL, x3dh_priv_key_2 = NULL, x3dh_pub_key_1 = EXCLUDED.x3dh_pub_key_1, @@ -650,10 +652,12 @@ Query: Plan: Query: - INSERT INTO ratchets (conn_id, ratchet_state) - VALUES (?, ?) + INSERT INTO ratchets (conn_id, ratchet_state, rc_verify_code_ad, rc_verify_code_pq) + VALUES (?, ?, ?, ?) ON CONFLICT (conn_id) DO UPDATE SET - ratchet_state = ?, + ratchet_state = EXCLUDED.ratchet_state, + rc_verify_code_ad = EXCLUDED.rc_verify_code_ad, + rc_verify_code_pq = EXCLUDED.rc_verify_code_pq, x3dh_priv_key_1 = NULL, x3dh_priv_key_2 = NULL, x3dh_pub_key_1 = NULL, @@ -1222,6 +1226,10 @@ Query: SELECT conn_id FROM connections WHERE user_id = ? Plan: SEARCH connections USING COVERING INDEX idx_connections_user (user_id=?) +Query: SELECT conn_id, rc_verify_code_ad, rc_verify_code_pq, CASE WHEN rc_verify_code_ad IS NULL THEN ratchet_state END FROM ratchets WHERE conn_id = ? +Plan: +SEARCH ratchets USING PRIMARY KEY (conn_id=?) + Query: SELECT count(1) FROM connections Plan: SCAN connections USING COVERING INDEX idx_connections_deleted 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 e887bb2548..152d6c0c8d 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -124,7 +124,7 @@ Query: SELECT -- Contact ct.contact_id, ct.contact_profile_id, ct.local_display_name, cp.display_name, cp.full_name, cp.short_descr, cp.description, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, - cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, cr2.rejection_supported, + cp.preferences, cp.preferences_json, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, cr2.rejection_supported, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx, cp.contact_domain, cp.contact_domain_proof, cp.contact_domain_verified, @@ -149,7 +149,7 @@ Query: -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, gp.group_type, gp.group_link, gp.public_group_id, gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding, gp.group_domain_proof, - g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, + g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.preferences_json, gp.member_admission, g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, @@ -160,13 +160,13 @@ Query: mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} - pu.display_name, pu.full_name, pu.short_descr, pu.description, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, + pu.display_name, pu.full_name, pu.short_descr, pu.description, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, pu.preferences_json, pu.badge_proof, pu.badge_pres_header, pu.badge_expiry, pu.badge_type, pu.badge_verified, pu.badge_extra, pu.badge_master_key, pu.badge_signature, pu.badge_key_idx, pu.contact_domain, pu.contact_domain_proof, pu.contact_domain_verified, mu.created_at, mu.updated_at, mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key, mu.relay_link, mu.member_security_code, mu.member_security_code_verified_at, -- from GroupMember m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, - m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, p.preferences_json, p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, m.member_security_code, m.member_security_code_verified_at @@ -405,7 +405,7 @@ Query: cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.business_group_id, cr.user_contact_link_id, cr.rejection_supported, cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, cr.xcontact_id, - cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, + cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, p.preferences_json, cr.created_at, cr.updated_at, cr.peer_chat_min_version, cr.peer_chat_max_version, p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified @@ -528,10 +528,11 @@ SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) Query: UPDATE group_profiles - SET display_name = ?, full_name = ?, short_descr = ?, description = ?, image = ?, + SET preferences = ?, preferences_json = ?, + display_name = ?, full_name = ?, short_descr = ?, description = ?, image = ?, group_type = ?, group_link = ?, group_web_page = ?, group_domain = CASE WHEN ? THEN ? ELSE group_domain END, domain_web_page = ?, allow_embedding = ?, group_domain_proof = ?, - preferences = ?, member_admission = ?, updated_at = ? + member_admission = ?, updated_at = ? WHERE group_profile_id IN ( SELECT group_profile_id FROM groups @@ -733,7 +734,7 @@ Plan: Query: SELECT c.contact_profile_id, c.local_display_name, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, c.contact_used, c.contact_status, c.enable_ntfs, c.send_rcpts, c.favorite, - p.preferences, c.user_preferences, c.created_at, c.updated_at, c.chat_ts, c.conn_full_link_to_connect, c.conn_short_link_to_connect, c.welcome_shared_msg_id, c.request_shared_msg_id, c.contact_request_id, cr2.rejection_supported, + p.preferences, p.preferences_json, c.user_preferences, c.created_at, c.updated_at, c.chat_ts, c.conn_full_link_to_connect, c.conn_short_link_to_connect, c.welcome_shared_msg_id, c.request_shared_msg_id, c.contact_request_id, cr2.rejection_supported, c.contact_group_member_id, c.contact_grp_inv_sent, c.grp_direct_inv_link, c.grp_direct_inv_from_group_id, c.grp_direct_inv_from_group_member_id, c.grp_direct_inv_from_member_conn_id, c.grp_direct_inv_started_connection, c.ui_themes, c.chat_deleted, c.custom_data, c.chat_item_ttl, p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, @@ -1053,7 +1054,7 @@ Query: -- GroupMember m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, - p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, p.preferences_json, p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, m.member_security_code, m.member_security_code_verified_at @@ -1327,8 +1328,8 @@ Query: (display_name, full_name, short_descr, description, image, group_type, group_link, public_group_id, group_web_page, group_domain, domain_web_page, allow_embedding, group_domain_proof, - user_id, preferences, member_admission, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + user_id, member_admission, created_at, updated_at, preferences, preferences_json) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: @@ -1405,7 +1406,7 @@ Query: -- GroupMember m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, - p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, p.preferences_json, p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, m.member_security_code, m.member_security_code_verified_at, @@ -1414,14 +1415,14 @@ Query: -- quoted GroupMember rm.group_member_id, rm.group_id, rm.index_in_group, rm.member_id, rm.peer_chat_min_version, rm.peer_chat_max_version, rm.member_role, rm.member_category, rm.member_status, rm.show_messages, rm.member_restriction, rm.invited_by, rm.invited_by_group_member_id, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, - rp.display_name, rp.full_name, rp.short_descr, rp.description, rp.image, rp.contact_link, rp.chat_peer_type, rp.local_alias, rp.preferences, + rp.display_name, rp.full_name, rp.short_descr, rp.description, rp.image, rp.contact_link, rp.chat_peer_type, rp.local_alias, rp.preferences, rp.preferences_json, rp.badge_proof, rp.badge_pres_header, rp.badge_expiry, rp.badge_type, rp.badge_verified, rp.badge_extra, rp.badge_master_key, rp.badge_signature, rp.badge_key_idx, rp.contact_domain, rp.contact_domain_proof, rp.contact_domain_verified, rm.created_at, rm.updated_at, rm.support_chat_ts, rm.support_chat_items_unread, rm.support_chat_items_member_attention, rm.support_chat_items_mentions, rm.support_chat_last_msg_from_member_ts, rm.member_pub_key, rm.relay_link, rm.member_security_code, rm.member_security_code_verified_at, -- deleted by GroupMember dbm.group_member_id, dbm.group_id, dbm.index_in_group, dbm.member_id, dbm.peer_chat_min_version, dbm.peer_chat_max_version, dbm.member_role, dbm.member_category, dbm.member_status, dbm.show_messages, dbm.member_restriction, dbm.invited_by, dbm.invited_by_group_member_id, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, - dbp.display_name, dbp.full_name, dbp.short_descr, dbp.description, dbp.image, dbp.contact_link, dbp.chat_peer_type, dbp.local_alias, dbp.preferences, + dbp.display_name, dbp.full_name, dbp.short_descr, dbp.description, dbp.image, dbp.contact_link, dbp.chat_peer_type, dbp.local_alias, dbp.preferences, dbp.preferences_json, dbp.badge_proof, dbp.badge_pres_header, dbp.badge_expiry, dbp.badge_type, dbp.badge_verified, dbp.badge_extra, dbp.badge_master_key, dbp.badge_signature, dbp.badge_key_idx, dbp.contact_domain, dbp.contact_domain_proof, dbp.contact_domain_verified, dbm.created_at, dbm.updated_at, dbm.support_chat_ts, dbm.support_chat_items_unread, dbm.support_chat_items_member_attention, dbm.support_chat_items_mentions, dbm.support_chat_last_msg_from_member_ts, dbm.member_pub_key, dbm.relay_link, dbm.member_security_code, dbm.member_security_code_verified_at @@ -1473,7 +1474,7 @@ Query: SELECT -- Contact ct.contact_id, ct.contact_profile_id, ct.local_display_name, cp.display_name, cp.full_name, cp.short_descr, cp.description, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, - cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, cr2.rejection_supported, + cp.preferences, cp.preferences_json, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, cr2.rejection_supported, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx, @@ -2065,7 +2066,7 @@ Query: SELECT -- Contact ct.contact_id, ct.contact_profile_id, ct.local_display_name, cp.display_name, cp.full_name, cp.short_descr, cp.description, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, - cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, cr2.rejection_supported, + cp.preferences, cp.preferences_json, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, cr2.rejection_supported, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx, @@ -2154,7 +2155,7 @@ Query: cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.business_group_id, cr.user_contact_link_id, cr.rejection_supported, cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, cr.xcontact_id, - cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, + cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, p.preferences_json, cr.created_at, cr.updated_at, cr.peer_chat_min_version, cr.peer_chat_max_version, p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified @@ -2184,7 +2185,7 @@ Query: cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.business_group_id, cr.user_contact_link_id, cr.rejection_supported, cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, cr.xcontact_id, - cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, + cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, p.preferences_json, cr.created_at, cr.updated_at, cr.peer_chat_min_version, cr.peer_chat_max_version, p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified @@ -2214,7 +2215,7 @@ Query: cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.business_group_id, cr.user_contact_link_id, cr.rejection_supported, cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, cr.xcontact_id, - cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, + cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, p.preferences_json, cr.created_at, cr.updated_at, cr.peer_chat_min_version, cr.peer_chat_max_version, p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified @@ -3765,7 +3766,7 @@ Plan: SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) Query: - SELECT cp.contact_profile_id, cp.display_name, cp.full_name, cp.short_descr, cp.description, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, cp.preferences, -- , ct.user_preferences + SELECT cp.contact_profile_id, cp.display_name, cp.full_name, cp.short_descr, cp.description, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, cp.preferences, cp.preferences_json, -- , ct.user_preferences cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx, cp.contact_domain, cp.contact_domain_proof, cp.contact_domain_verified FROM contact_profiles cp WHERE cp.user_id = ? AND cp.contact_profile_id = ? @@ -3828,7 +3829,7 @@ SEARCH f USING PRIMARY KEY (file_id=?) SEARCH d USING INTEGER PRIMARY KEY (rowid=?) Query: - SELECT display_name, full_name, short_descr, description, image, contact_link, chat_peer_type, contact_domain, preferences + SELECT display_name, full_name, short_descr, description, image, contact_link, chat_peer_type, contact_domain, preferences, preferences_json FROM contact_profiles WHERE user_id = ? @@ -3949,7 +3950,7 @@ Query: SELECT gp.display_name, gp.full_name, gp.short_descr, gp.description, gp.image, gp.group_type, gp.group_link, gp.public_group_id, gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding, gp.group_domain_proof, - gp.preferences, gp.member_admission + gp.preferences, gp.preferences_json, gp.member_admission FROM group_profiles gp JOIN groups g ON gp.group_profile_id = g.group_profile_id WHERE g.group_id = ? @@ -4061,7 +4062,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 @@ -5152,6 +5154,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 = ? @@ -5338,25 +5348,7 @@ SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) Query: UPDATE contact_profiles - SET display_name = ?, full_name = ?, short_descr = ?, description = ?, image = ?, contact_link = ?, preferences = ?, chat_peer_type = ?, updated_at = ? - WHERE user_id = ? AND contact_profile_id = ? - -Plan: -SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) - -Query: - UPDATE contact_profiles - SET display_name = ?, full_name = ?, short_descr = ?, description = ?, image = ?, contact_link = ?, preferences = ?, chat_peer_type = ?, updated_at = ?, - badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ?, - contact_domain = ?, contact_domain_proof = ? - WHERE user_id = ? AND contact_profile_id = ? - -Plan: -SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) - -Query: - UPDATE contact_profiles - SET display_name = ?, full_name = ?, short_descr = ?, description = ?, image = ?, contact_link = NULL, preferences = NULL, updated_at = ?, + SET display_name = ?, full_name = ?, short_descr = ?, description = ?, image = ?, contact_link = NULL, preferences = NULL, preferences_json = NULL, updated_at = ?, badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ?, contact_domain = ?, contact_domain_proof = ? WHERE user_id = ? AND contact_profile_id = ? @@ -5382,6 +5374,26 @@ Query: Plan: SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE contact_profiles + SET preferences = ?, preferences_json = ?, + display_name = ?, full_name = ?, short_descr = ?, description = ?, image = ?, contact_link = ?, chat_peer_type = ?, updated_at = ? + WHERE user_id = ? AND contact_profile_id = ? + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE contact_profiles + SET preferences = ?, preferences_json = ?, + display_name = ?, full_name = ?, short_descr = ?, description = ?, image = ?, contact_link = ?, chat_peer_type = ?, updated_at = ?, + badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ?, + contact_domain = ?, contact_domain_proof = ? + WHERE user_id = ? AND contact_profile_id = ? + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE contact_profiles SET contact_domain_verified = ? WHERE contact_profile_id IN (SELECT contact_profile_id FROM contacts WHERE user_id = ? AND contact_id = ?) @@ -5494,7 +5506,7 @@ SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) Query: UPDATE group_profiles - SET preferences = ?, updated_at = ? + SET preferences = ?, preferences_json = ?, updated_at = ? WHERE group_profile_id IN ( SELECT group_profile_id FROM groups @@ -5728,7 +5740,7 @@ Query: -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, gp.group_type, gp.group_link, gp.public_group_id, gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding, gp.group_domain_proof, - g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, + g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.preferences_json, gp.member_admission, g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, @@ -5738,7 +5750,7 @@ Query: -- GroupMember - membership mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, - pu.display_name, pu.full_name, pu.short_descr, pu.description, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, + pu.display_name, pu.full_name, pu.short_descr, pu.description, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, pu.preferences_json, pu.badge_proof, pu.badge_pres_header, pu.badge_expiry, pu.badge_type, pu.badge_verified, pu.badge_extra, pu.badge_master_key, pu.badge_signature, pu.badge_key_idx, pu.contact_domain, pu.contact_domain_proof, pu.contact_domain_verified, mu.created_at, mu.updated_at, mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key, mu.relay_link, mu.member_security_code, mu.member_security_code_verified_at @@ -5766,7 +5778,7 @@ Query: -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, gp.group_type, gp.group_link, gp.public_group_id, gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding, gp.group_domain_proof, - g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, + g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.preferences_json, gp.member_admission, g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, @@ -5776,7 +5788,7 @@ Query: -- GroupMember - membership mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, - pu.display_name, pu.full_name, pu.short_descr, pu.description, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, + pu.display_name, pu.full_name, pu.short_descr, pu.description, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, pu.preferences_json, pu.badge_proof, pu.badge_pres_header, pu.badge_expiry, pu.badge_type, pu.badge_verified, pu.badge_extra, pu.badge_master_key, pu.badge_signature, pu.badge_key_idx, pu.contact_domain, pu.contact_domain_proof, pu.contact_domain_verified, mu.created_at, mu.updated_at, mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key, mu.relay_link, mu.member_security_code, mu.member_security_code_verified_at @@ -5797,7 +5809,7 @@ Query: -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, gp.group_type, gp.group_link, gp.public_group_id, gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding, gp.group_domain_proof, - g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, + g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.preferences_json, gp.member_admission, g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, @@ -5807,7 +5819,7 @@ Query: -- GroupMember - membership mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, - pu.display_name, pu.full_name, pu.short_descr, pu.description, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, + pu.display_name, pu.full_name, pu.short_descr, pu.description, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, pu.preferences_json, pu.badge_proof, pu.badge_pres_header, pu.badge_expiry, pu.badge_type, pu.badge_verified, pu.badge_extra, pu.badge_master_key, pu.badge_signature, pu.badge_key_idx, pu.contact_domain, pu.contact_domain_proof, pu.contact_domain_verified, mu.created_at, mu.updated_at, mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key, mu.relay_link, mu.member_security_code, mu.member_security_code_verified_at @@ -5828,7 +5840,7 @@ Query: cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.business_group_id, cr.user_contact_link_id, cr.rejection_supported, cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, cr.xcontact_id, - cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, + cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, p.preferences_json, cr.created_at, cr.updated_at, cr.peer_chat_min_version, cr.peer_chat_max_version, p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, @@ -5845,7 +5857,7 @@ Query: cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.business_group_id, cr.user_contact_link_id, cr.rejection_supported, cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, cr.xcontact_id, - cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, + cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, p.preferences_json, cr.created_at, cr.updated_at, cr.peer_chat_min_version, cr.peer_chat_max_version, p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, @@ -5860,7 +5872,7 @@ SEARCH p USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, - m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, p.preferences_json, p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, m.member_security_code, m.member_security_code_verified_at, @@ -5888,7 +5900,7 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, - m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, p.preferences_json, p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, m.member_security_code, m.member_security_code_verified_at, @@ -5909,7 +5921,7 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, - m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, p.preferences_json, p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, m.member_security_code, m.member_security_code_verified_at, @@ -5929,7 +5941,7 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, - m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, p.preferences_json, p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, m.member_security_code, m.member_security_code_verified_at, @@ -5949,7 +5961,7 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, - m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, p.preferences_json, p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, m.member_security_code, m.member_security_code_verified_at, @@ -5969,7 +5981,7 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, - m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, p.preferences_json, p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, m.member_security_code, m.member_security_code_verified_at, @@ -5989,7 +6001,7 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, - m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, p.preferences_json, p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, m.member_security_code, m.member_security_code_verified_at, @@ -6009,7 +6021,7 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, - m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, p.preferences_json, p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, m.member_security_code, m.member_security_code_verified_at, @@ -6029,7 +6041,7 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, - m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, p.preferences_json, p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, m.member_security_code, m.member_security_code_verified_at, @@ -6049,7 +6061,7 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, - m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, p.preferences_json, p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, m.member_security_code, m.member_security_code_verified_at, @@ -6069,7 +6081,7 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, - m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, p.preferences_json, p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, m.member_security_code, m.member_security_code_verified_at, @@ -6089,7 +6101,7 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, - m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, p.preferences_json, p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, m.member_security_code, m.member_security_code_verified_at, @@ -6109,7 +6121,7 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, - m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, p.preferences_json, p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, m.member_security_code, m.member_security_code_verified_at, @@ -6129,7 +6141,7 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, - m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, p.preferences_json, p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, m.member_security_code, m.member_security_code_verified_at, @@ -6149,7 +6161,7 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, - m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, p.preferences_json, p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, m.member_security_code, m.member_security_code_verified_at, @@ -7148,15 +7160,15 @@ Query: INSERT INTO contact_profiles (display_name, full_name, short_descr, descr Plan: SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) -Query: INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, contact_link, chat_peer_type, user_id, local_alias, preferences, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx, contact_domain, contact_domain_proof) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) +Query: INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, contact_link, chat_peer_type, user_id, local_alias, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx, contact_domain, contact_domain_proof, preferences, preferences_json) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) -Query: INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, contact_link, user_id, local_alias, preferences, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) +Query: INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, contact_link, user_id, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx, preferences, preferences_json) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) -Query: INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, contact_link, user_id, preferences, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) +Query: INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, contact_link, user_id, local_alias, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx, preferences, preferences_json) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) @@ -7195,10 +7207,10 @@ Plan: Query: INSERT INTO files (user_id, group_id, file_name, file_size, chunk_size, file_inline, ci_file_status, protocol, file_type, shared_msg_id, roster_transfer_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: -Query: INSERT INTO group_profiles (display_name, full_name, short_descr, description, image, user_id, preferences, member_admission, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?) +Query: INSERT INTO group_profiles (display_name, full_name, short_descr, description, image, user_id, member_admission, created_at, updated_at, preferences, preferences_json) VALUES (?,?,?,?,?,?,?,?,?,?,?) Plan: -Query: INSERT INTO group_profiles (display_name, full_name, short_descr, image, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?) +Query: INSERT INTO group_profiles (display_name, full_name, short_descr, image, user_id, created_at, updated_at, preferences, preferences_json) VALUES (?,?,?,?,?,?,?,?,?) Plan: Query: INSERT INTO group_snd_item_statuses (chat_item_id, group_member_id, group_snd_item_status) VALUES (?,?,?) @@ -7576,6 +7588,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 @@ -7752,6 +7768,10 @@ Query: SELECT user_id FROM users WHERE local_display_name = ? Plan: SEARCH users USING COVERING INDEX sqlite_autoindex_users_2 (local_display_name=?) +Query: SELECT via_contact_uri FROM connections WHERE connection_id = ? +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + Query: SELECT via_contact_uri, via_contact_uri_hash FROM connections WHERE connection_id = ? Plan: SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) @@ -7764,6 +7784,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=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 090b6332ab..995497d5ee 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -32,7 +32,8 @@ CREATE TABLE contact_profiles( contact_domain TEXT, contact_domain_proof TEXT, contact_domain_verified INTEGER, - description TEXT + description TEXT, + preferences_json TEXT ) STRICT; CREATE TABLE users( user_id INTEGER PRIMARY KEY, @@ -146,7 +147,8 @@ CREATE TABLE group_profiles( group_domain TEXT, domain_web_page INTEGER, allow_embedding INTEGER, - group_domain_proof TEXT + group_domain_proof TEXT, + preferences_json TEXT ) STRICT; CREATE TABLE groups( group_id INTEGER PRIMARY KEY, -- local group ID @@ -932,6 +934,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 e39bf4e46c..aa8029ab4e 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -421,8 +421,8 @@ createContact_ db cxt User {userId} Profile {displayName, fullName, shortDescr, badgeVerified <- verifyBadge_ unboundProof (badgeKeys cxt) badge DB.execute db - "INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, contact_link, chat_peer_type, user_id, local_alias, preferences, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx, contact_domain, contact_domain_proof) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" - ((displayName, fullName, shortDescr, description, image, contactLink, peerType) :. (userId, localAlias, preferences, currentTs, currentTs) :. badgeToRow badge badgeVerified :. contactDomainToRow contactDomain) + "INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, contact_link, chat_peer_type, user_id, local_alias, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx, contact_domain, contact_domain_proof, preferences, preferences_json) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" + ((displayName, fullName, shortDescr, description, image, contactLink, peerType) :. (userId, localAlias, currentTs, currentTs) :. badgeToRow badge badgeVerified :. contactDomainToRow contactDomain :. prefsToRow preferences) profileId <- insertedRowId db DB.execute db @@ -489,15 +489,16 @@ type PreparedContactRow = (Maybe AConnectionRequestUri, Maybe AConnShortLink, Ma type GroupDirectInvitationRow = (Maybe ConnReqInvitation, Maybe GroupId, Maybe GroupMemberId, Maybe Int64, BoolInt) -type ContactRow' = (ProfileId, ContactName, ContactName, Text, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, BoolInt, ContactStatus) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime) :. PreparedContactRow :. (Maybe Int64, Maybe BoolInt, Maybe GroupMemberId, BoolInt) :. GroupDirectInvitationRow :. (Maybe UIThemeEntityOverrides, BoolInt, Maybe CustomData, Maybe Int64) :. BadgeRow :. ContactDomainRow +type ContactRow' = (ProfileId, ContactName, ContactName, Text, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, BoolInt, ContactStatus) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe Text, Maybe Text, Preferences, UTCTime, UTCTime, Maybe UTCTime) :. PreparedContactRow :. (Maybe Int64, Maybe BoolInt, Maybe GroupMemberId, BoolInt) :. GroupDirectInvitationRow :. (Maybe UIThemeEntityOverrides, BoolInt, Maybe CustomData, Maybe Int64) :. BadgeRow :. ContactDomainRow type ContactRow = Only ContactId :. ContactRow' type ContactDomainRow = (Maybe SimplexDomain, Maybe SimplexDomainProof, Maybe BoolInt) toContact :: UTCTime -> StoreCxt -> User -> [ChatTagId] -> ContactRow :. MaybeConnectionRow -> Contact -toContact now cxt user chatTags ((Only contactId :. (profileId, localDisplayName, displayName, fullName, shortDescr, description, image, contactLink, peerType, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, rejectionSupported_, contactGroupMemberId, BI contactGrpInvSent) :. groupDirectInvRow :. (uiThemes, BI chatDeleted, customData, chatItemTTL) :. badgeRow :. domainRow) :. connRow) = - let profile = LocalProfile {profileId, displayName, fullName, shortDescr, description, image, contactLink, contactDomain = rowToContactDomain domainRow, contactDomainVerified = rowToDomainVerified domainRow, peerType, localBadge = rowToBadge now badgeRow, preferences, localAlias} +toContact now cxt user chatTags ((Only contactId :. (profileId, localDisplayName, displayName, fullName, shortDescr, description, image, contactLink, peerType, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, encodedPrefs, receivedPrefs, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, rejectionSupported_, contactGroupMemberId, BI contactGrpInvSent) :. groupDirectInvRow :. (uiThemes, BI chatDeleted, customData, chatItemTTL) :. badgeRow :. domainRow) :. connRow) = + let preferences = chatPrefsFromRow encodedPrefs receivedPrefs + profile = LocalProfile {profileId, displayName, fullName, shortDescr, description, image, contactLink, contactDomain = rowToContactDomain domainRow, contactDomainVerified = rowToDomainVerified domainRow, peerType, localBadge = rowToBadge now badgeRow, preferences, localAlias} activeConn = toMaybeConnection cxt connRow chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} incognito = maybe False connIncognito activeConn @@ -539,18 +540,18 @@ getProfileById db userId profileId = do DB.query db [sql| - SELECT cp.contact_profile_id, cp.display_name, cp.full_name, cp.short_descr, cp.description, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, cp.preferences, -- , ct.user_preferences + SELECT cp.contact_profile_id, cp.display_name, cp.full_name, cp.short_descr, cp.description, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, cp.preferences, cp.preferences_json, -- , ct.user_preferences cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx, cp.contact_domain, cp.contact_domain_proof, cp.contact_domain_verified FROM contact_profiles cp WHERE cp.user_id = ? AND cp.contact_profile_id = ? |] (userId, profileId) -type ContactRequestRow = (Int64, ContactName, AgentInvId, Maybe ContactId, Maybe GroupId, Maybe Int64, BoolInt) :. (Int64, ContactName, Text, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias) :. (Maybe XContactId, PQSupport, Maybe SharedMsgId, Maybe SharedMsgId, Maybe Preferences, UTCTime, UTCTime, VersionChat, VersionChat) :. BadgeRow :. ContactDomainRow +type ContactRequestRow = (Int64, ContactName, AgentInvId, Maybe ContactId, Maybe GroupId, Maybe Int64, BoolInt) :. (Int64, ContactName, Text, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias) :. (Maybe XContactId, PQSupport, Maybe SharedMsgId, Maybe SharedMsgId, Maybe Text, Maybe Text, UTCTime, UTCTime, VersionChat, VersionChat) :. BadgeRow :. ContactDomainRow toContactRequest :: UTCTime -> ContactRequestRow -> UserContactRequest -toContactRequest now ((contactRequestId, localDisplayName, agentInvitationId, contactId_, businessGroupId_, userContactLinkId_, BI rejectionSupported) :. (profileId, displayName, fullName, shortDescr, description, image, contactLink, peerType, localAlias) :. (xContactId, pqSupport, welcomeSharedMsgId, requestSharedMsgId, preferences, createdAt, updatedAt, minVer, maxVer) :. badgeRow :. domainRow) = do - let profile = LocalProfile {profileId, displayName, fullName, shortDescr, description, image, contactLink, contactDomain = rowToContactDomain domainRow, contactDomainVerified = rowToDomainVerified domainRow, peerType, preferences, localBadge = rowToBadge now badgeRow, localAlias} +toContactRequest now ((contactRequestId, localDisplayName, agentInvitationId, contactId_, businessGroupId_, userContactLinkId_, BI rejectionSupported) :. (profileId, displayName, fullName, shortDescr, description, image, contactLink, peerType, localAlias) :. (xContactId, pqSupport, welcomeSharedMsgId, requestSharedMsgId, encodedPrefs, receivedPrefs, createdAt, updatedAt, minVer, maxVer) :. badgeRow :. domainRow) = do + let profile = LocalProfile {profileId, displayName, fullName, shortDescr, description, image, contactLink, contactDomain = rowToContactDomain domainRow, contactDomainVerified = rowToDomainVerified domainRow, peerType, preferences = chatPrefsFromRow encodedPrefs receivedPrefs, localBadge = rowToBadge now badgeRow, localAlias} cReqChatVRange = fromMaybe (versionToRange maxVer) $ safeVersionRange minVer maxVer in UserContactRequest {contactRequestId, agentInvitationId, contactId_, businessGroupId_, userContactLinkId_, cReqChatVRange, localDisplayName, profileId, profile, xContactId, pqSupport, welcomeSharedMsgId, requestSharedMsgId, createdAt, updatedAt, rejectionSupported} @@ -587,6 +588,14 @@ getConnReqInv db connId = "SELECT conn_req_inv FROM connections WHERE connection_id = ?" (Only connId) +getConnReqContact :: DB.Connection -> Int64 -> ExceptT StoreError IO ConnReqContact +getConnReqContact db connId = + ExceptT . firstRow fromOnly (SEConnectionNotFoundById connId) $ + DB.query + db + "SELECT via_contact_uri FROM connections WHERE connection_id = ?" + (Only connId) + -- | Saves unique local display name based on passed displayName, suffixed with _N if required. -- This function should be called inside transaction. withLocalDisplayName :: forall a. DB.Connection -> UserId -> Text -> (Text -> IO (Either StoreError a)) -> IO (Either StoreError a) @@ -686,18 +695,19 @@ type BusinessChatInfoRow = (Maybe BusinessChatType, Maybe MemberId, Maybe Member type GroupKeysRow = (Maybe C.PrivateKeyEd25519, Maybe C.PublicKeyEd25519, Maybe C.PrivateKeyEd25519) -type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Text, Maybe Text, Maybe ImageData, Maybe GroupType, Maybe ShortLinkContact, Maybe B64UrlByteString) :. PublicGroupAccessRow :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences, Maybe GroupMemberAdmission) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. PreparedGroupRow :. BusinessChatInfoRow :. (BoolInt, Maybe RelayStatus, Maybe UIThemeEntityOverrides, Int64, Maybe Int64, Maybe VersionRoster, Maybe CustomData, Maybe Int64, Int, Maybe ConnReqContact, Maybe BoolInt) :. GroupKeysRow :. GroupMemberRow +type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Text, Maybe Text, Maybe ImageData, Maybe GroupType, Maybe ShortLinkContact, Maybe B64UrlByteString) :. PublicGroupAccessRow :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe Text, Maybe Text, Maybe GroupMemberAdmission) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. PreparedGroupRow :. BusinessChatInfoRow :. (BoolInt, Maybe RelayStatus, Maybe UIThemeEntityOverrides, Int64, Maybe Int64, Maybe VersionRoster, Maybe CustomData, Maybe Int64, Int, Maybe ConnReqContact, Maybe BoolInt) :. GroupKeysRow :. GroupMemberRow type PublicGroupAccessRow = (Maybe Text, Maybe SimplexDomain, Maybe BoolInt, Maybe BoolInt, Maybe SimplexDomainProof) type GroupMemberRow = (GroupMemberId, GroupId, Int64, MemberId, VersionChat, VersionChat, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId) :. ProfileRow :. (UTCTime, UTCTime) :. (Maybe UTCTime, Int64, Int64, Int64, Maybe UTCTime, Maybe C.PublicKeyEd25519, Maybe ShortLinkContact, Maybe Text, Maybe UTCTime) -type ProfileRow = (ProfileId, ContactName, Text, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, Maybe Preferences) :. BadgeRow :. ContactDomainRow +type ProfileRow = (ProfileId, ContactName, Text, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, Maybe Text, Maybe Text) :. BadgeRow :. ContactDomainRow 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) = +toGroupInfo now cxt userContactId chatTags ((groupId, localDisplayName, displayName, fullName, shortDescr, localAlias, description, image, groupType_, groupLink_, publicGroupId_) :. accessRow :. (enableNtfs_, sendRcpts, BI favorite, encodedPrefs, receivedPrefs, 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} + groupPreferences = groupPrefsFromRow encodedPrefs receivedPrefs fullGroupPreferences = mergeGroupPreferences groupPreferences publicGroup = toPublicGroupProfile groupType_ groupLink_ publicGroupId_ (toPublicGroupAccess accessRow) groupProfile = GroupProfile {displayName, fullName, shortDescr, description, image, publicGroup, groupPreferences, memberAdmission} @@ -793,7 +803,7 @@ groupMemberQuery = [sql| SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, - m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, p.preferences_json, p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, m.member_security_code, m.member_security_code_verified_at, @@ -811,8 +821,8 @@ toContactMember now cxt User {userContactId} (memberRow :. connRow) = (toGroupMember now userContactId memberRow) {activeConn = toMaybeConnection cxt connRow} rowToLocalProfile :: UTCTime -> ProfileRow -> LocalProfile -rowToLocalProfile now ((profileId, displayName, fullName, shortDescr, description, image, contactLink, peerType, localAlias, preferences) :. badgeRow :. domainRow) = - LocalProfile {profileId, displayName, fullName, shortDescr, description, image, contactLink, contactDomain = rowToContactDomain domainRow, contactDomainVerified = rowToDomainVerified domainRow, peerType, localBadge = rowToBadge now badgeRow, localAlias, preferences} +rowToLocalProfile now ((profileId, displayName, fullName, shortDescr, description, image, contactLink, peerType, localAlias, encodedPrefs, receivedPrefs) :. badgeRow :. domainRow) = + LocalProfile {profileId, displayName, fullName, shortDescr, description, image, contactLink, contactDomain = rowToContactDomain domainRow, contactDomainVerified = rowToDomainVerified domainRow, peerType, localBadge = rowToBadge now badgeRow, localAlias, preferences = chatPrefsFromRow encodedPrefs receivedPrefs} toBusinessChatInfo :: Maybe SimplexDomainClaim -> BusinessChatInfoRow -> Maybe BusinessChatInfo toBusinessChatInfo businessDomain (Just chatType, Just businessId, Just customerId) = Just BusinessChatInfo {chatType, businessId, customerId, businessDomain} @@ -828,7 +838,7 @@ groupInfoQueryFields = -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, gp.group_type, gp.group_link, gp.public_group_id, gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding, gp.group_domain_proof, - g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, + g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.preferences_json, gp.member_admission, g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, @@ -838,7 +848,7 @@ groupInfoQueryFields = -- GroupMember - membership mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, - pu.display_name, pu.full_name, pu.short_descr, pu.description, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, + pu.display_name, pu.full_name, pu.short_descr, pu.description, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, pu.preferences_json, pu.badge_proof, pu.badge_pres_header, pu.badge_expiry, pu.badge_type, pu.badge_verified, pu.badge_extra, pu.badge_master_key, pu.badge_signature, pu.badge_key_idx, pu.contact_domain, pu.contact_domain_proof, pu.contact_domain_verified, mu.created_at, mu.updated_at, mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key, mu.relay_link, mu.member_security_code, mu.member_security_code_verified_at diff --git a/src/Simplex/Chat/Types/Preferences.hs b/src/Simplex/Chat/Types/Preferences.hs index c26f187343..7445a9c2d5 100644 --- a/src/Simplex/Chat/Types/Preferences.hs +++ b/src/Simplex/Chat/Types/Preferences.hs @@ -24,8 +24,10 @@ module Simplex.Chat.Types.Preferences where import Control.Applicative ((<|>)) -import Data.Aeson (FromJSON (..), ToJSON (..)) +import Data.Aeson (FromJSON (..), Object, ToJSON (..), Value (..), decodeStrictText) +import qualified Data.Aeson.Encoding as JE import qualified Data.Aeson.TH as J +import qualified Data.Aeson.Types as JT import qualified Data.Attoparsec.ByteString.Char8 as A import qualified Data.ByteString.Char8 as B import Data.Maybe (fromMaybe, isJust) @@ -37,7 +39,7 @@ import Simplex.Chat.Types.Shared import Simplex.Messaging.Agent.Store.DB (blobFieldDecoder, fromTextField_) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, sumTypeJSON, taggedObjectJSON) -import Simplex.Messaging.Util (decodeJSON, encodeJSON, safeDecodeUtf8, (<$?>)) +import Simplex.Messaging.Util (encodeJSON, safeDecodeUtf8, (<$?>)) data ChatFeature = CFTimedMessages @@ -149,6 +151,34 @@ setPreference_ f pref_ prefs = SCFCalls -> prefs {calls = pref_} SCFSessions -> prefs {sessions = pref_} +newtype PrefsJSON = PrefsJSON {unPrefsJSON :: Maybe Object} + deriving (Eq, Show) + +instance ToJSON PrefsJSON where + toJSON _ = Null + toEncoding _ = JE.null_ + omitField _ = True + +instance FromJSON PrefsJSON where + parseJSON _ = pure $ PrefsJSON Nothing + omittedField = Just $ PrefsJSON Nothing + +keepPrefsJSON :: (ToJSON p, HasField "_json" p PrefsJSON) => Value -> p -> p +keepPrefsJSON v ps = setField @"_json" ps . PrefsJSON $ case v of + Object o | v /= toJSON ps -> Just o + _ -> Nothing + +decodePrefs :: (Value -> JT.Parser p) -> Text -> Maybe p +decodePrefs prefsP t = JT.parseMaybe prefsP =<< decodeStrictText t + +prefsFromRow_ :: (Value -> JT.Parser p) -> Maybe Text -> Maybe Text -> Maybe p +prefsFromRow_ prefsP encodedPrefs receivedPrefs = (decode =<< receivedPrefs) <|> (decode =<< encodedPrefs) + where + decode = decodePrefs prefsP + +prefsToRow :: HasField "_json" p PrefsJSON => Maybe p -> (Maybe p, Maybe Text) +prefsToRow ps = (ps, encodeJSON . Object <$> (unPrefsJSON . getField @"_json" =<< ps)) + -- collection of optional chat preferences for the user and the contact data Preferences = Preferences { timedMessages :: Maybe TimedMessagesPreference, @@ -158,7 +188,8 @@ data Preferences = Preferences files :: Maybe FilesPreference, calls :: Maybe CallsPreference, sessions :: Maybe SessionsPreference, - commands :: Maybe [ChatBotCommand] + commands :: Maybe [ChatBotCommand], + _json :: PrefsJSON } deriving (Eq, Show) @@ -166,6 +197,9 @@ class HasCommands p where commands_ :: p -> Maybe [ChatBotCommand] instance HasCommands Preferences where commands_ Preferences {commands} = commands +instance HasField "_json" Preferences PrefsJSON where + hasField p@Preferences {_json} = (\j -> p {_json = j}, _json) + data GroupFeature = GFTimedMessages | GFDirectMessages @@ -371,12 +405,16 @@ data GroupPreferences = GroupPreferences sessions :: Maybe SessionsGroupPreference, comments :: Maybe CommentsGroupPreference, signMessages :: Maybe SignMessagesGroupPreference, - commands :: Maybe [ChatBotCommand] + commands :: Maybe [ChatBotCommand], + _json :: PrefsJSON } deriving (Eq, Show) instance HasCommands GroupPreferences where commands_ GroupPreferences {commands} = commands +instance HasField "_json" GroupPreferences PrefsJSON where + hasField p@GroupPreferences {_json} = (\j -> p {_json = j}, _json) + data ChatBotCommand = CBCCommand { keyword :: Text, -- "order" @@ -506,7 +544,8 @@ toChatPrefs FullPreferences {timedMessages, fullDelete, reactions, voice, files, files = Just files, calls = Just calls, sessions = Just sessions, - commands = Just cmds + commands = Just cmds, + _json = PrefsJSON Nothing } defaultChatPrefs :: FullPreferences @@ -523,7 +562,7 @@ defaultChatPrefs = } emptyChatPrefs :: Preferences -emptyChatPrefs = Preferences Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing +emptyChatPrefs = Preferences Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing (PrefsJSON Nothing) defaultGroupPrefs :: FullGroupPreferences defaultGroupPrefs = @@ -545,7 +584,7 @@ defaultGroupPrefs = } emptyGroupPrefs :: GroupPreferences -emptyGroupPrefs = GroupPreferences Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing +emptyGroupPrefs = GroupPreferences Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing (PrefsJSON Nothing) businessGroupPrefs :: Preferences -> GroupPreferences businessGroupPrefs Preferences {timedMessages, fullDelete, reactions, voice, files, sessions, commands} = @@ -580,7 +619,8 @@ defaultBusinessGroupPrefs = sessions = Just $ SessionsGroupPreference FEOn Nothing, comments = Just $ CommentsGroupPreference FEOff Nothing, signMessages = Just $ SignMessagesGroupPreference FEOff, - commands = Nothing + commands = Nothing, + _json = PrefsJSON Nothing } data TimedMessagesPreference = TimedMessagesPreference @@ -1092,7 +1132,8 @@ toGroupPreferences groupPreferences@FullGroupPreferences {commands = ListDef cmd sessions = pref SGFSessions, comments = pref SGFComments, signMessages = pref SGFSignMessages, - commands = Just cmds + commands = Just cmds, + _json = PrefsJSON Nothing } where pref :: SGroupFeature f -> Maybe (GroupFeaturePreference f) @@ -1192,13 +1233,22 @@ instance FromJSON SessionsPreference where $(J.deriveJSON (taggedObjectJSON $ dropPrefix "CBC") ''ChatBotCommand) -$(J.deriveJSON defaultJSON ''Preferences) +$(J.deriveToJSON defaultJSON ''Preferences) + +chatPrefsP :: Value -> JT.Parser Preferences +chatPrefsP = $(J.mkParseJSON defaultJSON ''Preferences) + +instance FromJSON Preferences where + parseJSON v = keepPrefsJSON v <$> chatPrefsP v + +chatPrefsFromRow :: Maybe Text -> Maybe Text -> Maybe Preferences +chatPrefsFromRow = prefsFromRow_ chatPrefsP instance ToField Preferences where toField = toField . encodeJSON instance FromField Preferences where - fromField = fromTextField_ decodeJSON + fromField = fromTextField_ $ decodePrefs chatPrefsP $(J.deriveJSON defaultJSON ''GroupPreference) @@ -1240,13 +1290,22 @@ instance FromJSON CommentsGroupPreference where parseJSON v = $(J.mkParseJSON defaultJSON ''CommentsGroupPreference) v omittedField = Just CommentsGroupPreference {enable = FEOff, duration = Nothing} -$(J.deriveJSON defaultJSON ''GroupPreferences) +$(J.deriveToJSON defaultJSON ''GroupPreferences) + +groupPrefsP :: Value -> JT.Parser GroupPreferences +groupPrefsP = $(J.mkParseJSON defaultJSON ''GroupPreferences) + +instance FromJSON GroupPreferences where + parseJSON v = keepPrefsJSON v <$> groupPrefsP v + +groupPrefsFromRow :: Maybe Text -> Maybe Text -> Maybe GroupPreferences +groupPrefsFromRow = prefsFromRow_ groupPrefsP instance ToField GroupPreferences where toField = toField . encodeJSON instance FromField GroupPreferences where - fromField = fromTextField_ decodeJSON + fromField = fromTextField_ $ decodePrefs groupPrefsP $(J.deriveJSON defaultJSON ''FullPreferences) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 66158303f9..1c62bc267b 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -46,7 +46,7 @@ import Simplex.Chat.Markdown import Simplex.Chat.Badges (BadgeInfo (..), BadgeStatus (..), BadgeType (..), LocalBadge, localBadgeInfo, localBadgeStatus) import Simplex.Chat.Badges.Ledger (creditTypeTag, debitTypeTag) import Simplex.Chat.Badges.Service (StatementEntry (..), StatementEntryType (..)) -import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeState (..)) +import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeIssueError (..), BadgeState (..)) import Simplex.Chat.Messages hiding (NewChatItem (..)) import Simplex.Chat.Messages.CIContent import Simplex.Chat.Operators @@ -1842,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 <> ": " @@ -1851,8 +1851,14 @@ 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] @@ -1871,6 +1877,9 @@ viewBadgeLedger entries = map viewEntry entries 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/tests/BadgeTests.hs b/tests/BadgeTests.hs index e1156f2545..08e8f155d2 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 @@ -84,6 +86,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 @@ -694,6 +701,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 4897722683..a5e3cb82c8 100644 --- a/tests/Bots/BadgeServiceTests.hs +++ b/tests/Bots/BadgeServiceTests.hs @@ -20,6 +20,7 @@ import Control.Concurrent.STM (atomically, readTMVar) 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) @@ -41,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 @@ -83,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 @@ -143,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 () @@ -931,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 66deaae225..cd82896c6c 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -5234,6 +5234,9 @@ testMemberContactAccept = cath #$> ("/_get chat @3 count=1", chat, [(0, "requested connection from group team")]) + cath ##> "/_connect contact 1 3" + cath <## "bad chat command: contact is a member contact request" + cath ##> "/accept_member_contact @bob" cath <## "contact bob is accepted, starting connection" concurrently_ diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 53ea90155a..0e5d219833 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -69,6 +69,7 @@ chatProfileTests = do it "rotate address ratchet keys" testRotateAddressRatchetKeys it "create address on specified server" testCreateAddressOnServer it "retry connecting via contact link" testRetryConnectingViaContactLink + it "retry connecting via address in contact profile after address keys rotation" testRetryConnectingContactViaAddress it "add contact link to profile" testProfileLink it "auto accept contact requests" testUserContactLinkAutoAccept it "deduplicate contact requests" testDeduplicateContactRequests @@ -877,6 +878,50 @@ testRetryConnectingViaContactLink ps = testChatCfgOpts2 cfg' opts' aliceProfile } } +testRetryConnectingContactViaAddress :: HasCallStack => TestParams -> IO () +testRetryConnectingContactViaAddress ps = + withNewTestChatOpts ps testOptsNoFullLinks "alice" aliceProfile $ \alice -> + withNewTestChatCfgOpts ps cfg' opts' "bob" bobProfile $ \bob -> do + alice ##> "/ad" + sLink <- getContactLink_ alice True + alice ##> "/pa on" + alice <## "new contact address set" + rotateAddressKeys alice + case A.parseOnly strP (B.pack sLink) of + Left _ -> error "error parsing contact link" + Right shortLink -> do + void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> runExceptT $ createContact db (storeCxt $ chatController bob) user aliceProfile {contactLink = Just shortLink} + bob ##> "/_connect contact 1 2" + bob <##. "smp agent error: BROKER" + rotateAddressKeys alice + withSmpServer' serverCfg' $ do + bob ##> "/_connect contact 1 2" + bob <## "connection request sent!" + alice <## "bob (Bob) wants to connect to you!" + alice <## "to accept: /ac bob" + alice <## "to reject: /rc bob (the sender will NOT be notified)" + alice ##> "/ac bob" + alice <## "bob (Bob): accepting contact request, you can send messages to contact" + concurrently_ + (bob <## "alice (Alice): contact is connected") + (alice <## "bob (Bob): contact is connected") + alice <##> bob + bob <## "disconnected 1 connections on server localhost" + where + rotateAddressKeys alice = do + alice ##> "/_rotate_address_keys 1" + _ <- getContactLink_ alice False + alice <## "auto_accept off" + serverCfg' = smpServerCfg {transports = [("7003", transport @TLS, False)]} + cfg' = testCfg {agentConfig = testAgentCfg {persistErrorInterval = 0}} + opts' = + testOpts + { coreOptions = + testCoreOpts + { smpServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7003"] + } + } + testProfileLink :: HasCallStack => TestParams -> IO () testProfileLink = testChat3 aliceProfile bobProfile cathProfile $ diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index e6aecfd295..b93e1c8c8c 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -25,7 +25,7 @@ import qualified Data.ByteString.Lazy.Char8 as LB import Data.Time.Clock (getCurrentTime) import Data.Word (Word8, Word32) import Foreign.C -import Foreign.Marshal.Alloc (mallocBytes) +import Foreign.Marshal.Alloc (alloca, mallocBytes) import Foreign.Marshal.Utils (copyBytes) import Foreign.Ptr import Foreign.StablePtr @@ -34,7 +34,7 @@ import GHC.IO.Encoding (setLocaleEncoding, setFileSystemEncoding, setForeignEnco import JSONFixtures import Simplex.Chat import Simplex.Chat.Badges (BadgeInfo (..), BadgeRequest (..), BadgeType (..), generateMasterKey, verifyCredential) -import Simplex.Chat.Controller (ChatController (..), ChatDatabase (..)) +import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), ChatDatabase (..)) import Simplex.Chat.Mobile hiding (error) import Simplex.Chat.Mobile.Badges hiding (error) import Simplex.Chat.Mobile.File @@ -44,6 +44,8 @@ import Simplex.Chat.Options.DB import Simplex.Chat.Store import Simplex.Chat.Store.Profiles import Simplex.Chat.Types (AgentUserId (..), Profile (..)) +import Simplex.Messaging.Agent.Client (AgentClient (..)) +import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), Env (..)) import Simplex.Messaging.Agent.Store.Shared (MigrationConfig (..), MigrationConfirmation (..)) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Crypto as C @@ -65,6 +67,7 @@ mobileTests = do setForeignEncoding utf8 it "start new chat without user" testChatApiNoUser it "start new chat with existing user" testChatApi + it "should set queue size via C API" testChatMigrateInitQueueCApi it "should encrypt/decrypt WebRTC frames" testMediaApi it "should encrypt/decrypt WebRTC frames via C API" testMediaCApi describe "should read/write encrypted files via C API" $ do @@ -165,6 +168,22 @@ testChatApi ps = do chatParseMarkdown "hello" `shouldBe` "{}" chatParseMarkdown "*hello*" `shouldBe` parsedMarkdown +testChatMigrateInitQueueCApi :: TestParams -> IO () +testChatMigrateInitQueueCApi ps = do + cPath <- newCString $ tmpPath ps "1" + cKey <- newCString "" + cConfirm <- newCString "yesUp" + alloca $ \ctrlPtr -> do + let migrateInit queueSize = peekCAString =<< cChatMigrateInitQueue cPath cKey cConfirm queueSize ctrlPtr + migrateInit 0 `shouldReturn` jsonStr DBMInvalidQueueSize + migrateInit (-1) `shouldReturn` jsonStr DBMInvalidQueueSize + migrateInit 65536 `shouldReturn` jsonStr DBMOk + ChatController {config = ChatConfig {tbqSize}, smpAgent = AgentClient {agentEnv = Env {config = AgentConfig {tbqSize = agentQSize}}}} <- deRefStablePtr =<< peek ctrlPtr + tbqSize `shouldBe` 65536 + agentQSize `shouldBe` 65536 + where + jsonStr = LB.unpack . J.encode + testMediaApi :: HasCallStack => TestParams -> IO () testMediaApi ps = do let tmp = tmpPath ps diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 633bbe876c..554398520e 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -1,5 +1,6 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE OverloadedLists #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE PatternSynonyms #-} @@ -12,6 +13,7 @@ import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import Data.List (isInfixOf) import qualified Data.List.NonEmpty as L +import Data.Maybe (fromMaybe) import Data.Time.Clock.System (SystemTime (..), systemToUTCTime) import Simplex.Chat.Library.Internal (decodeLinkUserData, encodeShortLinkData) import Simplex.Chat.Protocol @@ -33,6 +35,41 @@ protocolTests = do decodeChatMessageTest shortLinkDataTests batchLimitTests + preferencesJSONTests + +preferencesJSONTests :: Spec +preferencesJSONTests = describe "preferences JSON" $ do + it "stores no JSON when preferences encode to what was received" $ do + ps <- prefs "{\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"no\"}}" + storedJSON ps `shouldBe` Nothing + it "stores no JSON when group preferences encode to what was received" $ do + ps <- groupPrefs "{\"voice\":{\"enable\":\"on\"},\"reactions\":{\"enable\":\"off\"}}" + storedJSON ps `shouldBe` Nothing + it "stores JSON with a preference that is not defined" $ do + let s = "{\"voice\":{\"enable\":\"on\"},\"polls\":{\"enable\":\"on\"}}" + ps <- groupPrefs s + storedJSON ps `shouldBe` Just (object s) + J.toJSON ps `shouldBe` object "{\"voice\":{\"enable\":\"on\"}}" + it "stores JSON with a field that is not defined in a preference" $ do + let s = "{\"voice\":{\"enable\":\"on\",\"exceptRole\":\"observer\"}}" + ps <- groupPrefs s + storedJSON ps `shouldBe` Just (object s) + it "reads the received preferences from the stored JSON" $ + groupPrefsFromRow (Just "{\"voice\":{\"enable\":\"on\"}}") (Just "{\"voice\":{\"enable\":\"off\"}}") + `shouldBe` groupPrefs_ "{\"voice\":{\"enable\":\"off\"}}" + it "reads the stored preferences when the received JSON does not parse" $ + groupPrefsFromRow (Just "{\"voice\":{\"enable\":\"on\"}}") (Just "{\"voice\":{\"enable\":\"sometimes\"}}") + `shouldBe` groupPrefs_ "{\"voice\":{\"enable\":\"on\"}}" + where + prefs :: ByteString -> IO Preferences + prefs = either fail pure . J.eitherDecodeStrict' + groupPrefs :: ByteString -> IO GroupPreferences + groupPrefs = either fail pure . J.eitherDecodeStrict' + groupPrefs_ :: ByteString -> Maybe GroupPreferences + groupPrefs_ = J.decodeStrict' + storedJSON ps = J.decodeStrictText =<< snd (prefsToRow $ Just ps) :: Maybe J.Value + object :: ByteString -> J.Value + object s = fromMaybe (error $ "not JSON: " <> B.unpack s) $ J.decodeStrict' s batchLimitTests :: Spec batchLimitTests = describe "Chat message batch limits" $ do @@ -133,10 +170,10 @@ s #==# msg = do s ==# msg testChatPreferences :: Maybe Preferences -testChatPreferences = Just Preferences {voice = Just VoicePreference {allow = FAYes}, files = Nothing, fullDelete = Nothing, timedMessages = Nothing, calls = Nothing, reactions = Just ReactionsPreference {allow = FAYes}, sessions = Nothing, commands = Nothing} +testChatPreferences = Just Preferences {voice = Just VoicePreference {allow = FAYes}, files = Nothing, fullDelete = Nothing, timedMessages = Nothing, calls = Nothing, reactions = Just ReactionsPreference {allow = FAYes}, sessions = Nothing, commands = Nothing, _json = PrefsJSON Nothing} testGroupPreferences :: Maybe GroupPreferences -testGroupPreferences = Just GroupPreferences {timedMessages = Nothing, directMessages = Nothing, reactions = Just ReactionsGroupPreference {enable = FEOn}, voice = Just VoiceGroupPreference {enable = FEOn, role = Nothing}, files = Nothing, fullDelete = Nothing, simplexLinks = Nothing, history = Nothing, reports = Nothing, support = Nothing, sessions = Nothing, comments = Nothing, signMessages = Nothing, commands = Nothing} +testGroupPreferences = Just GroupPreferences {timedMessages = Nothing, directMessages = Nothing, reactions = Just ReactionsGroupPreference {enable = FEOn}, voice = Just VoiceGroupPreference {enable = FEOn, role = Nothing}, files = Nothing, fullDelete = Nothing, simplexLinks = Nothing, history = Nothing, reports = Nothing, support = Nothing, sessions = Nothing, comments = Nothing, signMessages = Nothing, commands = Nothing, _json = PrefsJSON Nothing} testProfile :: Profile testProfile = Profile {displayName = "alice", fullName = "Alice", shortDescr = Nothing, description = Nothing, image = Just (ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII="), peerType = Nothing, contactLink = Nothing, preferences = testChatPreferences, badge = Nothing, contactDomain = Nothing} 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..044c0d1d8b --- /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 November 22, you will also receive a public SimpleX name for 5 years. Learn more and invest on Wefunder.

diff --git a/website/src/crowdfunding.html b/website/src/crowdfunding.html index 1f87477b3c..a4faa67dc8 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%); @@ -170,7 +170,7 @@ templateEngineOverride: njk .cf-hero-text { margin-left: calc(var(--sec-vwu) * 7.5); - width: calc(var(--sec-vwu) * 45); + width: calc(var(--sec-vwu) * 53); position: relative; z-index: 4; } @@ -178,7 +178,7 @@ templateEngineOverride: njk .cf-hero h1 { font-family: "GT-Walsheim", sans-serif; font-weight: 400; - font-size: calc(var(--sec-vwu) * 4.94); + font-size: calc(var(--sec-vwu) * 4.45); line-height: 1.05; letter-spacing: -0.025em; color: #ffffff; @@ -193,11 +193,13 @@ templateEngineOverride: njk margin-top: calc(var(--sec-vhu) * 3); max-width: calc(var(--sec-vwu) * 40); } + .cf-hero .lead-short { display: none; } + .cf-hero h1 .cf-br-narrow { 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); } @@ -376,7 +378,7 @@ templateEngineOverride: njk .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; } } @@ -386,7 +388,7 @@ templateEngineOverride: njk .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; } @@ -426,7 +428,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; @@ -446,15 +448,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; @@ -596,12 +600,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))) + 75vw); /* 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 */ @@ -623,8 +627,11 @@ templateEngineOverride: njk .cf-hero h1, .cf-hero .lead, .cf-hero .cf-stats { position: relative; z-index: 4; } - .cf-hero h1 { font-size: calc(var(--sec-vwu) * 9.7); } + .cf-hero h1 { font-size: calc(var(--sec-vwu) * 9); } + .cf-hero h1 .cf-br-narrow { display: inline; } .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 { @@ -743,21 +750,26 @@ 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; } } + @media screen and (max-width: 959px) and (orientation: portrait) and (max-aspect-ratio: 62/100) { + .cf-hero h1 { font-size: calc(var(--sec-vwu) * 9.6); } + :root { --cf-hero-text-b: calc(var(--cf-top-base) + max(0px, calc(-1 * var(--cf-area-off))) + 72.8vw); } + } + /* 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.6); } } /* narrow phones: keep the corner link clear of the logo */ - @media screen and (max-width: 389px) { + @media screen and (max-width: 389px) and (orientation: portrait) { header#navbar > a.logo img { height: 24px; } .cf-hero-corner { height: 40px; } .cf-tag .v { font-size: 2.96cqh; line-height: 1.4; } @@ -787,7 +799,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; } @@ -917,11 +929,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.3); } + .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); } + } @@ -943,8 +1017,9 @@ 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.

+

The only
messaging network
without any
user identifiers

+

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

+

No phone numbers, emails, or accounts.

-
480KMonthly users
+
520KMonthly users
@@ -970,18 +1045,18 @@ templateEngineOverride: njk
-
+
-

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

@@ -993,13 +1068,13 @@ templateEngineOverride: njk
-
+
-
+
@@ -1046,7 +1121,7 @@ templateEngineOverride: njk
-
+

Design advantage that can't be copied

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

A network others build on

@@ -1069,7 +1144,7 @@ templateEngineOverride: njk
-
+

Get a stake
in SimpleX Chat

@@ -1082,7 +1157,7 @@ templateEngineOverride: njk
-

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

+

Invest $500+ during early bird terms and get a SimpleX public name for your channel or business for 5 years, ahead of public launch.

@@ -1238,6 +1313,85 @@ templateEngineOverride: njk + +