mirror of
https://github.com/lux-tel/session-ios.git
synced 2026-10-30 20:38:32 +00:00
942 lines
42 KiB
Swift
942 lines
42 KiB
Swift
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
|
|
|
import Foundation
|
|
import Combine
|
|
import GRDB
|
|
import DifferenceKit
|
|
import SignalUtilitiesKit
|
|
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, SessionThreadViewModel>
|
|
|
|
// 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 {
|
|
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<SessionThreadViewModel.ID>
|
|
let itemCache: [String: SessionThreadViewModel]
|
|
let profileCache: [String: Profile]
|
|
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),
|
|
.messageRequestAccepted,
|
|
.messageRequestDeleted,
|
|
.messageRequestMessageRead,
|
|
.messageRequestUnreadMessageReceived,
|
|
.profile(userProfile.id),
|
|
.feature(.serviceNetwork),
|
|
.feature(.forceOffline),
|
|
.setting(.hasSavedThread),
|
|
.setting(.hasSavedMessage),
|
|
.setting(.hasViewedSeed),
|
|
.setting(.hasHiddenMessageRequests),
|
|
.conversationCreated,
|
|
.anyMessageCreatedInAnyConversation,
|
|
.anyContactBlockedStatusChanged,
|
|
.userDefault(.hasVisitedPathScreen),
|
|
.userDefault(.hasPressedDonateButton),
|
|
.userDefault(.hasChangedTheme),
|
|
.updateScreen(HomeViewModel.self),
|
|
.feature(.versionDeprecationWarning),
|
|
.feature(.versionDeprecationMinimum),
|
|
.showDonationsCTAModal
|
|
]
|
|
|
|
itemCache.values.forEach { threadViewModel in
|
|
result.insert(contentsOf: [
|
|
.conversationUpdated(threadViewModel.threadId),
|
|
.conversationDeleted(threadViewModel.threadId),
|
|
.messageCreated(threadId: threadViewModel.threadId),
|
|
.messageUpdated(
|
|
id: threadViewModel.interactionId,
|
|
threadId: threadViewModel.threadId
|
|
),
|
|
.messageDeleted(
|
|
id: threadViewModel.interactionId,
|
|
threadId: threadViewModel.threadId
|
|
),
|
|
.typingIndicator(threadViewModel.threadId)
|
|
])
|
|
|
|
if let authorId: String = threadViewModel.authorId {
|
|
result.insert(.profile(authorId))
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
static func initialState(
|
|
using dependencies: Dependencies,
|
|
appReviewPromptState: AppReviewPromptState?,
|
|
appWasInstalledPriorToAppReviewRelease: Bool,
|
|
showVersionSupportBanner: Bool
|
|
) -> State {
|
|
return State(
|
|
viewState: .loading,
|
|
userProfile: Profile(id: dependencies[cache: .general].sessionId.hexString, name: ""),
|
|
serviceNetwork: dependencies[feature: .serviceNetwork],
|
|
forceOffline: dependencies[feature: .forceOffline],
|
|
hasSavedThread: false,
|
|
hasSavedMessage: false,
|
|
showViewedSeedBanner: true,
|
|
hasHiddenMessageRequests: false,
|
|
unreadMessageRequestThreadCount: 0,
|
|
loadedPageInfo: PagedData.LoadedInfo(
|
|
record: SessionThreadViewModel.self,
|
|
pageSize: HomeViewModel.pageSize,
|
|
/// **Note:** This `optimisedJoinSQL` value includes the required minimum joins needed
|
|
/// for the query but differs from the JOINs that are actually used for performance reasons as the
|
|
/// basic logic can be simpler for where it's used
|
|
requiredJoinSQL: SessionThreadViewModel.optimisedJoinSQL,
|
|
filterSQL: SessionThreadViewModel.homeFilterSQL(
|
|
userSessionId: dependencies[cache: .general].sessionId
|
|
),
|
|
groupSQL: SessionThreadViewModel.groupSQL,
|
|
orderSQL: SessionThreadViewModel.homeOrderSQL
|
|
),
|
|
itemCache: [:],
|
|
profileCache: [:],
|
|
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 itemCache: [String: SessionThreadViewModel] = previousState.itemCache
|
|
var profileCache: [String: Profile] = previousState.profileCache
|
|
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))
|
|
}
|
|
|
|
// TODO: [Database Relocation] All profiles should be stored in the `profileCache`
|
|
profileCache[userProfile.id] = 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 !eventsToProcess.isEmpty else { return previousState }
|
|
|
|
/// Split the events between those that need database access and those that don't
|
|
let splitEvents: [EventDataRequirement: Set<ObservedEvent>] = eventsToProcess
|
|
.reduce(into: [:]) { result, next in
|
|
switch next.dataRequirement {
|
|
case .databaseQuery: result[.databaseQuery, default: []].insert(next)
|
|
case .other: result[.other, default: []].insert(next)
|
|
case .bothDatabaseQueryAndOther:
|
|
result[.databaseQuery, default: []].insert(next)
|
|
result[.other, default: []].insert(next)
|
|
}
|
|
}
|
|
let groupedOtherEvents: [GenericObservableKey: Set<ObservedEvent>]? = splitEvents[.other]?
|
|
.reduce(into: [:]) { result, event in
|
|
result[event.key.generic, default: []].insert(event)
|
|
}
|
|
|
|
/// Handle profile events first
|
|
groupedOtherEvents?[.profile]?.forEach { event in
|
|
guard
|
|
let eventValue: ProfileEvent = event.value as? ProfileEvent,
|
|
eventValue.id == userProfile.id
|
|
else { return }
|
|
|
|
switch eventValue.change {
|
|
case .name(let name): userProfile = userProfile.with(name: name)
|
|
case .nickname(let nickname): userProfile = userProfile.with(nickname: .set(to: nickname))
|
|
case .displayPictureUrl(let url): userProfile = userProfile.with(displayPictureUrl: .set(to: url))
|
|
}
|
|
|
|
// TODO: [Database Relocation] All profiles should be stored in the `profileCache`
|
|
profileCache[eventValue.id] = userProfile
|
|
}
|
|
|
|
/// Then handle database events
|
|
if !dependencies[singleton: .storage].isSuspended, let databaseEvents: Set<ObservedEvent> = splitEvents[.databaseQuery], !databaseEvents.isEmpty {
|
|
do {
|
|
var fetchedConversations: [SessionThreadViewModel] = []
|
|
let idsNeedingRequery: Set<String> = self.extractIdsNeedingRequery(
|
|
events: databaseEvents,
|
|
cache: itemCache
|
|
)
|
|
let loadPageEvent: LoadPageEvent? = databaseEvents
|
|
.first(where: { $0.key.generic == .loadPage })?
|
|
.value as? LoadPageEvent
|
|
|
|
/// Identify any inserted/deleted records
|
|
var insertedIds: Set<String> = []
|
|
var deletedIds: Set<String> = []
|
|
|
|
databaseEvents.forEach { event in
|
|
switch (event.key.generic, event.value) {
|
|
case (GenericObservableKey(.messageRequestAccepted), let threadId as String):
|
|
insertedIds.insert(threadId)
|
|
|
|
case (GenericObservableKey(.conversationCreated), let event as ConversationEvent):
|
|
insertedIds.insert(event.id)
|
|
|
|
case (GenericObservableKey(.anyMessageCreatedInAnyConversation), let event as MessageEvent):
|
|
insertedIds.insert(event.threadId)
|
|
|
|
case (.conversationDeleted, let event as ConversationEvent):
|
|
deletedIds.insert(event.id)
|
|
|
|
case (GenericObservableKey(.anyContactBlockedStatusChanged), let event as ContactEvent):
|
|
if case .isBlocked(true) = event.change {
|
|
deletedIds.insert(event.id)
|
|
}
|
|
else if case .isBlocked(false) = event.change {
|
|
insertedIds.insert(event.id)
|
|
}
|
|
|
|
default: break
|
|
}
|
|
}
|
|
|
|
try await dependencies[singleton: .storage].readAsync { db in
|
|
/// Update the `unreadMessageRequestThreadCount` if needed (since multiple events need this)
|
|
if databaseEvents.contains(where: { $0.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)
|
|
}
|
|
|
|
/// Update loaded page info as needed (any change to a conversation could result in an order change so reload
|
|
/// the paged data if needed (as that will fetch the correct order)
|
|
if
|
|
loadPageEvent != nil ||
|
|
!idsNeedingRequery.isEmpty ||
|
|
!insertedIds.isEmpty ||
|
|
!deletedIds.isEmpty
|
|
{
|
|
loadResult = try loadResult.load(
|
|
db,
|
|
target: (
|
|
loadPageEvent?.target(with: loadResult) ??
|
|
.reloadCurrent(insertedIds: insertedIds, deletedIds: deletedIds)
|
|
)
|
|
)
|
|
}
|
|
|
|
/// Fetch any records needed
|
|
fetchedConversations.append(
|
|
contentsOf: try SessionThreadViewModel
|
|
.query(
|
|
userSessionId: dependencies[cache: .general].sessionId,
|
|
groupSQL: SessionThreadViewModel.groupSQL,
|
|
orderSQL: SessionThreadViewModel.homeOrderSQL,
|
|
ids: Array(idsNeedingRequery) + loadResult.newIds
|
|
)
|
|
.fetchAll(db)
|
|
)
|
|
}
|
|
|
|
/// Update the `itemCache` with the newly fetched values
|
|
fetchedConversations.forEach { itemCache[$0.threadId] = $0 }
|
|
|
|
/// Remove any deleted values
|
|
deletedIds.forEach { id in itemCache.removeValue(forKey: id) }
|
|
} catch {
|
|
let eventList: String = databaseEvents.map { $0.key.rawValue }.joined(separator: ", ")
|
|
Log.critical(.homeViewModel, "Failed to fetch state for events [\(eventList)], due to error: \(error)")
|
|
}
|
|
}
|
|
else if let databaseEvents: Set<ObservedEvent> = splitEvents[.databaseQuery], !databaseEvents.isEmpty {
|
|
Log.warn(.homeViewModel, "Ignored \(databaseEvents.count) database event(s) sent while storage was suspended.")
|
|
}
|
|
|
|
/// Then handle remaining non-database events
|
|
groupedOtherEvents?[.setting]?.forEach { event in
|
|
guard let updatedValue: Bool = event.value as? Bool else { return }
|
|
|
|
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
|
|
}
|
|
}
|
|
groupedOtherEvents?[.feature]?.forEach { event in
|
|
if event.key == .feature(.serviceNetwork), let updatedValue = event.value as? ServiceNetwork {
|
|
serviceNetwork = updatedValue
|
|
}
|
|
else if event.key == .feature(.forceOffline), let updatedValue = event.value as? Bool {
|
|
forceOffline = updatedValue
|
|
}
|
|
else if event.key == .feature(.versionDeprecationWarning), let updatedValue = event.value as? Bool {
|
|
showVersionSupportBanner = isOSVersionDeprecated(using: dependencies) && updatedValue
|
|
}
|
|
else if event.key == .feature(.versionDeprecationMinimum) {
|
|
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 {
|
|
groupedOtherEvents?[.userDefault]?.forEach { event in
|
|
guard let value: Bool = event.value as? Bool else { return }
|
|
|
|
switch (event.key, value, 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 event: HomeViewModelEvent = events.first?.value as? HomeViewModelEvent {
|
|
pendingAppReviewPromptState = event.pendingAppReviewPromptState
|
|
appReviewPromptState = event.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 groupedOtherEvents?[.showDonationsCTAModal] != nil {
|
|
showDonationsCTAModal = true
|
|
}
|
|
else if showDonationsCTAModal {
|
|
showDonationsCTAModal = false
|
|
}
|
|
|
|
/// 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,
|
|
itemCache: itemCache,
|
|
profileCache: profileCache,
|
|
appReviewPromptState: appReviewPromptState,
|
|
pendingAppReviewPromptState: pendingAppReviewPromptState,
|
|
appWasInstalledPriorToAppReviewRelease: appWasInstalledPriorToAppReviewRelease,
|
|
showVersionSupportBanner: showVersionSupportBanner,
|
|
showDonationsCTAModal: showDonationsCTAModal
|
|
)
|
|
}
|
|
|
|
private static func extractIdsNeedingRequery(
|
|
events: Set<ObservedEvent>,
|
|
cache: [String: SessionThreadViewModel]
|
|
) -> Set<String> {
|
|
let requireFullRefresh: Bool = events.contains(where: { event in
|
|
event.key == .appLifecycle(.willEnterForeground) ||
|
|
event.key == .databaseLifecycle(.resumed)
|
|
})
|
|
|
|
guard !requireFullRefresh else {
|
|
return Set(cache.keys)
|
|
}
|
|
|
|
return events.reduce(into: []) { result, event in
|
|
switch (event.key.generic, event.value) {
|
|
case (.conversationUpdated, let event as ConversationEvent): result.insert(event.id)
|
|
case (.typingIndicator, let event as TypingIndicatorEvent): result.insert(event.threadId)
|
|
|
|
case (.messageCreated, let event as MessageEvent),
|
|
(.messageUpdated, let event as MessageEvent),
|
|
(.messageDeleted, let event as MessageEvent):
|
|
result.insert(event.threadId)
|
|
|
|
case (.profile, let event as ProfileEvent):
|
|
result.insert(
|
|
contentsOf: Set(cache.values
|
|
.filter { threadViewModel -> Bool in
|
|
threadViewModel.threadId == event.id ||
|
|
threadViewModel.allProfileIds.contains(event.id)
|
|
}
|
|
.map { $0.threadId })
|
|
)
|
|
|
|
case (.contact, let event as ContactEvent):
|
|
result.insert(
|
|
contentsOf: Set(cache.values
|
|
.filter { threadViewModel -> Bool in
|
|
threadViewModel.threadId == event.id ||
|
|
threadViewModel.allProfileIds.contains(event.id)
|
|
}
|
|
.map { $0.threadId })
|
|
)
|
|
|
|
default: break
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func sections(state: State, viewModel: HomeViewModel) -> [SectionModel] {
|
|
let userSessionId: SessionId = viewModel.dependencies[cache: .general].sessionId
|
|
|
|
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: [
|
|
SessionThreadViewModel(
|
|
threadId: SessionThreadViewModel.messageRequestsSectionId,
|
|
unreadCount: UInt(state.unreadMessageRequestThreadCount),
|
|
using: viewModel.dependencies
|
|
)
|
|
]
|
|
)]
|
|
),
|
|
[
|
|
SectionModel(
|
|
section: .threads,
|
|
elements: state.loadedPageInfo.currentIds
|
|
.compactMap { state.itemCache[$0] }
|
|
.map { conversation -> SessionThreadViewModel in
|
|
conversation.populatingPostQueryData(
|
|
recentReactionEmoji: nil,
|
|
openGroupCapabilities: nil,
|
|
// TODO: [Database Relocation] Do we need all of these????
|
|
currentUserSessionIds: [userSessionId.hexString],
|
|
wasKickedFromGroup: (
|
|
conversation.threadVariant == .group &&
|
|
viewModel.dependencies.mutate(cache: .libSession) { cache in
|
|
cache.wasKickedFromGroup(
|
|
groupSessionId: SessionId(.group, hex: conversation.threadId)
|
|
)
|
|
}
|
|
),
|
|
groupIsDestroyed: (
|
|
conversation.threadVariant == .group &&
|
|
viewModel.dependencies.mutate(cache: .libSession) { cache in
|
|
cache.groupIsDestroyed(
|
|
groupSessionId: SessionId(.group, hex: conversation.threadId)
|
|
)
|
|
}
|
|
),
|
|
threadCanWrite: false, // Irrelevant for the HomeViewModel
|
|
threadCanUpload: false // Irrelevant for the HomeViewModel
|
|
)
|
|
}
|
|
)
|
|
],
|
|
(!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
|
|
showSessionProCTAIfNeeded()
|
|
}
|
|
|
|
func scheduleAppReviewRetry() {
|
|
/// Wait 2 weeks before trying again
|
|
dependencies[defaults: .standard, key: .rateAppRetryDate] = dependencies.dateNow
|
|
.addingTimeInterval(2 * 7 * 24 * 60 * 60)
|
|
}
|
|
|
|
func showSessionProCTAIfNeeded() {
|
|
switch dependencies[singleton: .sessionProState].sessionProStateSubject.value {
|
|
case .none, .refunding:
|
|
return
|
|
case .active(_, let expiredOn, _ , _):
|
|
guard !dependencies[defaults: .standard, key: .hasShownProExpiringCTA] else { return }
|
|
let expiryInSeconds: TimeInterval = expiredOn.timeIntervalSinceNow
|
|
guard expiryInSeconds <= 7 * 24 * 60 * 60 else { return }
|
|
|
|
scheduleExpiringSessionProCTA(expiryInSeconds.ceilingFormatted(format: .long, allowedUnits: [ .day, .hour, .minute ]))
|
|
dependencies[defaults: .standard, key: .hasShownProExpiringCTA] = true
|
|
case .expired(let expiredOn, _):
|
|
guard !dependencies[defaults: .standard, key: .hasShownProExpiredCTA] else { return }
|
|
let expiryInSeconds: TimeInterval = expiredOn.timeIntervalSinceNow
|
|
guard expiryInSeconds <= 30 * 24 * 60 * 60 && !dependencies[feature: .mockExpiredOverThirtyDays] else { return }
|
|
|
|
scheduleExpiringSessionProCTA(nil)
|
|
dependencies[defaults: .standard, key: .hasShownProExpiredCTA] = true
|
|
}
|
|
}
|
|
|
|
private func scheduleExpiringSessionProCTA(_ timeLeft: String?) {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) { [weak self, dependencies] in
|
|
dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded(
|
|
.expiring(timeLeft: timeLeft),
|
|
onConfirm: {
|
|
let viewController: SessionHostingViewController = SessionHostingViewController(
|
|
rootView: SessionProPaymentScreen(
|
|
viewModel: SessionProPaymentScreenContent.ViewModel(
|
|
dependencies: dependencies,
|
|
dataModel: .init(
|
|
flow: dependencies[singleton: .sessionProState].sessionProStateSubject.value.toPaymentFlow(using: dependencies),
|
|
plans: dependencies[singleton: .sessionProState].sessionProPlans.map { $0.info() }
|
|
),
|
|
isFromBottomSheet: false
|
|
)
|
|
)
|
|
)
|
|
self?.transitionToScreen(viewController)
|
|
},
|
|
presenting: { modal in
|
|
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.session_feedback_url) else { return }
|
|
|
|
// stringlint:disable
|
|
let surveyUrl: URL = url.appending(queryItems: [
|
|
.init(name: "platform", value: Constants.platform_name),
|
|
.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 loadNextPage() {
|
|
dependencies.notifyAsync(
|
|
key: .loadPage(HomeViewModel.self),
|
|
value: LoadPageEvent.nextPage(lastIndex: state.loadedPageInfo.lastIndex)
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Convenience
|
|
|
|
private enum EventDataRequirement {
|
|
case databaseQuery
|
|
case other
|
|
case bothDatabaseQueryAndOther
|
|
}
|
|
|
|
private extension ObservedEvent {
|
|
var dataRequirement: EventDataRequirement {
|
|
switch (key, key.generic) {
|
|
case (.setting(.hasHiddenMessageRequests), _): return .bothDatabaseQueryAndOther
|
|
|
|
case (_, .profile): return .bothDatabaseQueryAndOther
|
|
case (.feature(.serviceNetwork), _): return .other
|
|
case (.feature(.forceOffline), _): return .other
|
|
case (.setting(.hasViewedSeed), _): return .other
|
|
|
|
case (.appLifecycle(.willEnterForeground), _): return .databaseQuery
|
|
case (.messageRequestUnreadMessageReceived, _), (.messageRequestAccepted, _),
|
|
(.messageRequestDeleted, _), (.messageRequestMessageRead, _):
|
|
return .databaseQuery
|
|
case (_, .loadPage): return .databaseQuery
|
|
case (.conversationCreated, _): return .databaseQuery
|
|
case (.anyMessageCreatedInAnyConversation, _): return .databaseQuery
|
|
case (.anyContactBlockedStatusChanged, _): return .databaseQuery
|
|
case (_, .typingIndicator): return .databaseQuery
|
|
case (_, .conversationUpdated), (_, .conversationDeleted): return .databaseQuery
|
|
case (_, .messageCreated), (_, .messageUpdated), (_, .messageDeleted): return .databaseQuery
|
|
default: return .other
|
|
}
|
|
}
|
|
|
|
var requiresMessageRequestCountUpdate: Bool {
|
|
switch self.key {
|
|
case .messageRequestUnreadMessageReceived, .messageRequestAccepted, .messageRequestDeleted,
|
|
.messageRequestMessageRead:
|
|
return true
|
|
|
|
default: return false
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|