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.
		
		
		
		
		
			
		
			
				
	
	
		
			1056 lines
		
	
	
		
			48 KiB
		
	
	
	
		
			Swift
		
	
			
		
		
	
	
			1056 lines
		
	
	
		
			48 KiB
		
	
	
	
		
			Swift
		
	
| // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
 | |
| 
 | |
| import Foundation
 | |
| import Combine
 | |
| import GRDB
 | |
| import DifferenceKit
 | |
| import SessionMessagingKit
 | |
| import SessionUtilitiesKit
 | |
| 
 | |
| public class ConversationViewModel: OWSAudioPlayerDelegate {
 | |
|     public typealias SectionModel = ArraySection<Section, MessageViewModel>
 | |
|     
 | |
|     // MARK: - FocusBehaviour
 | |
|     
 | |
|     public enum FocusBehaviour {
 | |
|         case none
 | |
|         case highlight
 | |
|     }
 | |
|     
 | |
|     // 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 focusedInteractionInfo: Interaction.TimestampInfo? // Note: This is used for global search
 | |
|     public let focusBehaviour: FocusBehaviour
 | |
|     private let initialUnreadInteractionId: Int64?
 | |
|     private let markAsReadTrigger: PassthroughSubject<(SessionThreadViewModel.ReadTarget, Int64?), Never> = PassthroughSubject()
 | |
|     private var markAsReadPublisher: AnyPublisher<Void, Never>?
 | |
|     
 | |
|     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, focusedInteractionInfo: Interaction.TimestampInfo?) {
 | |
|         typealias InitialData = (
 | |
|             currentUserPublicKey: String,
 | |
|             initialUnreadInteractionInfo: Interaction.TimestampInfo?,
 | |
|             threadIsBlocked: Bool,
 | |
|             currentUserIsClosedGroupMember: Bool?,
 | |
|             openGroupPermissions: OpenGroup.Permissions?,
 | |
|             blinded15Key: String?,
 | |
|             blinded25Key: String?
 | |
|         )
 | |
|         
 | |
|         let initialData: InitialData? = Storage.shared.read { db -> InitialData in
 | |
|             let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
 | |
|             let groupMember: TypedTableAlias<GroupMember> = TypedTableAlias()
 | |
|             let currentUserPublicKey: String = getUserHexEncodedPublicKey(db)
 | |
|             
 | |
|             // If we have a specified 'focusedInteractionInfo' then use that, otherwise retrieve the oldest
 | |
|             // unread interaction and start focused around that one
 | |
|             let initialUnreadInteractionInfo: Interaction.TimestampInfo? = try Interaction
 | |
|                 .select(.id, .timestampMs)
 | |
|                 .filter(interaction[.wasRead] == false)
 | |
|                 .filter(interaction[.threadId] == threadId)
 | |
|                 .order(interaction[.timestampMs].asc)
 | |
|                 .asRequest(of: Interaction.TimestampInfo.self)
 | |
|                 .fetchOne(db)
 | |
|             let threadIsBlocked: Bool = (threadVariant != .contact ? false :
 | |
|                 try Contact
 | |
|                     .filter(id: threadId)
 | |
|                     .select(.isBlocked)
 | |
|                     .asRequest(of: Bool.self)
 | |
|                     .fetchOne(db)
 | |
|                     .defaulting(to: false)
 | |
|             )
 | |
|             let currentUserIsClosedGroupMember: Bool? = (![.legacyGroup, .group].contains(threadVariant) ? nil :
 | |
|                 GroupMember
 | |
|                     .filter(groupMember[.groupId] == threadId)
 | |
|                     .filter(groupMember[.profileId] == currentUserPublicKey)
 | |
|                     .filter(groupMember[.role] == GroupMember.Role.standard)
 | |
|                     .isNotEmpty(db)
 | |
|             )
 | |
|             let openGroupPermissions: OpenGroup.Permissions? = (threadVariant != .community ? nil :
 | |
|                 try OpenGroup
 | |
|                     .filter(id: threadId)
 | |
|                     .select(.permissions)
 | |
|                     .asRequest(of: OpenGroup.Permissions.self)
 | |
|                     .fetchOne(db)
 | |
|             )
 | |
|             let blinded15Key: String? = SessionThread.getUserHexEncodedBlindedKey(
 | |
|                 db,
 | |
|                 threadId: threadId,
 | |
|                 threadVariant: threadVariant,
 | |
|                 blindingPrefix: .blinded15
 | |
|             )
 | |
|             let blinded25Key: String? = SessionThread.getUserHexEncodedBlindedKey(
 | |
|                 db,
 | |
|                 threadId: threadId,
 | |
|                 threadVariant: threadVariant,
 | |
|                 blindingPrefix: .blinded25
 | |
|             )
 | |
|             
 | |
|             return (
 | |
|                 currentUserPublicKey,
 | |
|                 initialUnreadInteractionInfo,
 | |
|                 threadIsBlocked,
 | |
|                 currentUserIsClosedGroupMember,
 | |
|                 openGroupPermissions,
 | |
|                 blinded15Key,
 | |
|                 blinded25Key
 | |
|             )
 | |
|         }
 | |
|         
 | |
|         self.threadId = threadId
 | |
|         self.initialThreadVariant = threadVariant
 | |
|         self.focusedInteractionInfo = (focusedInteractionInfo ?? initialData?.initialUnreadInteractionInfo)
 | |
|         self.focusBehaviour = (focusedInteractionInfo == nil ? .none : .highlight)
 | |
|         self.initialUnreadInteractionId = initialData?.initialUnreadInteractionInfo?.id
 | |
|         self.threadData = SessionThreadViewModel(
 | |
|             threadId: threadId,
 | |
|             threadVariant: threadVariant,
 | |
|             threadIsNoteToSelf: (initialData?.currentUserPublicKey == threadId),
 | |
|             threadIsBlocked: initialData?.threadIsBlocked,
 | |
|             currentUserIsClosedGroupMember: initialData?.currentUserIsClosedGroupMember,
 | |
|             openGroupPermissions: initialData?.openGroupPermissions
 | |
|         ).populatingCurrentUserBlindedKeys(
 | |
|             currentUserBlinded15PublicKeyForThisThread: initialData?.blinded15Key,
 | |
|             currentUserBlinded25PublicKeyForThisThread: initialData?.blinded25Key
 | |
|         )
 | |
|         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: (initialData?.currentUserPublicKey ?? getUserHexEncodedPublicKey()),
 | |
|             blinded15PublicKey: initialData?.blinded15Key,
 | |
|             blinded25PublicKey: initialData?.blinded25Key
 | |
|         )
 | |
