mirror of https://github.com/oxen-io/session-ios
				
				
				
			
			You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			388 lines
		
	
	
		
			18 KiB
		
	
	
	
		
			Swift
		
	
			
		
		
	
	
			388 lines
		
	
	
		
			18 KiB
		
	
	
	
		
			Swift
		
	
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
 | 
						|
 | 
						|
import Foundation
 | 
						|
import GRDB
 | 
						|
import DifferenceKit
 | 
						|
import SignalUtilitiesKit
 | 
						|
import SessionMessagingKit
 | 
						|
import SessionUtilitiesKit
 | 
						|
 | 
						|
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
 | 
						|
    
 | 
						|
    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()
 | 
						|
        .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: [
 | 
						|
                        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
 | 
						|
    }
 | 
						|
}
 |