session-ios/Session/Home/HomeViewModel.swift

386 lines
18 KiB
Swift
Raw Normal View History

// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import DifferenceKit
import SignalUtilitiesKit
import SessionMessagingKit
import SessionUtilitiesKit
public class HomeViewModel {
public typealias SectionModel = ArraySection<Section, SessionThreadViewModel>
// MARK: - Section
public enum Section: Differentiable {
case messageRequests
case threads
case loadMore
}
// MARK: - Variables
Fixed a number of issues found during internal testing Added copy for an unrecoverable startup case Added some additional logs to better debug ValueObservation query errors Increased the pageSize to 20 on iPad devices (to prevent it immediately loading a second page) Cleaned up a bunch of threading logic (try to avoid overriding subscribe/receive threads specified at subscription) Consolidated the 'sendMessage' and 'sendAttachments' functions Updated the various frameworks to use 'DAWRF with DSYM' to allow for better debugging during debug mode (at the cost of a longer build time) Updated the logic to optimistically insert messages when sending to avoid any database write delays Updated the logic to avoid sending notifications for messages which are already marked as read by the config Fixed an issue where multiple paths could incorrectly get built at the same time in some cases Fixed an issue where other job queues could be started before the blockingQueue finishes Fixed a potential bug with the snode version comparison (was just a string comparison which would fail when getting to double-digit values) Fixed a bug where you couldn't remove the last reaction on a message Fixed the broken media message zoom animations Fixed a bug where the last message read in a conversation wouldn't be correctly detected as already read Fixed a bug where the QuoteView had no line limits (resulting in the '@You' mention background highlight being incorrectly positioned in the quote preview) Fixed a bug where a large number of configSyncJobs could be scheduled (only one would run at a time but this could result in performance impacts)
2023-06-23 09:54:29 +02:00
public static let pageSize: Int = (UIDevice.current.isIPad ? 20 : 15)
public struct State: Equatable {
let showViewedSeedBanner: Bool
let hasHiddenMessageRequests: Bool
let unreadMessageRequestThreadCount: Int
let userProfile: Profile
}
// MARK: - Initialization
init() {
typealias InitialData = (
showViewedSeedBanner: Bool,
hasHiddenMessageRequests: Bool,
profile: Profile
)
let initialData: InitialData? = Storage.shared.read { db -> InitialData in
(
!db[.hasViewedSeed],
db[.hasHiddenMessageRequests],
Profile.fetchOrCreateCurrentUser(db)
)
}
self.state = State(
showViewedSeedBanner: (initialData?.showViewedSeedBanner ?? true),
hasHiddenMessageRequests: (initialData?.hasHiddenMessageRequests ?? false),
unreadMessageRequestThreadCount: 0,
userProfile: (initialData?.profile ?? Profile.fetchOrCreateCurrentUser())
)
self.pagedDataObserver = nil
// Note: Since this references self we need to finish initializing before setting it, we
// also want to skip the initial query and trigger it async so that the push animation
// doesn't stutter (it should load basically immediately but without this there is a
// distinct stutter)
let userPublicKey: String = self.state.userProfile.id
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
self.pagedDataObserver = PagedDatabaseObserver(
pagedTable: SessionThread.self,
pageSize: HomeViewModel.pageSize,
idColumn: .id,
observedChanges: [
PagedData.ObservedChanges(
table: SessionThread.self,
columns: [
.id,
.shouldBeVisible,
.pinnedPriority,
.mutedUntilTimestamp,
.onlyNotifyForMentions,
.markedAsUnread
]
),
PagedData.ObservedChanges(
table: Interaction.self,
columns: [
.body,
.wasRead
],
joinToPagedType: {
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
return SQL("JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])")
}()
),
PagedData.ObservedChanges(
table: Contact.self,
columns: [.isBlocked],
joinToPagedType: {
let contact: TypedTableAlias<Contact> = TypedTableAlias()
return SQL("JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id])")
}()
),
PagedData.ObservedChanges(
table: Profile.self,
columns: [.name, .nickname, .profilePictureFileName],
joinToPagedType: {
let profile: TypedTableAlias<Profile> = TypedTableAlias()
let groupMember: TypedTableAlias<GroupMember> = TypedTableAlias()
let threadVariants: [SessionThread.Variant] = [.legacyGroup, .group]
let targetRole: GroupMember.Role = GroupMember.Role.standard
return SQL("""
JOIN \(Profile.self) ON (
( -- Contact profile change
\(profile[.id]) = \(thread[.id]) AND
\(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)"))
) OR ( -- Closed group profile change
\(SQL("\(thread[.variant]) IN \(threadVariants)")) AND (
profile.id = ( -- Front profile
SELECT MIN(\(groupMember[.profileId]))
FROM \(GroupMember.self)
JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId])
WHERE (
\(groupMember[.groupId]) = \(thread[.id]) AND
\(SQL("\(groupMember[.role]) = \(targetRole)")) AND
\(groupMember[.profileId]) != \(userPublicKey)
)
) OR
profile.id = ( -- Back profile
SELECT MAX(\(groupMember[.profileId]))
FROM \(GroupMember.self)
JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId])
WHERE (
\(groupMember[.groupId]) = \(thread[.id]) AND
\(SQL("\(groupMember[.role]) = \(targetRole)")) AND
\(groupMember[.profileId]) != \(userPublicKey)
)
) OR ( -- Fallback profile
profile.id = \(userPublicKey) AND
(
SELECT COUNT(\(groupMember[.profileId]))
FROM \(GroupMember.self)
JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId])
WHERE (
\(groupMember[.groupId]) = \(thread[.id]) AND
\(SQL("\(groupMember[.role]) = \(targetRole)")) AND
\(groupMember[.profileId]) != \(userPublicKey)
)
) = 1
)
)
)
)
""")
}()
),
PagedData.ObservedChanges(
table: ClosedGroup.self,
columns: [.name],
joinToPagedType: {
let closedGroup: TypedTableAlias<ClosedGroup> = TypedTableAlias()
return SQL("JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id])")
}()
),
PagedData.ObservedChanges(
table: OpenGroup.self,
columns: [.name, .imageData],
joinToPagedType: {
let openGroup: TypedTableAlias<OpenGroup> = TypedTableAlias()
return SQL("JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id])")
}()
),
PagedData.ObservedChanges(
table: RecipientState.self,
columns: [.state],
joinToPagedType: {
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let recipientState: TypedTableAlias<RecipientState> = TypedTableAlias()
return """
JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])
JOIN \(RecipientState.self) ON \(recipientState[.interactionId]) = \(interaction[.id])
"""
}()
),
PagedData.ObservedChanges(
table: ThreadTypingIndicator.self,
columns: [.threadId],
joinToPagedType: {
let typingIndicator: TypedTableAlias<ThreadTypingIndicator> = TypedTableAlias()
return SQL("JOIN \(ThreadTypingIndicator.self) ON \(typingIndicator[.threadId]) = \(thread[.id])")
}()
)
],
/// **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
joinSQL: SessionThreadViewModel.optimisedJoinSQL,
filterSQL: SessionThreadViewModel.homeFilterSQL(userPublicKey: userPublicKey),
groupSQL: SessionThreadViewModel.groupSQL,
orderSQL: SessionThreadViewModel.homeOrderSQL,
dataQuery: SessionThreadViewModel.baseQuery(
userPublicKey: userPublicKey,
groupSQL: SessionThreadViewModel.groupSQL,
orderSQL: SessionThreadViewModel.homeOrderSQL
),
onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in
PagedData.processAndTriggerUpdates(
updatedData: self?.process(data: updatedData, for: updatedPageInfo),
currentDataRetriever: { self?.threadData },
onDataChange: self?.onThreadChange,
onUnobservedDataChange: { updatedData, changeset in
self?.unobservedThreadDataChanges = (changeset.isEmpty ?
nil :
(updatedData, changeset)
)
}
)
self?.hasReceivedInitialThreadData = true
}
)
// Run the initial query on a background thread so we don't block the main thread
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
// The `.pageBefore` will query from a `0` offset loading the first page
self?.pagedDataObserver?.load(.pageBefore)
}
}
// MARK: - State
/// This value is the current state of the view
public private(set) var state: State
/// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise
/// performance https://github.com/groue/GRDB.swift#valueobservation-performance
///
/// **Note:** This observation will be triggered twice immediately (and be de-duped by the `removeDuplicates`)
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
/// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this
public lazy var observableState = ValueObservation
.trackingConstantRegion { db -> State in try HomeViewModel.retrieveState(db) }
.removeDuplicates()
Fixed a number of issues found during internal testing Added copy for an unrecoverable startup case Added some additional logs to better debug ValueObservation query errors Increased the pageSize to 20 on iPad devices (to prevent it immediately loading a second page) Cleaned up a bunch of threading logic (try to avoid overriding subscribe/receive threads specified at subscription) Consolidated the 'sendMessage' and 'sendAttachments' functions Updated the various frameworks to use 'DAWRF with DSYM' to allow for better debugging during debug mode (at the cost of a longer build time) Updated the logic to optimistically insert messages when sending to avoid any database write delays Updated the logic to avoid sending notifications for messages which are already marked as read by the config Fixed an issue where multiple paths could incorrectly get built at the same time in some cases Fixed an issue where other job queues could be started before the blockingQueue finishes Fixed a potential bug with the snode version comparison (was just a string comparison which would fail when getting to double-digit values) Fixed a bug where you couldn't remove the last reaction on a message Fixed the broken media message zoom animations Fixed a bug where the last message read in a conversation wouldn't be correctly detected as already read Fixed a bug where the QuoteView had no line limits (resulting in the '@You' mention background highlight being incorrectly positioned in the quote preview) Fixed a bug where a large number of configSyncJobs could be scheduled (only one would run at a time but this could result in performance impacts)
2023-06-23 09:54:29 +02:00
.handleEvents(didFail: { SNLog("[HomeViewModel] Observation failed with error: \($0)") })
private static func retrieveState(_ db: Database) throws -> State {
let hasViewedSeed: Bool = db[.hasViewedSeed]
let hasHiddenMessageRequests: Bool = db[.hasHiddenMessageRequests]
let userProfile: Profile = Profile.fetchOrCreateCurrentUser(db)
let unreadMessageRequestThreadCount: Int = try SessionThread
.unreadMessageRequestsCountQuery(userPublicKey: userProfile.id)
.fetchOne(db)
.defaulting(to: 0)
return State(
showViewedSeedBanner: !hasViewedSeed,
hasHiddenMessageRequests: hasHiddenMessageRequests,
unreadMessageRequestThreadCount: unreadMessageRequestThreadCount,
userProfile: userProfile
)
}
public func updateState(_ updatedState: State) {
let oldState: State = self.state
self.state = updatedState
// If the messageRequest content changed then we need to re-process the thread data (assuming
// we've received the initial thread data)
guard
self.hasReceivedInitialThreadData,
(
oldState.hasHiddenMessageRequests != updatedState.hasHiddenMessageRequests ||
oldState.unreadMessageRequestThreadCount != updatedState.unreadMessageRequestThreadCount
),
let currentPageInfo: PagedData.PageInfo = self.pagedDataObserver?.pageInfo.wrappedValue
else { return }
/// **MUST** have the same logic as in the 'PagedDataObserver.onChangeUnsorted' above
let currentData: [SectionModel] = (self.unobservedThreadDataChanges?.0 ?? self.threadData)
let updatedThreadData: [SectionModel] = self.process(
data: (currentData.first(where: { $0.model == .threads })?.elements ?? []),
for: currentPageInfo
)
PagedData.processAndTriggerUpdates(
updatedData: updatedThreadData,
currentDataRetriever: { [weak self] in (self?.unobservedThreadDataChanges?.0 ?? self?.threadData) },
onDataChange: onThreadChange,
onUnobservedDataChange: { [weak self] updatedData, changeset in
self?.unobservedThreadDataChanges = (changeset.isEmpty ?
nil :
(updatedData, changeset)
)
}
)
}
// MARK: - Thread Data
private var hasReceivedInitialThreadData: Bool = false
public private(set) var unobservedThreadDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>)?
public private(set) var threadData: [SectionModel] = []
public private(set) var pagedDataObserver: PagedDatabaseObserver<SessionThread, SessionThreadViewModel>?
public var onThreadChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ())? {
didSet {
// When starting to observe interaction changes we want to trigger a UI update just in case the
// data was changed while we weren't observing
if let changes: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedThreadDataChanges {
let performChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ())? = onThreadChange
switch Thread.isMainThread {
case true: performChange?(changes.0, changes.1)
case false: DispatchQueue.main.async { performChange?(changes.0, changes.1) }
}
self.unobservedThreadDataChanges = nil
}
}
}
private func process(data: [SessionThreadViewModel], for pageInfo: PagedData.PageInfo) -> [SectionModel] {
let finalUnreadMessageRequestCount: Int = (self.state.hasHiddenMessageRequests ?
0 :
self.state.unreadMessageRequestThreadCount
)
let groupedOldData: [String: [SessionThreadViewModel]] = (self.threadData
.first(where: { $0.model == .threads })?
.elements)
.defaulting(to: [])
.grouped(by: \.threadId)
return [
// If there are no unread message requests then hide the message request banner
(finalUnreadMessageRequestCount == 0 ?
[] :
[SectionModel(
section: .messageRequests,
elements: [
Merge remote-tracking branch 'upstream/dev' into feature/updated-user-config-handling # Conflicts: # Podfile.lock # Session.xcodeproj/project.pbxproj # Session/Closed Groups/EditClosedGroupVC.swift # Session/Conversations/Settings/ThreadSettingsViewModel.swift # Session/Home/HomeVC.swift # Session/Home/HomeViewModel.swift # Session/Meta/Translations/de.lproj/Localizable.strings # Session/Meta/Translations/en.lproj/Localizable.strings # Session/Meta/Translations/es.lproj/Localizable.strings # Session/Meta/Translations/fa.lproj/Localizable.strings # Session/Meta/Translations/fi.lproj/Localizable.strings # Session/Meta/Translations/fr.lproj/Localizable.strings # Session/Meta/Translations/hi.lproj/Localizable.strings # Session/Meta/Translations/hr.lproj/Localizable.strings # Session/Meta/Translations/id-ID.lproj/Localizable.strings # Session/Meta/Translations/it.lproj/Localizable.strings # Session/Meta/Translations/ja.lproj/Localizable.strings # Session/Meta/Translations/nl.lproj/Localizable.strings # Session/Meta/Translations/pl.lproj/Localizable.strings # Session/Meta/Translations/pt_BR.lproj/Localizable.strings # Session/Meta/Translations/ru.lproj/Localizable.strings # Session/Meta/Translations/si.lproj/Localizable.strings # Session/Meta/Translations/sk.lproj/Localizable.strings # Session/Meta/Translations/sv.lproj/Localizable.strings # Session/Meta/Translations/th.lproj/Localizable.strings # Session/Meta/Translations/vi-VN.lproj/Localizable.strings # Session/Meta/Translations/zh-Hant.lproj/Localizable.strings # Session/Meta/Translations/zh_CN.lproj/Localizable.strings # Session/Shared/FullConversationCell.swift # SessionMessagingKit/Configuration.swift # SessionMessagingKit/Database/Models/SessionThread.swift # SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift # SessionMessagingKit/Shared Models/SessionThreadViewModel.swift # SessionUIKit/Utilities/UIContextualAction+Theming.swift # SessionUtilitiesKit/Database/Models/Job.swift # SessionUtilitiesKit/General/Dictionary+Utilities.swift # SessionUtilitiesKit/JobRunner/JobRunner.swift
2023-04-06 10:09:26 +02:00
SessionThreadViewModel(
threadId: SessionThreadViewModel.messageRequestsSectionId,
unreadCount: UInt(finalUnreadMessageRequestCount)
)
]
)]
),
[
SectionModel(
section: .threads,
elements: data
.filter { threadViewModel in
threadViewModel.id != SessionThreadViewModel.invalidId &&
threadViewModel.id != SessionThreadViewModel.messageRequestsSectionId
}
.sorted { lhs, rhs -> Bool in
guard lhs.threadPinnedPriority == rhs.threadPinnedPriority else {
return lhs.threadPinnedPriority > rhs.threadPinnedPriority
}
return lhs.lastInteractionDate > rhs.lastInteractionDate
}
.map { viewModel -> SessionThreadViewModel in
viewModel.populatingCurrentUserBlindedKeys(
currentUserBlinded15PublicKeyForThisThread: groupedOldData[viewModel.threadId]?
.first?
.currentUserBlinded15PublicKey,
currentUserBlinded25PublicKeyForThisThread: groupedOldData[viewModel.threadId]?
.first?
.currentUserBlinded25PublicKey
)
}
)
],
(!data.isEmpty && (pageInfo.pageOffset + pageInfo.currentCount) < pageInfo.totalCount ?
[SectionModel(section: .loadMore)] :
[]
)
].flatMap { $0 }
}
public func updateThreadData(_ updatedData: [SectionModel]) {
self.threadData = updatedData
}
}