|         
 | |
|         // Run the initial query on a background thread so we don't block the push transition
 | |
|         DispatchQueue.global(qos: .userInitiated).async { [weak self] in
 | |
|             // If we don't have a `initialFocusedInfo` then default to `.pageBefore` (it'll query
 | |
|             // from a `0` offset)
 | |
|             guard let initialFocusedInfo: Interaction.TimestampInfo = (focusedInteractionInfo ?? initialData?.initialUnreadInteractionInfo) else {
 | |
|                 self?.pagedDataObserver?.load(.pageBefore)
 | |
|                 return
 | |
|             }
 | |
|             
 | |
|             self?.pagedDataObserver?.load(.initialPageAround(id: initialFocusedInfo.id))
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     // MARK: - Thread Data
 | |
|     
 | |
|     /// This value is the current state of the view
 | |
|     public private(set) var threadData: SessionThreadViewModel
 | |
|     
 | |
|     /// 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 typealias ThreadObservation = ValueObservation<ValueReducers.Trace<ValueReducers.RemoveDuplicates<ValueReducers.Fetch<SessionThreadViewModel?>>>>
 | |
|     public lazy var observableThreadData: ThreadObservation = setupObservableThreadData(for: self.threadId)
 | |
|     
 | |
|     private func setupObservableThreadData(for threadId: String) -> ThreadObservation {
 | |
|         return ValueObservation
 | |
|             .trackingConstantRegion { [weak self] 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) }
 | |
|                     .map { viewModel -> SessionThreadViewModel in
 | |
|                         viewModel.populatingCurrentUserBlindedKeys(
 | |
|                             db,
 | |
|                             currentUserBlinded15PublicKeyForThisThread: self?.threadData.currentUserBlinded15PublicKey,
 | |
|                             currentUserBlinded25PublicKeyForThisThread: self?.threadData.currentUserBlinded25PublicKey
 | |
|                         )
 | |
|                     }
 | |
|             }
 | |
