mirror of
https://github.com/lux-tel/session-ios.git
synced 2026-10-30 20:38:32 +00:00
1659 lines
75 KiB
Swift
1659 lines
75 KiB
Swift
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
|
|
|
import Foundation
|
|
import Combine
|
|
import UniformTypeIdentifiers
|
|
import Lucide
|
|
import GRDB
|
|
import DifferenceKit
|
|
import SessionNetworkingKit
|
|
import SessionMessagingKit
|
|
import SessionUtilitiesKit
|
|
import SessionUIKit
|
|
|
|
// MARK: - Log.Category
|
|
|
|
public extension Log.Category {
|
|
static let conversation: Log.Category = .create("Conversation", defaultLevel: .info)
|
|
}
|
|
|
|
// MARK: - ConversationViewModel
|
|
|
|
public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHolder {
|
|
public typealias SectionModel = ArraySection<Section, MessageViewModel>
|
|
|
|
// MARK: - FocusBehaviour
|
|
|
|
public enum FocusBehaviour: Sendable, Equatable, Hashable {
|
|
case none
|
|
case highlight
|
|
}
|
|
|
|
// MARK: - ContentSwapLocation
|
|
|
|
public enum ContentSwapLocation {
|
|
case none
|
|
case earlier
|
|
case later
|
|
}
|
|
|
|
// 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: - OptimisticMessageData
|
|
|
|
public struct OptimisticMessageData: Sendable, Equatable, Hashable {
|
|
let temporaryId: Int64
|
|
let interaction: Interaction
|
|
let attachmentData: [Attachment]?
|
|
let linkPreviewViewModel: LinkPreviewViewModel?
|
|
let linkPreviewPreparedAttachment: PreparedAttachment?
|
|
let quoteViewModel: QuoteViewModel?
|
|
}
|
|
|
|
// MARK: - Variables
|
|
|
|
public static let pageSize: Int = 50
|
|
public static let legacyGroupsBannerFont: UIFont = .systemFont(ofSize: Values.miniFontSize)
|
|
|
|
public let navigatableState: NavigatableState = NavigatableState()
|
|
public var disposables: Set<AnyCancellable> = Set()
|
|
|
|
public let dependencies: Dependencies
|
|
public var sentMessageBeforeUpdate: Bool = false
|
|
public var lastSearchedText: String?
|
|
|
|
// FIXME: Can avoid this by making the view model an actor (but would require more work)
|
|
/// Marked as `@MainActor` just to force thread safety
|
|
@MainActor private var pendingMarkAsReadInfo: Interaction.TimestampInfo?
|
|
@MainActor private var lastMarkAsReadInfo: Interaction.TimestampInfo?
|
|
|
|
/// This value is the current state of the view
|
|
@MainActor @Published private(set) var state: State
|
|
private var observationTask: Task<Void, Never>?
|
|
|
|
// MARK: - Initialization
|
|
|
|
@MainActor init(
|
|
threadInfo: ConversationInfoViewModel,
|
|
focusedInteractionInfo: Interaction.TimestampInfo? = nil,
|
|
currentUserMentionImage: UIImage,
|
|
using dependencies: Dependencies
|
|
) {
|
|
self.dependencies = dependencies
|
|
self.state = State.initialState(
|
|
threadInfo: threadInfo,
|
|
focusedInteractionInfo: focusedInteractionInfo,
|
|
currentUserMentionImage: currentUserMentionImage,
|
|
using: dependencies
|
|
)
|
|
|
|
/// Bind the state
|
|
self.observationTask = ObservationBuilder
|
|
.initialValue(self.state)
|
|
.debounce(for: .milliseconds(10)) /// Changes trigger multiple events at once so debounce them
|
|
.using(dependencies: dependencies)
|
|
.query(ConversationViewModel.queryState)
|
|
.assign { [weak self] updatedState in self?.state = updatedState }
|
|
}
|
|
|
|
deinit {
|
|
// Stop any audio playing when leaving the screen
|
|
Task { @MainActor [audioPlayer] in
|
|
audioPlayer?.stop()
|
|
}
|
|
|
|
observationTask?.cancel()
|
|
}
|
|
|
|
public enum ConversationViewModelEvent: Hashable {
|
|
case sendMessage(data: OptimisticMessageData)
|
|
case failedToStoreMessage(temporaryId: Int64)
|
|
case resolveOptimisticMessage(temporaryId: Int64, databaseId: Int64)
|
|
}
|
|
|
|
// MARK: - State
|
|
|
|
public struct State: ObservableKeyProvider {
|
|
enum ViewState: Equatable {
|
|
case loading
|
|
case empty
|
|
case loaded
|
|
}
|
|
|
|
let viewState: ViewState
|
|
let threadInfo: ConversationInfoViewModel
|
|
let authMethod: EquatableAuthenticationMethod
|
|
let currentUserMentionImage: UIImage
|
|
let isBlindedContact: Bool
|
|
let wasPreviouslyBlindedContact: Bool
|
|
|
|
/// Used to determine where the paged data should start loading from, and which message should be focused on initial load
|
|
let focusedInteractionInfo: Interaction.TimestampInfo?
|
|
let focusBehaviour: FocusBehaviour
|
|
let initialUnreadInteractionInfo: Interaction.TimestampInfo?
|
|
|
|
let loadedPageInfo: PagedData.LoadedInfo<MessageViewModel.ID>
|
|
let dataCache: ConversationDataCache
|
|
let itemCache: [MessageViewModel.ID: MessageViewModel]
|
|
|
|
let titleViewModel: ConversationTitleViewModel
|
|
let legacyGroupsBannerIsVisible: Bool
|
|
let reactionsSupported: Bool
|
|
let recentReactionEmoji: [String]
|
|
let isUserModeratorOrAdmin: Bool
|
|
let shouldShowTypingIndicator: Bool
|
|
|
|
let optimisticallyInsertedMessages: [Int64: OptimisticMessageData]
|
|
|
|
// Convenience
|
|
|
|
var threadId: String { threadInfo.id }
|
|
var threadVariant: SessionThread.Variant { threadInfo.variant }
|
|
var userSessionId: SessionId { threadInfo.userSessionId }
|
|
|
|
var emptyStateText: String {
|
|
let blocksCommunityMessageRequests: Bool = (threadInfo.profile?.blocksCommunityMessageRequests == true)
|
|
|
|
switch (threadInfo.isNoteToSelf, threadInfo.canWrite, blocksCommunityMessageRequests, threadInfo.groupInfo?.wasKicked, threadInfo.groupInfo?.isDestroyed) {
|
|
case (true, _, _, _, _): return "noteToSelfEmpty".localized()
|
|
case (_, false, true, _, _):
|
|
return "messageRequestsTurnedOff"
|
|
.put(key: "name", value: threadInfo.displayName.deformatted())
|
|
.localized()
|
|
|
|
case (_, _, _, _, true):
|
|
return "groupDeletedMemberDescription"
|
|
.put(key: "group_name", value: threadInfo.displayName.deformatted())
|
|
.localized()
|
|
|
|
case (_, _, _, true, _):
|
|
return "groupRemovedYou"
|
|
.put(key: "group_name", value: threadInfo.displayName.deformatted())
|
|
.localized()
|
|
|
|
case (_, false, false, _, _):
|
|
return "conversationsEmpty"
|
|
.put(key: "conversation_name", value: threadInfo.displayName.deformatted())
|
|
.localized()
|
|
|
|
default:
|
|
return "groupNoMessages"
|
|
.put(key: "group_name", value: threadInfo.displayName.deformatted())
|
|
.localized()
|
|
}
|
|
}
|
|
|
|
var legacyGroupsBannerMessage: ThemedAttributedString {
|
|
let localizationKey: String
|
|
|
|
switch threadInfo.groupInfo?.currentUserRole == .admin {
|
|
case false: localizationKey = "legacyGroupAfterDeprecationMember"
|
|
case true: localizationKey = "legacyGroupAfterDeprecationAdmin"
|
|
}
|
|
|
|
// FIXME: Strings should be updated in Crowdin to include the {icon}
|
|
return LocalizationHelper(template: localizationKey)
|
|
.put(key: "date", value: Date(timeIntervalSince1970: 1743631200).formattedForBanner)
|
|
.localizedFormatted(baseFont: ConversationViewModel.legacyGroupsBannerFont)
|
|
.appending(string: " ") // Designs have a space before the icon
|
|
.appending(
|
|
Lucide.Icon.squareArrowUpRight
|
|
.attributedString(for: ConversationViewModel.legacyGroupsBannerFont)
|
|
)
|
|
.appending(string: " ") // In case it's a RTL font
|
|
}
|
|
|
|
public var messageInputState: InputView.InputState {
|
|
guard !threadInfo.isNoteToSelf else { return InputView.InputState(inputs: .all) }
|
|
guard !threadInfo.isBlocked else {
|
|
return InputView.InputState(
|
|
inputs: .disabled,
|
|
message: "blockBlockedDescription".localized(),
|
|
messageAccessibility: Accessibility(
|
|
identifier: "Blocked banner"
|
|
)
|
|
)
|
|
}
|
|
|
|
// TODO: [BUGFIXING] Need copy for these cases
|
|
guard threadInfo.canWrite else {
|
|
switch threadInfo.variant {
|
|
case .contact:
|
|
return InputView.InputState(
|
|
inputs: .disabled,
|
|
message: "You cannot send messages to this user." // TODO: [BUGFIXING] blocks community message requests or generic
|
|
)
|
|
|
|
case .group:
|
|
return InputView.InputState(
|
|
inputs: .disabled,
|
|
message: "You cannot send messages to this group."
|
|
)
|
|
|
|
case .legacyGroup:
|
|
return InputView.InputState(
|
|
inputs: .disabled,
|
|
message: "This group is read-only."
|
|
)
|
|
|
|
case .community:
|
|
return InputView.InputState(
|
|
inputs: .disabled,
|
|
message: "permissionsWriteCommunity".localized()
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Attachments shouldn't be allowed for message requests or if uploads are disabled
|
|
let finalInputs: InputView.Input
|
|
|
|
switch (threadInfo.requiresApproval, threadInfo.isMessageRequest, threadInfo.canUpload) {
|
|
case (false, false, true): finalInputs = .all
|
|
default: finalInputs = [.text, .attachmentsDisabled, .voiceMessagesDisabled]
|
|
}
|
|
|
|
return InputView.InputState(
|
|
inputs: finalInputs
|
|
)
|
|
}
|
|
|
|
@MainActor public func sections(viewModel: ConversationViewModel) -> [SectionModel] {
|
|
ConversationViewModel.sections(state: self, viewModel: viewModel)
|
|
}
|
|
|
|
public var observedKeys: Set<ObservableKey> {
|
|
var result: Set<ObservableKey> = [
|
|
.appLifecycle(.willEnterForeground),
|
|
.databaseLifecycle(.resumed),
|
|
.loadPage(ConversationViewModel.self),
|
|
.updateScreen(ConversationViewModel.self),
|
|
.conversationUpdated(threadInfo.id),
|
|
.conversationDeleted(threadInfo.id),
|
|
.profile(threadInfo.userSessionId.hexString),
|
|
.typingIndicator(threadInfo.id),
|
|
.messageCreated(threadId: threadInfo.id),
|
|
.recentReactionsUpdated
|
|
]
|
|
|
|
if SessionId.Prefix.isCommunityBlinded(threadInfo.id) {
|
|
result.insert(.anyContactUnblinded)
|
|
}
|
|
|
|
result.insert(contentsOf: threadInfo.observedKeys)
|
|
result.insert(contentsOf: Set(itemCache.values.flatMap { $0.observedKeys }))
|
|
|
|
return result
|
|
}
|
|
|
|
static func initialState(
|
|
threadInfo: ConversationInfoViewModel,
|
|
focusedInteractionInfo: Interaction.TimestampInfo?,
|
|
currentUserMentionImage: UIImage,
|
|
using dependencies: Dependencies
|
|
) -> State {
|
|
let dataCache: ConversationDataCache = ConversationDataCache(
|
|
userSessionId: dependencies[cache: .general].sessionId,
|
|
context: ConversationDataCache.Context(
|
|
source: .messageList(threadId: threadInfo.id),
|
|
requireFullRefresh: false,
|
|
requireAuthMethodFetch: false,
|
|
requiresMessageRequestCountUpdate: false,
|
|
requiresInitialUnreadInteractionInfo: false,
|
|
requireRecentReactionEmojiUpdate: false
|
|
)
|
|
)
|
|
|
|
return State(
|
|
viewState: .loading,
|
|
threadInfo: threadInfo,
|
|
authMethod: EquatableAuthenticationMethod(value: Authentication.invalid),
|
|
currentUserMentionImage: currentUserMentionImage,
|
|
isBlindedContact: SessionId.Prefix.isCommunityBlinded(threadInfo.id),
|
|
wasPreviouslyBlindedContact: SessionId.Prefix.isCommunityBlinded(threadInfo.id),
|
|
focusedInteractionInfo: focusedInteractionInfo,
|
|
focusBehaviour: (focusedInteractionInfo == nil ? .none : .highlight),
|
|
initialUnreadInteractionInfo: nil,
|
|
loadedPageInfo: PagedData.LoadedInfo(
|
|
record: Interaction.self,
|
|
pageSize: ConversationViewModel.pageSize,
|
|
requiredJoinSQL: nil,
|
|
filterSQL: MessageViewModel.interactionFilterSQL(threadId: threadInfo.id),
|
|
groupSQL: nil,
|
|
orderSQL: MessageViewModel.interactionOrderSQL
|
|
),
|
|
dataCache: dataCache,
|
|
itemCache: [:],
|
|
titleViewModel: ConversationTitleViewModel(
|
|
threadInfo: threadInfo,
|
|
dataCache: dataCache,
|
|
using: dependencies
|
|
),
|
|
legacyGroupsBannerIsVisible: (threadInfo.variant == .legacyGroup),
|
|
reactionsSupported: (
|
|
threadInfo.variant != .legacyGroup &&
|
|
threadInfo.isMessageRequest != true
|
|
),
|
|
recentReactionEmoji: [],
|
|
isUserModeratorOrAdmin: false,
|
|
shouldShowTypingIndicator: false,
|
|
optimisticallyInsertedMessages: [:]
|
|
)
|
|
}
|
|
|
|
fileprivate static func orderedIdsIncludingOptimisticMessages(
|
|
loadedPageInfo: PagedData.LoadedInfo<MessageViewModel.ID>,
|
|
optimisticMessages: [Int64: OptimisticMessageData],
|
|
dataCache: ConversationDataCache
|
|
) -> [Int64] {
|
|
guard !optimisticMessages.isEmpty else { return loadedPageInfo.currentIds }
|
|
|
|
/// **Note:** The sorting of `currentIds` is newest to oldest so we need to insert in the same way
|
|
var remainingPagedIds: [Int64] = loadedPageInfo.currentIds
|
|
var remainingSortedOptimisticMessages: [(Int64, OptimisticMessageData)] = optimisticMessages
|
|
.sorted { lhs, rhs in
|
|
lhs.value.interaction.timestampMs > rhs.value.interaction.timestampMs
|
|
}
|
|
var result: [Int64] = []
|
|
|
|
while !remainingPagedIds.isEmpty || !remainingSortedOptimisticMessages.isEmpty {
|
|
let nextPaged: Interaction? = remainingPagedIds.first.map { dataCache.interaction(for: $0) }
|
|
let nextOptimistic: OptimisticMessageData? = remainingSortedOptimisticMessages.first?.1
|
|
|
|
switch (nextPaged, nextOptimistic) {
|
|
case (.some(let paged), .some(let optimistic)): /// Add the newest first and loop
|
|
if optimistic.interaction.timestampMs >= paged.timestampMs {
|
|
result.append(optimistic.temporaryId)
|
|
remainingSortedOptimisticMessages.removeFirst()
|
|
}
|
|
else {
|
|
paged.id.map { result.append($0) }
|
|
remainingPagedIds.removeFirst()
|
|
}
|
|
|
|
case (.some, .none): /// No optimistic messages left, add the remaining paged messages
|
|
result.append(contentsOf: remainingPagedIds)
|
|
remainingPagedIds.removeAll()
|
|
|
|
case (.none, .some): /// No paged results left, add the remaining optimistic messages
|
|
result.append(contentsOf: remainingSortedOptimisticMessages.map { $0.0 })
|
|
remainingSortedOptimisticMessages.removeAll()
|
|
|
|
case (.none, .none): return result /// Invalid case
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
fileprivate static func interaction(
|
|
at index: Int,
|
|
orderedIds: [Int64],
|
|
optimisticMessages: [Int64: OptimisticMessageData],
|
|
dataCache: ConversationDataCache
|
|
) -> Interaction? {
|
|
guard index >= 0, index < orderedIds.count else { return nil }
|
|
guard orderedIds[index] >= 0 else {
|
|
/// If the `id` is less than `0` then it's an optimistic message
|
|
return optimisticMessages[orderedIds[index]]?.interaction
|
|
}
|
|
|
|
return dataCache.interaction(for: orderedIds[index])
|
|
}
|
|
}
|
|
|
|
@Sendable private static func queryState(
|
|
previousState: State,
|
|
events: [ObservedEvent],
|
|
isInitialQuery: Bool,
|
|
using dependencies: Dependencies
|
|
) async -> State {
|
|
var threadId: String = previousState.threadInfo.id
|
|
var threadInfo: ConversationInfoViewModel = previousState.threadInfo
|
|
var authMethod: EquatableAuthenticationMethod = previousState.authMethod
|
|
var focusedInteractionInfo: Interaction.TimestampInfo? = previousState.focusedInteractionInfo
|
|
var initialUnreadInteractionInfo: Interaction.TimestampInfo? = previousState.initialUnreadInteractionInfo
|
|
var loadResult: PagedData.LoadResult = previousState.loadedPageInfo.asResult
|
|
var dataCache: ConversationDataCache = previousState.dataCache
|
|
var itemCache: [MessageViewModel.ID: MessageViewModel] = previousState.itemCache
|
|
var reactionsSupported: Bool = previousState.reactionsSupported
|
|
var recentReactionEmoji: [String] = previousState.recentReactionEmoji
|
|
var isUserModeratorOrAdmin: Bool = previousState.isUserModeratorOrAdmin
|
|
var shouldShowTypingIndicator: Bool = false
|
|
var optimisticallyInsertedMessages: [Int64: OptimisticMessageData] = previousState.optimisticallyInsertedMessages
|
|
|
|
/// Store a local copy of the events so we can manipulate it based on the state changes
|
|
var eventsToProcess: [ObservedEvent] = events
|
|
var shouldFetchInitialUnreadInteractionInfo: Bool = false
|
|
var shouldFetchInitialRecentReactions: Bool = false
|
|
|
|
/// If this is the initial query then we need to properly fetch the initial state
|
|
if isInitialQuery {
|
|
/// Insert a fake event to force the initial page load
|
|
eventsToProcess.append(ObservedEvent(
|
|
key: .loadPage(ConversationViewModelEvent.self),
|
|
value: (
|
|
focusedInteractionInfo.map { LoadPageEvent.initialPageAround(id: $0.id) } ??
|
|
LoadPageEvent.initial
|
|
)
|
|
))
|
|
|
|
/// Determine reactions support
|
|
switch threadInfo.variant {
|
|
case .legacyGroup:
|
|
reactionsSupported = false
|
|
isUserModeratorOrAdmin = (threadInfo.groupInfo?.currentUserRole == .admin)
|
|
|
|
case .contact:
|
|
reactionsSupported = !threadInfo.isMessageRequest
|
|
shouldShowTypingIndicator = await dependencies[singleton: .typingIndicators]
|
|
.isRecipientTyping(threadId: threadInfo.id)
|
|
|
|
case .group:
|
|
reactionsSupported = !threadInfo.isMessageRequest
|
|
isUserModeratorOrAdmin = (threadInfo.groupInfo?.currentUserRole == .admin)
|
|
|
|
case .community:
|
|
reactionsSupported = await dependencies[singleton: .communityManager].doesOpenGroupSupport(
|
|
capability: .reactions,
|
|
on: threadInfo.communityInfo?.server
|
|
)
|
|
}
|
|
|
|
/// Determine whether we need to fetch the initial unread interaction info
|
|
shouldFetchInitialUnreadInteractionInfo = (initialUnreadInteractionInfo == nil)
|
|
|
|
/// We need to fetch the recent reactions if they are supported
|
|
shouldFetchInitialRecentReactions = reactionsSupported
|
|
|
|
/// Check if the typing indicator should be visible
|
|
shouldShowTypingIndicator = await dependencies[singleton: .typingIndicators].isRecipientTyping(
|
|
threadId: threadId
|
|
)
|
|
}
|
|
|
|
/// If there are no events we want to process then just return the current state
|
|
guard isInitialQuery || !eventsToProcess.isEmpty else { return previousState }
|
|
|
|
/// Split the events between those that need database access and those that don't
|
|
var changes: EventChangeset = eventsToProcess.split(by: { $0.handlingStrategy })
|
|
var loadPageEvent: LoadPageEvent? = changes.latestGeneric(.loadPage, as: LoadPageEvent.self)
|
|
|
|
/// Need to handle a potential "unblinding" event first since it changes the `threadId` (and then we reload the messages
|
|
/// based on the initial paged data query just in case - there isn't a perfect solution to capture the current messages plus any
|
|
/// others that may have been added by the merge so do the best we can)
|
|
if let event: ContactEvent = changes.latest(.anyContactUnblinded, as: ContactEvent.self) {
|
|
switch event.change {
|
|
case .unblinded(let blindedId, let unblindedId):
|
|
/// Need to handle a potential "unblinding" event first since it changes the `threadId` (and then
|
|
/// we reload the messages based on the initial paged data query just in case - there isn't a perfect
|
|
/// solution to capture the current messages plus any others that may have been added by the
|
|
/// merge so do the best we can)
|
|
guard blindedId == threadId else { break }
|
|
|
|
threadId = unblindedId
|
|
loadResult = loadResult.info
|
|
.with(filterSQL: MessageViewModel.interactionFilterSQL(threadId: unblindedId))
|
|
.asResult
|
|
loadPageEvent = .initial
|
|
eventsToProcess = eventsToProcess
|
|
.filter { $0.key.generic != .loadPage }
|
|
.appending(
|
|
ObservedEvent(
|
|
key: .loadPage(ConversationViewModel.self),
|
|
value: LoadPageEvent.initial
|
|
)
|
|
)
|
|
changes = eventsToProcess.split(by: { $0.handlingStrategy })
|
|
|
|
default: break
|
|
}
|
|
}
|
|
|
|
/// Update the context
|
|
dataCache.withContext(
|
|
source: .messageList(threadId: threadId),
|
|
requireFullRefresh: (
|
|
isInitialQuery ||
|
|
threadInfo.id != threadId ||
|
|
changes.containsAny(
|
|
.appLifecycle(.willEnterForeground),
|
|
.databaseLifecycle(.resumed)
|
|
)
|
|
),
|
|
requireAuthMethodFetch: authMethod.value.isInvalid,
|
|
requiresInitialUnreadInteractionInfo: shouldFetchInitialUnreadInteractionInfo,
|
|
requireRecentReactionEmojiUpdate: (
|
|
shouldFetchInitialRecentReactions ||
|
|
changes.contains(.recentReactionsUpdated)
|
|
)
|
|
)
|
|
|
|
/// Handle thread specific changes first (as this could include a conversation being unblinded)
|
|
switch threadInfo.variant {
|
|
case .community:
|
|
/// Handle community changes (users could change to mods which would need all of their interaction data updated)
|
|
changes.forEach(.communityUpdated, as: CommunityEvent.self) { event in
|
|
switch event.change {
|
|
case .receivedInitialMessages:
|
|
/// If we already have a `loadPageEvent` then that takes prescedence, otherwise we should load
|
|
/// the initial page once we've received the initial messages for a community
|
|
guard loadPageEvent == nil else { break }
|
|
|
|
loadPageEvent = .initial
|
|
|
|
case .role, .moderatorsAndAdmins, .capabilities, .permissions: break
|
|
}
|
|
}
|
|
|
|
default: break
|
|
}
|
|
|
|
/// Then process cache updates
|
|
dataCache = await ConversationDataHelper.applyNonDatabaseEvents(
|
|
changes,
|
|
currentCache: dataCache,
|
|
using: dependencies
|
|
)
|
|
|
|
/// Then determine the fetch requirements
|
|
let fetchRequirements: ConversationDataHelper.FetchRequirements = ConversationDataHelper.determineFetchRequirements(
|
|
for: changes,
|
|
currentCache: dataCache,
|
|
itemCache: itemCache,
|
|
loadPageEvent: loadPageEvent
|
|
)
|
|
|
|
/// Peform any database changes
|
|
if !dependencies[singleton: .storage].isSuspended, fetchRequirements.needsAnyFetch {
|
|
do {
|
|
try await dependencies[singleton: .storage].readAsync { db in
|
|
/// Fetch the `authMethod` if needed
|
|
///
|
|
/// **Note:** It's possible that we won't be able to fetch the `authMethod` (eg. if a group was destroyed or
|
|
/// the user was kicked from a group), in that case just fail silently (it's an expected behaviour - won't be able to
|
|
/// send requests anymore)
|
|
if fetchRequirements.requireAuthMethodFetch {
|
|
// TODO: [Database Relocation] Should be able to remove the database requirement now we have the CommunityManager
|
|
let maybeAuthMethod: AuthenticationMethod? = try? Authentication.with(
|
|
db,
|
|
threadId: threadInfo.id,
|
|
threadVariant: threadInfo.variant,
|
|
using: dependencies
|
|
)
|
|
|
|
authMethod = EquatableAuthenticationMethod(
|
|
value: (maybeAuthMethod ?? Authentication.invalid)
|
|
)
|
|
}
|
|
|
|
/// Fetch the `initialUnreadInteractionInfo` if needed
|
|
if fetchRequirements.requiresInitialUnreadInteractionInfo {
|
|
initialUnreadInteractionInfo = try Interaction
|
|
.select(.id, .timestampMs)
|
|
.filter(Interaction.Columns.wasRead == false)
|
|
.filter(Interaction.Columns.threadId == threadId)
|
|
.order(Interaction.Columns.timestampMs.asc)
|
|
.asRequest(of: Interaction.TimestampInfo.self)
|
|
.fetchOne(db)
|
|
}
|
|
|
|
if fetchRequirements.requireRecentReactionEmojiUpdate {
|
|
recentReactionEmoji = try Emoji.getRecent(db, withDefaultEmoji: true)
|
|
}
|
|
|
|
/// If we don't have an initial `focusedInteractionInfo` (as determined by the `loadPageEvent.target`
|
|
/// being `initial`) then we should default to loading data around the `initialUnreadInteractionInfo`
|
|
/// and focusing on it
|
|
if
|
|
loadPageEvent?.target == .initial,
|
|
let initialUnreadInteractionInfo: Interaction.TimestampInfo = initialUnreadInteractionInfo
|
|
{
|
|
loadPageEvent = .initialPageAround(id: initialUnreadInteractionInfo.id)
|
|
focusedInteractionInfo = initialUnreadInteractionInfo
|
|
}
|
|
|
|
/// Fetch any required data from the cache
|
|
(loadResult, dataCache) = try ConversationDataHelper.fetchFromDatabase(
|
|
db,
|
|
requirements: fetchRequirements,
|
|
currentCache: dataCache,
|
|
loadResult: loadResult,
|
|
loadPageEvent: loadPageEvent,
|
|
using: dependencies
|
|
)
|
|
}
|
|
} catch {
|
|
let eventList: String = changes.databaseEvents.map { $0.key.rawValue }.joined(separator: ", ")
|
|
Log.critical(.conversation, "Failed to fetch state for events [\(eventList)], due to error: \(error)")
|
|
}
|
|
}
|
|
else if !changes.databaseEvents.isEmpty {
|
|
Log.warn(.conversation, "Ignored \(changes.databaseEvents.count) database event(s) sent while storage was suspended.")
|
|
}
|
|
|
|
/// Peform any `libSession` changes
|
|
if fetchRequirements.needsAnyFetch {
|
|
do {
|
|
dataCache = try ConversationDataHelper.fetchFromLibSession(
|
|
requirements: fetchRequirements,
|
|
cache: dataCache,
|
|
using: dependencies
|
|
)
|
|
}
|
|
catch {
|
|
Log.warn(.conversation, "Failed to handle \(changes.libSessionEvents.count) libSession event(s) due to error: \(error).")
|
|
}
|
|
}
|
|
|
|
/// Update the typing indicator state if needed
|
|
changes.forEach(.typingIndicator, as: TypingIndicatorEvent.self) { event in
|
|
shouldShowTypingIndicator = (event.change == .started)
|
|
}
|
|
|
|
/// Handle optimistic messages
|
|
changes.forEach(.updateScreen, as: ConversationViewModelEvent.self) { event in
|
|
switch event {
|
|
case .sendMessage(let data):
|
|
optimisticallyInsertedMessages[data.temporaryId] = data
|
|
|
|
if let attachments: [Attachment] = data.attachmentData {
|
|
dataCache.insert(attachments: attachments)
|
|
dataCache.insert(
|
|
attachmentMap: [
|
|
data.temporaryId: Set(attachments.enumerated().map { index, attachment in
|
|
InteractionAttachment(
|
|
albumIndex: index,
|
|
interactionId: data.temporaryId,
|
|
attachmentId: attachment.id
|
|
)
|
|
})
|
|
]
|
|
)
|
|
}
|
|
|
|
if let viewModel: LinkPreviewViewModel = data.linkPreviewViewModel {
|
|
dataCache.insert(linkPreviews: [
|
|
LinkPreview(
|
|
url: viewModel.urlString,
|
|
title: viewModel.title,
|
|
attachmentId: nil, /// Can't save to db optimistically
|
|
using: dependencies
|
|
)
|
|
])
|
|
}
|
|
|
|
case .failedToStoreMessage(let temporaryId):
|
|
guard let data: OptimisticMessageData = optimisticallyInsertedMessages[temporaryId] else {
|
|
break
|
|
}
|
|
|
|
optimisticallyInsertedMessages[temporaryId] = OptimisticMessageData(
|
|
temporaryId: temporaryId,
|
|
interaction: data.interaction.with(
|
|
state: .failed,
|
|
mostRecentFailureText: "shareExtensionDatabaseError".localized()
|
|
),
|
|
attachmentData: data.attachmentData,
|
|
linkPreviewViewModel: data.linkPreviewViewModel,
|
|
linkPreviewPreparedAttachment: data.linkPreviewPreparedAttachment,
|
|
quoteViewModel: data.quoteViewModel
|
|
)
|
|
|
|
case .resolveOptimisticMessage(let temporaryId, let databaseId):
|
|
guard dataCache.interaction(for: databaseId) != nil else {
|
|
Log.warn(.conversation, "Attempted to resolve an optimistic message but it was missing from the cache")
|
|
return
|
|
}
|
|
|
|
optimisticallyInsertedMessages.removeValue(forKey: temporaryId)
|
|
dataCache.removeAttachmentMap(for: temporaryId)
|
|
itemCache.removeValue(forKey: temporaryId)
|
|
}
|
|
}
|
|
|
|
/// Update the `threadInfo` with the latest `dataCache`
|
|
if let thread: SessionThread = dataCache.thread(for: threadId) {
|
|
threadInfo = ConversationInfoViewModel(
|
|
thread: thread,
|
|
dataCache: dataCache,
|
|
using: dependencies
|
|
)
|
|
}
|
|
|
|
/// Update the flag indicating whether reactions are supproted
|
|
switch threadInfo.variant {
|
|
case .legacyGroup: reactionsSupported = false
|
|
case .contact, .group: reactionsSupported = !threadInfo.isMessageRequest
|
|
case .community:
|
|
reactionsSupported = (threadInfo.communityInfo?.capabilities.contains(.reactions) == true)
|
|
isUserModeratorOrAdmin = !dataCache.communityModAdminIds(for: threadId).isDisjoint(
|
|
with: dataCache.currentUserSessionIds(for: threadId)
|
|
)
|
|
}
|
|
|
|
/// Generating the `MessageViewModel` requires both the "preview" and "next" messages that will appear on
|
|
/// the screen in order to be generated correctly so we need to iterate over the interactions again - additionally since
|
|
/// modifying interactions could impact this clustering behaviour (or ever other cached content), and we add messages
|
|
/// optimistically, it's simplest to just fully regenerate the entire `itemCache` and rely on diffing to prevent incorrect changes
|
|
let orderedIds: [Int64] = State.orderedIdsIncludingOptimisticMessages(
|
|
loadedPageInfo: loadResult.info,
|
|
optimisticMessages: optimisticallyInsertedMessages,
|
|
dataCache: dataCache
|
|
)
|
|
|
|
itemCache = orderedIds.enumerated().reduce(into: [:]) { result, next in
|
|
let optimisticMessageId: Int64?
|
|
let interaction: Interaction
|
|
let reactionInfo: [MessageViewModel.ReactionInfo]?
|
|
let maybeUnresolvedQuotedInfo: MessageViewModel.MaybeUnresolvedQuotedInfo?
|
|
|
|
/// Source the interaction data from the appropriate location
|
|
switch next.element {
|
|
case ..<0: /// If the `id` is less than `0` then it's an optimistic message
|
|
guard let data: OptimisticMessageData = optimisticallyInsertedMessages[next.element] else {
|
|
return
|
|
}
|
|
|
|
optimisticMessageId = data.temporaryId
|
|
interaction = data.interaction
|
|
reactionInfo = nil /// Can't react to an optimistic message
|
|
maybeUnresolvedQuotedInfo = data.quoteViewModel.map { model -> MessageViewModel.MaybeUnresolvedQuotedInfo? in
|
|
guard let interactionId: Int64 = model.quotedInfo?.interactionId else { return nil }
|
|
|
|
return MessageViewModel.MaybeUnresolvedQuotedInfo(
|
|
foundQuotedInteractionId: interactionId,
|
|
resolvedQuotedInteraction: dataCache.interaction(for: interactionId)
|
|
)
|
|
}
|
|
|
|
default:
|
|
guard let targetInteraction: Interaction = dataCache.interaction(for: next.element) else {
|
|
return
|
|
}
|
|
|
|
optimisticMessageId = nil
|
|
interaction = targetInteraction
|
|
|
|
let reactions: [Reaction] = dataCache.reactions(for: next.element)
|
|
|
|
if !reactions.isEmpty {
|
|
reactionInfo = reactions.map { reaction in
|
|
/// If the reactor is the current user then use the proper profile from the cache (instead of a random
|
|
/// blinded one)
|
|
let targetId: String = (threadInfo.currentUserSessionIds.contains(reaction.authorId) ?
|
|
previousState.userSessionId.hexString :
|
|
reaction.authorId
|
|
)
|
|
|
|
return MessageViewModel.ReactionInfo(
|
|
reaction: reaction,
|
|
profile: dataCache.profile(for: targetId)
|
|
)
|
|
}
|
|
}
|
|
else {
|
|
reactionInfo = nil
|
|
}
|
|
|
|
maybeUnresolvedQuotedInfo = dataCache.quoteInfo(for: next.element).map { info in
|
|
MessageViewModel.MaybeUnresolvedQuotedInfo(
|
|
foundQuotedInteractionId: info.foundQuotedInteractionId,
|
|
resolvedQuotedInteraction: info.foundQuotedInteractionId.map {
|
|
dataCache.interaction(for: $0)
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
result[next.element] = MessageViewModel(
|
|
optimisticMessageId: optimisticMessageId,
|
|
interaction: interaction,
|
|
reactionInfo: reactionInfo,
|
|
maybeUnresolvedQuotedInfo: maybeUnresolvedQuotedInfo,
|
|
userSessionId: previousState.userSessionId,
|
|
threadInfo: threadInfo,
|
|
dataCache: dataCache,
|
|
previousInteraction: State.interaction(
|
|
at: next.offset + 1, /// Order is inverted so `previousInteraction` is the next element
|
|
orderedIds: orderedIds,
|
|
optimisticMessages: optimisticallyInsertedMessages,
|
|
dataCache: dataCache
|
|
),
|
|
nextInteraction: State.interaction(
|
|
at: next.offset - 1, /// Order is inverted so `nextInteraction` is the previous element
|
|
orderedIds: orderedIds,
|
|
optimisticMessages: optimisticallyInsertedMessages,
|
|
dataCache: dataCache
|
|
),
|
|
isLast: (
|
|
/// Order is inverted so we need to check the start of the list
|
|
next.offset == 0 &&
|
|
!loadResult.info.hasPrevPage
|
|
),
|
|
isLastOutgoing: (
|
|
/// Order is inverted so we need to check the start of the list
|
|
next.element == orderedIds
|
|
.prefix(next.offset + 1) /// Want to include the value for `index` in the result
|
|
.enumerated()
|
|
.compactMap { prefixIndex, _ in
|
|
State.interaction(
|
|
at: prefixIndex,
|
|
orderedIds: orderedIds,
|
|
optimisticMessages: optimisticallyInsertedMessages,
|
|
dataCache: dataCache
|
|
)
|
|
}
|
|
.first(where: { threadInfo.currentUserSessionIds.contains($0.authorId) })?
|
|
.id
|
|
),
|
|
currentUserMentionImage: previousState.currentUserMentionImage,
|
|
using: dependencies
|
|
)
|
|
}
|
|
|
|
return State(
|
|
viewState: (loadResult.info.totalCount == 0 ? .empty : .loaded),
|
|
threadInfo: threadInfo,
|
|
authMethod: authMethod,
|
|
currentUserMentionImage: previousState.currentUserMentionImage,
|
|
isBlindedContact: SessionId.Prefix.isCommunityBlinded(threadId),
|
|
wasPreviouslyBlindedContact: SessionId.Prefix.isCommunityBlinded(previousState.threadId),
|
|
focusedInteractionInfo: focusedInteractionInfo,
|
|
focusBehaviour: previousState.focusBehaviour,
|
|
initialUnreadInteractionInfo: initialUnreadInteractionInfo,
|
|
loadedPageInfo: loadResult.info,
|
|
dataCache: dataCache,
|
|
itemCache: itemCache,
|
|
titleViewModel: ConversationTitleViewModel(
|
|
threadInfo: threadInfo,
|
|
dataCache: dataCache,
|
|
using: dependencies
|
|
),
|
|
legacyGroupsBannerIsVisible: previousState.legacyGroupsBannerIsVisible,
|
|
reactionsSupported: reactionsSupported,
|
|
recentReactionEmoji: recentReactionEmoji,
|
|
isUserModeratorOrAdmin: isUserModeratorOrAdmin,
|
|
shouldShowTypingIndicator: shouldShowTypingIndicator,
|
|
optimisticallyInsertedMessages: optimisticallyInsertedMessages
|
|
)
|
|
}
|
|
|
|
private static func sections(state: State, viewModel: ConversationViewModel) -> [SectionModel] {
|
|
let orderedIds: [Int64] = State.orderedIdsIncludingOptimisticMessages(
|
|
loadedPageInfo: state.loadedPageInfo,
|
|
optimisticMessages: state.optimisticallyInsertedMessages,
|
|
dataCache: state.dataCache
|
|
)
|
|
|
|
/// Messages are fetched in decending order (so the message at index `0` is the most recent message), we then render the
|
|
/// messages in the reverse order (so the most recent appears at the bottom of the screen) so as a result the `loadOlder`
|
|
/// section is based on `hasNextPage` and vice-versa
|
|
return [
|
|
(!state.loadedPageInfo.currentIds.isEmpty && state.loadedPageInfo.hasNextPage ?
|
|
[SectionModel(section: .loadOlder)] :
|
|
[]
|
|
),
|
|
[
|
|
SectionModel(
|
|
section: .messages,
|
|
elements: orderedIds
|
|
.reversed() /// Interactions are loaded from newest to oldest, but we want the newest at the bottom so reverse the result
|
|
.compactMap { state.itemCache[$0] }
|
|
.reduce(into: []) { result, next in
|
|
/// Insert the unread indicator above the first unread message
|
|
if next.id == state.initialUnreadInteractionInfo?.id {
|
|
result.append(
|
|
MessageViewModel(
|
|
cellType: .unreadMarker,
|
|
timestampMs: next.timestampMs
|
|
)
|
|
)
|
|
}
|
|
|
|
/// If we should have a date header above this message then add it
|
|
if next.shouldShowDateHeader {
|
|
result.append(
|
|
MessageViewModel(
|
|
cellType: .dateHeader,
|
|
timestampMs: next.timestampMs
|
|
)
|
|
)
|
|
}
|
|
|
|
/// Since we've added whatever was needed before the message we can now add it to the result
|
|
result.append(next)
|
|
}
|
|
.appending(!state.shouldShowTypingIndicator ? nil :
|
|
MessageViewModel.typingIndicator
|
|
)
|
|
)
|
|
],
|
|
(!state.loadedPageInfo.currentIds.isEmpty && state.loadedPageInfo.hasPrevPage ?
|
|
[SectionModel(section: .loadNewer)] :
|
|
[]
|
|
)
|
|
].flatMap { $0 }
|
|
}
|
|
|
|
// MARK: - Interaction Data
|
|
|
|
@MainActor public private(set) var reactionExpandedInteractionIds: Set<Int64> = []
|
|
@MainActor public private(set) var messageExpandedInteractionIds: Set<Int64> = []
|
|
|
|
// MARK: - Optimistic Message Handling
|
|
|
|
public func optimisticallyAppendOutgoingMessage(
|
|
text: String?,
|
|
sentTimestampMs: Int64,
|
|
attachments: [PendingAttachment]?,
|
|
linkPreviewViewModel: LinkPreviewViewModel?,
|
|
quoteViewModel: QuoteViewModel?
|
|
) async throws -> OptimisticMessageData {
|
|
// Generate the optimistic data
|
|
let optimisticMessageId: Int64 = (-Int64.max + sentTimestampMs) /// Unique but avoids collisions with messages
|
|
let currentState: State = await self.state
|
|
let proMessageFeatures: SessionPro.MessageFeatures = try {
|
|
let result: SessionPro.FeaturesForMessage = dependencies[singleton: .sessionProManager].messageFeatures(
|
|
for: (text ?? "")
|
|
)
|
|
|
|
switch result.status {
|
|
case .success: return result.features
|
|
case .utfDecodingError:
|
|
Log.warn(.messageSender, "Failed to extract features for message, falling back to manual handling")
|
|
guard (text ?? "").utf16.count > SessionPro.CharacterLimit else {
|
|
return .none
|
|
}
|
|
|
|
return .largerCharacterLimit
|
|
|
|
case .exceedsCharacterLimit: throw MessageError.messageTooLarge
|
|
}
|
|
}()
|
|
let proProfileFeatures: SessionPro.ProfileFeatures = dependencies[singleton: .sessionProManager]
|
|
.currentUserCurrentProState
|
|
.profileFeatures
|
|
let interaction: Interaction = Interaction(
|
|
threadId: currentState.threadId,
|
|
threadVariant: currentState.threadVariant,
|
|
authorId: currentState.threadInfo.currentUserSessionIds
|
|
.first { $0.hasPrefix(SessionId.Prefix.blinded15.rawValue) }
|
|
.defaulting(to: currentState.userSessionId.hexString),
|
|
variant: .standardOutgoing,
|
|
body: text,
|
|
timestampMs: sentTimestampMs,
|
|
hasMention: Interaction.isUserMentioned(
|
|
publicKeysToCheck: currentState.threadInfo.currentUserSessionIds,
|
|
body: text
|
|
),
|
|
expiresInSeconds: currentState.threadInfo.disappearingMessagesConfiguration?.expiresInSeconds(),
|
|
linkPreviewUrl: linkPreviewViewModel?.urlString,
|
|
proMessageFeatures: proMessageFeatures,
|
|
proProfileFeatures: proProfileFeatures,
|
|
using: dependencies
|
|
)
|
|
var optimisticAttachments: [Attachment]?
|
|
var linkPreviewPreparedAttachment: PreparedAttachment?
|
|
|
|
if let pendingAttachments: [PendingAttachment] = attachments {
|
|
optimisticAttachments = try? await AttachmentUploadJob.preparePriorToUpload(
|
|
attachments: pendingAttachments,
|
|
using: dependencies
|
|
)
|
|
}
|
|
|
|
if let draft: LinkPreviewViewModel = linkPreviewViewModel {
|
|
linkPreviewPreparedAttachment = try? await LinkPreview.prepareAttachmentIfPossible(
|
|
urlString: draft.urlString,
|
|
imageSource: draft.imageSource,
|
|
using: dependencies
|
|
)
|
|
}
|
|
|
|
let optimisticData: OptimisticMessageData = OptimisticMessageData(
|
|
temporaryId: optimisticMessageId,
|
|
interaction: interaction,
|
|
attachmentData: optimisticAttachments,
|
|
linkPreviewViewModel: linkPreviewViewModel,
|
|
linkPreviewPreparedAttachment: linkPreviewPreparedAttachment,
|
|
quoteViewModel: quoteViewModel
|
|
)
|
|
|
|
await dependencies.notify(
|
|
key: .updateScreen(ConversationViewModel.self),
|
|
value: ConversationViewModelEvent.sendMessage(data: optimisticData)
|
|
)
|
|
|
|
return optimisticData
|
|
}
|
|
|
|
public func failedToStoreOptimisticOutgoingMessage(id: Int64, error: Error) async {
|
|
await dependencies.notify(
|
|
key: .updateScreen(ConversationViewModel.self),
|
|
value: ConversationViewModelEvent.failedToStoreMessage(temporaryId: id)
|
|
)
|
|
}
|
|
|
|
/// Record an association between an `optimisticMessageId` and a specific `interactionId`
|
|
public func associate(_ db: ObservingDatabase, optimisticMessageId: Int64, to interactionId: Int64?) {
|
|
guard let interactionId: Int64 = interactionId else { return }
|
|
|
|
db.addEvent(
|
|
ConversationViewModelEvent.resolveOptimisticMessage(
|
|
temporaryId: optimisticMessageId,
|
|
databaseId: interactionId
|
|
),
|
|
forKey: .updateScreen(ConversationViewModel.self)
|
|
)
|
|
}
|
|
|
|
// MARK: - Profiles
|
|
|
|
@MainActor public func displayName(for sessionId: String, inMessageBody: Bool) -> String? {
|
|
return state.dataCache.profile(for: sessionId)?.displayName(
|
|
includeSessionIdSuffix: (state.threadVariant == .community && inMessageBody)
|
|
)
|
|
}
|
|
|
|
public func mentions(for query: String = "") async throws -> [MentionSelectionView.ViewModel] {
|
|
let state: State = await self.state
|
|
|
|
return try await MentionSelectionView.ViewModel.mentions(
|
|
for: query,
|
|
threadId: state.threadId,
|
|
threadVariant: state.threadVariant,
|
|
currentUserSessionIds: state.threadInfo.currentUserSessionIds,
|
|
communityInfo: state.threadInfo.communityInfo.map { info in
|
|
(server: info.server, roomToken: info.roomToken)
|
|
},
|
|
using: dependencies
|
|
)
|
|
}
|
|
|
|
@MainActor public func draftQuote(for viewModel: MessageViewModel) -> QuoteViewModel {
|
|
let targetAttachment: Attachment? = (
|
|
viewModel.attachments.first ??
|
|
viewModel.linkPreviewAttachment
|
|
)
|
|
|
|
return QuoteViewModel(
|
|
mode: .draft,
|
|
direction: (viewModel.variant == .standardOutgoing ? .outgoing : .incoming),
|
|
quotedInfo: QuoteViewModel.QuotedInfo(
|
|
interactionId: viewModel.id,
|
|
authorId: viewModel.authorId,
|
|
authorName: viewModel.authorName(),
|
|
timestampMs: viewModel.timestampMs,
|
|
body: viewModel.bubbleBody,
|
|
attachmentInfo: targetAttachment?.quoteAttachmentInfo(using: dependencies)
|
|
),
|
|
showProBadge: viewModel.profile.proFeatures.contains(.proBadge), /// Quote pro badge is profile data
|
|
currentUserSessionIds: viewModel.currentUserSessionIds,
|
|
displayNameRetriever: state.dataCache.displayNameRetriever(
|
|
for: viewModel.threadId,
|
|
includeSessionIdSuffixWhenInMessageBody: (viewModel.threadVariant == .community)
|
|
),
|
|
currentUserMentionImage: viewModel.currentUserMentionImage
|
|
)
|
|
}
|
|
|
|
// MARK: - Functions
|
|
|
|
@MainActor func loadPageBefore() {
|
|
/// We render the messages in the reverse order from the way we fetch them (see `sections`) so as a result when loading
|
|
/// the "page before" we _actually_ need to load the `nextPage`
|
|
dependencies.notifyAsync(
|
|
key: .loadPage(ConversationViewModel.self),
|
|
value: LoadPageEvent.nextPage(lastIndex: state.loadedPageInfo.lastIndex)
|
|
)
|
|
}
|
|
|
|
@MainActor public func loadPageAfter() {
|
|
/// We render the messages in the reverse order from the way we fetch them (see `sections`) so as a result when loading
|
|
/// the "page after" we _actually_ need to load the `previousPage`
|
|
dependencies.notifyAsync(
|
|
key: .loadPage(ConversationViewModel.self),
|
|
value: LoadPageEvent.previousPage(firstIndex: state.loadedPageInfo.firstIndex)
|
|
)
|
|
}
|
|
|
|
@MainActor public func jumpToPage(for id: Int64, padding: Int) {
|
|
dependencies.notifyAsync(
|
|
key: .loadPage(ConversationViewModel.self),
|
|
value: LoadPageEvent.jumpTo(id: id, padding: padding)
|
|
)
|
|
}
|
|
|
|
@MainActor public func updateDraft(to draft: String) {
|
|
/// Kick off an async process to save the `draft` message to the conversation (don't want to block the UI while doing this,
|
|
/// worst case the `draft` just won't be saved)
|
|
Task.detached(priority: .userInitiated) { [threadInfo = state.threadInfo, dependencies] in
|
|
do { try await threadInfo.updateDraft(draft, using: dependencies) }
|
|
catch { Log.error(.conversation, "Failed to update draft due to error: \(error)") }
|
|
}
|
|
}
|
|
|
|
public func markThreadAsRead() async {
|
|
let threadInfo: ConversationInfoViewModel = await state.threadInfo
|
|
try? await threadInfo.markAsRead(target: .thread, using: dependencies)
|
|
}
|
|
|
|
/// This method marks a thread as read and depending on the target may also update the interactions within a thread as read
|
|
public func markAsReadIfNeeded(
|
|
interactionInfo: Interaction.TimestampInfo?,
|
|
visibleViewModelRetriever: ((@MainActor () -> [MessageViewModel]?))?
|
|
) async {
|
|
/// 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:
|
|
/// - Only retrieve the visible message view models if the state suggests there is something that can be marked as read
|
|
/// - 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
|
|
let needsToMarkAsRead: Bool = await MainActor.run {
|
|
guard
|
|
state.threadInfo.unreadCount > 0 ||
|
|
state.threadInfo.wasMarkedUnread
|
|
else { return false }
|
|
|
|
/// We want to mark messages as read while we scroll, so grab the "newest" visible message and mark everything older as read
|
|
let targetInfo: Interaction.TimestampInfo
|
|
|
|
if let newestCellViewModel: MessageViewModel = visibleViewModelRetriever?()?.last {
|
|
targetInfo = Interaction.TimestampInfo(
|
|
id: newestCellViewModel.id,
|
|
timestampMs: newestCellViewModel.timestampMs
|
|
)
|
|
}
|
|
else if let interactionInfo: Interaction.TimestampInfo = interactionInfo {
|
|
/// If we weren't able to get any visible cells for some reason then we should fall back to marking the provided
|
|
/// `interactionInfo` as read just in case
|
|
targetInfo = interactionInfo
|
|
}
|
|
else {
|
|
/// If we can't get any interaction info then there is nothing to mark as read
|
|
return false
|
|
}
|
|
|
|
/// If we previously marked something as read and it's "newer" than the target info then it should already be read so no
|
|
/// need to do anything
|
|
if
|
|
let oldValue: Interaction.TimestampInfo = lastMarkAsReadInfo, (
|
|
targetInfo.id < oldValue.id ||
|
|
targetInfo.timestampMs < oldValue.timestampMs
|
|
)
|
|
{
|
|
return false
|
|
}
|
|
|
|
/// If we already have pending info to mark as read then no need to trigger another update
|
|
if let pendingValue: Interaction.TimestampInfo = pendingMarkAsReadInfo {
|
|
/// If the target info is "newer" than the pending info then we should update the pending info so the "newer" value ends
|
|
/// up getting marked as read
|
|
if targetInfo.id > pendingValue.id || targetInfo.timestampMs > pendingValue.timestampMs {
|
|
pendingMarkAsReadInfo = targetInfo
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
/// If we got here then we do need to mark the target info as read
|
|
pendingMarkAsReadInfo = targetInfo
|
|
return true
|
|
}
|
|
|
|
/// Only continue if we need to
|
|
guard needsToMarkAsRead else { return }
|
|
|
|
do { try await Task.sleep(for: .milliseconds(100)) }
|
|
catch { return }
|
|
|
|
/// Get the latest values
|
|
let (threadInfo, pendingInfo): (ConversationInfoViewModel, Interaction.TimestampInfo?) = await MainActor.run {
|
|
let result: (ConversationInfoViewModel, Interaction.TimestampInfo?) = (
|
|
state.threadInfo,
|
|
pendingMarkAsReadInfo
|
|
)
|
|
|
|
/// Immediately clear the pending info so we can mark something else as read while waiting in this message to be marked
|
|
/// as read
|
|
pendingMarkAsReadInfo = nil
|
|
|
|
return result
|
|
}
|
|
|
|
guard let info: Interaction.TimestampInfo = pendingInfo else { return }
|
|
|
|
try? await threadInfo.markAsRead(
|
|
target: .threadAndInteractions(interactionsBeforeInclusive: info.id),
|
|
using: dependencies
|
|
)
|
|
}
|
|
|
|
@MainActor public func trustContact() {
|
|
guard state.threadVariant == .contact else { return }
|
|
|
|
Task.detached(priority: .userInitiated) { [threadId = state.threadId, dependencies] in
|
|
try? await dependencies[singleton: .storage].writeAsync { db in
|
|
try Contact
|
|
.filter(id: threadId)
|
|
.updateAll(db, Contact.Columns.isTrusted.set(to: true))
|
|
db.addContactEvent(id: threadId, change: .isTrusted(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
|
|
dependencies[singleton: .jobRunner].add(
|
|
db,
|
|
job: Job(
|
|
variant: .attachmentDownload,
|
|
threadId: threadId,
|
|
interactionId: attachmentDownloadInfo.interactionId,
|
|
details: AttachmentDownloadJob.Details(
|
|
attachmentId: attachmentDownloadInfo.attachmentId
|
|
)
|
|
),
|
|
canStartJob: true
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor public func unblockContact() {
|
|
guard state.threadVariant == .contact else { return }
|
|
|
|
Task.detached(priority: .userInitiated) { [threadId = state.threadId, dependencies] in
|
|
try? await dependencies[singleton: .storage].writeAsync { db in
|
|
try Contact
|
|
.filter(id: threadId)
|
|
.updateAllAndConfig(
|
|
db,
|
|
Contact.Columns.isBlocked.set(to: false),
|
|
using: dependencies
|
|
)
|
|
db.addContactEvent(id: threadId, change: .isBlocked(false))
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor public func expandReactions(for interactionId: Int64) {
|
|
reactionExpandedInteractionIds.insert(interactionId)
|
|
}
|
|
|
|
@MainActor public func collapseReactions(for interactionId: Int64) {
|
|
reactionExpandedInteractionIds.remove(interactionId)
|
|
}
|
|
|
|
@MainActor public func expandMessage(for interactionId: Int64) {
|
|
messageExpandedInteractionIds.insert(interactionId)
|
|
}
|
|
|
|
@MainActor public func deletionActions(for cellViewModels: [MessageViewModel]) throws -> MessageViewModel.DeletionBehaviours? {
|
|
return try MessageViewModel.DeletionBehaviours.deletionActions(
|
|
for: cellViewModels,
|
|
threadInfo: state.threadInfo,
|
|
authMethod: state.authMethod.value,
|
|
isUserModeratorOrAdmin: state.isUserModeratorOrAdmin,
|
|
using: dependencies
|
|
)
|
|
}
|
|
|
|
// 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)
|
|
)
|
|
}
|
|
}
|
|
|
|
@MainActor private var audioPlayer: OWSAudioPlayer? = nil
|
|
@MainActor private var currentPlayingInteraction: Int64? = nil
|
|
@MainActor private var playbackInfo: [Int64: PlaybackInfo] = [:]
|
|
|
|
@MainActor 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[viewModel.id] {
|
|
let updatedPlaybackInfo: PlaybackInfo = currentPlaybackInfo
|
|
.with(updateCallback: updateCallback)
|
|
playbackInfo[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 path: String = try? dependencies[singleton: .attachmentManager].path(for: attachment.downloadUrl),
|
|
dependencies[singleton: .fileManager].fileExists(atPath: path)
|
|
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[viewModel.id] = newPlaybackInfo
|
|
|
|
return newPlaybackInfo
|
|
}
|
|
|
|
@MainActor public func playOrPauseAudio(for viewModel: MessageViewModel) {
|
|
guard
|
|
let attachment: Attachment = viewModel.attachments.first,
|
|
let filePath: String = try? dependencies[singleton: .attachmentManager].path(for: attachment.downloadUrl),
|
|
dependencies[singleton: .fileManager].fileExists(atPath: filePath)
|
|
else { return }
|
|
|
|
// If the user interacted with the currently playing item
|
|
guard currentPlayingInteraction != viewModel.id else {
|
|
let currentPlaybackInfo: PlaybackInfo? = playbackInfo[viewModel.id]
|
|
let updatedPlaybackInfo: PlaybackInfo? = currentPlaybackInfo?
|
|
.with(
|
|
state: (currentPlaybackInfo?.state != .playing ? .playing : .paused),
|
|
playbackRate: 1
|
|
)
|
|
|
|
audioPlayer?.playbackRate = 1
|
|
|
|
switch currentPlaybackInfo?.state {
|
|
case .playing: audioPlayer?.pause()
|
|
default: audioPlayer?.play()
|
|
}
|
|
|
|
// Update the state and then update the UI with the updated state
|
|
playbackInfo[viewModel.id] = updatedPlaybackInfo
|
|
updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil)
|
|
return
|
|
}
|
|
|
|
// First stop any existing audio
|
|
audioPlayer?.stop()
|
|
|
|
// Then setup the state for the new audio
|
|
currentPlayingInteraction = viewModel.id
|
|
|
|
let currentPlaybackTime: TimeInterval? = playbackInfo[viewModel.id]?.progress
|
|
|
|
// 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
|
|
audioPlayer?.delegate = nil
|
|
audioPlayer = nil
|
|
|
|
let newAudioPlayer: OWSAudioPlayer = OWSAudioPlayer(
|
|
mediaUrl: URL(fileURLWithPath: filePath),
|
|
audioBehavior: .audioMessagePlayback,
|
|
delegate: self,
|
|
using: dependencies
|
|
)
|
|
newAudioPlayer.play()
|
|
newAudioPlayer.currentTime = (currentPlaybackTime ?? 0)
|
|
audioPlayer = newAudioPlayer
|
|
}
|
|
|
|
@MainActor public func speedUpAudio(for viewModel: MessageViewModel) {
|
|
// If we aren't playing the specified item then just start playing it
|
|
guard viewModel.id == currentPlayingInteraction else {
|
|
playOrPauseAudio(for: viewModel)
|
|
return
|
|
}
|
|
|
|
let updatedPlaybackInfo: PlaybackInfo? = playbackInfo[viewModel.id]?
|
|
.with(playbackRate: 1.5)
|
|
|
|
// Speed up the audio player
|
|
audioPlayer?.playbackRate = 1.5
|
|
playbackInfo[viewModel.id] = updatedPlaybackInfo
|
|
updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil)
|
|
}
|
|
|
|
@MainActor public func stopAudioIfNeeded(for viewModel: MessageViewModel) {
|
|
guard viewModel.id == currentPlayingInteraction else { return }
|
|
|
|
stopAudio()
|
|
}
|
|
|
|
@MainActor public func stopAudio() {
|
|
audioPlayer?.stop()
|
|
|
|
currentPlayingInteraction = nil
|
|
// 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
|
|
audioPlayer?.delegate = nil
|
|
audioPlayer = nil
|
|
}
|
|
|
|
// MARK: - OWSAudioPlayerDelegate
|
|
|
|
@MainActor public var audioPlaybackState: AudioPlaybackState {
|
|
get {
|
|
guard let interactionId: Int64 = currentPlayingInteraction else { return .stopped }
|
|
|
|
return (playbackInfo[interactionId]?.state ?? .stopped)
|
|
}
|
|
set {
|
|
guard let interactionId: Int64 = currentPlayingInteraction else { return }
|
|
|
|
let updatedPlaybackInfo: PlaybackInfo? = playbackInfo[interactionId]?
|
|
.with(state: newValue)
|
|
|
|
playbackInfo[interactionId] = updatedPlaybackInfo
|
|
updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil)
|
|
}
|
|
}
|
|
|
|
@MainActor public func setAudioProgress(_ progress: CGFloat, duration: CGFloat) {
|
|
guard let interactionId: Int64 = currentPlayingInteraction else { return }
|
|
|
|
let updatedPlaybackInfo: PlaybackInfo? = playbackInfo[interactionId]?
|
|
.with(progress: TimeInterval(progress))
|
|
|
|
playbackInfo[interactionId] = updatedPlaybackInfo
|
|
updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil)
|
|
}
|
|
|
|
@MainActor public func audioPlayerDidFinishPlaying(_ player: OWSAudioPlayer, successfully: Bool) {
|
|
guard let interactionId: Int64 = currentPlayingInteraction else { return }
|
|
guard successfully else { return }
|
|
|
|
let updatedPlaybackInfo: PlaybackInfo? = playbackInfo[interactionId]?
|
|
.with(
|
|
state: .stopped,
|
|
progress: 0,
|
|
playbackRate: 1
|
|
)
|
|
|
|
// Safe the changes and send one final update to the UI
|
|
playbackInfo[interactionId] = updatedPlaybackInfo
|
|
updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil)
|
|
|
|
// Clear out the currently playing record
|
|
stopAudio()
|
|
|
|
/// If the next interaction is another voice message then autoplay it
|
|
///
|
|
/// **Note:** Order is inverted so the next item has an earlier index
|
|
guard
|
|
let currentIndex: Int = state.loadedPageInfo.currentIds
|
|
.firstIndex(where: { $0 == interactionId }),
|
|
currentIndex > 0,
|
|
let nextItem: MessageViewModel = state.itemCache[state.loadedPageInfo.currentIds[currentIndex - 1]],
|
|
nextItem.cellType == .voiceMessage,
|
|
dependencies.mutate(cache: .libSession, { $0.get(.shouldAutoPlayConsecutiveAudioMessages) })
|
|
else { return }
|
|
|
|
playOrPauseAudio(for: nextItem)
|
|
}
|
|
|
|
@MainActor public func showInvalidAudioFileAlert() {
|
|
guard let interactionId: Int64 = currentPlayingInteraction else { return }
|
|
|
|
let updatedPlaybackInfo: PlaybackInfo? = playbackInfo[interactionId]?
|
|
.with(
|
|
state: .stopped,
|
|
progress: 0,
|
|
playbackRate: 1
|
|
)
|
|
|
|
stopAudio()
|
|
playbackInfo[interactionId] = updatedPlaybackInfo
|
|
updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, AttachmentError.invalidData)
|
|
}
|
|
}
|
|
|
|
// MARK: - Convenience
|
|
|
|
private extension ObservedEvent {
|
|
var handlingStrategy: EventHandlingStrategy {
|
|
let threadInfoStrategy: EventHandlingStrategy? = ConversationInfoViewModel.handlingStrategy(for: self)
|
|
let messageStrategy: EventHandlingStrategy? = MessageViewModel.handlingStrategy(for: self)
|
|
let localStrategy: EventHandlingStrategy = {
|
|
switch (key, key.generic) {
|
|
case (_, .loadPage): return .databaseQuery
|
|
case (.anyMessageCreatedInAnyConversation, _): return .databaseQuery
|
|
case (.anyContactBlockedStatusChanged, _): return .databaseQuery
|
|
case (.anyContactUnblinded, _): return [.databaseQuery, .directCacheUpdate]
|
|
case (.recentReactionsUpdated, _): return .databaseQuery
|
|
case (_, .conversationUpdated), (_, .conversationDeleted): return .databaseQuery
|
|
case (_, .messageCreated), (_, .messageUpdated), (_, .messageDeleted): return .databaseQuery
|
|
case (_, .attachmentCreated), (_, .attachmentUpdated), (_, .attachmentDeleted): return .databaseQuery
|
|
case (_, .reactionsChanged): return .databaseQuery
|
|
case (_, .communityUpdated): return [.databaseQuery, .directCacheUpdate]
|
|
case (_, .contact): return [.databaseQuery, .directCacheUpdate]
|
|
case (_, .profile): return [.databaseQuery, .directCacheUpdate]
|
|
case (_, .typingIndicator): return .directCacheUpdate
|
|
default: return .directCacheUpdate
|
|
}
|
|
}()
|
|
|
|
return localStrategy
|
|
.union(threadInfoStrategy ?? .none)
|
|
.union(messageStrategy ?? .none)
|
|
}
|
|
}
|
|
|
|
private extension ConversationTitleViewModel {
|
|
init(
|
|
threadInfo: ConversationInfoViewModel,
|
|
dataCache: ConversationDataCache,
|
|
using dependencies: Dependencies
|
|
) {
|
|
self.threadVariant = threadInfo.variant
|
|
self.displayName = threadInfo.displayName.deformatted()
|
|
self.isNoteToSelf = threadInfo.isNoteToSelf
|
|
self.isMessageRequest = threadInfo.isMessageRequest
|
|
self.showProBadge = (dataCache.profile(for: threadInfo.id)?.proFeatures.contains(.proBadge) == true)
|
|
self.isMuted = (dependencies.dateNow.timeIntervalSince1970 <= (threadInfo.mutedUntilTimestamp ?? 0))
|
|
self.onlyNotifyForMentions = threadInfo.onlyNotifyForMentions
|
|
self.userCount = threadInfo.userCount
|
|
self.disappearingMessagesConfig = threadInfo.disappearingMessagesConfiguration
|
|
}
|
|
}
|
|
|
|
public extension ConversationViewModel {
|
|
static func fetchConversationInfo(
|
|
threadId: String,
|
|
using dependencies: Dependencies
|
|
) async throws -> ConversationInfoViewModel {
|
|
return try await dependencies[singleton: .storage].readAsync { [dependencies] db in
|
|
try ConversationViewModel.fetchConversationInfo(
|
|
db,
|
|
threadId: threadId,
|
|
using: dependencies
|
|
)
|
|
}
|
|
}
|
|
|
|
static func fetchConversationInfo(
|
|
_ db: ObservingDatabase,
|
|
threadId: String,
|
|
using dependencies: Dependencies
|
|
) throws -> ConversationInfoViewModel {
|
|
var dataCache: ConversationDataCache = ConversationDataCache(
|
|
userSessionId: dependencies[cache: .general].sessionId,
|
|
context: ConversationDataCache.Context(
|
|
source: .messageList(threadId: threadId),
|
|
requireFullRefresh: true,
|
|
requireAuthMethodFetch: false,
|
|
requiresMessageRequestCountUpdate: false,
|
|
requiresInitialUnreadInteractionInfo: false,
|
|
requireRecentReactionEmojiUpdate: false
|
|
)
|
|
)
|
|
let fetchRequirements: ConversationDataHelper.FetchRequirements = ConversationDataHelper.FetchRequirements(
|
|
requireAuthMethodFetch: false,
|
|
requiresMessageRequestCountUpdate: false,
|
|
requiresInitialUnreadInteractionInfo: false,
|
|
requireRecentReactionEmojiUpdate: false,
|
|
threadIdsNeedingFetch: [threadId]
|
|
)
|
|
|
|
dataCache = try ConversationDataHelper.fetchFromDatabase(
|
|
db,
|
|
requirements: fetchRequirements,
|
|
currentCache: dataCache,
|
|
using: dependencies
|
|
)
|
|
dataCache = try ConversationDataHelper.fetchFromLibSession(
|
|
requirements: fetchRequirements,
|
|
cache: dataCache,
|
|
using: dependencies
|
|
)
|
|
|
|
guard let thread: SessionThread = dataCache.thread(for: threadId) else {
|
|
Log.error(.conversation, "Unable to fetch conversation info for thread: \(threadId).")
|
|
throw StorageError.objectNotFound
|
|
}
|
|
|
|
return ConversationInfoViewModel(
|
|
thread: thread,
|
|
dataCache: dataCache,
|
|
using: dependencies
|
|
)
|
|
}
|
|
}
|