Files
Morgan Pretty 03df067dc8 Fixed some build errors, resolved some TODOs, fixed a couple of bugs
• Added logic to handle non-originating accounts
• Added explicit SessionPro errors
• Moved profile pro decisions into the SessionProManager to consolidate the logic
• Resolved a bunch of TODOs
• Fixed an issue where a invalid pro key would prevent messages from decoding
2025-12-17 13:10:34 +11:00

1306 lines
63 KiB
Swift

// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import Combine
import SwiftUI
import GRDB
import DifferenceKit
import SessionUIKit
import SessionNetworkingKit
import SessionUtilitiesKit
// MARK: - Log.Category
public extension Log.Category {
static let proSettingsViewModel: Log.Category = .create("ProSettingsViewModel", defaultLevel: .warn)
}
// MARK: - SessionProSettingsViewModel
public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType, NavigatableStateHolder, NavigatableStateHolder_SwiftUI {
public let dependencies: Dependencies
public let navigatableState: NavigatableState = NavigatableState()
public var navigatableStateSwiftUI: NavigatableState_SwiftUI = NavigatableState_SwiftUI()
public let title: String = ""
public let state: SessionListScreenContent.ListItemDataState<Section, ListItem> = SessionListScreenContent.ListItemDataState()
/// This value is the current state of the view
@MainActor @Published private(set) var internalState: State
private var observationTask: Task<Void, Never>?
// MARK: - Initialization
@MainActor public init(
isInBottomSheet: Bool = false,
using dependencies: Dependencies
) {
self.dependencies = dependencies
self.internalState = State.initialState(
isInBottomSheet: isInBottomSheet,
using: dependencies
)
self.observationTask = ObservationBuilder
.initialValue(self.internalState)
.debounce(for: .never)
.using(dependencies: dependencies)
.query(SessionProSettingsViewModel.queryState)
.assign { [weak self] updatedState in
guard let self = self else { return }
self.state.updateTableData(updatedState.sections(viewModel: self, previousState: self.internalState))
self.internalState = updatedState
}
}
// MARK: - Config
public enum Section: SessionListScreenContent.ListSection {
case logoWithPro
case proStats
case proSettings
case proFeatures
case proManagement
case help
public var title: String? {
switch self {
case .proStats: return "proStats".put(key: "pro", value: Constants.pro).localized()
case .proSettings: return "proSettings".put(key: "pro", value: Constants.pro).localized()
case .proFeatures: return "proBetaFeatures".put(key: "pro", value: Constants.pro).localized()
case .proManagement: return "managePro".put(key: "pro", value: Constants.pro).localized()
case .help: return "sessionHelp".localized()
default: return nil
}
}
public var style: SessionListScreenContent.ListSectionStyle {
switch self {
case .proStats:
return .titleWithTooltips(
info: .init(
id: "SessionListScreen.SectionHeader.ToolTip", // stringlint:ignore
content: "proStatsTooltip"
.put(key: "pro", value: Constants.pro)
.localizedFormatted(),
tintColor: .textSecondary,
position: .topRight
)
)
case .proSettings, .proFeatures, .proManagement, .help: return .titleNoBackgroundContent
default: return .none
}
}
public var divider: Bool {
switch self {
case .proSettings, .proManagement, .help: return true
default: return false
}
}
public var footer: String? { return nil }
}
public enum ListItem: Differentiable {
case logoWithPro
case continueButton
case proStats
case updatePlan
case refundRequested
case renewPlan
case recoverPlan
case proBadge
case longerMessages
case unlimitedPins
case animatedDisplayPictures
case badges
case plusLoadsMore
case cancelPlan
case requestRefund
case faq
case support
}
// MARK: - Content
public struct State: ObservableKeyProvider {
let isInBottomSheet: Bool
let profile: Profile
let proState: SessionPro.State
let numberOfGroupsUpgraded: Int
let numberOfPinnedConversations: Int
let numberOfProBadgesSent: Int
let numberOfLongerMessagesSent: Int
@MainActor public func sections(viewModel: SessionProSettingsViewModel, previousState: State) -> [SectionModel] {
SessionProSettingsViewModel.sections(
state: self,
previousState: previousState,
viewModel: viewModel
)
}
/// We need `dependencies` to generate the keys in this case so set the variable `observedKeys` to an empty array to
/// suppress the conformance warning
public let observedKeys: Set<ObservableKey> = []
public func observedKeys(using dependencies: Dependencies) -> Set<ObservableKey> {
let sessionProManager: SessionProManagerType = dependencies[singleton: .sessionProManager]
return [
.anyConversationPinnedPriorityChanged,
.profile(profile.id),
.currentUserProState(sessionProManager),
.setting(.groupsUpgradedCounter),
.setting(.proBadgesSentCounter),
.setting(.longerMessagesSentCounter)
]
}
static func initialState(isInBottomSheet: Bool, using dependencies: Dependencies) -> State {
return State(
isInBottomSheet: isInBottomSheet,
profile: dependencies.mutate(cache: .libSession) { $0.profile },
proState: dependencies[singleton: .sessionProManager].currentUserCurrentProState,
numberOfGroupsUpgraded: 0,
numberOfPinnedConversations: 0,
numberOfProBadgesSent: 0,
numberOfLongerMessagesSent: 0
)
}
}
@Sendable private static func queryState(
previousState: State,
events: [ObservedEvent],
isInitialQuery: Bool,
using dependencies: Dependencies
) async -> State {
var profile: Profile = previousState.profile
var proState: SessionPro.State = previousState.proState
var numberOfGroupsUpgraded: Int = previousState.numberOfGroupsUpgraded
var numberOfPinnedConversations: Int = previousState.numberOfPinnedConversations
var numberOfProBadgesSent: Int = previousState.numberOfProBadgesSent
var numberOfLongerMessagesSent: Int = previousState.numberOfLongerMessagesSent
/// Store a local copy of the events so we can manipulate it based on the state changes
let eventsToProcess: [ObservedEvent] = events
/// If we have no previous state then we need to fetch the initial state
if isInitialQuery {
do {
proState = await dependencies[singleton: .sessionProManager].state
.first(defaultValue: .invalid)
try await dependencies[singleton: .storage].readAsync { db in
numberOfGroupsUpgraded = (db[.groupsUpgradedCounter] ?? 0)
numberOfPinnedConversations = (
try? SessionThread
.filter(SessionThread.Columns.pinnedPriority > 0)
.fetchCount(db)
).defaulting(to: 0)
numberOfProBadgesSent = (db[.proBadgesSentCounter] ?? 0)
numberOfLongerMessagesSent = (db[.longerMessagesSentCounter] ?? 0)
}
}
catch {
Log.critical(.proSettingsViewModel, "Failed to fetch initial state, due to error: \(error)")
}
}
/// Split the events between those that need database access and those that don't
let changes: EventChangeset = eventsToProcess.split(by: { $0.handlingStrategy })
/// Process any general event changes
if let value = changes.latestGeneric(.currentUserProState, as: SessionPro.State.self) {
proState = value
}
changes.forEach(.profile, as: ProfileEvent.self) { event in
switch event.change {
case .name(let name): profile = profile.with(name: name)
case .nickname(let nickname): profile = profile.with(nickname: .set(to: nickname))
case .displayPictureUrl(let url): profile = profile.with(displayPictureUrl: .set(to: url))
case .proStatus(_, let features, let expiryUnixTimestampMs, let genIndexHashHex):
profile = profile.with(
proFeatures: .set(to: features),
proExpiryUnixTimestampMs: .set(to: expiryUnixTimestampMs),
proGenIndexHashHex: .set(to: genIndexHashHex)
)
}
}
changes.forEachEvent(.setting, as: Int.self) { event, value in
switch event.key {
case .setting(.groupsUpgradedCounter): numberOfGroupsUpgraded = value
case .setting(.proBadgesSentCounter): numberOfProBadgesSent = value
case .setting(.longerMessagesSentCounter): numberOfLongerMessagesSent = value
default: break
}
}
/// Then handle database events
if !dependencies[singleton: .storage].isSuspended, !changes.databaseEvents.isEmpty {
do {
try await dependencies[singleton: .storage].readAsync { db in
if changes.latest(.anyConversationPinnedPriorityChanged) != nil {
numberOfPinnedConversations = (
try? SessionThread
.filter(SessionThread.Columns.pinnedPriority > 0)
.fetchCount(db)
).defaulting(to: 0)
}
}
} catch {
let eventList: String = changes.databaseEvents.map { $0.key.rawValue }.joined(separator: ", ")
Log.critical(.proSettingsViewModel, "Failed to fetch state for events [\(eventList)], due to error: \(error)")
}
}
else if !changes.databaseEvents.isEmpty {
Log.warn(.proSettingsViewModel, "Ignored \(changes.databaseEvents.count) database event(s) sent while storage was suspended.")
}
return State(
isInBottomSheet: previousState.isInBottomSheet,
profile: profile,
proState: proState,
numberOfGroupsUpgraded: numberOfGroupsUpgraded,
numberOfPinnedConversations: numberOfPinnedConversations,
numberOfProBadgesSent: numberOfProBadgesSent,
numberOfLongerMessagesSent: numberOfLongerMessagesSent
)
}
private static func sections(
state: State,
previousState: State,
viewModel: SessionProSettingsViewModel
) -> [SectionModel] {
var logo: SectionModel = SectionModel(
model: .logoWithPro,
elements: [
SessionListScreenContent.ListItemInfo(
id: .logoWithPro,
variant: .logoWithPro(
info: ListItemLogoWithPro.Info(
themeStyle: {
switch (state.proState.status, state.isInBottomSheet) {
case (.expired, false): .disabled
default: .normal
}
}(),
glowingBackgroundStyle: .base,
state: {
switch (state.proState.loadingState, state.proState.status) {
case (.success, _): return .success
case (.loading, .expired), (.loading, .neverBeenPro):
return .loading(
message: "checkingProStatus"
.put(key: "pro", value: Constants.pro)
.localized()
)
case (.loading, .active):
return .loading(
message: "proStatusLoading"
.put(key: "pro", value: Constants.pro)
.localized()
)
case (.error, .expired), (.error, .neverBeenPro):
return .error(
message: "errorCheckingProStatus"
.put(key: "pro", value: Constants.pro)
.localized()
)
case (.error, .active):
return .error(
message: "proErrorRefreshingStatus"
.put(key: "pro", value: Constants.pro)
.localized()
)
}
}(),
description: {
switch (state.proState.status, state.isInBottomSheet) {
case (.expired, true):
return "proAccessRenewStart"
.put(key: "pro", value: Constants.pro)
.put(key: "app_pro", value: Constants.app_pro)
.localizedFormatted()
case (.neverBeenPro, _):
return "proFullestPotential"
.put(key: "app_name", value: Constants.app_name)
.put(key: "app_pro", value: Constants.app_pro)
.localizedFormatted()
default: return nil
}
}()
)
),
onTap: { [weak viewModel] in
guard state.proState.status != .neverBeenPro else { return }
switch state.proState.loadingState {
case .success: break
case .loading:
viewModel?.showLoadingModal(
from: .logoWithPro,
title: {
switch state.proState.status {
case .active:
"proStatusLoading"
.put(key: "pro", value: Constants.pro)
.localized()
case .expired, .neverBeenPro:
"checkingProStatus"
.put(key: "pro", value: Constants.pro)
.localized()
}
}(),
description: {
switch state.proState.status {
case .active:
"proStatusLoadingDescription"
.put(key: "pro", value: Constants.pro)
.localized()
case .expired:
"checkingProStatusDescription"
.put(key: "pro", value: Constants.pro)
.localized()
case .neverBeenPro:
"checkingProStatusContinue"
.put(key: "pro", value: Constants.pro)
.localized()
}
}()
)
case .error:
viewModel?.showErrorModal(
from: .logoWithPro,
title: "proStatusError"
.put(key: "pro", value: Constants.pro)
.localized(),
description: {
switch state.proState.status {
case .neverBeenPro:
"proStatusNetworkErrorContinue"
.put(key: "pro", value: Constants.pro)
.localizedFormatted()
default:
"proStatusRefreshNetworkError"
.put(key: "pro", value: Constants.pro)
.localizedFormatted()
}
}()
)
}
}
)
]
)
switch (state.proState.status, state.isInBottomSheet) {
case (.active, _), (.expired, _), (.neverBeenPro, false): break
case (.neverBeenPro, true):
logo.elements.append(
SessionListScreenContent.ListItemInfo(
id: .continueButton,
variant: .button(
title: "theContinue".localized(),
enabled: (state.proState.loadingState == .success)
),
onTap: { [weak viewModel] in
switch state.proState.loadingState {
case .success: viewModel?.updateProPlan(state: state)
case .loading:
viewModel?.showLoadingModal(
from: .logoWithPro,
title: "checkingProStatus"
.put(key: "pro", value: Constants.pro)
.localized(),
description: "checkingProStatusContinue"
.put(key: "pro", value: Constants.pro)
.localized()
)
case .error:
viewModel?.showErrorModal(
from: .logoWithPro,
title: "proStatusError"
.put(key: "pro", value: Constants.pro)
.localized(),
description: "proStatusRefreshNetworkError"
.put(key: "pro", value: Constants.pro)
.localizedFormatted()
)
}
}
)
)
}
let proFeatures: SectionModel = SectionModel(
model: .proFeatures,
elements: getProFeaturesElements(state: state, viewModel: viewModel)
)
// We can return the logo and proFeatures here since they are the only 2 sections that
// the bottom sheet needs
guard !state.isInBottomSheet else {
return [ logo, proFeatures ]
}
let proStats: SectionModel = SectionModel(
model: .proStats,
elements: getProStatsElements(state: state, viewModel: viewModel)
)
let proSettings: SectionModel = SectionModel(
model: .proSettings,
elements: getProSettingsElements(state: state, previousState: previousState, viewModel: viewModel)
)
let proManagement: SectionModel = SectionModel(
model: .proManagement,
elements: getProManagementElements(state: state, viewModel: viewModel)
)
let help: SectionModel = SectionModel(
model: .help,
elements: [
SessionListScreenContent.ListItemInfo(
id: .faq,
variant: .cell(
info: .init(
title: .init(
"proFaq"
.put(key: "pro", value: Constants.pro)
.localized(),
font: .Headings.H8
),
description: .init(
"proFaqDescription"
.put(key: "app_pro", value: Constants.app_pro)
.localized(),
font: .Body.smallRegular
),
trailingAccessory: .icon(
.squareArrowUpRight,
size: .large,
customTint: {
switch state.proState.status {
case .expired: return .textPrimary
default: return .sessionButton_text
}
}()
)
)
),
onTap: { [weak viewModel] in viewModel?.openUrl(Constants.urls.proFaq) }
),
SessionListScreenContent.ListItemInfo(
id: .support,
variant: .cell(
info: ListItemCell.Info(
title: SessionListScreenContent.TextInfo(
"helpSupport".localized(),
font: .Headings.H8
),
description: SessionListScreenContent.TextInfo(
"proSupportDescription"
.put(key: "pro", value: Constants.pro)
.localized(),
font: .Body.smallRegular
),
trailingAccessory: .icon(
.squareArrowUpRight,
size: .large,
customTint: {
switch state.proState.status {
case .expired: return .textPrimary
default: return .sessionButton_text
}
}()
)
)
),
onTap: { [weak viewModel] in viewModel?.openUrl(Constants.urls.support) }
)
]
)
return switch (state.proState.status, state.proState.refundingStatus) {
case (.neverBeenPro, _): [ logo, proFeatures, proManagement, help ]
case (.active, .notRefunding): [ logo, proStats, proSettings, proFeatures, proManagement, help ]
case (.expired, _): [ logo, proManagement, proFeatures, help ]
case (.active, .refunding): [ logo, proStats, proSettings, proFeatures, help ]
}
}
// MARK: - Pro Stats Elements
private static func getProStatsElements(
state: State,
viewModel: SessionProSettingsViewModel
) -> [SessionListScreenContent.ListItemInfo<ListItem>] {
return [
SessionListScreenContent.ListItemInfo(
id: .proStats,
variant: .dataMatrix(
info: [
[
ListItemDataMatrix.Info(
leadingAccessory: .icon(
.messageSquare,
size: .large,
customTint: .primary
),
title: SessionListScreenContent.TextInfo(
"proLongerMessagesSent"
.putNumber(state.numberOfLongerMessagesSent)
.put(key: "total", value: (state.proState.loadingState == .loading ? "" : state.numberOfLongerMessagesSent))
.localized(),
font: .Headings.H9
),
isLoading: (state.proState.loadingState == .loading)
),
ListItemDataMatrix.Info(
leadingAccessory: .icon(
.pin,
size: .large,
customTint: .primary
),
title: SessionListScreenContent.TextInfo(
"proPinnedConversations"
.putNumber(state.numberOfPinnedConversations)
.put(key: "total", value: (state.proState.loadingState == .loading ? "" : state.numberOfPinnedConversations))
.localized(),
font: .Headings.H9
),
isLoading: (state.proState.loadingState == .loading)
)
],
[
ListItemDataMatrix.Info(
leadingAccessory: .icon(
.rectangleEllipsis,
size: .large,
customTint: .primary
),
title: SessionListScreenContent.TextInfo(
"proBadgesSent"
.putNumber(state.numberOfProBadgesSent)
.put(key: "total", value: (state.proState.loadingState == .loading ? "" : state.numberOfProBadgesSent))
.put(key: "pro", value: Constants.pro)
.localized(),
font: .Headings.H9
),
isLoading: (state.proState.loadingState == .loading)
),
ListItemDataMatrix.Info(
leadingAccessory: .icon(
UIImage(named: "ic_user_group"),
size: .large,
customTint: .disabled
),
title: SessionListScreenContent.TextInfo(
"proGroupsUpgraded"
.putNumber(state.numberOfGroupsUpgraded)
.put(key: "total", value: (state.proState.loadingState == .loading ? "" : state.numberOfGroupsUpgraded))
.localized(),
font: .Headings.H9,
color: (state.proState.loadingState == .loading ? .textPrimary : .disabled)
),
tooltipInfo: SessionListScreenContent.TooltipInfo(
id: "SessionListScreen.DataMatrix.UpgradedGroups.ToolTip", // stringlint:ignore
content: "proLargerGroupsTooltip"
.localizedFormatted(baseFont: .systemFont(ofSize: Values.smallFontSize)),
tintColor: .disabled,
position: .topLeft
),
isLoading: (state.proState.loadingState == .loading)
)
]
]
),
onTap: { [weak viewModel] in
guard state.proState.loadingState == .loading else { return }
viewModel?.showLoadingModal(
from: .proStats,
title: "proStatsLoading"
.put(key: "pro", value: Constants.pro)
.localized(),
description: "proStatsLoadingDescription"
.put(key: "pro", value: Constants.pro)
.localized()
)
}
)
]
}
// MARK: - Pro Features Elements
private static func getProFeaturesElements(
state: State,
viewModel: SessionProSettingsViewModel
) -> [SessionListScreenContent.ListItemInfo<ListItem>] {
let proFeaturesIds: [ListItem] = [ .longerMessages, .unlimitedPins, .animatedDisplayPictures, .badges ]
let proState: ProFeaturesInfo.ProState = {
guard !state.isInBottomSheet else { return .neverBeenPro }
switch state.proState.status {
case .neverBeenPro: return .neverBeenPro
case .expired: return .expired
default: return .active
}
}()
let proFeatureInfos: [ProFeaturesInfo] = ProFeaturesInfo.allCases(proState: proState)
let plusMoreFeatureInfo: ProFeaturesInfo = ProFeaturesInfo.plusMoreFeatureInfo(proState: proState)
var result = zip(proFeaturesIds, proFeatureInfos).map { id, info in
SessionListScreenContent.ListItemInfo(
id: id,
variant: .cell(
info: ListItemCell.Info(
leadingAccessory: .icon(
info.icon,
iconSize: .medium,
customTint: .black,
gradientBackgroundColors: info.backgroundColors,
backgroundSize: .veryLarge,
backgroundCornerRadius: 8
),
title: SessionListScreenContent.TextInfo(
info.title,
font: .Headings.H9,
accessory: info.accessory
),
description: SessionListScreenContent.TextInfo(
font: .Body.smallRegular,
attributedString: info.description,
color: .textSecondary
)
)
)
)
}
result.append(
SessionListScreenContent.ListItemInfo(
id: .plusLoadsMore,
variant: .cell(
info: ListItemCell.Info(
leadingAccessory: .icon(
plusMoreFeatureInfo.icon,
iconSize: .medium,
customTint: .black,
gradientBackgroundColors: plusMoreFeatureInfo.backgroundColors,
backgroundSize: .veryLarge,
backgroundCornerRadius: 8
),
title: SessionListScreenContent.TextInfo(
plusMoreFeatureInfo.title,
font: .Headings.H9
),
description: SessionListScreenContent.TextInfo(
font: .Body.smallRegular,
attributedString: plusMoreFeatureInfo.description,
color: .textSecondary
)
)
),
onTap: { [weak viewModel] in
viewModel?.openUrl(Constants.urls.proRoadmap)
}
)
)
return result
}
// MARK: - Pro Settings Elements
private static func getProSettingsElements(
state: State,
previousState: State,
viewModel: SessionProSettingsViewModel
) -> [SessionListScreenContent.ListItemInfo<ListItem>] {
let initialProSettingsElements: [SessionListScreenContent.ListItemInfo<ListItem>]
switch (state.proState.status, state.proState.refundingStatus) {
case (.neverBeenPro, _), (.expired, _): initialProSettingsElements = []
case (.active, .notRefunding):
initialProSettingsElements = [
SessionListScreenContent.ListItemInfo(
id: .updatePlan,
variant: .cell(
info: ListItemCell.Info(
title: SessionListScreenContent.TextInfo(
"updateAccess"
.put(key: "pro", value: Constants.pro)
.localized(),
font: .Headings.H8
),
description: {
switch state.proState.loadingState {
case .loading:
return SessionListScreenContent.TextInfo(
font: .Body.smallRegular,
attributedString: "proAccessLoadingEllipsis"
.put(key: "pro", value: Constants.pro)
.localizedFormatted(Fonts.Body.smallRegular)
)
case .error:
return SessionListScreenContent.TextInfo(
font: .Body.smallRegular,
attributedString: "errorLoadingProAccess"
.put(key: "pro", value: Constants.pro)
.localizedFormatted(Fonts.Body.smallRegular),
color: .warning
)
case .success:
let expirationDate: Date = Date(
timeIntervalSince1970: floor(Double(state.proState.accessExpiryTimestampMs ?? 0) / 1000)
)
let expirationString: String = expirationDate
.timeIntervalSince(viewModel.dependencies.dateNow)
.ceilingFormatted(
format: .long,
allowedUnits: [.day, .hour, .minute]
)
return SessionListScreenContent.TextInfo(
font: .Body.smallRegular,
attributedString: (state.proState.autoRenewing ?
"proAutoRenewTime"
.put(key: "pro", value: Constants.pro)
.put(key: "time", value: expirationString)
.localizedFormatted(Fonts.Body.smallRegular) :
"proExpiringTime"
.put(key: "pro", value: Constants.pro)
.put(key: "time", value: expirationString)
.localizedFormatted(Fonts.Body.smallRegular)
)
)
}
}(),
trailingAccessory: (state.proState.loadingState == .loading ?
.loadingIndicator(size: .large) :
.icon(.chevronRight, size: .large)
)
)
),
onTap: { [weak viewModel] in
switch state.proState.loadingState {
case .success: viewModel?.updateProPlan(state: state)
case .loading:
viewModel?.showLoadingModal(
from: .updatePlan,
title: "proAccessLoading"
.put(key: "pro", value: Constants.pro)
.localized(),
description: "proAccessLoadingDescription"
.put(key: "pro", value: Constants.pro)
.localized()
)
case .error:
viewModel?.showErrorModal(
from: .updatePlan,
title: "proAccessError"
.put(key: "pro", value: Constants.pro)
.localized(),
description: "proAccessNetworkLoadError"
.put(key: "pro", value: Constants.pro)
.put(key: "app_name", value: Constants.app_name)
.localizedFormatted(baseFont: .systemFont(ofSize: Values.smallFontSize))
)
}
}
)
]
case (.active, .refunding):
initialProSettingsElements = [
SessionListScreenContent.ListItemInfo(
id: .refundRequested,
variant: .cell(
info: ListItemCell.Info(
title: SessionListScreenContent.TextInfo(
"proRequestedRefund".localized(),
font: .Headings.H8
),
description: SessionListScreenContent.TextInfo(
font: .Body.smallRegular,
attributedString: "processingRefundRequest"
.put(key: "platform", value: state.proState.originatingPlatform.platform)
.localizedFormatted(Fonts.Body.smallRegular)
),
trailingAccessory: .icon(.circleAlert, size: .large)
)
),
onTap: { [weak viewModel] in
switch state.proState.loadingState {
case .success: viewModel?.updateProPlan(state: state)
case .loading:
viewModel?.showLoadingModal(
from: .updatePlan,
title: "proAccessLoading"
.put(key: "pro", value: Constants.pro)
.localized(),
description: "proAccessLoadingDescription"
.put(key: "pro", value: Constants.pro)
.localized()
)
case .error:
viewModel?.showErrorModal(
from: .updatePlan,
title: "proAccessError"
.put(key: "pro", value: Constants.pro)
.localized(),
description: "proAccessNetworkLoadError"
.put(key: "pro", value: Constants.pro)
.put(key: "app_name", value: Constants.app_name)
.localizedFormatted(baseFont: .systemFont(ofSize: Values.smallFontSize))
)
}
}
)
]
}
return initialProSettingsElements + [
SessionListScreenContent.ListItemInfo(
id: .proBadge,
variant: .cell(
info: ListItemCell.Info(
title: SessionListScreenContent.TextInfo(
"proBadge"
.put(key: "pro", value: Constants.pro)
.localized(),
font: .Headings.H8
),
description: SessionListScreenContent.TextInfo(
"proBadgeVisible"
.put(key: "app_pro", value: Constants.app_pro)
.localized(),
font: .Body.smallRegular
),
trailingAccessory: .toggle(
state.profile.proFeatures.contains(.proBadge),
oldValue: previousState.profile.proFeatures.contains(.proBadge)
)
)
),
onTap: { [dependencies = viewModel.dependencies] in
Task.detached(priority: .userInitiated) {
try? await Profile.updateLocal(
proFeatures: (state.profile.proFeatures.contains(.proBadge) ?
state.profile.proFeatures.removing(.proBadge) :
state.profile.proFeatures.inserting(.proBadge)
),
using: dependencies
)
}
}
)
]
}
// MARK: - Pro Management Elements
private static func getProManagementElements(
state: State,
viewModel: SessionProSettingsViewModel
) -> [SessionListScreenContent.ListItemInfo<ListItem>] {
switch (state.proState.status, state.proState.refundingStatus) {
case (.active, .refunding): return []
case (.neverBeenPro, _):
return [
SessionListScreenContent.ListItemInfo(
id: .recoverPlan,
variant: .cell(
info: ListItemCell.Info(
title: SessionListScreenContent.TextInfo(
"proAccessRecover"
.put(key: "pro", value: Constants.pro)
.localized(),
font: .Headings.H8,
color: .textPrimary
),
trailingAccessory: .icon(
.refreshCcw,
size: .large,
customTint: .textPrimary
)
)
),
onTap: { [weak viewModel] in viewModel?.recoverProPlan() }
)
]
case (.active, .notRefunding):
var renewingItems: [SessionListScreenContent.ListItemInfo<ListItem>] = []
if state.proState.autoRenewing {
renewingItems.append(
SessionListScreenContent.ListItemInfo(
id: .cancelPlan,
variant: .cell(
info: ListItemCell.Info(
title: SessionListScreenContent.TextInfo(
"cancelAccess"
.put(key: "pro", value: Constants.pro)
.localized(),
font: .Headings.H8,
color: .danger
),
trailingAccessory: .icon(.circleX, size: .large, customTint: .danger)
)
),
onTap: { [weak viewModel] in viewModel?.cancelPlan(state: state) }
)
)
}
return renewingItems + [
SessionListScreenContent.ListItemInfo(
id: .requestRefund,
variant: .cell(
info: ListItemCell.Info(
title: SessionListScreenContent.TextInfo(
"requestRefund".localized(),
font: .Headings.H8,
color: .danger
),
trailingAccessory: .icon(.circleAlert, size: .large, customTint: .danger)
)
),
onTap: { [weak viewModel] in viewModel?.requestRefund(state: state) }
)
]
case (.expired, _):
return [
SessionListScreenContent.ListItemInfo(
id: .renewPlan,
variant: .cell(
info: ListItemCell.Info(
title: SessionListScreenContent.TextInfo(
"proAccessRenew"
.put(key: "pro", value: Constants.pro)
.localized(),
font: .Headings.H8,
color: state.proState.loadingState == .success ? .primary : .textPrimary
),
description: {
switch state.proState.loadingState {
case .success: return nil
case .error:
return SessionListScreenContent.TextInfo(
font: .Body.smallRegular,
attributedString: "errorCheckingProStatus"
.put(key: "pro", value: Constants.pro)
.localizedFormatted(Fonts.Body.smallRegular),
color: .warning
)
case .loading:
return SessionListScreenContent.TextInfo(
font: .Body.smallRegular,
attributedString: "checkingProStatusEllipsis"
.put(key: "pro", value: Constants.pro)
.localizedFormatted(Fonts.Body.smallRegular),
color: .textPrimary
)
}
}(),
trailingAccessory: (
state.proState.loadingState == .loading ?
.loadingIndicator(size: .large) :
.icon(
.circlePlus,
size: .large,
customTint: state.proState.loadingState == .success ? .primary : .textPrimary
)
)
)
),
onTap: { [weak viewModel] in
switch state.proState.loadingState {
case .success: viewModel?.updateProPlan(state: state)
case .loading:
viewModel?.showLoadingModal(
from: .renewPlan,
title: "checkingProStatus"
.put(key: "pro", value: Constants.pro)
.localized(),
description: "checkingProStatusRenew"
.put(key: "pro", value: Constants.pro)
.localized()
)
case .error:
viewModel?.showErrorModal(
from: .updatePlan,
title: "proStatusError"
.put(key: "pro", value: Constants.pro)
.localized(),
description: "proStatusRenewError"
.put(key: "pro", value: Constants.pro)
.put(key: "app_name", value: Constants.app_name)
.localizedFormatted(baseFont: .systemFont(ofSize: Values.smallFontSize))
)
}
}
),
SessionListScreenContent.ListItemInfo(
id: .recoverPlan,
variant: .cell(
info: ListItemCell.Info(
title: SessionListScreenContent.TextInfo(
"proAccessRecover"
.put(key: "pro", value: Constants.pro)
.localized(),
font: .Headings.H8,
color: .textPrimary
),
trailingAccessory: .icon(
.refreshCcw,
size: .large,
customTint: .textPrimary
)
)
),
onTap: { [weak viewModel] in viewModel?.recoverProPlan() }
)
]
}
}
}
// MARK: - Interactions
extension SessionProSettingsViewModel {
@MainActor func openUrl(_ urlString: String) {
guard let url: URL = URL(string: urlString) else { return }
let modal: ConfirmationModal = ConfirmationModal(
info: ConfirmationModal.Info(
title: "urlOpen".localized(),
body: .attributedText(
"urlOpenDescription"
.put(key: "url", value: url.absoluteString)
.localizedFormatted(baseFont: .systemFont(ofSize: Values.smallFontSize))
),
confirmTitle: "open".localized(),
confirmStyle: .danger,
cancelTitle: "urlCopy".localized(),
cancelStyle: .alert_text,
hasCloseButton: true,
onConfirm: { [dependencies] modal in
dependencies[singleton: .appContext].openUrl(url)
modal.dismiss(animated: true)
},
onCancel: { _ in
UIPasteboard.general.string = url.absoluteString
}
)
)
self.transitionToScreen(modal, transitionType: .present)
}
@MainActor func showLoadingModal(
from item: ListItem,
title: String,
description: String
) {
guard [ .logoWithPro, .updatePlan, .proStats, .renewPlan ].contains(item) else { return }
let modal: ConfirmationModal = ConfirmationModal(
info: ConfirmationModal.Info(
title: title,
body: .text(description, scrollMode: .never),
cancelTitle: "okay".localized(),
cancelStyle: .alert_text
)
)
self.transitionToScreen(modal, transitionType: .present)
}
@MainActor func showErrorModal(
from item: ListItem,
title: String,
description: ThemedAttributedString
) {
guard [ .logoWithPro, .updatePlan, .renewPlan ].contains(item) else { return }
let modal: ConfirmationModal = ConfirmationModal(
info: ConfirmationModal.Info(
title: title,
body: .attributedText(description, scrollMode: .never),
confirmTitle: "retry".localized(),
confirmStyle: .alert_text,
cancelTitle: "helpSupport".localized(),
cancelStyle: .alert_text,
onConfirm: { [dependencies] _ in
Task.detached(priority: .userInitiated) {
try? await dependencies[singleton: .sessionProManager].refreshProState()
}
},
onCancel: { [weak self] _ in self?.openUrl(Constants.urls.support) }
)
)
self.transitionToScreen(modal, transitionType: .present)
}
@MainActor func updateProPlan(state: State) {
let paymentScreen: SessionProPaymentScreen = SessionProPaymentScreen(
viewModel: SessionProPaymentScreenContent.ViewModel(
dataModel: SessionProPaymentScreenContent.DataModel(
flow: SessionProPaymentScreenContent.SessionProPlanPaymentFlow(state: state.proState),
plans: state.proState.plans.map { SessionProPaymentScreenContent.SessionProPlanInfo(plan: $0) }
),
isFromBottomSheet: state.isInBottomSheet,
using: dependencies
)
)
guard !state.isInBottomSheet else {
self.transitionToScreen(paymentScreen, transitionType: .push)
return
}
self.transitionToScreen(SessionHostingViewController(rootView: paymentScreen))
}
@MainActor func recoverProPlan() {
Task.detached(priority: .userInitiated) { [weak self, manager = dependencies[singleton: .sessionProManager]] in
try? await manager.refreshProState()
let state: SessionPro.State = manager.currentUserCurrentProState
await MainActor.run { [weak self] in
let modal: ConfirmationModal = ConfirmationModal(
info: ConfirmationModal.Info(
title: {
switch state.status {
case .active:
return "proAccessRestored"
.put(key: "pro", value: Constants.pro)
.localized()
case .neverBeenPro, .expired:
return "proAccessNotFound"
.put(key: "pro", value: Constants.pro)
.localized()
}
}(),
body: {
switch state.status {
case .active:
return .text(
"proAccessRestoredDescription"
.put(key: "app_name", value: Constants.app_name)
.put(key: "pro", value: Constants.pro)
.localized(),
scrollMode: .never
)
case .neverBeenPro, .expired:
return .text(
"proAccessNotFoundDescription"
.put(key: "app_name", value: Constants.app_name)
.put(key: "pro", value: Constants.pro)
.localized(),
scrollMode: .never
)
}
}(),
confirmTitle: (state.status == .active ? nil : "helpSupport".localized()),
cancelTitle: (state.status == .active ? "okay".localized() : "close".localized()),
cancelStyle: (state.status == .active ? .textPrimary : .danger),
dismissOnConfirm: false,
onConfirm: { [weak self] modal in
guard state.status != .active else {
return modal.dismiss(animated: true)
}
self?.openUrl(Constants.urls.proAccessNotFound)
}
)
)
self?.transitionToScreen(modal, transitionType: .present)
}
}
}
func cancelPlan(state: State) {
let viewController: SessionHostingViewController = SessionHostingViewController(
rootView: SessionProPaymentScreen(
viewModel: SessionProPaymentScreenContent.ViewModel(
dataModel: SessionProPaymentScreenContent.DataModel(
flow: .cancel(originatingPlatform: state.proState.originatingPlatform),
plans: state.proState.plans.map { SessionProPaymentScreenContent.SessionProPlanInfo(plan: $0) }
),
isFromBottomSheet: false,
using: dependencies
)
)
)
self.transitionToScreen(viewController)
}
func requestRefund(state: State) {
let viewController: SessionHostingViewController = SessionHostingViewController(
rootView: SessionProPaymentScreen(
viewModel: SessionProPaymentScreenContent.ViewModel(
dataModel: SessionProPaymentScreenContent.DataModel(
flow: .refund(
originatingPlatform: state.proState.originatingPlatform,
isNonOriginatingAccount: (state.proState.originatingAccount == .nonOriginatingAccount),
requestedAt: nil
),
plans: state.proState.plans.map { SessionProPaymentScreenContent.SessionProPlanInfo(plan: $0) }
),
isFromBottomSheet: false,
using: dependencies
)
)
)
self.transitionToScreen(viewController)
}
}
// MARK: - Convenience
private extension ObservedEvent {
var handlingStrategy: EventHandlingStrategy {
switch (key, key.generic) {
case (.anyConversationPinnedPriorityChanged, _): return .databaseQuery
default: return .directCacheUpdate
}
}
}