mirror of
https://github.com/oxen-io/session-ios.git
synced 2023-12-13 21:30:14 +01:00
1224e539ea
Updated the database to better support the application getting suspended (0xdead10cc crash) Updated the SOGS message handling to delete messages based on a new 'deleted' flag instead of 'data' being null Updated the code to prevent the typing indicator from needing a DB write block as frequently Updated the code to stop any pending jobs when entering the background (in an attempt to prevent the database suspension from causing issues) Removed the duplicate 'Capabilities.Capability' type (updated 'Capability.Variant' to work in the same way) Fixed a bug where a number of icons (inc. the "download document" icon) were the wrong colour in dark mode Fixed a bug where the '@You' highlight could incorrectly have it's width reduced in some cases (had protection to prevent it being larger than the line, but that is a valid case) Fixed a bug where the JobRunner was starting the background (which could lead to trying to access the database once it had been suspended) Updated to the latest version of GRDB Added some logic to the BackgroundPoller process to try and stop processing if the timeout is triggered (will catch some cases but others will end up logging a bunch of "Database is suspended" errors) Added in some protection to prevent future deferral loops in the JobRunner
715 lines
32 KiB
Swift
715 lines
32 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)
|
|
|
|
return try SessionThreadViewModel
|
|
.conversationQuery(threadId: threadId, userPublicKey: userPublicKey)
|
|
.fetchOne(db)
|
|
}
|
|
.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 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])")
|
|
}()
|
|
)
|
|
],
|
|
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.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
|
|
}
|
|
|
|
// 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
|
|
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)
|
|
}
|
|
}
|