|             .removeDuplicates()
 | |
|             .handleEvents(didFail: { SNLog("[ConversationViewModel] Observation failed with error: \($0)") })
 | |
|     }
 | |
| 
 | |
|     public func updateThreadData(_ updatedData: SessionThreadViewModel) {
 | |
|         self.threadData = updatedData
 | |
|     }
 | |
|     
 | |
|     // MARK: - Interaction Data
 | |
|     
 | |
|     private var lastInteractionIdMarkedAsRead: Int64? = nil
 | |
|     private var lastInteractionTimestampMsMarkedAsRead: Int64 = 0
 | |
|     public private(set) var unobservedInteractionDataChanges: ([SectionModel], StagedChangeset<[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], 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.unobservedInteractionDataChanges {
 | |
|                 let performChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ())? = onInteractionChange
 | |
|                 
 | |
|                 switch Thread.isMainThread {
 | |
|                     case true: performChange?(changes.0, changes.1)
 | |
|                     case false: DispatchQueue.main.async { performChange?(changes.0, changes.1) }
 | |
|                 }
 | |
|                 
 | |
|                 self.unobservedInteractionDataChanges = nil
 | |
|             }
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     private func setupPagedObserver(
 | |
|         for threadId: String,
 | |
|         userPublicKey: String,
 | |
|         blinded15PublicKey: String?,
 | |
|         blinded25PublicKey: 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("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("JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId])")
 | |
|                     }()
 | |
|                 ),
 | |
|                 PagedData.ObservedChanges(
 | |
|                     table: RecipientState.self,
 | |
|                     columns: [.state, .readTimestampMs, .mostRecentFailureText],
 | |
|                     joinToPagedType: {
 | |
|                         let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
 | |
|                         let recipientState: TypedTableAlias<RecipientState> = TypedTableAlias()
 | |
|                         
 | |
|                         return SQL("LEFT JOIN \(RecipientState.self) ON \(recipientState[.interactionId]) = \(interaction[.id])")
 | |
|                     }()
 | |
|                 ),
 | |
|             ],
 | |
|             filterSQL: MessageViewModel.filterSQL(threadId: threadId),
 | |
|             groupSQL: MessageViewModel.groupSQL,
 | |
|             orderSQL: MessageViewModel.orderSQL,
 | |
|             dataQuery: MessageViewModel.baseQuery(
 | |
|                 userPublicKey: userPublicKey,
 | |
|                 blinded15PublicKey: blinded15PublicKey,
 | |
|                 blinded25PublicKey: blinded25PublicKey,
 | |
|                 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
 | |
|                 self?.resolveOptimisticUpdates(with: updatedData)
 | |
|                 
 | |
|                 PagedData.processAndTriggerUpdates(
 | |
|                     updatedData: self?.process(
 | |
|                         data: updatedData,
 | |
|                         for: updatedPageInfo,
 | |
|                         optimisticMessages: (self?.optimisticallyInsertedMessages.wrappedValue.values)
 | |
|                             .map { $0.map { $0.messageViewModel } },
 | |
|                         initialUnreadInteractionId: self?.initialUnreadInteractionId
 | |
|                     ),
 | |
|                     currentDataRetriever: { self?.interactionData },
 | |
|                     onDataChange: self?.onInteractionChange,
 | |
|                     onUnobservedDataChange: { updatedData, changeset in
 | |
|                         self?.unobservedInteractionDataChanges = (changeset.isEmpty ?
 | |
|                             nil :
 | |
|                             (updatedData, changeset)
 | |
|                         )
 | |
|                     }
 | |
|                 )
 | |
|             }
 | |
