mirror of
https://github.com/lux-tel/session-ios.git
synced 2026-10-30 20:38:32 +00:00
• Fixed a possible deadlock during dependency initialisation • Fixed an issue preventing destroyed/kicked groups from rendering correctly • Fixed an auth issue when creating a new group • Fixed an issue where the input would incorrect allow text entry when the user can't send messages • Fixed an issue where conversations weren't correctly being identified as message requests • Fixed an issue where the empty state in a conversation could end up with the wrong font
785 lines
34 KiB
Swift
785 lines
34 KiB
Swift
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
|
|
|
import Foundation
|
|
import Combine
|
|
import GRDB
|
|
import DifferenceKit
|
|
import SignalUtilitiesKit
|
|
import SessionNetworkingKit
|
|
import SessionMessagingKit
|
|
import SessionUtilitiesKit
|
|
import StoreKit
|
|
import SessionUIKit
|
|
|
|
// MARK: - Log.Category
|
|
|
|
public extension Log.Category {
|
|
static let homeViewModel: Log.Category = .create("HomeViewModel", defaultLevel: .warn)
|
|
}
|
|
|
|
// MARK: - HomeViewModel
|
|
|
|
public class HomeViewModel: NavigatableStateHolder {
|
|
public let navigatableState: NavigatableState = NavigatableState()
|
|
|
|
public typealias SectionModel = ArraySection<Section, ConversationInfoViewModel>
|
|
|
|
// MARK: - Section
|
|
|
|
public enum Section: Differentiable {
|
|
case messageRequests
|
|
case threads
|
|
case loadMore
|
|
}
|
|
|
|
// MARK: - Variables
|
|
|
|
private static let pageSize: Int = (UIDevice.current.isIPad ? 20 : 15)
|
|
|
|
// Reusable OS version check for initial and updated state check
|
|
// Check if the current device is running a version LESS THAN iOS 16.0
|
|
private static func isOSVersionDeprecated(using dependencies: Dependencies) -> Bool {
|
|
let systemVersion = ProcessInfo.processInfo.operatingSystemVersion
|
|
return systemVersion.majorVersion < dependencies[feature: .versionDeprecationMinimum]
|
|
}
|
|
|
|
public let dependencies: Dependencies
|
|
private let userSessionId: SessionId
|
|
private var didPresentAppReviewPrompt: Bool = false
|
|
|
|
/// This value is the current state of the view
|
|
@MainActor @Published private(set) var state: State
|
|
private var observationTask: Task<Void, Never>?
|
|
|
|
// MARK: - Initialization
|
|
@MainActor init(using dependencies: Dependencies) {
|
|
self.dependencies = dependencies
|
|
self.userSessionId = dependencies[cache: .general].sessionId
|
|
|
|
self.state = State.initialState(
|
|
using: dependencies,
|
|
appReviewPromptState: AppReviewPromptModel
|
|
.loadInitialAppReviewPromptState(using: dependencies),
|
|
appWasInstalledPriorToAppReviewRelease: AppReviewPromptModel
|
|
.checkIfAppWasInstalledPriorToAppReviewRelease(using: dependencies),
|
|
showVersionSupportBanner: Self.isOSVersionDeprecated(using: dependencies) && dependencies[feature: .versionDeprecationWarning]
|
|
)
|
|
|
|
/// Bind the state
|
|
self.observationTask = ObservationBuilder
|
|
.initialValue(self.state)
|
|
.debounce(for: .milliseconds(250))
|
|
.using(dependencies: dependencies)
|
|
.query(HomeViewModel.queryState)
|
|
.assign { [weak self] updatedState in self?.state = updatedState }
|
|
}
|
|
|
|
deinit {
|
|
observationTask?.cancel()
|
|
NotificationCenter.default.removeObserver(self)
|
|
}
|
|
|
|
public struct HomeViewModelEvent: Hashable {
|
|
let pendingAppReviewPromptState: AppReviewPromptState?
|
|
let appReviewPromptState: AppReviewPromptState?
|
|
}
|
|
|
|
// MARK: - State
|
|
|
|
public struct State: ObservableKeyProvider {
|
|
enum ViewState: Equatable {
|
|
case loading
|
|
case empty(isNewUser: Bool)
|
|
case loaded
|
|
}
|
|
|
|
let viewState: ViewState
|
|
let userProfile: Profile
|
|
let serviceNetwork: ServiceNetwork
|
|
let forceOffline: Bool
|
|
let hasSavedThread: Bool
|
|
let hasSavedMessage: Bool
|
|
let showViewedSeedBanner: Bool
|
|
let hasHiddenMessageRequests: Bool
|
|
let unreadMessageRequestThreadCount: Int
|
|
|
|
let loadedPageInfo: PagedData.LoadedInfo<ConversationInfoViewModel.ID>
|
|
let dataCache: ConversationDataCache
|
|
let itemCache: [ConversationInfoViewModel.ID: ConversationInfoViewModel]
|
|
|
|
let appReviewPromptState: AppReviewPromptState?
|
|
let pendingAppReviewPromptState: AppReviewPromptState?
|
|
let appWasInstalledPriorToAppReviewRelease: Bool
|
|
let showVersionSupportBanner: Bool
|
|
let showDonationsCTAModal: Bool
|
|
|
|
@MainActor public func sections(viewModel: HomeViewModel) -> [SectionModel] {
|
|
HomeViewModel.sections(state: self, viewModel: viewModel)
|
|
}
|
|
|
|
public var observedKeys: Set<ObservableKey> {
|
|
var result: Set<ObservableKey> = [
|
|
.appLifecycle(.willEnterForeground),
|
|
.databaseLifecycle(.resumed),
|
|
.loadPage(HomeViewModel.self),
|
|
.conversationCreated,
|
|
.messageRequestAccepted,
|
|
.messageRequestDeleted,
|
|
.messageRequestMessageRead,
|
|
.messageRequestUnreadMessageReceived,
|
|
.anyMessageCreatedInAnyConversation,
|
|
.anyContactBlockedStatusChanged,
|
|
.profile(userProfile.id),
|
|
.feature(.serviceNetwork),
|
|
.feature(.forceOffline),
|
|
.setting(.hasSavedThread),
|
|
.setting(.hasSavedMessage),
|
|
.setting(.hasViewedSeed),
|
|
.setting(.hasHiddenMessageRequests),
|
|
.userDefault(.hasVisitedPathScreen),
|
|
.userDefault(.hasPressedDonateButton),
|
|
.userDefault(.hasChangedTheme),
|
|
.updateScreen(HomeViewModel.self),
|
|
.feature(.versionDeprecationWarning),
|
|
.feature(.versionDeprecationMinimum),
|
|
.showDonationsCTAModal
|
|
]
|
|
|
|
result.insert(contentsOf: Set(itemCache.values.flatMap { $0.observedKeys }))
|
|
|
|
return result
|
|
}
|
|
|
|
static func initialState(
|
|
using dependencies: Dependencies,
|
|
appReviewPromptState: AppReviewPromptState?,
|
|
appWasInstalledPriorToAppReviewRelease: Bool,
|
|
showVersionSupportBanner: Bool
|
|
) -> State {
|
|
let userSessionId: SessionId = dependencies[cache: .general].sessionId
|
|
|
|
return State(
|
|
viewState: .loading,
|
|
userProfile: Profile.with(id: userSessionId.hexString, name: ""),
|
|
serviceNetwork: dependencies[feature: .serviceNetwork],
|
|
forceOffline: dependencies[feature: .forceOffline],
|
|
hasSavedThread: false,
|
|
hasSavedMessage: false,
|
|
showViewedSeedBanner: true,
|
|
hasHiddenMessageRequests: false,
|
|
unreadMessageRequestThreadCount: 0,
|
|
loadedPageInfo: PagedData.LoadedInfo(
|
|
record: SessionThread.self,
|
|
pageSize: HomeViewModel.pageSize,
|
|
requiredJoinSQL: ConversationInfoViewModel.requiredJoinSQL,
|
|
filterSQL: ConversationInfoViewModel.homeFilterSQL(userSessionId: userSessionId),
|
|
groupSQL: nil,
|
|
orderSQL: ConversationInfoViewModel.homeOrderSQL
|
|
),
|
|
dataCache: ConversationDataCache(
|
|
userSessionId: userSessionId,
|
|
context: ConversationDataCache.Context(
|
|
source: .conversationList,
|
|
requireFullRefresh: false,
|
|
requireAuthMethodFetch: false,
|
|
requiresMessageRequestCountUpdate: false,
|
|
requiresInitialUnreadInteractionInfo: false,
|
|
requireRecentReactionEmojiUpdate: false
|
|
)
|
|
),
|
|
itemCache: [:],
|
|
appReviewPromptState: nil,
|
|
pendingAppReviewPromptState: appReviewPromptState,
|
|
appWasInstalledPriorToAppReviewRelease: appWasInstalledPriorToAppReviewRelease,
|
|
showVersionSupportBanner: showVersionSupportBanner,
|
|
showDonationsCTAModal: false
|
|
)
|
|
}
|
|
}
|
|
|
|
@Sendable private static func queryState(
|
|
previousState: State,
|
|
events: [ObservedEvent],
|
|
isInitialQuery: Bool,
|
|
using dependencies: Dependencies
|
|
) async -> State {
|
|
let startedAsNewUser: Bool = (dependencies[cache: .onboarding].initialFlow == .register)
|
|
var userProfile: Profile = previousState.userProfile
|
|
var serviceNetwork: ServiceNetwork = previousState.serviceNetwork
|
|
var forceOffline: Bool = previousState.forceOffline
|
|
var hasSavedThread: Bool = previousState.hasSavedThread
|
|
var hasSavedMessage: Bool = previousState.hasSavedMessage
|
|
var showViewedSeedBanner: Bool = previousState.showViewedSeedBanner
|
|
var hasHiddenMessageRequests: Bool = previousState.hasHiddenMessageRequests
|
|
var unreadMessageRequestThreadCount: Int = previousState.unreadMessageRequestThreadCount
|
|
var loadResult: PagedData.LoadResult = previousState.loadedPageInfo.asResult
|
|
var dataCache: ConversationDataCache = previousState.dataCache
|
|
var itemCache: [ConversationInfoViewModel.ID: ConversationInfoViewModel] = previousState.itemCache
|
|
|
|
var appReviewPromptState: AppReviewPromptState? = previousState.appReviewPromptState
|
|
var pendingAppReviewPromptState: AppReviewPromptState? = previousState.pendingAppReviewPromptState
|
|
let appWasInstalledPriorToAppReviewRelease: Bool = previousState.appWasInstalledPriorToAppReviewRelease
|
|
var showVersionSupportBanner: Bool = previousState.showVersionSupportBanner
|
|
var showDonationsCTAModal: Bool = previousState.showDonationsCTAModal
|
|
|
|
/// Store a local copy of the events so we can manipulate it based on the state changes
|
|
var eventsToProcess: [ObservedEvent] = events
|
|
|
|
/// If this is the initial query then we need to properly fetch the initial state
|
|
if isInitialQuery {
|
|
/// Insert a fake event to force the initial page load
|
|
eventsToProcess.append(ObservedEvent(
|
|
key: .loadPage(HomeViewModel.self),
|
|
value: LoadPageEvent.initial
|
|
))
|
|
|
|
/// Load the values needed from `libSession`
|
|
dependencies.mutate(cache: .libSession) { libSession in
|
|
userProfile = libSession.profile
|
|
showViewedSeedBanner = !libSession.get(.hasViewedSeed)
|
|
hasHiddenMessageRequests = libSession.get(.hasHiddenMessageRequests)
|
|
}
|
|
|
|
/// If the users profile picture doesn't exist on disk then clear out the value (that way if we get events after downloading
|
|
/// it then then there will be a diff in the `State` and the UI will update
|
|
if
|
|
let displayPictureUrl: String = userProfile.displayPictureUrl,
|
|
let filePath: String = try? dependencies[singleton: .displayPictureManager]
|
|
.path(for: displayPictureUrl),
|
|
!dependencies[singleton: .fileManager].fileExists(atPath: filePath)
|
|
{
|
|
userProfile = userProfile.with(displayPictureUrl: .set(to: nil))
|
|
}
|
|
|
|
dataCache.insert(userProfile)
|
|
|
|
/// If we haven't hidden the message requests banner then we should include that in the initial fetch
|
|
if !hasHiddenMessageRequests {
|
|
eventsToProcess.append(ObservedEvent(
|
|
key: .messageRequestUnreadMessageReceived,
|
|
value: nil
|
|
))
|
|
}
|
|
}
|
|
|
|
/// If there are no events we want to process then just return the current state
|
|
guard isInitialQuery || !eventsToProcess.isEmpty else { return previousState }
|
|
|
|
/// Split the events between those that need database access and those that don't
|
|
let changes: EventChangeset = eventsToProcess.split(by: { $0.handlingStrategy })
|
|
let loadPageEvent: LoadPageEvent? = changes.latestGeneric(.loadPage, as: LoadPageEvent.self)
|
|
|
|
/// Update the context
|
|
dataCache.withContext(
|
|
source: .conversationList,
|
|
requireFullRefresh: (
|
|
isInitialQuery ||
|
|
changes.containsAny(
|
|
.appLifecycle(.willEnterForeground),
|
|
.databaseLifecycle(.resumed)
|
|
)
|
|
),
|
|
requiresMessageRequestCountUpdate: changes.containsAny(
|
|
.messageRequestUnreadMessageReceived,
|
|
.messageRequestAccepted,
|
|
.messageRequestDeleted,
|
|
.messageRequestMessageRead
|
|
)
|
|
)
|
|
|
|
/// Process cache updates first
|
|
dataCache = await ConversationDataHelper.applyNonDatabaseEvents(
|
|
changes,
|
|
currentCache: dataCache,
|
|
using: dependencies
|
|
)
|
|
|
|
/// Then determine the fetch requirements
|
|
let fetchRequirements: ConversationDataHelper.FetchRequirements = ConversationDataHelper.determineFetchRequirements(
|
|
for: changes,
|
|
currentCache: dataCache,
|
|
itemCache: itemCache,
|
|
loadPageEvent: loadPageEvent
|
|
)
|
|
|
|
/// Peform any database changes
|
|
if !dependencies[singleton: .storage].isSuspended, fetchRequirements.needsAnyFetch {
|
|
do {
|
|
try await dependencies[singleton: .storage].readAsync { db in
|
|
/// Update the `unreadMessageRequestThreadCount` if needed (since multiple events need this)
|
|
if fetchRequirements.requiresMessageRequestCountUpdate {
|
|
// TODO: [Database Relocation] Should be able to clean this up by getting the conversation list and filtering
|
|
struct ThreadIdVariant: Decodable, Hashable, FetchableRecord {
|
|
let id: String
|
|
let variant: SessionThread.Variant
|
|
}
|
|
|
|
let potentialMessageRequestThreadInfo: Set<ThreadIdVariant> = try SessionThread
|
|
.select(.id, .variant)
|
|
.filter(
|
|
SessionThread.Columns.variant == SessionThread.Variant.contact ||
|
|
SessionThread.Columns.variant == SessionThread.Variant.group
|
|
)
|
|
.asRequest(of: ThreadIdVariant.self)
|
|
.fetchSet(db)
|
|
let messageRequestThreadIds: Set<String> = Set(
|
|
dependencies.mutate(cache: .libSession) { libSession in
|
|
potentialMessageRequestThreadInfo.compactMap {
|
|
guard libSession.isMessageRequest(threadId: $0.id, threadVariant: $0.variant) else {
|
|
return nil
|
|
}
|
|
|
|
return $0.id
|
|
}
|
|
}
|
|
)
|
|
|
|
unreadMessageRequestThreadCount = try SessionThread
|
|
.unreadMessageRequestsQuery(messageRequestThreadIds: messageRequestThreadIds)
|
|
.fetchCount(db)
|
|
}
|
|
|
|
/// Fetch any required data from the cache
|
|
(loadResult, dataCache) = try ConversationDataHelper.fetchFromDatabase(
|
|
db,
|
|
requirements: fetchRequirements,
|
|
currentCache: dataCache,
|
|
loadResult: loadResult,
|
|
loadPageEvent: loadPageEvent,
|
|
using: dependencies
|
|
)
|
|
}
|
|
} catch {
|
|
let eventList: String = changes.databaseEvents.map { $0.key.rawValue }.joined(separator: ", ")
|
|
Log.critical(.homeViewModel, "Failed to fetch state for events [\(eventList)], due to error: \(error)")
|
|
}
|
|
}
|
|
else if !changes.databaseEvents.isEmpty {
|
|
Log.warn(.homeViewModel, "Ignored \(changes.databaseEvents.count) database event(s) sent while storage was suspended.")
|
|
}
|
|
|
|
/// Peform any `libSession` changes
|
|
if fetchRequirements.needsAnyFetch {
|
|
do {
|
|
dataCache = try ConversationDataHelper.fetchFromLibSession(
|
|
requirements: fetchRequirements,
|
|
cache: dataCache,
|
|
using: dependencies
|
|
)
|
|
}
|
|
catch {
|
|
Log.warn(.homeViewModel, "Failed to handle \(changes.libSessionEvents.count) libSession event(s) due to error: \(error).")
|
|
}
|
|
}
|
|
|
|
/// Then handle remaining non-database events
|
|
changes.forEachEvent(.setting, as: Bool.self) { event, updatedValue in
|
|
switch event.key {
|
|
case .setting(.hasSavedThread): hasSavedThread = (updatedValue || hasSavedThread)
|
|
case .setting(.hasSavedMessage): hasSavedMessage = (updatedValue || hasSavedMessage)
|
|
case .setting(.hasViewedSeed): showViewedSeedBanner = !updatedValue // Inverted
|
|
case .setting(.hasHiddenMessageRequests): hasHiddenMessageRequests = updatedValue
|
|
default: break
|
|
}
|
|
}
|
|
|
|
if let updatedValue: ServiceNetwork = changes.latest(.feature(.serviceNetwork), as: ServiceNetwork.self) {
|
|
serviceNetwork = updatedValue
|
|
}
|
|
|
|
if let updatedValue: Bool = changes.latest(.feature(.forceOffline), as: Bool.self) {
|
|
forceOffline = updatedValue
|
|
}
|
|
|
|
// FIXME: Should be able to consolodate these two into a single value
|
|
if let updatedValue: Bool = changes.latest(.feature(.versionDeprecationWarning), as: Bool.self) {
|
|
showVersionSupportBanner = (isOSVersionDeprecated(using: dependencies) && updatedValue)
|
|
}
|
|
|
|
if changes.latest(.feature(.versionDeprecationMinimum), as: Int.self) != nil {
|
|
showVersionSupportBanner = (isOSVersionDeprecated(using: dependencies) && dependencies[feature: .versionDeprecationWarning])
|
|
}
|
|
|
|
/// Next trigger should be ignored if `didShowAppReviewPrompt` is true
|
|
if dependencies[defaults: .standard, key: .didShowAppReviewPrompt] == true {
|
|
pendingAppReviewPromptState = nil
|
|
} else {
|
|
changes.forEachEvent(.userDefault, as: Bool.self) { event, updatedValue in
|
|
switch (event.key, updatedValue, appWasInstalledPriorToAppReviewRelease) {
|
|
case (.userDefault(.hasVisitedPathScreen), true, false):
|
|
pendingAppReviewPromptState = .enjoyingSession
|
|
|
|
case (.userDefault(.hasPressedDonateButton), true, _):
|
|
pendingAppReviewPromptState = .enjoyingSession
|
|
|
|
case (.userDefault(.hasChangedTheme), true, false):
|
|
pendingAppReviewPromptState = .enjoyingSession
|
|
|
|
default: break
|
|
}
|
|
}
|
|
}
|
|
|
|
if let updatedValue: HomeViewModelEvent = changes.latestGeneric(.updateScreen, as: HomeViewModelEvent.self) {
|
|
pendingAppReviewPromptState = updatedValue.pendingAppReviewPromptState
|
|
appReviewPromptState = updatedValue.appReviewPromptState
|
|
}
|
|
|
|
/// If this update has an event indicating we should show the donations modal then do so, the next change will result in the flag
|
|
/// being reset so we don't unintentionally show it again
|
|
if changes.contains(.showDonationsCTAModal) {
|
|
showDonationsCTAModal = true
|
|
}
|
|
else if showDonationsCTAModal {
|
|
showDonationsCTAModal = false
|
|
}
|
|
|
|
/// Regenerate the `itemCache` now that the `dataCache` is updated
|
|
itemCache = loadResult.info.currentIds.reduce(into: [:]) { result, id in
|
|
guard let thread: SessionThread = dataCache.thread(for: id) else { return }
|
|
|
|
result[id] = ConversationInfoViewModel(
|
|
thread: thread,
|
|
dataCache: dataCache,
|
|
using: dependencies
|
|
)
|
|
}
|
|
|
|
/// Generate the new state
|
|
return State(
|
|
viewState: (loadResult.info.totalCount == 0 && unreadMessageRequestThreadCount == 0 ?
|
|
.empty(isNewUser: (startedAsNewUser && !hasSavedThread && !hasSavedMessage)) :
|
|
.loaded
|
|
),
|
|
userProfile: userProfile,
|
|
serviceNetwork: serviceNetwork,
|
|
forceOffline: forceOffline,
|
|
hasSavedThread: hasSavedThread,
|
|
hasSavedMessage: hasSavedMessage,
|
|
showViewedSeedBanner: showViewedSeedBanner,
|
|
hasHiddenMessageRequests: hasHiddenMessageRequests,
|
|
unreadMessageRequestThreadCount: unreadMessageRequestThreadCount,
|
|
loadedPageInfo: loadResult.info,
|
|
dataCache: dataCache,
|
|
itemCache: itemCache,
|
|
appReviewPromptState: appReviewPromptState,
|
|
pendingAppReviewPromptState: pendingAppReviewPromptState,
|
|
appWasInstalledPriorToAppReviewRelease: appWasInstalledPriorToAppReviewRelease,
|
|
showVersionSupportBanner: showVersionSupportBanner,
|
|
showDonationsCTAModal: showDonationsCTAModal
|
|
)
|
|
}
|
|
|
|
private static func sections(state: State, viewModel: HomeViewModel) -> [SectionModel] {
|
|
return [
|
|
/// If the message request section is hidden or there are no unread message requests then hide the message request banner
|
|
(state.hasHiddenMessageRequests || state.unreadMessageRequestThreadCount == 0 ?
|
|
[] :
|
|
[SectionModel(
|
|
section: .messageRequests,
|
|
elements: [
|
|
ConversationInfoViewModel.unreadMessageRequestsBanner(
|
|
unreadCount: state.unreadMessageRequestThreadCount
|
|
)
|
|
]
|
|
)]
|
|
),
|
|
[
|
|
SectionModel(
|
|
section: .threads,
|
|
elements: state.loadedPageInfo.currentIds.compactMap { state.itemCache[$0] }
|
|
)
|
|
],
|
|
(!state.loadedPageInfo.currentIds.isEmpty && state.loadedPageInfo.hasNextPage ?
|
|
[SectionModel(section: .loadMore)] :
|
|
[]
|
|
)
|
|
].flatMap { $0 }
|
|
}
|
|
|
|
// MARK: - Handle App review
|
|
|
|
@MainActor func viewDidAppear() {
|
|
dependencies[singleton: .donationsManager].conversationListDidAppear()
|
|
|
|
if state.pendingAppReviewPromptState != nil {
|
|
// Handle App review
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) { [weak self, dependencies] in
|
|
guard let updatedState: AppReviewPromptState = self?.state.pendingAppReviewPromptState else { return }
|
|
|
|
dependencies[defaults: .standard, key: .didActionAppReviewPrompt] = false
|
|
|
|
self?.handlePromptChangeState(updatedState)
|
|
}
|
|
}
|
|
|
|
// Camera reminder
|
|
willShowCameraPermissionReminder()
|
|
|
|
// Pro expiring/expired CTA
|
|
Task { await showSessionProCTAIfNeeded() }
|
|
}
|
|
|
|
func scheduleAppReviewRetry() {
|
|
/// Wait 2 weeks before trying again
|
|
dependencies[defaults: .standard, key: .rateAppRetryDate] = dependencies.dateNow
|
|
.addingTimeInterval(2 * 7 * 24 * 60 * 60)
|
|
}
|
|
|
|
@MainActor func showSessionProCTAIfNeeded() async {
|
|
guard let info = await dependencies[singleton: .sessionProManager].sessionProExpiringCTAInfo() else {
|
|
return
|
|
}
|
|
|
|
try? await Task.sleep(for: .seconds(1)) /// Cooperative suspension, so safe to call on main thread
|
|
|
|
dependencies[singleton: .sessionProManager].showSessionProCTAIfNeeded(
|
|
info.variant,
|
|
onConfirm: { [weak self, dependencies] in
|
|
let viewController: SessionHostingViewController = SessionHostingViewController(
|
|
rootView: SessionProPaymentScreen(
|
|
viewModel: SessionProPaymentScreenContent.ViewModel(
|
|
dataModel: SessionProPaymentScreenContent.DataModel(
|
|
flow: info.paymentFlow,
|
|
plans: info.planInfo
|
|
),
|
|
isFromBottomSheet: false,
|
|
using: dependencies
|
|
)
|
|
)
|
|
)
|
|
self?.transitionToScreen(viewController)
|
|
},
|
|
presenting: { [weak self, dependencies] modal in
|
|
dependencies[defaults: .standard, key: .hasShownProExpiringCTA] = true
|
|
self?.transitionToScreen(modal, transitionType: .present)
|
|
}
|
|
)
|
|
}
|
|
|
|
func handlePromptChangeState(_ state: AppReviewPromptState?) {
|
|
// Set`didActionAppReviewPrompt` to true when closed from `x` button of prompt
|
|
// or in show rate limit prompt so it does not show again on relaunch
|
|
if state == nil || state == .rateLimit { dependencies[defaults: .standard, key: .didActionAppReviewPrompt] = true }
|
|
|
|
// Set `didShowAppReviewPrompt` when a new state is presented
|
|
if state != nil { dependencies[defaults: .standard, key: .didShowAppReviewPrompt] = true }
|
|
|
|
dependencies.notifyAsync(
|
|
priority: .immediate,
|
|
key: .updateScreen(HomeViewModel.self),
|
|
value: HomeViewModelEvent(
|
|
pendingAppReviewPromptState: nil,
|
|
appReviewPromptState: state
|
|
)
|
|
)
|
|
}
|
|
|
|
@MainActor
|
|
func submitAppStoreReview() {
|
|
dependencies[defaults: .standard, key: .rateAppRetryDate] = nil
|
|
dependencies[defaults: .standard, key: .rateAppRetryAttemptCount] = 0
|
|
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(self.windowBecameVisibleAfterTriggeringAppStoreReview(notification:)),
|
|
name: UIWindow.didBecomeVisibleNotification, object: nil
|
|
)
|
|
|
|
if !dependencies[feature: .simulateAppReviewLimit] {
|
|
requestAppReview()
|
|
}
|
|
|
|
// Added 2 sec delay to give time for requet review to proc
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) { [weak self] in
|
|
guard let this = self else { return }
|
|
|
|
NotificationCenter.default.removeObserver(this, name: UIWindow.didBecomeVisibleNotification, object: nil)
|
|
|
|
guard this.didPresentAppReviewPrompt else {
|
|
// Show rate limit prompt
|
|
this.handlePromptChangeState(.rateLimit)
|
|
return
|
|
}
|
|
|
|
// Reset flag just in case it will be triggered again
|
|
this.didPresentAppReviewPrompt = false
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func requestAppReview() {
|
|
guard let scene = UIApplication.shared.connectedScenes.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene else {
|
|
return
|
|
}
|
|
|
|
if #available(iOS 16.0, *) {
|
|
AppStore.requestReview(in: scene)
|
|
} else {
|
|
SKStoreReviewController.requestReview(in: scene)
|
|
}
|
|
}
|
|
|
|
@objc
|
|
private func windowBecameVisibleAfterTriggeringAppStoreReview(notification: Notification) {
|
|
didPresentAppReviewPrompt = true
|
|
}
|
|
|
|
@MainActor
|
|
func submitFeedbackSurvery() {
|
|
guard let url: URL = URL(string: Constants.urls.feedback) else { return }
|
|
|
|
// stringlint:disable
|
|
let surveyUrl: URL = url.appending(queryItems: [
|
|
.init(name: "platform", value: Constants.PaymentProvider.appStore.device),
|
|
.init(name: "version", value: dependencies[cache: .appVersion].appVersion)
|
|
])
|
|
|
|
let modal: ConfirmationModal = ConfirmationModal(
|
|
info: ConfirmationModal.Info(
|
|
title: "urlOpen".localized(),
|
|
body: .attributedText(
|
|
"urlOpenDescription"
|
|
.put(key: "url", value: surveyUrl.absoluteString)
|
|
.localizedFormatted(baseFont: .systemFont(ofSize: Values.smallFontSize))
|
|
),
|
|
confirmTitle: "open".localized(),
|
|
confirmStyle: .danger,
|
|
cancelTitle: "urlCopy".localized(),
|
|
cancelStyle: .alert_text,
|
|
hasCloseButton: true,
|
|
onConfirm: { modal in
|
|
UIApplication.shared.open(surveyUrl, options: [:], completionHandler: nil)
|
|
modal.dismiss(animated: true)
|
|
},
|
|
onCancel: { modal in
|
|
UIPasteboard.general.string = surveyUrl.absoluteString
|
|
|
|
modal.dismiss(animated: true)
|
|
}
|
|
)
|
|
)
|
|
|
|
self.transitionToScreen(modal, transitionType: .present)
|
|
}
|
|
|
|
@MainActor func handlePrimaryTappedForState(_ state: AppReviewPromptState) {
|
|
dependencies[defaults: .standard, key: .didActionAppReviewPrompt] = true
|
|
|
|
switch state {
|
|
case .enjoyingSession: handlePromptChangeState(.feedback)
|
|
case .feedback:
|
|
// Close prompt before showing survery
|
|
handlePromptChangeState(nil)
|
|
submitFeedbackSurvery()
|
|
case .rateSession:
|
|
// Close prompt before showing app review
|
|
handlePromptChangeState(nil)
|
|
submitAppStoreReview()
|
|
default: break
|
|
}
|
|
}
|
|
|
|
@MainActor func handleSecondayTappedForState(_ state: AppReviewPromptState) {
|
|
dependencies[defaults: .standard, key: .didActionAppReviewPrompt] = true
|
|
|
|
switch state {
|
|
case .feedback, .rateSession: handlePromptChangeState(nil)
|
|
case .enjoyingSession:
|
|
handlePromptChangeState(.rateSession)
|
|
scheduleAppReviewRetry()
|
|
dependencies[singleton: .donationsManager].positiveReviewChosen()
|
|
|
|
default: break
|
|
}
|
|
}
|
|
|
|
// Camera permission
|
|
func willShowCameraPermissionReminder() {
|
|
guard
|
|
dependencies[singleton: .screenLock].checkIfScreenIsUnlocked(), // Show camera access reminder when app has been unlocked
|
|
!dependencies[defaults: .appGroup, key: .isCallOngoing] // Checks if there is still an ongoing call
|
|
else {
|
|
return
|
|
}
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) { [dependencies] in
|
|
Permissions.remindCameraAccessRequirement(using: dependencies)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
@objc func didReturnFromBackground() {
|
|
// Observe changes to app state retry and flags when app goes to bg to fg
|
|
if AppReviewPromptModel.checkAndRefreshAppReviewState(using: dependencies) {
|
|
// state.appReviewPromptState check so it does not replace existing prompt if there is any
|
|
let updatedState = state.appReviewPromptState ?? .rateSession
|
|
|
|
// Handles scenario where app is in background -> foreground when the retry date is hit
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) { [weak self] in
|
|
self?.handlePromptChangeState(updatedState)
|
|
}
|
|
}
|
|
|
|
// Camera reminder
|
|
willShowCameraPermissionReminder()
|
|
}
|
|
|
|
// MARK: - Functions
|
|
|
|
@MainActor func loadPageBefore() {
|
|
dependencies.notifyAsync(
|
|
key: .loadPage(HomeViewModel.self),
|
|
value: LoadPageEvent.previousPage(firstIndex: state.loadedPageInfo.firstIndex)
|
|
)
|
|
}
|
|
|
|
@MainActor public func loadPageAfter() {
|
|
dependencies.notifyAsync(
|
|
key: .loadPage(HomeViewModel.self),
|
|
value: LoadPageEvent.nextPage(lastIndex: state.loadedPageInfo.lastIndex)
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Convenience
|
|
|
|
private extension ObservedEvent {
|
|
var handlingStrategy: EventHandlingStrategy {
|
|
let threadInfoStrategy: EventHandlingStrategy? = ConversationInfoViewModel.handlingStrategy(for: self)
|
|
let localStrategy: EventHandlingStrategy = {
|
|
switch (key, key.generic) {
|
|
case (.setting(.hasHiddenMessageRequests), _): return [.databaseQuery, .directCacheUpdate]
|
|
case (ObservableKey.feature(.serviceNetwork), _): return .directCacheUpdate
|
|
case (ObservableKey.feature(.forceOffline), _): return .directCacheUpdate
|
|
case (.setting(.hasViewedSeed), _): return .directCacheUpdate
|
|
|
|
case (.appLifecycle(.willEnterForeground), _): return .databaseQuery
|
|
case (.messageRequestUnreadMessageReceived, _), (.messageRequestAccepted, _),
|
|
(.messageRequestDeleted, _), (.messageRequestMessageRead, _):
|
|
return .databaseQuery
|
|
case (_, .loadPage): return .databaseQuery
|
|
|
|
default: return .directCacheUpdate
|
|
}
|
|
}()
|
|
|
|
return localStrategy.union(threadInfoStrategy ?? .none)
|
|
}
|
|
}
|
|
|
|
private extension URL {
|
|
@available(iOS, introduced: 13.0, obsoleted: 16.0)
|
|
func appending(queryItems: [URLQueryItem]) -> URL {
|
|
guard var components = URLComponents(url: self, resolvingAgainstBaseURL: false) else {
|
|
return self
|
|
}
|
|
|
|
var existingItems = components.queryItems ?? []
|
|
existingItems.append(contentsOf: queryItems)
|
|
components.queryItems = existingItems
|
|
|
|
return components.url ?? self
|
|
}
|
|
}
|