Files
Morgan Pretty 7dfa2a77ae Merge remote-tracking branch 'upstream/dev' into feature/libsession-message-wrapping
# Conflicts:
#	Session.xcodeproj/project.pbxproj
#	Session/Conversations/ConversationVC+Interaction.swift
#	Session/Conversations/ConversationVC.swift
#	Session/Conversations/Settings/ThreadSettingsViewModel.swift
#	Session/Home/App Review/AppReviewPromptModel.swift
#	Session/Home/HomeViewModel.swift
#	Session/Media Viewing & Editing/MessageInfoScreen.swift
#	Session/Meta/AppDelegate.swift
#	Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift
#	Session/Settings/SettingsViewModel.swift
#	Session/Shared/BaseVC.swift
#	Session/Utilities/UIContextualAction+Utilities.swift
#	SessionMessagingKit/Database/Models/SessionThread.swift
#	SessionMessagingKit/SessionPro/SessionProPaymentScreenContent.swift
#	SessionMessagingKit/SessionPro/SessionProSettingsViewModel.swift
#	SessionMessagingKit/SessionPro/SessionProState.swift
#	SessionMessagingKit/Shared Models/SessionThreadViewModel.swift
#	SessionNotificationServiceExtension/NotificationServiceExtension.swift
#	SessionUIKit/Components/Input View/InputView.swift
#	SessionUIKit/Components/SwiftUI/ProCTAModal.swift
#	SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+CancelPlan.swift
#	SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+Models.swift
#	SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+Purchase.swift
#	SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+Renew.swift
#	SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+RequestRefund.swift
#	SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+UpdatePlan.swift
#	SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen.swift
#	SessionUIKit/Screens/Settings/SessionProSettings/SessionProPlanUpdatedScreen.swift
#	SessionUIKit/Style Guide/Constants+Apple.swift
#	SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift
#	SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift
2025-12-17 09:36:05 +11:00

583 lines
26 KiB
Swift

// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved.
import SwiftUI
import Lucide
import Combine
public struct ProCTAModal: View {
public enum Variant {
case generic(renew: Bool)
case longerMessages(renew: Bool)
case animatedProfileImage(isSessionProActivated: Bool, renew: Bool)
case morePinnedConvos(isGrandfathered: Bool, renew: Bool)
case groupLimit(isAdmin: Bool, isSessionProActivated: Bool, proBadgeImage: UIImage)
case expiring(timeLeft: String?)
}
@EnvironmentObject var host: HostWrapper
@State var proCTAImageHeight: CGFloat = 0
private let variant: ProCTAModal.Variant
private let dataManager: ImageDataManagerType
private let sessionProUIManager: SessionProUIManagerType
let dismissType: Modal.DismissType
let onConfirm: (() -> Void)?
let onCancel: (() -> Void)?
let afterClosed: (() -> Void)?
public init(
variant: ProCTAModal.Variant,
dataManager: ImageDataManagerType,
sessionProUIManager: SessionProUIManagerType,
dismissType: Modal.DismissType = .recursive,
onConfirm: (() -> Void)? = nil,
onCancel: (() -> Void)? = nil,
afterClosed: (() -> Void)? = nil
) {
self.variant = variant
self.dataManager = dataManager
self.sessionProUIManager = sessionProUIManager
self.dismissType = dismissType
self.onConfirm = onConfirm
self.onCancel = onCancel
self.afterClosed = afterClosed
}
public var body: some View {
Modal_SwiftUI(
host: host,
dismissType: dismissType,
afterClosed: afterClosed
) { close in
VStack(spacing: 0) {
// Background images
ZStack {
if let animatedAvatarImageURL = variant.animatedAvatarImageURL {
GeometryReader { geometry in
let size: CGFloat = geometry.size.width / 1522.0 * variant.animatedAvatarImageSize
let scale: CGFloat = geometry.size.width / 1522.0
SessionAsyncImage(
source: .url(animatedAvatarImageURL),
dataManager: dataManager,
shouldAnimateImage: true,
grayscale: variant.grayscale,
content: { image in
image
.resizable()
.aspectRatio(1, contentMode: .fit)
.frame(width: size, height: size)
},
placeholder: {
if let data = try? Data(contentsOf: animatedAvatarImageURL) {
Image(uiImage: UIImage(data: data) ?? UIImage())
.resizable()
.aspectRatio(1, contentMode: .fit)
.grayscale(variant.grayscale)
.frame(width: size, height: size)
} else {
EmptyView()
}
}
)
.padding(.leading, variant.animatedAvatarImagePadding.leading * scale)
.padding(.top, variant.animatedAvatarImagePadding.top * scale)
.onAppear {
proCTAImageHeight = geometry.size.width / 1522.0 * 1258.0
}
}
.frame(height: proCTAImageHeight)
}
Image(uiImage: UIImage(named: variant.backgroundImageName) ?? UIImage())
.resizable()
.aspectRatio((1522.0/1258.0), contentMode: .fit)
.frame(maxWidth: .infinity)
}
.backgroundColor(themeColor: variant.themeColor)
.overlay(alignment: .bottom, content: {
ThemeLinearGradient(
themeColors: [
.clear,
.alert_background
],
startPoint: .top,
endPoint: .bottom
)
.framing(
maxWidth: .infinity,
height: 90
)
})
.frame(
maxWidth: .infinity,
alignment: .bottom
)
// Content
VStack(spacing: Values.largeSpacing) {
// Title
if variant.isRenewing {
HStack(spacing: Values.smallSpacing) {
Text("renew".localized())
.font(.Headings.H4)
.foregroundColor(themeColor: .textPrimary)
SessionProBadge_SwiftUI(size: .large)
}
} else {
switch variant {
case .animatedProfileImage(let isSessionProActivated, _) where isSessionProActivated:
HStack(spacing: Values.smallSpacing) {
SessionProBadge_SwiftUI(size: .large)
Text("proActivated".localized())
.font(.Headings.H4)
.foregroundColor(themeColor: .textPrimary)
}
case .groupLimit(_, let isSessionProActivated, _) where isSessionProActivated:
HStack(spacing: Values.smallSpacing) {
SessionProBadge_SwiftUI(size: .large)
Text("proGroupActivated".localized())
.font(.Headings.H4)
.foregroundColor(themeColor: .textPrimary)
}
case .expiring(let timeLeft):
let isExpired: Bool = (timeLeft?.isEmpty != false)
HStack(spacing: Values.smallSpacing) {
SessionProBadge_SwiftUI(
size: .large,
themeBackgroundColor: variant.themeColor
)
Text(isExpired ? "proExpired".localized() : "proExpiringSoon".localized())
.font(.Headings.H4)
.foregroundColor(themeColor: isExpired ? .disabled : .textPrimary)
}
default:
HStack(spacing: Values.smallSpacing) {
Text("upgradeTo".localized())
.font(.Headings.H4)
.foregroundColor(themeColor: .textPrimary)
SessionProBadge_SwiftUI(size: .large)
}
}
}
// Description, Subtitle
VStack(spacing: 0) {
if case .animatedProfileImage(let isSessionProActivated, _) = variant, isSessionProActivated {
HStack(spacing: Values.verySmallSpacing) {
Text("proAlreadyPurchased".localized())
.font(.Body.largeRegular)
.foregroundColor(themeColor: .textSecondary)
SessionProBadge_SwiftUI(size: .medium)
}
}
if
case .groupLimit(_, let isSessionProActivated, let proBadgeImage) = variant,
isSessionProActivated
{
(Text(variant.subtitle(sessionProUIManager: sessionProUIManager).string) + Text(" \(Image(uiImage: proBadgeImage))"))
.font(.Body.largeRegular)
.foregroundColor(themeColor: .textSecondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
} else {
AttributedText(variant.subtitle(sessionProUIManager: sessionProUIManager))
.font(.Body.largeRegular)
.foregroundColor(themeColor: .textSecondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
}
// Benefits
if !variant.benefits.isEmpty {
VStack(alignment: .leading, spacing: Values.mediumSmallSpacing) {
ForEach(
0..<variant.benefits.count,
id: \.self
) { index in
HStack(spacing: Values.smallSpacing) {
if case .loadsMore = variant.benefits[index] {
CyclicGradientView {
AttributedText(Lucide.Icon.sparkles.attributedString(size: 17))
.font(.system(size: 17))
}
} else {
AttributedText(Lucide.Icon.circleCheck.attributedString(size: 17))
.font(.system(size: 17))
.foregroundColor(themeColor: .primary)
}
Text(variant.benefits[index].description)
.font(.Body.largeRegular)
.foregroundColor(themeColor: .textPrimary)
.frame(
maxWidth: .infinity,
alignment: .leading
)
}
}
}
}
// Buttons
if variant.onlyShowCloseButton {
GeometryReader { geometry in
HStack {
Button {
close(nil)
} label: {
Text(variant.confirmButtonTitle)
.font(.Body.baseRegular)
.foregroundColor(themeColor: .textPrimary)
}
.frame(
width: (geometry.size.width - Values.smallSpacing) / 2,
height: Values.largeButtonHeight
)
.backgroundColor(themeColor: .inputButton_background)
.cornerRadius(6)
.clipped()
.buttonStyle(PlainButtonStyle())
}
.frame(
width: geometry.size.width,
height: geometry.size.height,
alignment: .center
)
}
.frame(height: Values.largeButtonHeight)
} else {
HStack(spacing: Values.smallSpacing) {
// Upgrade Button
ShineButton {
close(onConfirm)
} label: {
Text(variant.confirmButtonTitle)
.font(.Body.baseRegular)
.foregroundColor(themeColor: .sessionButton_primaryFilledText)
.framing(
maxWidth: .infinity,
height: Values.largeButtonHeight
)
}
.frame(height: Values.largeButtonHeight)
.backgroundColor(themeColor: .sessionButton_primaryFilledBackground)
.cornerRadius(6)
.clipped()
.buttonStyle(PlainButtonStyle()) // prevents default blue highlight
// Cancel Button
Button {
onCancel?()
close(nil)
onCancel?()
} label: {
Text(variant.cancelButtonTitle)
.font(.Body.baseRegular)
.foregroundColor(themeColor: .textPrimary)
.framing(
maxWidth: .infinity,
height: Values.largeButtonHeight
)
}
.backgroundColor(themeColor: .inputButton_background)
.cornerRadius(6)
.clipped()
.buttonStyle(PlainButtonStyle())
}
}
}
.padding(Values.mediumSpacing)
}
}
}
}
// MARK: - ProCTAModal.Benefits
public extension ProCTAModal {
enum Benefits: Equatable {
case largerGroups
case longerMessages
case animatedProfileImage
case morePinnedConvos
case loadsMore
var description: String {
return switch self {
case .largerGroups: "proFeatureListLargerGroups".localized()
case .longerMessages: "proFeatureListLongerMessages".localized()
case .animatedProfileImage: "proFeatureListAnimatedDisplayPicture".localized()
case .morePinnedConvos: "proFeatureListPinnedConversations".localized()
case .loadsMore: "proFeatureListLoadsMore".localized()
}
}
}
}
// MARK: - Variant Content
public extension ProCTAModal.Variant {
var isRenewing: Bool {
switch self {
case .generic(let renew), .longerMessages(let renew), .animatedProfileImage(_, let renew), .morePinnedConvos(_, let renew):
return renew
case .groupLimit, .expiring: return false
}
}
// stringlint:ignore_contents
var backgroundImageName: String {
switch self {
case .generic, .expiring: return "GenericCTA.webp"
case .longerMessages: return "HigherCharLimitCTA.webp"
case .animatedProfileImage: return "AnimatedProfileCTA.webp"
case .morePinnedConvos: return "PinnedConversationsCTA.webp"
case .groupLimit(false, false, _): return "GroupNonAdminCTA.webp"
case .groupLimit: return "GroupAdminCTA.webp"
}
}
var themeColor: ThemeValue {
switch self {
case .expiring(let timeLeft): return (timeLeft?.isEmpty == false ? .primary : .disabled)
default: return .primary
}
}
var grayscale: Double {
switch self {
case .expiring(let timeLeft): return (timeLeft?.isEmpty == false ? 0.0 : 1.0)
default: return 0.0
}
}
// stringlint:ignore_contents
var animatedAvatarImageURL: URL? {
switch self {
case .generic, .animatedProfileImage, .expiring:
return Bundle.main.url(forResource: "AnimatedProfileCTAAnimationCropped", withExtension: "webp")
default: return nil
}
}
/// Note: This is a hack to manually position the animated avatar in the CTA background image to prevent heavy loading for the
/// animated webp. These coordinates are based on the full size image and get scaled during rendering based on the actual size
/// of the modal.
var animatedAvatarImagePadding: (leading: CGFloat, top: CGFloat) {
switch self {
case .generic, .expiring: return (1303, 743)
case .animatedProfileImage: return (680, 363)
default: return (0, 0)
}
}
var animatedAvatarImageSize: CGFloat {
switch self {
case .generic, .expiring: return 115
case .animatedProfileImage: return 200
default: return 0
}
}
func subtitle(sessionProUIManager: SessionProUIManagerType) -> ThemedAttributedString {
switch self {
case .generic(renew: true):
return "proRenewMaxPotential"
.put(key: "pro", value: Constants.pro)
.put(key: "app_name", value: Constants.app_name)
.localizedFormatted(baseFont: Fonts.Body.largeRegular)
case .generic(renew: false):
return "proUserProfileModalCallToAction"
.put(key: "app_pro", value: Constants.app_pro)
.put(key: "app_name", value: Constants.app_name)
.localizedFormatted(baseFont: Fonts.Body.largeRegular)
case .longerMessages(renew: true):
return "proRenewLongerMessages"
.put(key: "pro", value: Constants.pro)
.localizedFormatted(baseFont: Fonts.Body.largeRegular)
case .longerMessages(renew: false):
return "proCallToActionLongerMessages"
.put(key: "app_pro", value: Constants.app_pro)
.localizedFormatted(baseFont: Fonts.Body.largeRegular)
case .animatedProfileImage(isSessionProActivated: true, _):
return "proAnimatedDisplayPicture"
.localizedFormatted(baseFont: Fonts.Body.largeRegular)
case .animatedProfileImage(isSessionProActivated: false, renew: true):
return "proRenewAnimatedDisplayPicture"
.put(key: "pro", value: Constants.pro)
.localizedFormatted(baseFont: Fonts.Body.largeRegular)
case .animatedProfileImage(isSessionProActivated: false, renew: false):
return "proAnimatedDisplayPictureCallToActionDescription"
.put(key: "app_pro", value: Constants.app_pro)
.localizedFormatted(baseFont: Fonts.Body.largeRegular)
case .morePinnedConvos(isGrandfathered: true, renew: false):
return "proCallToActionPinnedConversations"
.put(key: "app_pro", value: Constants.app_pro)
.localizedFormatted(baseFont: Fonts.Body.largeRegular)
case .morePinnedConvos(isGrandfathered: false, renew: false):
return "proCallToActionPinnedConversationsMoreThan"
.put(key: "app_pro", value: Constants.app_pro)
.put(key: "limit", value: sessionProUIManager.pinnedConversationLimit)
.localizedFormatted(baseFont: Fonts.Body.largeRegular)
case .morePinnedConvos(isGrandfathered: true, renew: true):
return "proRenewPinMoreConversations"
.put(key: "pro", value: Constants.pro)
.localizedFormatted(baseFont: Fonts.Body.largeRegular)
case .morePinnedConvos(isGrandfathered: false, renew: true):
return "proRenewPinFiveConversations"
.put(key: "pro", value: Constants.pro)
.put(key: "limit", value: sessionProUIManager.pinnedConversationLimit)
.localizedFormatted(baseFont: Fonts.Body.largeRegular)
case .groupLimit(_, isSessionProActivated: true, _):
return "proGroupActivatedDescription"
.localizedFormatted(baseFont: Fonts.Body.largeRegular)
case .groupLimit(isAdmin: true, isSessionProActivated: false, _):
return "proUserProfileModalCallToAction"
.put(key: "app_pro", value: Constants.app_pro)
.put(key: "app_name", value: Constants.app_name)
.localizedFormatted(baseFont: Fonts.Body.largeRegular)
case .groupLimit(isAdmin: false, isSessionProActivated: false, _):
// TODO: [PRO] Localised
return ThemedAttributedString(
string: "Want to upgrade this group to Pro? Tell one of the group admins to upgrade to Pro"
)
case .expiring(let timeLeft) where timeLeft?.isEmpty == false:
return "proExpiringSoonDescription"
.put(key: "pro", value: Constants.pro)
.put(key: "time", value: timeLeft ?? "")
.put(key: "app_pro", value: Constants.app_pro)
.localizedFormatted(baseFont: Fonts.Body.largeRegular)
case .expiring:
return "proExpiredDescription"
.put(key: "pro", value: Constants.pro)
.put(key: "app_pro", value: Constants.app_pro)
.localizedFormatted(baseFont: Fonts.Body.largeRegular)
}
}
var benefits: [ProCTAModal.Benefits] {
switch self {
case .generic: return [ .longerMessages, .morePinnedConvos, .loadsMore ]
case .longerMessages: return [ .longerMessages, .morePinnedConvos, .loadsMore ]
case .animatedProfileImage: return [ .animatedProfileImage, .longerMessages, .loadsMore ]
case .morePinnedConvos: return [ .morePinnedConvos, .longerMessages, .loadsMore ]
case .groupLimit(isAdmin: true, isSessionProActivated: false, _):
return [ .largerGroups, .longerMessages, .loadsMore ]
case .groupLimit: return []
case .expiring: return [ .longerMessages, .morePinnedConvos, .animatedProfileImage ]
}
}
var confirmButtonTitle: String {
switch self {
case .expiring(let timeLeft) where timeLeft?.isEmpty == false: return "update".localized()
case .expiring: return "renew".localized()
default: return "theContinue".localized()
}
}
var cancelButtonTitle: String {
guard !self.onlyShowCloseButton else {
return "close".localized()
}
switch self {
case .expiring(let timeLeft) where timeLeft?.isEmpty == false: return "close".localized()
case .expiring: return "cancel".localized()
default: return "cancel".localized()
}
}
var onlyShowCloseButton: Bool {
switch self {
case .animatedProfileImage(let isSessionProActivated, _): return isSessionProActivated
case .groupLimit(let isAdmin, let isSessionProActivated, _): return (!isAdmin || isSessionProActivated)
default: return false
}
}
}
// MARK: - Previews
struct ProCTAModal_Previews: PreviewProvider {
static var previews: some View {
Group {
PreviewThemeWrapper(theme: .classicDark) {
ProCTAModal(
variant: .generic(renew: false),
dataManager: ImageDataManager(),
sessionProUIManager: NoopSessionProUIManager(),
dismissType: .single,
afterClosed: nil
)
.environmentObject(HostWrapper())
.previewDisplayName("Classic Dark")
}
PreviewThemeWrapper(theme: .classicLight) {
ProCTAModal(
variant: .generic(renew: false),
dataManager: ImageDataManager(),
sessionProUIManager: NoopSessionProUIManager(),
dismissType: .single,
afterClosed: nil
)
.environmentObject(HostWrapper())
.previewDisplayName("Classic Light")
}
PreviewThemeWrapper(theme: .oceanDark) {
ProCTAModal(
variant: .generic(renew: false),
dataManager: ImageDataManager(),
sessionProUIManager: NoopSessionProUIManager(),
dismissType: .single,
afterClosed: nil
)
.environmentObject(HostWrapper())
.previewDisplayName("Ocean Dark")
}
PreviewThemeWrapper(theme: .oceanLight) {
ProCTAModal(
variant: .generic(renew: false),
dataManager: ImageDataManager(),
sessionProUIManager: NoopSessionProUIManager(),
dismissType: .single,
afterClosed: nil
)
.environmentObject(HostWrapper())
.previewDisplayName("Ocean Light")
}
}
}
}