|         )
 | |
|     }
 | |
|     
 | |
|     private func process(
 | |
|         data: [MessageViewModel],
 | |
|         for pageInfo: PagedData.PageInfo,
 | |
|         optimisticMessages: [MessageViewModel]?,
 | |
|         initialUnreadInteractionId: Int64?
 | |
|     ) -> [SectionModel] {
 | |
|         let typingIndicator: MessageViewModel? = data.first(where: { $0.isTypingIndicator == true })
 | |
|         let sortedData: [MessageViewModel] = data
 | |
|             .filter { $0.id != MessageViewModel.optimisticUpdateId }    // Remove old optimistic updates
 | |
|             .appending(contentsOf: (optimisticMessages ?? []))          // Insert latest optimistic updates
 | |
|             .filter { !$0.cellType.isPostProcessed }                    // Remove headers and other
 | |
|             .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
 | |
|                                 ),
 | |
|                                 isLastOutgoing: (
 | |
|                                     cellViewModel.id == sortedData
 | |
|                                         .filter {
 | |
|                                             $0.authorId == threadData.currentUserPublicKey ||
 | |
|                                             $0.authorId == threadData.currentUserBlinded15PublicKey ||
 | |
|                                             $0.authorId == threadData.currentUserBlinded25PublicKey
 | |
|                                         }
 | |
|                                         .last?
 | |
|                                         .id
 | |
|                                 ),
 | |
|                                 currentUserBlinded15PublicKey: threadData.currentUserBlinded15PublicKey,
 | |
|                                 currentUserBlinded25PublicKey: threadData.currentUserBlinded25PublicKey
 | |
|                             )
 | |
|                         }
 | |
|                         .reduce([]) { result, message in
 | |
|                             let updatedResult: [MessageViewModel] = result
 | |
|                                 .appending(initialUnreadInteractionId == nil || message.id != initialUnreadInteractionId ?
 | |
|                                    nil :
 | |
|                                     MessageViewModel(
 | |
|                                         timestampMs: message.timestampMs,
 | |
|                                         cellType: .unreadMarker
 | |
|                                     )
 | |
|                             )
 | |
|                             
 | |
|                             guard message.shouldShowDateHeader else {
 | |
|                                 return updatedResult.appending(message)
 | |
|                             }
 | |
|                             
 | |
|                             return updatedResult
 | |
|                                 .appending(
 | |
|                                     MessageViewModel(
 | |
|                                         timestampMs: message.timestampMs,
 | |
|                                         cellType: .dateHeader
 | |
|                                     )
 | |
|                                 )
 | |
|                                 .appending(message)
 | |
|                         }
 | |
|                         .appending(typingIndicator)
 | |
|                 )
 | |
|             ],
 | |
|             (!data.isEmpty && pageInfo.pageOffset > 0 ?
 | |
|                 [SectionModel(section: .loadNewer)] :
 | |
|                 []
 | |
|             )
 | |
|         ].flatMap { $0 }
 | |
|     }
 | |
|     
 | |
|     public func updateInteractionData(_ updatedData: [SectionModel]) {
 | |
|         self.interactionData = updatedData
 | |
|     }
 | |
|     
 | |
|     // MARK: - Optimistic Message Handling
 | |
|     
 | |
|     public typealias OptimisticMessageData = (
 | |
|         id: UUID,
 | |
|         messageViewModel: MessageViewModel,
 | |
|         interaction: Interaction,
 | |
|         attachmentData: Attachment.PreparedData?,
 | |
|         linkPreviewDraft: LinkPreviewDraft?,
 | |
|         linkPreviewAttachment: Attachment?,
 | |
|         quoteModel: QuotedReplyModel?
 | |
|     )
 | |
|     
 | |
|     private var optimisticallyInsertedMessages: Atomic<[UUID: OptimisticMessageData]> = Atomic([:])
 | |
|     private var optimisticMessageAssociatedInteractionIds: Atomic<[Int64: UUID]> = Atomic([:])
 | |
|     
 | |
