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.
		
		
		
		
		
			
		
			
				
	
	
		
			741 lines
		
	
	
		
			33 KiB
		
	
	
	
		
			Swift
		
	
			
		
		
	
	
			741 lines
		
	
	
		
			33 KiB
		
	
	
	
		
			Swift
		
	
| // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
 | |
| 
 | |
| import Foundation
 | |
| import GRDB
 | |
| import DifferenceKit
 | |
| import SessionMessagingKit
 | |
| import SessionUtilitiesKit
 | |
| 
 | |
| public class ConversationViewModel: OWSAudioPlayerDelegate {
 | |
|     public typealias SectionModel = ArraySection<Section, MessageViewModel>
 | |
|     
 | |
|     // MARK: - Action
 | |
|     
 | |
|     public enum Action {
 | |
|         case none
 | |
|         case compose
 | |
|         case audioCall
 | |
|         case videoCall
 | |
|     }
 | |
|     
 | |
|     // MARK: - Section
 | |
|     
 | |
|     public enum Section: Differentiable, Equatable, Comparable, Hashable {
 | |
|         case loadOlder
 | |
|         case messages
 | |
|         case loadNewer
 | |
|     }
 | |
|     
 | |
|     // MARK: - Variables
 | |
|     
 | |
|     public static let pageSize: Int = 50
 | |
|     
 | |
|     private var threadId: String
 | |
|     public let initialThreadVariant: SessionThread.Variant
 | |
|     public var sentMessageBeforeUpdate: Bool = false
 | |
|     public var lastSearchedText: String?
 | |
|     public let focusedInteractionId: Int64?    // Note: This is used for global search
 | |
|     
 | |
|     public lazy var blockedBannerMessage: String = {
 | |
|         switch self.threadData.threadVariant {
 | |
|             case .contact:
 | |
|                 let name: String = Profile.displayName(
 | |
|                     id: self.threadData.threadId,
 | |
|                     threadVariant: self.threadData.threadVariant
 | |
|                 )
 | |
|                 
 | |
|                 return "\(name) is blocked. Unblock them?"
 | |
|                 
 | |
|             default: return "Thread is blocked. Unblock it?"
 | |
|         }
 | |
|     }()
 | |
|     
 | |
|     // MARK: - Initialization
 | |
|     
 | |
|     init(threadId: String, threadVariant: SessionThread.Variant, focusedInteractionId: Int64?) {
 | |
|         // If we have a specified 'focusedInteractionId' then use that, otherwise retrieve the oldest
 | |
|         // unread interaction and start focused around that one
 | |
|         let targetInteractionId: Int64? = {
 | |
|             if let focusedInteractionId: Int64 = focusedInteractionId { return focusedInteractionId }
 | |
|             
 | |
|             return Storage.shared.read { db in
 | |
|                 let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
 | |
|                 
 | |
|                 return try Interaction
 | |
|                     .select(.id)
 | |
|                     .filter(interaction[.wasRead] == false)
 | |
|                     .filter(interaction[.threadId] == threadId)
 | |
|                     .order(interaction[.timestampMs].asc)
 | |
|                     .asRequest(of: Int64.self)
 | |
|                     .fetchOne(db)
 | |
|             }
 | |
|         }()
 | |
|         
 | |
|         self.threadId = threadId
 | |
|         self.initialThreadVariant = threadVariant
 | |
|         self.focusedInteractionId = targetInteractionId
 | |
|         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)
 | |
|         self.pagedDataObserver = self.setupPagedObserver(
 | |
|             for: threadId,
 | |
|             userPublicKey: getUserHexEncodedPublicKey()
 | |
|         )
 | |
|         
 | |
|         // Run the initial query on a background thread so we don't block the push transition
 | |
|         DispatchQueue.global(qos: .default).async { [weak self] in
 | |
|             // If we don't have a `initialFocusedId` then default to `.pageBefore` (it'll query
 | |
|             // from a `0` offset)
 | |
|             guard let initialFocusedId: Int64 = targetInteractionId else {
 | |
|                 self?.pagedDataObserver?.load(.pageBefore)
 | |
|                 return
 | |
|             }
 | |
|             
 | |
|             self?.pagedDataObserver?.load(.initialPageAround(id: initialFocusedId))
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     // MARK: - Thread Data
 | |
|     
 | |
|     /// This value is the current state of the view
 | |
|     public private(set) lazy var threadData: SessionThreadViewModel = SessionThreadViewModel(
 | |
|         threadId: self.threadId,
 | |
|         threadVariant: self.initialThreadVariant,
 | |
|         currentUserIsClosedGroupMember: (self.initialThreadVariant != .closedGroup ?
 | |
|             nil :
 | |
|             Storage.shared.read { db in
 | |
|                 try GroupMember
 | |
|                     .filter(GroupMember.Columns.groupId == self.threadId)
 | |
|                     .filter(GroupMember.Columns.profileId == getUserHexEncodedPublicKey(db))
 | |
|                     .filter(GroupMember.Columns.role == GroupMember.Role.standard)
 | |
|                     .isNotEmpty(db)
 | |
|             }
 | |
|         )
 | |
|     )
 | |
|     
 | |
|     /// 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:** The 'trackingConstantRegion' is optimised in such a way that the request needs to be static
 | |
|     /// otherwise there may be situations where it doesn't get updates, this means we can't have conditional queries
 | |
|     ///
 | |
|     /// **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 observableThreadData: ValueObservation<ValueReducers.RemoveDuplicates<ValueReducers.Fetch<SessionThreadViewModel?>>> = setupObservableThreadData(for: self.threadId)
 | |
|     
 | |
|     private func setupObservableThreadData(for threadId: String) -> ValueObservation<ValueReducers.RemoveDuplicates<ValueReducers.Fetch<SessionThreadViewModel?>>> {
 | |
|         return ValueObservation
 | |
|             .trackingConstantRegion { db -> SessionThreadViewModel? in
 | |
|                 let userPublicKey: String = getUserHexEncodedPublicKey(db)
 | |
|                 let recentReactionEmoji: [String] = try Emoji.getRecent(db, withDefaultEmoji: true)
 | |
|                 let threadViewModel: SessionThreadViewModel? = try SessionThreadViewModel
 | |
|                     .conversationQuery(threadId: threadId, userPublicKey: userPublicKey)
 | |
|                     .fetchOne(db)
 | |
|                 
 | |
|                 return threadViewModel
 | |
|                     .map { $0.with(recentReactionEmoji: recentReactionEmoji) }
 | |
|             }
 | |
|             .removeDuplicates()
 | |
|     }
 | |
| 
 | |
|     public func updateThreadData(_ updatedData: SessionThreadViewModel) {
 | |
|         self.threadData = updatedData
 | |
|     }
 | |
|     
 | |
|     // MARK: - Interaction Data
 | |
|     
 | |
|     public private(set) var unobservedInteractionDataChanges: [SectionModel]?
 | |
|     public private(set) var interactionData: [SectionModel] = []
 | |
|     public private(set) var reactionExpandedInteractionIds: Set<Int64> = []
 | |
|     public private(set) var pagedDataObserver: PagedDatabaseObserver<Interaction, MessageViewModel>?
 | |
|     
 | |
|     public var onInteractionChange: (([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 unobservedInteractionDataChanges: [SectionModel] = self.unobservedInteractionDataChanges {
 | |
|                 onInteractionChange?(unobservedInteractionDataChanges)
 | |
|                 self.unobservedInteractionDataChanges = nil
 | |
|             }
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     private func setupPagedObserver(for threadId: String, userPublicKey: String) -> PagedDatabaseObserver<Interaction, MessageViewModel> {
 | |
|         return PagedDatabaseObserver(
 | |
|             pagedTable: Interaction.self,
 | |
|             pageSize: ConversationViewModel.pageSize,
 | |
|             idColumn: .id,
 | |
|             observedChanges: [
 | |
|                 PagedData.ObservedChanges(
 | |
|                     table: Interaction.self,
 | |
|                     columns: Interaction.Columns
 | |
|                         .allCases
 | |
|                         .filter { $0 != .wasRead }
 | |
|                 ),
 | |
|                 PagedData.ObservedChanges(
 | |
|                     table: Contact.self,
 | |
|                     columns: [.isTrusted],
 | |
|                     joinToPagedType: {
 | |
|                         let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
 | |
|                         let contact: TypedTableAlias<Contact> = TypedTableAlias()
 | |
|                         
 | |
|                         return SQL("LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(interaction[.threadId])")
 | |
|                     }()
 | |
|                 ),
 | |
|                 PagedData.ObservedChanges(
 | |
|                     table: Profile.self,
 | |
|                     columns: [.profilePictureFileName],
 | |
|                     joinToPagedType: {
 | |
|                         let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
 | |
|                         let profile: TypedTableAlias<Profile> = TypedTableAlias()
 | |
|                         
 | |
|                         return SQL("LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId])")
 | |
|                     }()
 | |
|                 )
 | |
|             ],
 | |
|             joinSQL: MessageViewModel.optimisedJoinSQL,
 | |
|             filterSQL: MessageViewModel.filterSQL(threadId: threadId),
 | |
|             groupSQL: MessageViewModel.groupSQL,
 | |
|             orderSQL: MessageViewModel.orderSQL,
 | |
|             dataQuery: MessageViewModel.baseQuery(
 | |
|                 userPublicKey: userPublicKey,
 | |
|                 orderSQL: MessageViewModel.orderSQL,
 | |
|                 groupSQL: MessageViewModel.groupSQL
 | |
|             ),
 | |
|             associatedRecords: [
 | |
|                 AssociatedRecord<MessageViewModel.AttachmentInteractionInfo, MessageViewModel>(
 | |
|                     trackedAgainst: Attachment.self,
 | |
|                     observedChanges: [
 | |
|                         PagedData.ObservedChanges(
 | |
|                             table: Attachment.self,
 | |
|                             columns: [.state]
 | |
|                         )
 | |
|                     ],
 | |
|                     dataQuery: MessageViewModel.AttachmentInteractionInfo.baseQuery,
 | |
|                     joinToPagedType: MessageViewModel.AttachmentInteractionInfo.joinToViewModelQuerySQL,
 | |
|                     associateData: MessageViewModel.AttachmentInteractionInfo.createAssociateDataClosure()
 | |
|                 ),
 | |
|                 AssociatedRecord<MessageViewModel.ReactionInfo, MessageViewModel>(
 | |
|                     trackedAgainst: Reaction.self,
 | |
|                     observedChanges: [
 | |
|                         PagedData.ObservedChanges(
 | |
|                             table: Reaction.self,
 | |
|                             columns: [.count]
 | |
|                         )
 | |
|                     ],
 | |
|                     dataQuery: MessageViewModel.ReactionInfo.baseQuery,
 | |
|                     joinToPagedType: MessageViewModel.ReactionInfo.joinToViewModelQuerySQL,
 | |
|                     associateData: MessageViewModel.ReactionInfo.createAssociateDataClosure()
 | |
|                 ),
 | |
|                 AssociatedRecord<MessageViewModel.TypingIndicatorInfo, MessageViewModel>(
 | |
|                     trackedAgainst: ThreadTypingIndicator.self,
 | |
|                     observedChanges: [
 | |
|                         PagedData.ObservedChanges(
 | |
|                             table: ThreadTypingIndicator.self,
 | |
|                             events: [.insert, .delete],
 | |
|                             columns: []
 | |
|                         )
 | |
|                     ],
 | |
|                     dataQuery: MessageViewModel.TypingIndicatorInfo.baseQuery,
 | |
|                     joinToPagedType: MessageViewModel.TypingIndicatorInfo.joinToViewModelQuerySQL,
 | |
|                     associateData: MessageViewModel.TypingIndicatorInfo.createAssociateDataClosure()
 | |
|                 )
 | |
|             ],
 | |
|             onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in
 | |
|                 guard let updatedInteractionData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo) else {
 | |
|                     return
 | |
|                 }
 | |
|                 
 | |
|                 // If we have the 'onInteractionChanged' callback then trigger it, otherwise just store the changes
 | |
|                 // to be sent to the callback if we ever start observing again (when we have the callback it needs
 | |
|                 // to do the data updating as it's tied to UI updates and can cause crashes if not updated in the
 | |
|                 // correct order)
 | |
|                 guard let onInteractionChange: (([SectionModel]) -> ()) = self?.onInteractionChange else {
 | |
|                     self?.unobservedInteractionDataChanges = updatedInteractionData
 | |
|                     return
 | |
|                 }
 | |
| 
 | |
|                 onInteractionChange(updatedInteractionData)
 | |
|             }
 | |
|         )
 | |
|     }
 | |
|     
 | |
|     private func process(data: [MessageViewModel], for pageInfo: PagedData.PageInfo) -> [SectionModel] {
 | |
|         let typingIndicator: MessageViewModel? = data.first(where: { $0.isTypingIndicator == true })
 | |
|         let sortedData: [MessageViewModel] = data
 | |
|             .filter { $0.isTypingIndicator != true }
 | |
|             .sorted { lhs, rhs -> Bool in lhs.timestampMs < rhs.timestampMs }
 | |
|         
 | |
|         // We load messages from newest to oldest so having a pageOffset larger than zero means
 | |
|         // there are newer pages to load
 | |
|         return [
 | |
|             (!data.isEmpty && (pageInfo.pageOffset + pageInfo.currentCount) < pageInfo.totalCount ?
 | |
|                 [SectionModel(section: .loadOlder)] :
 | |
|                 []
 | |
|             ),
 | |
|             [
 | |
|                 SectionModel(
 | |
|                     section: .messages,
 | |
|                     elements: sortedData
 | |
|                         .enumerated()
 | |
|                         .map { index, cellViewModel -> MessageViewModel in
 | |
|                             cellViewModel.withClusteringChanges(
 | |
|                                 prevModel: (index > 0 ? sortedData[index - 1] : nil),
 | |
|                                 nextModel: (index < (sortedData.count - 1) ? sortedData[index + 1] : nil),
 | |
|                                 isLast: (
 | |
|                                     // The database query sorts by timestampMs descending so the "last"
 | |
|                                     // interaction will actually have a 'pageOffset' of '0' even though
 | |
|                                     // it's the last element in the 'sortedData' array
 | |
|                                     index == (sortedData.count - 1) &&
 | |
|                                     pageInfo.pageOffset == 0
 | |
|                                 ),
 | |
|                                 currentUserBlindedPublicKey: threadData.currentUserBlindedPublicKey
 | |
|                             )
 | |
|                         }
 | |
|                         .appending(typingIndicator)
 | |
|                 )
 | |
|             ],
 | |
|             (!data.isEmpty && pageInfo.pageOffset > 0 ?
 | |
|                 [SectionModel(section: .loadNewer)] :
 | |
|                 []
 | |
|             )
 | |
|         ].flatMap { $0 }
 | |
|     }
 | |
|     
 | |
|     public func updateInteractionData(_ updatedData: [SectionModel]) {
 | |
|         self.interactionData = updatedData
 | |
|     }
 | |
|     
 | |
|     public func expandReactions(for interactionId: Int64) {
 | |
|         reactionExpandedInteractionIds.insert(interactionId)
 | |
|     }
 | |
|     
 | |
|     public func collapseReactions(for interactionId: Int64) {
 | |
|         reactionExpandedInteractionIds.remove(interactionId)
 | |
|     }
 | |
|     
 | |
|     // MARK: - Mentions
 | |
|     
 | |
|     public struct MentionInfo: FetchableRecord, Decodable {
 | |
|         fileprivate static let threadVariantKey = CodingKeys.threadVariant.stringValue
 | |
|         fileprivate static let openGroupServerKey = CodingKeys.openGroupServer.stringValue
 | |
|         fileprivate static let openGroupRoomTokenKey = CodingKeys.openGroupRoomToken.stringValue
 | |
|         
 | |
|         let profile: Profile
 | |
|         let threadVariant: SessionThread.Variant
 | |
|         let openGroupServer: String?
 | |
|         let openGroupRoomToken: String?
 | |
|     }
 | |
|     
 | |
|     public func mentions(for query: String = "") -> [MentionInfo] {
 | |
|         let threadData: SessionThreadViewModel = self.threadData
 | |
|         
 | |
|         let results: [MentionInfo] = Storage.shared
 | |
|             .read { db -> [MentionInfo] in
 | |
|                 let userPublicKey: String = getUserHexEncodedPublicKey(db)
 | |
|                 
 | |
|                 switch threadData.threadVariant {
 | |
|                     case .contact:
 | |
|                         guard userPublicKey != threadData.threadId else { return [] }
 | |
|                         
 | |
|                         return [Profile.fetchOrCreate(db, id: threadData.threadId)]
 | |
|                             .map { profile in
 | |
|                                 MentionInfo(
 | |
|                                     profile: profile,
 | |
|                                     threadVariant: threadData.threadVariant,
 | |
|                                     openGroupServer: nil,
 | |
|                                     openGroupRoomToken: nil
 | |
|                                 )
 | |
|                             }
 | |
|                             .filter {
 | |
|                                 query.count < 2 ||
 | |
|                                 $0.profile.displayName(for: $0.threadVariant).contains(query)
 | |
|                             }
 | |
|                         
 | |
|                     case .closedGroup:
 | |
|                         let profile: TypedTableAlias<Profile> = TypedTableAlias()
 | |
|                         
 | |
|                         return try GroupMember
 | |
|                             .select(
 | |
|                                 profile.allColumns(),
 | |
|                                 SQL("\(threadData.threadVariant)").forKey(MentionInfo.threadVariantKey)
 | |
|                             )
 | |
|                             .filter(GroupMember.Columns.groupId == threadData.threadId)
 | |
|                             .filter(GroupMember.Columns.profileId != userPublicKey)
 | |
|                             .filter(GroupMember.Columns.role == GroupMember.Role.standard)
 | |
|                             .joining(
 | |
|                                 required: GroupMember.profile
 | |
|                                     .aliased(profile)
 | |
|                                     // Note: LIKE is case-insensitive in SQLite
 | |
|                                     .filter(
 | |
|                                         query.count < 2 || (
 | |
|                                             profile[.nickname] != nil &&
 | |
|                                             profile[.nickname].like("%\(query)%")
 | |
|                                         ) || (
 | |
|                                             profile[.nickname] == nil &&
 | |
|                                             profile[.name].like("%\(query)%")
 | |
|                                         )
 | |
|                                     )
 | |
|                             )
 | |
|                             .asRequest(of: MentionInfo.self)
 | |
|                             .fetchAll(db)
 | |
|                         
 | |
|                     case .openGroup:
 | |
|                         let profile: TypedTableAlias<Profile> = TypedTableAlias()
 | |
|                         
 | |
|                         return try Interaction
 | |
|                             .select(
 | |
|                                 profile.allColumns(),
 | |
|                                 SQL("\(threadData.threadVariant)").forKey(MentionInfo.threadVariantKey),
 | |
|                                 SQL("\(threadData.openGroupServer)").forKey(MentionInfo.openGroupServerKey),
 | |
|                                 SQL("\(threadData.openGroupRoomToken)").forKey(MentionInfo.openGroupRoomTokenKey)
 | |
|                             )
 | |
|                             .distinct()
 | |
|                             .group(Interaction.Columns.authorId)
 | |
|                             .filter(Interaction.Columns.threadId == threadData.threadId)
 | |
|                             .filter(Interaction.Columns.authorId != userPublicKey)
 | |
|                             .joining(
 | |
|                                 required: Interaction.profile
 | |
|                                     .aliased(profile)
 | |
|                                     // Note: LIKE is case-insensitive in SQLite
 | |
|                                     .filter(
 | |
|                                         query.count < 2 || (
 | |
|                                             profile[.nickname] != nil &&
 | |
|                                             profile[.nickname].like("%\(query)%")
 | |
|                                         ) || (
 | |
|                                             profile[.nickname] == nil &&
 | |
|                                             profile[.name].like("%\(query)%")
 | |
|                                         )
 | |
|                                     )
 | |
|                             )
 | |
|                             .order(Interaction.Columns.timestampMs.desc)
 | |
|                             .limit(20)
 | |
|                             .asRequest(of: MentionInfo.self)
 | |
|                             .fetchAll(db)
 | |
|                 }
 | |
|             }
 | |
|             .defaulting(to: [])
 | |
|         
 | |
|         guard query.count >= 2 else {
 | |
|             return results.sorted { lhs, rhs -> Bool in
 | |
|                 lhs.profile.displayName(for: lhs.threadVariant) < rhs.profile.displayName(for: rhs.threadVariant)
 | |
|             }
 | |
|         }
 | |
|         
 | |
|         return results
 | |
|             .sorted { lhs, rhs -> Bool in
 | |
|                 let maybeLhsRange = lhs.profile.displayName(for: lhs.threadVariant).lowercased().range(of: query.lowercased())
 | |
|                 let maybeRhsRange = rhs.profile.displayName(for: rhs.threadVariant).lowercased().range(of: query.lowercased())
 | |
|                 
 | |
|                 guard let lhsRange: Range<String.Index> = maybeLhsRange, let rhsRange: Range<String.Index> = maybeRhsRange else {
 | |
|                     return true
 | |
|                 }
 | |
|                 
 | |
|                 return (lhsRange.lowerBound < rhsRange.lowerBound)
 | |
|             }
 | |
|     }
 | |
|     
 | |
|     // MARK: - Functions
 | |
|     
 | |
|     public func updateDraft(to draft: String) {
 | |
|         let threadId: String = self.threadId
 | |
|         let currentDraft: String = Storage.shared
 | |
|             .read { db in
 | |
|                 try SessionThread
 | |
|                     .select(.messageDraft)
 | |
|                     .filter(id: threadId)
 | |
|                     .asRequest(of: String.self)
 | |
|                     .fetchOne(db)
 | |
|             }
 | |
|             .defaulting(to: "")
 | |
|         
 | |
|         // Only write the updated draft to the database if it's changed (avoid unnecessary writes)
 | |
|         guard draft != currentDraft else { return }
 | |
|         
 | |
|         Storage.shared.writeAsync { db in
 | |
|             try SessionThread
 | |
|                 .filter(id: threadId)
 | |
|                 .updateAll(db, SessionThread.Columns.messageDraft.set(to: draft))
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     public func markAllAsRead() {
 | |
|         // Don't bother marking anything as read if there are no unread interactions (we can rely
 | |
|         // on the 'threadData.threadUnreadCount' to always be accurate)
 | |
|         guard
 | |
|             (self.threadData.threadUnreadCount ?? 0) > 0,
 | |
|             let lastInteractionId: Int64 = self.threadData.interactionId
 | |
|         else { return }
 | |
|         
 | |
|         let threadId: String = self.threadData.threadId
 | |
|         let trySendReadReceipt: Bool = (self.threadData.threadIsMessageRequest == false)
 | |
|         
 | |
|         Storage.shared.writeAsync { db in
 | |
|             try Interaction.markAsRead(
 | |
|                 db,
 | |
|                 interactionId: lastInteractionId,
 | |
|                 threadId: threadId,
 | |
|                 includingOlder: true,
 | |
|                 trySendReadReceipt: trySendReadReceipt
 | |
|             )
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     public func swapToThread(updatedThreadId: String) {
 | |
|         let oldestMessageId: Int64? = self.interactionData
 | |
|             .filter { $0.model == .messages }
 | |
|             .first?
 | |
|             .elements
 | |
|             .first?
 | |
|             .id
 | |
|         
 | |
|         self.threadId = updatedThreadId
 | |
|         self.observableThreadData = self.setupObservableThreadData(for: updatedThreadId)
 | |
|         self.pagedDataObserver = self.setupPagedObserver(
 | |
|             for: updatedThreadId,
 | |
|             userPublicKey: getUserHexEncodedPublicKey()
 | |
|         )
 | |
|         
 | |
|         // Try load everything up to the initial visible message, fallback to just the initial page of messages
 | |
|         // if we don't have one
 | |
|         switch oldestMessageId {
 | |
|             case .some(let id): self.pagedDataObserver?.load(.untilInclusive(id: id, padding: 0))
 | |
|             case .none: self.pagedDataObserver?.load(.pageBefore)
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     // MARK: - Audio Playback
 | |
|     
 | |
|     public struct PlaybackInfo {
 | |
|         let state: AudioPlaybackState
 | |
|         let progress: TimeInterval
 | |
|         let playbackRate: Double
 | |
|         let oldPlaybackRate: Double
 | |
|         let updateCallback: (PlaybackInfo?, Error?) -> ()
 | |
|         
 | |
|         public func with(
 | |
|             state: AudioPlaybackState? = nil,
 | |
|             progress: TimeInterval? = nil,
 | |
|             playbackRate: Double? = nil,
 | |
|             updateCallback: ((PlaybackInfo?, Error?) -> ())? = nil
 | |
|         ) -> PlaybackInfo {
 | |
|             return PlaybackInfo(
 | |
|                 state: (state ?? self.state),
 | |
|                 progress: (progress ?? self.progress),
 | |
|                 playbackRate: (playbackRate ?? self.playbackRate),
 | |
|                 oldPlaybackRate: self.playbackRate,
 | |
|                 updateCallback: (updateCallback ?? self.updateCallback)
 | |
|             )
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     private var audioPlayer: Atomic<OWSAudioPlayer?> = Atomic(nil)
 | |
|     private var currentPlayingInteraction: Atomic<Int64?> = Atomic(nil)
 | |
|     private var playbackInfo: Atomic<[Int64: PlaybackInfo]> = Atomic([:])
 | |
|     
 | |
|     public func playbackInfo(for viewModel: MessageViewModel, updateCallback: ((PlaybackInfo?, Error?) -> ())? = nil) -> PlaybackInfo? {
 | |
|         // Use the existing info if it already exists (update it's callback if provided as that means
 | |
|         // the cell was reloaded)
 | |
|         if let currentPlaybackInfo: PlaybackInfo = playbackInfo.wrappedValue[viewModel.id] {
 | |
|             let updatedPlaybackInfo: PlaybackInfo = currentPlaybackInfo
 | |
|                 .with(updateCallback: updateCallback)
 | |
|             
 | |
|             playbackInfo.mutate { $0[viewModel.id] = updatedPlaybackInfo }
 | |
|             
 | |
|             return updatedPlaybackInfo
 | |
|         }
 | |
|         
 | |
|         // Validate the item is a valid audio item
 | |
|         guard
 | |
|             let updateCallback: ((PlaybackInfo?, Error?) -> ()) = updateCallback,
 | |
|             let attachment: Attachment = viewModel.attachments?.first,
 | |
|             attachment.isAudio,
 | |
|             attachment.isValid,
 | |
|             let originalFilePath: String = attachment.originalFilePath,
 | |
|             FileManager.default.fileExists(atPath: originalFilePath)
 | |
|         else { return nil }
 | |
|         
 | |
|         // Create the info with the update callback
 | |
|         let newPlaybackInfo: PlaybackInfo = PlaybackInfo(
 | |
|             state: .stopped,
 | |
|             progress: 0,
 | |
|             playbackRate: 1,
 | |
|             oldPlaybackRate: 1,
 | |
|             updateCallback: updateCallback
 | |
|         )
 | |
|         
 | |
|         // Cache the info
 | |
|         playbackInfo.mutate { $0[viewModel.id] = newPlaybackInfo }
 | |
|         
 | |
|         return newPlaybackInfo
 | |
|     }
 | |
|     
 | |
|     public func playOrPauseAudio(for viewModel: MessageViewModel) {
 | |
|         guard
 | |
|             let attachment: Attachment = viewModel.attachments?.first,
 | |
|             let originalFilePath: String = attachment.originalFilePath,
 | |
|             FileManager.default.fileExists(atPath: originalFilePath)
 | |
|         else { return }
 | |
|         
 | |
|         // If the user interacted with the currently playing item
 | |
|         guard currentPlayingInteraction.wrappedValue != viewModel.id else {
 | |
|             let currentPlaybackInfo: PlaybackInfo? = playbackInfo.wrappedValue[viewModel.id]
 | |
|             let updatedPlaybackInfo: PlaybackInfo? = currentPlaybackInfo?
 | |
|                 .with(
 | |
|                     state: (currentPlaybackInfo?.state != .playing ? .playing : .paused),
 | |
|                     playbackRate: 1
 | |
|                 )
 | |
|             
 | |
|             audioPlayer.wrappedValue?.playbackRate = 1
 | |
|             
 | |
|             switch currentPlaybackInfo?.state {
 | |
|                 case .playing: audioPlayer.wrappedValue?.pause()
 | |
|                 default: audioPlayer.wrappedValue?.play()
 | |
|             }
 | |
|             
 | |
|             // Update the state and then update the UI with the updated state
 | |
|             playbackInfo.mutate { $0[viewModel.id] = updatedPlaybackInfo }
 | |
|             updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil)
 | |
|             return
 | |
|         }
 | |
|         
 | |
|         // First stop any existing audio
 | |
|         audioPlayer.wrappedValue?.stop()
 | |
|         
 | |
|         // Then setup the state for the new audio
 | |
|         currentPlayingInteraction.mutate { $0 = viewModel.id }
 | |
|         
 | |
|         audioPlayer.mutate { [weak self] player in
 | |
|             // Note: We clear the delegate and explicitly set to nil here as when the OWSAudioPlayer
 | |
|             // gets deallocated it triggers state changes which cause UI bugs when auto-playing
 | |
|             player?.delegate = nil
 | |
|             player = nil
 | |
|             
 | |
|             let audioPlayer: OWSAudioPlayer = OWSAudioPlayer(
 | |
|                 mediaUrl: URL(fileURLWithPath: originalFilePath),
 | |
|                 audioBehavior: .audioMessagePlayback,
 | |
|                 delegate: self
 | |
|             )
 | |
|             audioPlayer.play()
 | |
|             audioPlayer.setCurrentTime(playbackInfo.wrappedValue[viewModel.id]?.progress ?? 0)
 | |
|             player = audioPlayer
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     public func speedUpAudio(for viewModel: MessageViewModel) {
 | |
|         // If we aren't playing the specified item then just start playing it
 | |
|         guard viewModel.id == currentPlayingInteraction.wrappedValue else {
 | |
|             playOrPauseAudio(for: viewModel)
 | |
|             return
 | |
|         }
 | |
|         
 | |
|         let updatedPlaybackInfo: PlaybackInfo? = playbackInfo.wrappedValue[viewModel.id]?
 | |
|             .with(playbackRate: 1.5)
 | |
|         
 | |
|         // Speed up the audio player
 | |
|         audioPlayer.wrappedValue?.playbackRate = 1.5
 | |
|         
 | |
|         playbackInfo.mutate { $0[viewModel.id] = updatedPlaybackInfo }
 | |
|         updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil)
 | |
|     }
 | |
|     
 | |
|     public func stopAudio() {
 | |
|         audioPlayer.wrappedValue?.stop()
 | |
|         
 | |
|         currentPlayingInteraction.mutate { $0 = nil }
 | |
|         audioPlayer.mutate {
 | |
|             // Note: We clear the delegate and explicitly set to nil here as when the OWSAudioPlayer
 | |
|             // gets deallocated it triggers state changes which cause UI bugs when auto-playing
 | |
|             $0?.delegate = nil
 | |
|             $0 = nil
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     // MARK: - OWSAudioPlayerDelegate
 | |
|     
 | |
|     public func audioPlaybackState() -> AudioPlaybackState {
 | |
|         guard let interactionId: Int64 = currentPlayingInteraction.wrappedValue else { return .stopped }
 | |
|         
 | |
|         return (playbackInfo.wrappedValue[interactionId]?.state ?? .stopped)
 | |
|     }
 | |
|     
 | |
|     public func setAudioPlaybackState(_ state: AudioPlaybackState) {
 | |
|         guard let interactionId: Int64 = currentPlayingInteraction.wrappedValue else { return }
 | |
|         
 | |
|         let updatedPlaybackInfo: PlaybackInfo? = playbackInfo.wrappedValue[interactionId]?
 | |
|             .with(state: state)
 | |
|         
 | |
|         playbackInfo.mutate { $0[interactionId] = updatedPlaybackInfo }
 | |
|         updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil)
 | |
|     }
 | |
|     
 | |
|     public func setAudioProgress(_ progress: CGFloat, duration: CGFloat) {
 | |
|         guard let interactionId: Int64 = currentPlayingInteraction.wrappedValue else { return }
 | |
|         
 | |
|         let updatedPlaybackInfo: PlaybackInfo? = playbackInfo.wrappedValue[interactionId]?
 | |
|             .with(progress: TimeInterval(progress))
 | |
|         
 | |
|         playbackInfo.mutate { $0[interactionId] = updatedPlaybackInfo }
 | |
|         updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil)
 | |
|     }
 | |
|     
 | |
|     public func audioPlayerDidFinishPlaying(_ player: OWSAudioPlayer, successfully: Bool) {
 | |
|         guard let interactionId: Int64 = currentPlayingInteraction.wrappedValue else { return }
 | |
|         guard successfully else { return }
 | |
|         
 | |
|         let updatedPlaybackInfo: PlaybackInfo? = playbackInfo.wrappedValue[interactionId]?
 | |
|             .with(
 | |
|                 state: .stopped,
 | |
|                 progress: 0,
 | |
|                 playbackRate: 1
 | |
|             )
 | |
|         
 | |
|         // Safe the changes and send one final update to the UI
 | |
|         playbackInfo.mutate { $0[interactionId] = updatedPlaybackInfo }
 | |
|         updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil)
 | |
|         
 | |
|         // Clear out the currently playing record
 | |
|         currentPlayingInteraction.mutate { $0 = nil }
 | |
|         audioPlayer.mutate {
 | |
|             // Note: We clear the delegate and explicitly set to nil here as when the OWSAudioPlayer
 | |
|             // gets deallocated it triggers state changes which cause UI bugs when auto-playing
 | |
|             $0?.delegate = nil
 | |
|             $0 = nil
 | |
|         }
 | |
|         
 | |
|         // If the next interaction is another voice message then autoplay it
 | |
|         guard
 | |
|             let messageSection: SectionModel = self.interactionData
 | |
|                 .first(where: { $0.model == .messages }),
 | |
|             let currentIndex: Int = messageSection.elements
 | |
|                 .firstIndex(where: { $0.id == interactionId }),
 | |
|             currentIndex < (messageSection.elements.count - 1),
 | |
|             messageSection.elements[currentIndex + 1].cellType == .audio,
 | |
|             Storage.shared[.shouldAutoPlayConsecutiveAudioMessages] == true
 | |
|         else { return }
 | |
|         
 | |
|         let nextItem: MessageViewModel = messageSection.elements[currentIndex + 1]
 | |
|         playOrPauseAudio(for: nextItem)
 | |
|     }
 | |
|     
 | |
|     public func showInvalidAudioFileAlert() {
 | |
|         guard let interactionId: Int64 = currentPlayingInteraction.wrappedValue else { return }
 | |
|         
 | |
|         let updatedPlaybackInfo: PlaybackInfo? = playbackInfo.wrappedValue[interactionId]?
 | |
|             .with(
 | |
|                 state: .stopped,
 | |
|                 progress: 0,
 | |
|                 playbackRate: 1
 | |
|             )
 | |
|         
 | |
|         currentPlayingInteraction.mutate { $0 = nil }
 | |
|         playbackInfo.mutate { $0[interactionId] = updatedPlaybackInfo }
 | |
|         updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, AttachmentError.invalidData)
 | |
|     }
 | |
| }
 |