diff --git a/apps/ios/Shared/Assets.xcassets/wefunder_logo.imageset/Contents.json b/apps/ios/Shared/Assets.xcassets/wefunder_logo.imageset/Contents.json deleted file mode 100644 index 182abac4a6..0000000000 --- a/apps/ios/Shared/Assets.xcassets/wefunder_logo.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images" : [ - { - "filename" : "wefunder_logo.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "idiom" : "universal", - "scale" : "2x" - }, - { - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/apps/ios/Shared/Assets.xcassets/wefunder_logo.imageset/wefunder_logo.png b/apps/ios/Shared/Assets.xcassets/wefunder_logo.imageset/wefunder_logo.png deleted file mode 100644 index ac90142f8d..0000000000 Binary files a/apps/ios/Shared/Assets.xcassets/wefunder_logo.imageset/wefunder_logo.png and /dev/null differ 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/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 5654d6e5aa..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 @@ -52,14 +53,45 @@ struct BadgesYourBadgeView: View { } } } + if let issueError = badgeState.issueError { + Section { + Text(issueError.reason.text) + .foregroundColor(theme.colors.secondary) + infoRow("Since", badgeTimestamp(issueError.failedSince)) + if issueError.lastAttemptAt != issueError.failedSince { + infoRow("Last attempt", badgeTimestamp(issueError.lastAttemptAt)) + } + settingsRow("number", color: theme.colors.secondary) { + Button("Contact SimpleX team") { + dismiss() + DispatchQueue.main.async { + // simplexTeamURL targets this same app; route to the in-app connect flow + ChatModel.shared.appOpenUrl = simplexTeamURL + } + } + } + } header: { + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle") + .foregroundColor(.red) + Text("Error") + } + } + } if developerTools { Section(header: Text("Credential").foregroundColor(theme.colors.secondary)) { if let badge = chatModel.currentUser?.profile.localBadge { infoRow("Status", badge.status.rawValue) - infoRow("Expires", 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/SupportSimpleXBanner.swift b/apps/ios/Shared/Views/Badges/SupportSimpleXBanner.swift index c2f6136d31..507be8ce5e 100644 --- a/apps/ios/Shared/Views/Badges/SupportSimpleXBanner.swift +++ b/apps/ios/Shared/Views/Badges/SupportSimpleXBanner.swift @@ -14,17 +14,15 @@ struct SupportSimpleXBanner: View { @Environment(\.colorScheme) var colorScheme: ColorScheme var title: LocalizedStringKey = "Support SimpleX" 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 3797ec0f63..5c5021b79f 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -177,6 +177,7 @@ 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 @@ -381,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("Your badge expired", 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) } @@ -433,10 +434,11 @@ struct ChatListView: View { if shouldShowOnboarding { VStack(spacing: 0) { ConnectOnboardingView() - if isInUS && !getStakeBannerDismissed { + 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) @@ -485,16 +487,37 @@ struct ChatListView: View { 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) @@ -502,7 +525,8 @@ struct ChatListView: View { .listRowSeparator(.hidden) .listRowBackground(Color.clear) .zIndex(1) - } else if isInUS && !getStakeBannerDismissed { + .onAppear { chatModel.chatListBanner = .badgePitch } + } else if chatModel.bannerSlotFree(for: .getStake) && isInUS && !getStakeBannerDismissed { GetStakeBanner( showDismiss: getStakeBannerTapped && !chatModel.chats.isEmpty, onTap: openGetStake, @@ -513,6 +537,7 @@ struct ChatListView: View { .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 index 1ba9b4ab09..c9924223b5 100644 --- a/apps/ios/Shared/Views/ChatList/GetStakeBanner.swift +++ b/apps/ios/Shared/Views/ChatList/GetStakeBanner.swift @@ -12,6 +12,7 @@ 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 @@ -31,12 +32,11 @@ struct GetStakeBanner: View { .lineLimit(2) } Spacer(minLength: 6) - Image("wefunder_logo") + Image(colorScheme == .light ? "decentralized" : "decentralized-light") .resizable() .scaledToFit() - .frame(width: 48, height: 48) - // roughly where the phone of the badge banner's hero is: 40pt from the card edge, less its 8pt inset - .padding(.trailing, 32) + .frame(width: 37, height: 37) + .padding(.trailing, showDismiss ? 32 : 10) } .modifier(BannerCard()) } 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/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index efc16a6309..c1d1e5b111 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -57,6 +57,7 @@ let DEFAULT_CHAT_ITEM_TAIL = "chatItemTail" let DEFAULT_ONE_HAND_UI_CARD_SHOWN = "oneHandUICardShown" let DEFAULT_ADDRESS_CREATION_CARD_SHOWN = "addressCreationCardShown" let DEFAULT_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" @@ -121,6 +122,7 @@ 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, @@ -155,6 +157,7 @@ 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, 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/spec/client/chat-list.md b/apps/ios/spec/client/chat-list.md index 900e0d4341..2087d0dee3 100644 --- a/apps/ios/spec/client/chat-list.md +++ b/apps/ios/spec/client/chat-list.md @@ -293,6 +293,8 @@ Gradient card inviting the user to invest on Wefunder. Shown only when [`isInUS` | 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 @@ -301,6 +303,10 @@ In the onboarding branch the `.scaleEffect` and `ThemedBackground` are applied t |---------|--------|--------| | `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. 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/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/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 92f4c6d5c0..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,7 @@ 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) @@ -277,6 +278,7 @@ class AppPreferences { hintPref(oneHandUICardShown, false), hintPref(addressCreationCardShown, false), hintPref(supporterBannerShown, false), + hintPref(supporterBannerTapped, false), hintPref(getStakeBannerTapped, false), hintPref(getStakeBannerDismissed, false), hintPref(liveMessageAlertShown, false), @@ -471,6 +473,7 @@ 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" @@ -591,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) } @@ -4545,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 @@ -7387,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/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/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/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 3d0e7c7d2e..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,7 @@ 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 @@ -1046,26 +1047,46 @@ 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 (crowdfunding && !getStakeBannerDismissed.value) { + } 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(), 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 index 81ddf8ae53..d383366dd4 100644 --- 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 @@ -37,8 +37,7 @@ fun GetStakeBanner(showDismiss: Boolean, onTap: () -> Unit, onDismiss: () -> Uni Row( Modifier .bannerCard(onTap) - // the logo sits roughly where the phone of the badge banner's hero is, 40dp from the card edge - .padding(start = 16.dp, end = 40.dp, top = 12.dp, bottom = 12.dp), + .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) ) { @@ -59,7 +58,11 @@ fun GetStakeBanner(showDismiss: Boolean, onTap: () -> Unit, onDismiss: () -> Uni overflow = TextOverflow.Ellipsis ) } - Image(painterResource(MR.images.wefunder_logo), contentDescription = null, modifier = Modifier.size(48.dp)) + Image( + painterResource(if (isInDarkTheme()) MR.images.decentralized_light else MR.images.decentralized), + contentDescription = null, + modifier = Modifier.size(37.dp) + ) } if (showDismiss) { 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/newchat/OnboardingCards.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/OnboardingCards.kt index a1cc8676de..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 @@ -52,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 -> @@ -420,7 +425,7 @@ fun ConnectOnboardingView() { } val getStakeBannerDismissed = remember { appPrefs.getStakeBannerDismissed.state } - val showGetStakeBanner = crowdfundingAvailable() && !getStakeBannerDismissed.value + 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 { @@ -429,6 +434,7 @@ fun ConnectOnboardingView() { 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, 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 9e1a6eb4ad..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() 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 feed7b3c8e..42aecc9cfe 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -3134,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. @@ -3176,6 +3175,7 @@ Support SimpleX 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. @@ -3189,13 +3189,15 @@ 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. @@ -3227,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/images/wefunder_logo@4x.png b/apps/multiplatform/common/src/commonMain/resources/MR/images/wefunder_logo@4x.png deleted file mode 100644 index ac90142f8d..0000000000 Binary files a/apps/multiplatform/common/src/commonMain/resources/MR/images/wefunder_logo@4x.png and /dev/null differ 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/spec/client/chat-list.md b/apps/multiplatform/spec/client/chat-list.md index a5eeb268fa..e34da9613d 100644 --- a/apps/multiplatform/spec/client/chat-list.md +++ b/apps/multiplatform/spec/client/chat-list.md @@ -336,6 +336,8 @@ Gradient card inviting the user to invest on Wefunder. Shown only when `crowdfun | 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 @@ -344,6 +346,10 @@ Gradient card inviting the user to invest on Wefunder. Shown only when `crowdfun |------------|--------|--------| | `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. 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/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 index ebe19ef431..7aa2b2e4ac 100644 --- a/blog/20260919-simplex-supporter-badges.md +++ b/blog/20260919-simplex-supporter-badges.md @@ -55,10 +55,10 @@ Investors in our [equity crowdfunding on Wefunder](https://wefunder.com/simplex. | $2,500 | supporter, 12 months, or legend, 3 months | | $10,000 | legend, 12 months | -If you invest $500 or more by September 22, you will also receive [a public SimpleX name](https://simplex.domains?utm_source=blog) for 7 years[^name]. +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]: After September 22, investors of $500 or more receive a name for 5 years as early bird investors, and for 3 years after that — ahead of the public launch of names on December 12. +[^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/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index 121f88ed63..f07fab0f9b 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 @@ -178,11 +178,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)] @@ -303,7 +307,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, "", [], "", ""), @@ -339,7 +343,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 1b078d29df..b11a45c380 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: 3af92e4a5d8069d376b4ba68be564786c7829c24 + tag: b589c2497a0a956ddc5454ddd9f7f2dbe305740d source-repository-package type: git diff --git a/docs/rfcs/2026-09-10-wallet-keys.md b/docs/rfcs/2026-09-10-wallet-keys.md index 2b6f5e4644..dab6ed31bb 100644 --- a/docs/rfcs/2026-09-10-wallet-keys.md +++ b/docs/rfcs/2026-09-10-wallet-keys.md @@ -169,7 +169,7 @@ A null `account_index` marks an account whose key was imported rather than deriv - `src/Simplex/Chat/Wallet.hs`, derivation, pure. - `src/Simplex/Chat/Store/Wallets.hs`, the two tables. -- `src/Simplex/Chat/Store/SQLite/Migrations/M20260921_wallet_seeds.hs` and the Postgres twin. +- `src/Simplex/Chat/Store/SQLite/Migrations/M20260924_wallet_seeds.hs` and the Postgres twin. - `tests/WalletTests.hs`. - `tests/SchemaDump.hs` and `tests/PostgresSchemaDump.hs`, which selected what to test by taking every migration after the last one without a reverse step, and now take every migration from the first one that has a reverse step, applying any that has none. - Derivation uses the `BIP32` and `BIP39` modules already in simplexmq and adds no dependency. diff --git a/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 958d1b248e..71b6c178f4 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."3af92e4a5d8069d376b4ba68be564786c7829c24" = "1d3s1ng1wfz1j4cpml9ky09fkj0l55cpfbh0mlxm3m211rlbb7mk"; + "https://github.com/simplex-chat/simplexmq.git"."b589c2497a0a956ddc5454ddd9f7f2dbe305740d" = "1mc880zpbrj9by3vf6xdsxskf90z83fl77jkhls54qw27rsg1a1x"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 82dc113452..e1bfecf4a0 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -168,7 +168,9 @@ 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.M20260921_wallet_seeds + Simplex.Chat.Store.Postgres.Migrations.M20260918_badge_issue_errors + Simplex.Chat.Store.Postgres.Migrations.M20260923_preferences_json + Simplex.Chat.Store.Postgres.Migrations.M20260924_wallet_seeds else exposed-modules: Simplex.Chat.Archive @@ -344,7 +346,9 @@ 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.M20260921_wallet_seeds + Simplex.Chat.Store.SQLite.Migrations.M20260918_badge_issue_errors + Simplex.Chat.Store.SQLite.Migrations.M20260923_preferences_json + Simplex.Chat.Store.SQLite.Migrations.M20260924_wallet_seeds 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 b615d06ae6..c112c7ddb0 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) @@ -1756,7 +1756,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 @@ -2461,7 +2461,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 @@ -2879,7 +2880,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 @@ -3433,7 +3434,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 @@ -3792,7 +3793,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 @@ -3853,7 +3854,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 @@ -3908,13 +3909,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 @@ -3947,13 +3948,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" @@ -4000,7 +4001,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 @@ -4339,7 +4340,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 @@ -4742,7 +4743,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 @@ -5291,14 +5292,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 == '_' @@ -5321,7 +5326,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) @@ -5340,16 +5345,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. @@ -5402,40 +5424,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 @@ -5449,18 +5483,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. @@ -5520,22 +5557,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 @@ -5548,24 +5602,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 e40e2466a5..f6c1fb3b9e 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 @@ -2328,7 +2328,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_ = @@ -3028,7 +3028,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 () @@ -3054,7 +3054,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 2288763791..1dbf1adcb9 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -1390,7 +1390,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 @@ -1908,7 +1908,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 @@ -3877,7 +3877,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 @@ -3898,7 +3898,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/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 f87a1e242a..5ef9cbe723 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 a87d266b03..0e68221056 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 2c0b2dc080..f9cb76782a 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) @@ -2087,8 +2087,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 @@ -2169,8 +2169,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 @@ -2440,8 +2440,8 @@ createNewMemberProfile_ db cxt User {userId} Profile {displayName, fullName, sho badgeVerified <- verifyBadge_ (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) @@ -2719,19 +2719,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 @@ -2768,14 +2770,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 @@ -2795,15 +2797,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 f580298663..60ae8f999e 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -51,7 +51,9 @@ 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.M20260921_wallet_seeds +import Simplex.Chat.Store.Postgres.Migrations.M20260918_badge_issue_errors +import Simplex.Chat.Store.Postgres.Migrations.M20260923_preferences_json +import Simplex.Chat.Store.Postgres.Migrations.M20260924_wallet_seeds import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -103,7 +105,9 @@ schemaMigrations = ("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), - ("20260921_wallet_seeds", m20260921_wallet_seeds, Nothing) + ("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), + ("20260924_wallet_seeds", m20260924_wallet_seeds, Nothing) ] -- | 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/M20260921_wallet_seeds.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260924_wallet_seeds.hs similarity index 90% rename from src/Simplex/Chat/Store/Postgres/Migrations/M20260921_wallet_seeds.hs rename to src/Simplex/Chat/Store/Postgres/Migrations/M20260924_wallet_seeds.hs index 568bd6a21a..9640249a6e 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20260921_wallet_seeds.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260924_wallet_seeds.hs @@ -1,13 +1,13 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Store.Postgres.Migrations.M20260921_wallet_seeds where +module Simplex.Chat.Store.Postgres.Migrations.M20260924_wallet_seeds where import Data.Text (Text) import Text.RawString.QQ (r) -m20260921_wallet_seeds :: Text -m20260921_wallet_seeds = +m20260924_wallet_seeds :: Text +m20260924_wallet_seeds = [r| -- the columns are commented in the SQLite migration CREATE TABLE wallet_seeds ( diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index c37ae7ed7e..1dc62f53db 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 f6bea39306..9e6ff903a6 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -174,7 +174,9 @@ 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.M20260921_wallet_seeds +import Simplex.Chat.Store.SQLite.Migrations.M20260918_badge_issue_errors +import Simplex.Chat.Store.SQLite.Migrations.M20260923_preferences_json +import Simplex.Chat.Store.SQLite.Migrations.M20260924_wallet_seeds import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -349,7 +351,9 @@ schemaMigrations = ("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), - ("20260921_wallet_seeds", m20260921_wallet_seeds, Nothing) + ("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), + ("20260924_wallet_seeds", m20260924_wallet_seeds, Nothing) ] -- | 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/M20260921_wallet_seeds.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260924_wallet_seeds.hs similarity index 89% rename from src/Simplex/Chat/Store/SQLite/Migrations/M20260921_wallet_seeds.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20260924_wallet_seeds.hs index 014c01894b..7d31f1cf1c 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20260921_wallet_seeds.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260924_wallet_seeds.hs @@ -1,12 +1,12 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Store.SQLite.Migrations.M20260921_wallet_seeds where +module Simplex.Chat.Store.SQLite.Migrations.M20260924_wallet_seeds where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) -m20260921_wallet_seeds :: Query -m20260921_wallet_seeds = +m20260924_wallet_seeds :: Query +m20260924_wallet_seeds = [sql| CREATE TABLE wallet_seeds ( wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT, 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 d98ebb1534..febb17586b 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 8eeefa328f..c3d42de3d1 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_ (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 051848e40f..ae6b0aaae7 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 @@ -1865,7 +1865,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 <> ": " @@ -1874,8 +1874,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] @@ -1894,6 +1900,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 06c5ccfe86..bd8f62d3fd 100644 --- a/tests/BadgeTests.hs +++ b/tests/BadgeTests.hs @@ -27,14 +27,16 @@ import Simplex.Chat.Badges import Simplex.Chat.Badges.Code import Simplex.Chat.Badges.Ledger import Simplex.Chat.Badges.Service +import Simplex.Chat.Badges.Types (BadgeIssueFailure (..)) import Simplex.Chat (defaultChatConfig) import Simplex.Chat.Controller (ChatError (..), ChatErrorType (..), badgeRetryInterval, chatErrorAgent) -import Simplex.Chat.Library.Commands (badgeErrorRetry, badgeRetryAfter, badgeStalledInterval) +import Simplex.Chat.Library.Commands (badgeErrorRetry, badgeFailureTransient, badgeIssueFailure, badgeRetryAfter, badgeServiceErrorText, badgeStalledInterval) import Simplex.Messaging.Agent.Protocol (AgentErrorType (..), AgentServiceError (..), SMPAgentError (..)) import Simplex.Messaging.Agent.RetryInterval (RetryInterval (..), nextRetryDelay) import Simplex.Messaging.Crypto.BBS import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Protocol (BrokerErrorType (..), NetworkError (..)) +import Simplex.Messaging.Protocol (BrokerErrorType (..), ErrorType (AUTH), NetworkError (..)) +import Simplex.Messaging.Util (tshow) import Simplex.Messaging.Version.Internal (Version (..)) import Test.Hspec @@ -83,6 +85,11 @@ badgeTests = do it "backs off to the cap" testRetryBackoff it "floors the wait a service asks for, and honours anything above it" testServiceRetryFloor it "sends retryAfter with the transient service codes and no other" testServiceRetryAfter + describe "recording a failed renewal" $ do + it "records the agent error a request failed with, not the chat error around it" testIssueFailureClassification + it "waits for the credential to lapse on internal, as on a refusal the service marks transient" testServiceErrorTransience + it "stores every failure so that it reads back, whatever the service called its code" testIssueFailureEncoding + it "bounds and strips a code this version does not know" testServiceErrorCodeBounded describe "service protocol JSON" $ do it "redeemBadgeCode request matches the schema" testRedeemRequestJSON it "badgeCredential response matches the schema" testCredentialResponseJSON @@ -677,6 +684,66 @@ testServiceRetryAfter = do (\code -> badgeErrorRetryAfter code `shouldBe` Nothing) [BSEBadRequest, BSEUnsupportedVersion, BSEUnknownPurchaseKey, BSECodeInvalid, BSECodeUsed, BSECodeExpired, BSEUnknown "future_code"] +-- The app shows the recorded failure in a sentence, so an agent error is stored as the agent +-- error and not as the chat error wrapping it, whether or not it can clear on its own. +testIssueFailureClassification :: IO () +testIssueFailureClassification = do + let failureFor = badgeIssueFailure . chatErrorAgent + timeout = BROKER "localhost" TIMEOUT + auth = SMP "localhost" AUTH + failureFor (AGENT (A_SERVICE ASETimeout)) `shouldBe` BIFServiceTimeout + failureFor timeout `shouldBe` BIFNetwork {agentError = tshow timeout} + failureFor auth `shouldBe` BIFUnexpected {message = tshow auth} + badgeIssueFailure (ChatError (CECommandError "unexpected badge service response")) `shouldBe` BIFUnexpected {message = "unexpected badge service response"} + +-- A refusal the service marks transient is not worth a word before the credential lapses. Internal +-- comes without retryAfter so that a failing service is not pressed, not because the fault is +-- final, so it waits the same way; any other refusal is told at once. +testServiceErrorTransience :: IO () +testServiceErrorTransience = do + badgeFailureTransient BIFServiceError {code = BSERateLimited, retryable = True} `shouldBe` True + badgeFailureTransient BIFServiceError {code = BSEInternal, retryable = False} `shouldBe` True + badgeFailureTransient BIFServiceError {code = BSEUnknownPurchaseKey, retryable = False} `shouldBe` False + badgeFailureTransient BIFServiceError {code = BSEUnknown "future_code", retryable = False} `shouldBe` False + badgeFailureTransient BIFServiceError {code = BSEUnknown "future_code", retryable = True} `shouldBe` True + +-- The failure is stored as text and read back by getBadgePurchase, so every value this version +-- writes must read back as itself, and any other value must still read: a row it cannot parse +-- would otherwise fail every read of the purchase. +testIssueFailureEncoding :: IO () +testIssueFailureEncoding = do + mapM_ + (\f -> strDecode (strEncode f) `shouldBe` Right f) + [ BIFServiceError {code = BSEUnknownPurchaseKey, retryable = False}, + BIFServiceError {code = BSERateLimited, retryable = True}, + BIFServiceError {code = BSEUnknown "future_code", retryable = True}, + BIFServiceError {code = BSEUnknown "code with spaces", retryable = False}, + BIFServiceError {code = BSEUnknown "", retryable = False}, + BIFServiceTimeout, + BIFNetwork {agentError = "BROKER \"smp://x\" NETWORK"}, + BIFInvalidCredential, + BIFUnexpected {message = "unexpected badge service response"}, + BIFUnexpected {message = "several words and : punctuation"} + ] + -- the row the CLI prints, and what a reader of the database sees + strEncode BIFServiceError {code = BSECodeUsed, retryable = False} `shouldBe` "service_error final code_used" + strEncode BIFServiceError {code = BSERateLimited, retryable = True} `shouldBe` "service_error retry rate_limited" + strEncode BIFServiceTimeout `shouldBe` "service_timeout" + -- a row a later version wrote, or one edited by hand, still reads - as the text it holds + strDecode "future_failure with text" `shouldBe` Right BIFUnexpected {message = "future_failure with text"} + strDecode "service_timeout trailing" `shouldBe` Right BIFUnexpected {message = "service_timeout trailing"} + strDecode "service_error maybe code_used" `shouldBe` Right BIFUnexpected {message = "service_error maybe code_used"} + +-- The text of a code this version does not know is chosen by the service, and the app shows it in +-- a sentence of its own - so it is bounded and stripped of anything but a code before it is stored. +testServiceErrorCodeBounded :: IO () +testServiceErrorCodeBounded = do + badgeServiceErrorText BSECodeUsed `shouldBe` "code_used" + badgeServiceErrorText (BSEUnknown "future_code") `shouldBe` "future_code" + badgeServiceErrorText (BSEUnknown "two words") `shouldBe` "twowords" + badgeServiceErrorText (BSEUnknown (T.replicate 100 "a")) `shouldBe` T.replicate 32 "a" + badgeServiceErrorText (BSEUnknown "Visit evil.example.com!") `shouldBe` "isitevilexamplecom" + -- The client replicates entry_credit_type / entry_debit_type verbatim, so a stored tag that -- disagreed with the wire tag would put a different row on each side. testEntryTypeColumns :: IO () diff --git a/tests/Bots/BadgeServiceTests.hs b/tests/Bots/BadgeServiceTests.hs index 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/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 10cb6e45ff..6fb8ad44fe 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -67,6 +67,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 @@ -822,6 +823,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/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/src/_includes/blog_previews/20260919.html b/website/src/_includes/blog_previews/20260919.html index d6333c3c62..044c0d1d8b 100644 --- a/website/src/_includes/blog_previews/20260919.html +++ b/website/src/_includes/blog_previews/20260919.html @@ -2,4 +2,4 @@

Investors in our equity crowdfunding receive badges as perks.

-

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

+

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 177f722097..a4faa67dc8 100644 --- a/website/src/crowdfunding.html +++ b/website/src/crowdfunding.html @@ -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; @@ -194,6 +194,7 @@ templateEngineOverride: njk 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; @@ -219,61 +220,6 @@ templateEngineOverride: njk color: #dfeaff; } - .cf-hero-offer { - position: relative; - margin-top: calc(var(--sec-vhu) * 6.3); - width: calc(var(--sec-vwu) * 56); - } - .cf-hero-offer p { font-family: "Manrope", sans-serif; } - .cf-hero-offer .cf-offer-lead { - font-weight: 600; - font-size: calc(var(--sec-vwu) * 2.1); - line-height: 1.4; - color: #ffffff; - font-variant-numeric: tabular-nums; - } - .cf-hero-offer .cf-offer-lead b { - font-weight: 700; - background: linear-gradient(90deg, #019bfe 0%, #64fdff 58%, #c8feff 100%); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; - color: transparent; - } - .cf-hero-offer .cf-offer-lead span { white-space: nowrap; } - .cf-hero-offer .cf-offer-terms { - font-weight: 300; - font-size: calc(var(--sec-vwu) * 1.55); - line-height: 1.55; - color: #dfeaff; - margin-top: calc(var(--sec-vhu) * 0.4); - } - .cf-hero-offer .cf-offer-terms a { position: relative; color: inherit; text-decoration: none; } - .cf-hero-offer .cf-offer-terms abbr { text-decoration: underline dotted; text-underline-offset: 3px; cursor: pointer; } - .cf-hero-offer .cf-offer-terms a::after { - content: attr(data-tip); - position: absolute; - left: calc(100% + 12px); - top: 50%; - transform: translateY(-50%); - white-space: nowrap; - padding: 7px 12px; - border-radius: 8px; - border: 1px solid rgba(255, 255, 255, .18); - background: rgba(5, 9, 32, .96); - color: #dfeaff; - font-family: "Manrope", sans-serif; - font-weight: 400; - font-size: calc(var(--sec-vwu) * 1.1); - line-height: 1.4; - opacity: 0; - visibility: hidden; - transition: opacity .12s ease; - pointer-events: none; - } - .cf-hero-offer .cf-offer-terms a:hover::after, - .cf-hero-offer .cf-offer-terms a:focus-visible::after { opacity: 1; visibility: visible; } - /* ---- close: same left-aligned layout as the hero ---- */ .cf-close .area { position: relative; display: flex; align-items: flex-end; padding: 0 calc(var(--sec-vwu) * 7.5) calc(var(--sec-vhu) * 22) 0; } @@ -654,7 +600,7 @@ 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))) + 76.7vw); + --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 */ @@ -681,7 +627,8 @@ 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) * 8.54); } + .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; } @@ -712,19 +659,6 @@ templateEngineOverride: njk } .cf-close-offer p { font-size: calc(var(--sec-vwu) * 3.6); } .cf-stats { top: 0; gap: calc(var(--sec-vwu) * 8); margin-top: calc(var(--sec-vhu) * 3); } - .cf-hero .cf-hero-offer { - position: relative; - top: 0; - width: auto; - z-index: 4; - margin-top: calc(var(--sec-vhu) * 2); - padding: 10px var(--cf-text-pad) 12px; - margin-left: calc(-1 * var(--cf-gutter)); - margin-right: calc(-1 * var(--cf-gutter)); - background: linear-gradient(to bottom, rgba(2, 7, 29, 0) 0%, rgba(2, 7, 29, var(--cf-offer-veil, 0)) 22%, rgba(2, 7, 29, var(--cf-offer-veil, 0)) 78%, rgba(2, 7, 29, 0) 100%); - } - .cf-hero-offer .cf-offer-lead { font-size: calc(var(--sec-vwu) * 4.2); line-height: 1.3; white-space: nowrap; } - .cf-hero-offer .cf-offer-terms { font-size: calc(var(--sec-vwu) * 4); margin-top: calc(var(--sec-vhu) * 0.8); } .cf-stat img { width: calc(var(--sec-vwu) * 8); } .cf-stat b { font-size: calc(var(--sec-vwu) * 6.5); } .cf-stat span { font-size: calc(var(--sec-vwu) * 3.4); } @@ -824,9 +758,14 @@ templateEngineOverride: njk .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) and (orientation: portrait) { - .cf-hero h1 { font-size: calc(var(--sec-vwu) * 8.18); } + .cf-hero h1 { font-size: calc(var(--sec-vwu) * 8.6); } } /* narrow phones: keep the corner link clear of the logo */ @@ -1041,7 +980,7 @@ templateEngineOverride: njk .page .text-container p { font-weight: 200; font-size: calc(var(--sec-vwu) * 1.62); } .page .text-container p span { font-weight: 500; } .page .text-container a { font-weight: 200; font-size: calc(var(--sec-vwu) * 1.62); } - .cf-hero h1 { font-size: calc(var(--sec-vwu) * 4.4); } + .cf-hero 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); } @@ -1078,8 +1017,8 @@ 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.

-
@@ -1222,13 +1157,13 @@ 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.

-