|     public func optimisticallyAppendOutgoingMessage(
 | |
|         text: String?,
 | |
|         sentTimestampMs: Int64,
 | |
|         attachments: [SignalAttachment]?,
 | |
|         linkPreviewDraft: LinkPreviewDraft?,
 | |
|         quoteModel: QuotedReplyModel?
 | |
|     ) -> OptimisticMessageData {
 | |
|         // Generate the optimistic data
 | |
|         let optimisticMessageId: UUID = UUID()
 | |
|         let currentUserProfile: Profile = Profile.fetchOrCreateCurrentUser()
 | |
|         let interaction: Interaction = Interaction(
 | |
|             threadId: threadData.threadId,
 | |
|             authorId: (threadData.currentUserBlinded15PublicKey ?? threadData.currentUserPublicKey),
 | |
|             variant: .standardOutgoing,
 | |
|             body: text,
 | |
|             timestampMs: sentTimestampMs,
 | |
|             hasMention: Interaction.isUserMentioned(
 | |
|                 publicKeysToCheck: [
 | |
|                     threadData.currentUserPublicKey,
 | |
|                     threadData.currentUserBlinded15PublicKey,
 | |
|                     threadData.currentUserBlinded25PublicKey
 | |
|                 ].compactMap { $0 },
 | |
|                 body: text
 | |
|             ),
 | |
|             expiresInSeconds: threadData.disappearingMessagesConfiguration
 | |
|                 .map { disappearingConfig in
 | |
|                     guard disappearingConfig.isEnabled else { return nil }
 | |
| 
 | |
|                     return disappearingConfig.durationSeconds
 | |
|                 },
 | |
|             linkPreviewUrl: linkPreviewDraft?.urlString
 | |
|         )
 | |
|         let optimisticAttachments: Attachment.PreparedData? = attachments
 | |
|             .map { Attachment.prepare(attachments: $0) }
 | |
|         let linkPreviewAttachment: Attachment? = linkPreviewDraft.map { draft in
 | |
|             try? LinkPreview.generateAttachmentIfPossible(
 | |
|                 imageData: draft.jpegImageData,
 | |
|                 mimeType: OWSMimeTypeImageJpeg
 | |
|             )
 | |
|         }
 | |
|         
 | |
|         // Generate the actual 'MessageViewModel'
 | |
|         let messageViewModel: MessageViewModel = MessageViewModel(
 | |
|             optimisticMessageId: optimisticMessageId,
 | |
|             threadId: threadData.threadId,
 | |
|             threadVariant: threadData.threadVariant,
 | |
|             threadHasDisappearingMessagesEnabled: (threadData.disappearingMessagesConfiguration?.isEnabled ?? false),
 | |
|             threadOpenGroupServer: threadData.openGroupServer,
 | |
|             threadOpenGroupPublicKey: threadData.openGroupPublicKey,
 | |
|             threadContactNameInternal: threadData.threadContactName(),
 | |
|             timestampMs: interaction.timestampMs,
 | |
|             receivedAtTimestampMs: interaction.receivedAtTimestampMs,
 | |
|             authorId: interaction.authorId,
 | |
|             authorNameInternal: currentUserProfile.displayName(),
 | |
|             body: interaction.body,
 | |
|             expiresStartedAtMs: interaction.expiresStartedAtMs,
 | |
|             expiresInSeconds: interaction.expiresInSeconds,
 | |
|             isSenderOpenGroupModerator: OpenGroupManager.isUserModeratorOrAdmin(
 | |
|                 threadData.currentUserPublicKey,
 | |
|                 for: threadData.openGroupRoomToken,
 | |
|                 on: threadData.openGroupServer
 | |
|             ),
 | |
|             currentUserProfile: currentUserProfile,
 | |
|             quote: quoteModel.map { model in
 | |
|                 // Don't care about this optimistic quote (the proper one will be generated in the database)
 | |
|                 Quote(
 | |
|                     interactionId: -1,    // Can't save to db optimistically
 | |
|                     authorId: model.authorId,
 | |
|                     timestampMs: model.timestampMs,
 | |
|                     body: model.body,
 | |
|                     attachmentId: model.attachment?.id
 | |
|                 )
 | |
|             },
 | |
|             quoteAttachment: quoteModel?.attachment,
 | |
|             linkPreview: linkPreviewDraft.map { draft in
 | |
|                 LinkPreview(
 | |
|                     url: draft.urlString,
 | |
|                     title: draft.title,
 | |
|                     attachmentId: nil    // Can't save to db optimistically
 | |
|                 )
 | |
|             },
 | |
|             linkPreviewAttachment: linkPreviewAttachment,
 | |
|             attachments: optimisticAttachments?.attachments
 | |
|         )
 | |
|         let optimisticData: OptimisticMessageData = (
 | |
|             optimisticMessageId,
 | |
|             messageViewModel,
 | |
|             interaction,
 | |
|             optimisticAttachments,
 | |
|             linkPreviewDraft,
 | |
|             linkPreviewAttachment,
 | |
|             quoteModel
 | |
|         )
 | |
|         
 | |
|         optimisticallyInsertedMessages.mutate { $0[optimisticMessageId] = optimisticData }
 | |
|         forceUpdateDataIfPossible()
 | |
|         
 | |
|         return optimisticData
 | |
|     }
 | |
|     
 | |
|     public func failedToStoreOptimisticOutgoingMessage(id: UUID, error: Error) {
 | |
|         optimisticallyInsertedMessages.mutate {
 | |
|             $0[id] = $0[id].map {
 | |
|                 (
 | |
|                     $0.id,
 | |
|                     $0.messageViewModel.with(
 | |
|                         state: .failed,
 | |
|                         mostRecentFailureText: "FAILED_TO_STORE_OUTGOING_MESSAGE".localized()
 | |
|                     ),
 | |
|                     $0.interaction,
 | |
|                     $0.attachmentData,
 | |
|                     $0.linkPreviewDraft,
 | |
|                     $0.linkPreviewAttachment,
 | |
|                     $0.quoteModel
 | |
|                 )
 | |
|             }
 | |
|         }
 | |
|         
 | |
|         forceUpdateDataIfPossible()
 | |
|     }
 | |
|     
 | |
|     /// Record an association between an `optimisticMessageId` and a specific `interactionId`
 | |
|     public func associate(optimisticMessageId: UUID, to interactionId: Int64?) {
 | |
|         guard let interactionId: Int64 = interactionId else { return }
 | |
|         
 | |
|         optimisticMessageAssociatedInteractionIds.mutate { $0[interactionId] = optimisticMessageId }
 | |
|     }
 | |
|     
 | |
|     public func optimisticMessageData(for optimisticMessageId: UUID) -> OptimisticMessageData? {
 | |
|         return optimisticallyInsertedMessages.wrappedValue[optimisticMessageId]
 | |
|     }
 | |
|     
 | |
|     /// Remove any optimisticUpdate entries which have an associated interactionId in the provided data
 | |
|     private func resolveOptimisticUpdates(with data: [MessageViewModel]) {
 | |
|         let interactionIds: [Int64] = data.map { $0.id }
 | |
|         let idsToRemove: [UUID] = optimisticMessageAssociatedInteractionIds
 | |
|             .mutate { associatedIds in interactionIds.compactMap { associatedIds.removeValue(forKey: $0) } }
 | |
|         
 | |
|         optimisticallyInsertedMessages.mutate { messages in idsToRemove.forEach { messages.removeValue(forKey: $0) } }
 | |
|     }
 | |
|     
 | |
|     private func forceUpdateDataIfPossible() {
 | |
|         // If we can't get the current page data then don't bother trying to update (it's not going to work)
 | |
|         guard let currentPageInfo: PagedData.PageInfo = self.pagedDataObserver?.pageInfo.wrappedValue else { return }
 | |
|         
 | |
|         /// **MUST** have the same logic as in the 'PagedDataObserver.onChangeUnsorted' above
 | |
|         let currentData: [SectionModel] = (unobservedInteractionDataChanges?.0 ?? interactionData)
 | |
|         
 | |
|         PagedData.processAndTriggerUpdates(
 | |
|             updatedData: process(
 | |
|                 data: (currentData.first(where: { $0.model == .messages })?.elements ?? []),
 | |
|                 for: currentPageInfo,
 | |
|                 optimisticMessages: optimisticallyInsertedMessages.wrappedValue.values.map { $0.messageViewModel },
 | |
|                 initialUnreadInteractionId: initialUnreadInteractionId
 | |
|             ),
 | |
|             currentDataRetriever: { [weak self] in self?.interactionData },
 | |
|             onDataChange: self.onInteractionChange,
 | |
|             onUnobservedDataChange: { [weak self] updatedData, changeset in
 | |
|                 self?.unobservedInteractionDataChanges = (changeset.isEmpty ?
 | |
|                     nil :
 | |
|                     (updatedData, changeset)
 | |
|                 )
 | |
|             }
 | |
|         )
 | |
|     }
 | |
|     
 | |
|     // MARK: - Mentions
 | |
|     
 | |
|     public func mentions(for query: String = "") -> [MentionInfo] {
 | |
|         let threadData: SessionThreadViewModel = self.threadData
 | |
|         
 | |
|         return Storage.shared
 | |
|             .read { db -> [MentionInfo] in
 | |
|                 let userPublicKey: String = getUserHexEncodedPublicKey(db)
 | |
|                 let pattern: FTS5Pattern? = try? SessionThreadViewModel.pattern(db, searchTerm: query, forTable: Profile.self)
 | |
|                 let capabilities: Set<Capability.Variant> = (threadData.threadVariant != .community ?
 | |
|                     nil :
 | |
|                     try? Capability
 | |
|                         .select(.variant)
 | |
|                         .filter(Capability.Columns.openGroupServer == threadData.openGroupServer)
 | |
|                         .asRequest(of: Capability.Variant.self)
 | |
|                         .fetchSet(db)
 | |
|                 )
 | |
|                 .defaulting(to: [])
 | |
|                 let targetPrefixes: [SessionId.Prefix] = (capabilities.contains(.blind) ?
 | |
|                     [.blinded15, .blinded25] :
 | |
|                     [.standard]
 | |
|                 )
 | |
|                 
 | |
|                 return (try MentionInfo
 | |
|                     .query(
 | |
|                         userPublicKey: userPublicKey,
 | |
|                         threadId: threadData.threadId,
 | |
|                         threadVariant: threadData.threadVariant,
 | |
|                         targetPrefixes: targetPrefixes,
 | |
|                         pattern: pattern
 | |
|                     )?
 | |
|                     .fetchAll(db))
 | |
|                     .defaulting(to: [])
 | |
|             }
 | |
|             .defaulting(to: [])
 | |
|     }
 | |
|     
 | |
|     // 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))
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     /// This method marks a thread as read and depending on the target may also update the interactions within a thread as read
 | |
|     public func markAsRead(
 | |
|         target: SessionThreadViewModel.ReadTarget,
 | |
|         timestampMs: Int64?
 | |
|     ) {
 | |
|         /// Since this method now gets triggered when scrolling we want to try to optimise it and avoid busying the database
 | |
|         /// write queue when it isn't needed, in order to do this we:
 | |
|         /// - Throttle the updates to 100ms (quick enough that users shouldn't notice, but will help the DB when the user flings the list)
 | |
|         /// - Only mark interactions as read if they have newer `timestampMs` or `id` values (ie. were sent later or were more-recent
 | |
|         /// entries in the database), **Note:** Old messages will be marked as read upon insertion so shouldn't be an issue
 | |
|         ///
 | |
|         /// The `ThreadViewModel.markAsRead` method also tries to avoid marking as read if a conversation is already fully read
 | |
|         if markAsReadPublisher == nil {
 | |
|             markAsReadPublisher = markAsReadTrigger
 | |
|                 .throttle(for: .milliseconds(100), scheduler: DispatchQueue.global(qos: .userInitiated), latest: true)
 | |
|                 .handleEvents(
 | |
|                     receiveOutput: { [weak self] target, timestampMs in
 | |
|                         switch target {
 | |
|                             case .thread: self?.threadData.markAsRead(target: target)
 | |
|                             case .threadAndInteractions(let interactionId):
 | |
|                                 guard
 | |
|                                     timestampMs == nil ||
 | |
|                                     (self?.lastInteractionTimestampMsMarkedAsRead ?? 0) < (timestampMs ?? 0) ||
 | |
|                                     (self?.lastInteractionIdMarkedAsRead ?? 0) < (interactionId ?? 0)
 | |
|                                 else {
 | |
|                                     self?.threadData.markAsRead(target: .thread)
 | |
|                                     return
 | |
|                                 }
 | |
|                                 
 | |
|                                 // If we were given a timestamp then update the 'lastInteractionTimestampMsMarkedAsRead'
 | |
|                                 // to avoid needless updates
 | |
|                                 if let timestampMs: Int64 = timestampMs {
 | |
|                                     self?.lastInteractionTimestampMsMarkedAsRead = timestampMs
 | |
|                                 }
 | |
|                                 
 | |
|                                 self?.lastInteractionIdMarkedAsRead = (interactionId ?? self?.threadData.interactionId)
 | |
|                                 self?.threadData.markAsRead(target: target)
 | |
|                         }
 | |
|                     }
 | |
|                 )
 | |
|                 .map { _ in () }
 | |
|                 .eraseToAnyPublisher()
 | |
|             
 | |
|             markAsReadPublisher?.sinkUntilComplete()
 | |
|         }
 | |
|         
 | |
|         markAsReadTrigger.send((target, timestampMs))
 | |
|     }
 | |
|     
 | |
|     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(),
 | |
|             blinded15PublicKey: nil,
 | |
|             blinded25PublicKey: nil
 | |
|         )
 | |
|         
 | |
|         // 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)
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     public func trustContact() {
 | |
|         guard self.threadData.threadVariant == .contact else { return }
 | |
|         
 | |
|         let threadId: String = self.threadId
 | |
|         
 | |
|         Storage.shared.writeAsync { db in
 | |
|             try Contact
 | |
|                 .filter(id: threadId)
 | |
|                 .updateAll(db, Contact.Columns.isTrusted.set(to: true))
 | |
|             
 | |
|             // Start downloading any pending attachments for this contact (UI will automatically be
 | |
|             // updated due to the database observation)
 | |
|             try Attachment
 | |
|                 .stateInfo(authorId: threadId, state: .pendingDownload)
 | |
|                 .fetchAll(db)
 | |
|                 .forEach { attachmentDownloadInfo in
 | |
|                     JobRunner.add(
 | |
|                         db,
 | |
|                         job: Job(
 | |
|                             variant: .attachmentDownload,
 | |
|                             threadId: threadId,
 | |
|                             interactionId: attachmentDownloadInfo.interactionId,
 | |
|                             details: AttachmentDownloadJob.Details(
 | |
|                                 attachmentId: attachmentDownloadInfo.attachmentId
 | |
|                             )
 | |
|                         )
 | |
|                     )
 | |
|                 }
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     public func unblockContact() {
 | |
|         guard self.threadData.threadVariant == .contact else { return }
 | |
|         
 | |
|         let threadId: String = self.threadId
 | |
|         
 | |
|         Storage.shared.writeAsync { db in
 | |
|             try Contact
 | |
|                 .filter(id: threadId)
 | |
|                 .updateAllAndConfig(db, Contact.Columns.isBlocked.set(to: false))
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     public func expandReactions(for interactionId: Int64) {
 | |
|         reactionExpandedInteractionIds.insert(interactionId)
 | |
|     }
 | |
|     
 | |
|     public func collapseReactions(for interactionId: Int64) {
 | |
|         reactionExpandedInteractionIds.remove(interactionId)
 | |
|     }
 | |
|     
 | |
|     // 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 }
 | |
|         
 | |
|         let currentPlaybackTime: TimeInterval? = playbackInfo.wrappedValue[viewModel.id]?.progress
 | |
|         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(currentPlaybackTime ?? 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)
 | |
|     }
 | |
| }
 |