// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation import GRDB import DifferenceKit import SessionUIKit import SessionUtilitiesKit fileprivate typealias ViewModel = MessageViewModel fileprivate typealias AttachmentInteractionInfo = MessageViewModel.AttachmentInteractionInfo fileprivate typealias ReactionInfo = MessageViewModel.ReactionInfo fileprivate typealias TypingIndicatorInfo = MessageViewModel.TypingIndicatorInfo public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, Hashable, Identifiable, Differentiable { public static let threadIdKey: SQL = SQL(stringLiteral: CodingKeys.threadId.stringValue) public static let threadVariantKey: SQL = SQL(stringLiteral: CodingKeys.threadVariant.stringValue) public static let threadIsTrustedKey: SQL = SQL(stringLiteral: CodingKeys.threadIsTrusted.stringValue) public static let threadHasDisappearingMessagesEnabledKey: SQL = SQL(stringLiteral: CodingKeys.threadHasDisappearingMessagesEnabled.stringValue) public static let threadOpenGroupServerKey: SQL = SQL(stringLiteral: CodingKeys.threadOpenGroupServer.stringValue) public static let threadOpenGroupPublicKeyKey: SQL = SQL(stringLiteral: CodingKeys.threadOpenGroupPublicKey.stringValue) public static let threadContactNameInternalKey: SQL = SQL(stringLiteral: CodingKeys.threadContactNameInternal.stringValue) public static let rowIdKey: SQL = SQL(stringLiteral: CodingKeys.rowId.stringValue) public static let authorNameInternalKey: SQL = SQL(stringLiteral: CodingKeys.authorNameInternal.stringValue) public static let stateKey: SQL = SQL(stringLiteral: CodingKeys.state.stringValue) public static let hasAtLeastOneReadReceiptKey: SQL = SQL(stringLiteral: CodingKeys.hasAtLeastOneReadReceipt.stringValue) public static let mostRecentFailureTextKey: SQL = SQL(stringLiteral: CodingKeys.mostRecentFailureText.stringValue) public static let isTypingIndicatorKey: SQL = SQL(stringLiteral: CodingKeys.isTypingIndicator.stringValue) public static let isSenderOpenGroupModeratorKey: SQL = SQL(stringLiteral: CodingKeys.isSenderOpenGroupModerator.stringValue) public static let profileKey: SQL = SQL(stringLiteral: CodingKeys.profile.stringValue) public static let quoteKey: SQL = SQL(stringLiteral: CodingKeys.quote.stringValue) public static let quoteAttachmentKey: SQL = SQL(stringLiteral: CodingKeys.quoteAttachment.stringValue) public static let linkPreviewKey: SQL = SQL(stringLiteral: CodingKeys.linkPreview.stringValue) public static let linkPreviewAttachmentKey: SQL = SQL(stringLiteral: CodingKeys.linkPreviewAttachment.stringValue) public static let currentUserPublicKeyKey: SQL = SQL(stringLiteral: CodingKeys.currentUserPublicKey.stringValue) public static let cellTypeKey: SQL = SQL(stringLiteral: CodingKeys.cellType.stringValue) public static let authorNameKey: SQL = SQL(stringLiteral: CodingKeys.authorName.stringValue) public static let canHaveProfileKey: SQL = SQL(stringLiteral: CodingKeys.canHaveProfile.stringValue) public static let shouldShowProfileKey: SQL = SQL(stringLiteral: CodingKeys.shouldShowProfile.stringValue) public static let shouldShowDateHeaderKey: SQL = SQL(stringLiteral: CodingKeys.shouldShowDateHeader.stringValue) public static let positionInClusterKey: SQL = SQL(stringLiteral: CodingKeys.positionInCluster.stringValue) public static let isOnlyMessageInClusterKey: SQL = SQL(stringLiteral: CodingKeys.isOnlyMessageInCluster.stringValue) public static let isLastKey: SQL = SQL(stringLiteral: CodingKeys.isLast.stringValue) public static let isLastOutgoingKey: SQL = SQL(stringLiteral: CodingKeys.isLastOutgoing.stringValue) public static let profileString: String = CodingKeys.profile.stringValue public static let quoteString: String = CodingKeys.quote.stringValue public static let quoteAttachmentString: String = CodingKeys.quoteAttachment.stringValue public static let linkPreviewString: String = CodingKeys.linkPreview.stringValue public static let linkPreviewAttachmentString: String = CodingKeys.linkPreviewAttachment.stringValue public enum CellType: Int, Decodable, Equatable, Hashable, DatabaseValueConvertible { case textOnlyMessage case mediaMessage case audio case genericAttachment case typingIndicator case dateHeader case unreadMarker /// A number of the `CellType` entries are dynamically added to the dataset after processing, this flag indicates /// whether the given type is one of them public var isPostProcessed: Bool { switch self { case .typingIndicator, .dateHeader, .unreadMarker: return true default: return false } } } public var differenceIdentifier: Int64 { id } // Thread Info public let threadId: String public let threadVariant: SessionThread.Variant public let threadIsTrusted: Bool public let threadHasDisappearingMessagesEnabled: Bool public let threadOpenGroupServer: String? public let threadOpenGroupPublicKey: String? private let threadContactNameInternal: String? // Interaction Info public let rowId: Int64 public let id: Int64 public let openGroupServerMessageId: Int64? public let variant: Interaction.Variant public let timestampMs: Int64 public let receivedAtTimestampMs: Int64 public let authorId: String private let authorNameInternal: String? public let body: String? public let rawBody: String? public let expiresStartedAtMs: Double? public let expiresInSeconds: TimeInterval? public let state: RecipientState.State public let hasAtLeastOneReadReceipt: Bool public let mostRecentFailureText: String? public let isSenderOpenGroupModerator: Bool public let isTypingIndicator: Bool? public let profile: Profile? public let quote: Quote? public let quoteAttachment: Attachment? public let linkPreview: LinkPreview? public let linkPreviewAttachment: Attachment? public let currentUserPublicKey: String // Post-Query Processing Data /// This value includes the associated attachments public let attachments: [Attachment]? /// This value includes the associated reactions public let reactionInfo: [ReactionInfo]? /// This value defines what type of cell should appear and is generated based on the interaction variant /// and associated attachment data public let cellType: CellType /// This value includes the author name information public let authorName: String /// This value will be used to populate the author label, if it's null then the label will be hidden /// /// **Note:** This will only be populated for incoming messages public let senderName: String? /// A flag indicating whether the profile view can be displayed public let canHaveProfile: Bool /// A flag indicating whether the profile view should be displayed public let shouldShowProfile: Bool /// A flag which controls whether the date header should be displayed public let shouldShowDateHeader: Bool /// This value will be used to populate the Context Menu and date header (if present) public var dateForUI: Date { Date(timeIntervalSince1970: (TimeInterval(self.timestampMs) / 1000)) } /// This value will be used to populate the Message Info (if present) public var receivedDateForUI: Date { Date(timeIntervalSince1970: (TimeInterval(self.receivedAtTimestampMs) / 1000)) } /// This value specifies whether the body contains only emoji characters public let containsOnlyEmoji: Bool? /// This value specifies the number of emoji characters the body contains public let glyphCount: Int? /// This value indicates the variant of the previous ViewModel item, if it's null then there is no previous item public let previousVariant: Interaction.Variant? /// This value indicates the position of this message within a cluser of messages public let positionInCluster: Position /// This value indicates whether this is the only message in a cluser of messages public let isOnlyMessageInCluster: Bool /// This value indicates whether this is the last message in the thread public let isLast: Bool public let isLastOutgoing: Bool /// This is the users blinded key (will only be set for messages within open groups) public let currentUserBlindedPublicKey: String? // MARK: - Mutation public func with( attachments: [Attachment]? = nil, reactionInfo: [ReactionInfo]? = nil ) -> MessageViewModel { return MessageViewModel( threadId: self.threadId, threadVariant: self.threadVariant, threadIsTrusted: self.threadIsTrusted, threadHasDisappearingMessagesEnabled: self.threadHasDisappearingMessagesEnabled, threadOpenGroupServer: self.threadOpenGroupServer, threadOpenGroupPublicKey: self.threadOpenGroupPublicKey, threadContactNameInternal: self.threadContactNameInternal, rowId: self.rowId, id: self.id, openGroupServerMessageId: self.openGroupServerMessageId, variant: self.variant, timestampMs: self.timestampMs, receivedAtTimestampMs: self.receivedAtTimestampMs, authorId: self.authorId, authorNameInternal: self.authorNameInternal, body: self.body, rawBody: self.rawBody, expiresStartedAtMs: self.expiresStartedAtMs, expiresInSeconds: self.expiresInSeconds, state: self.state, hasAtLeastOneReadReceipt: self.hasAtLeastOneReadReceipt, mostRecentFailureText: self.mostRecentFailureText, isSenderOpenGroupModerator: self.isSenderOpenGroupModerator, isTypingIndicator: self.isTypingIndicator, profile: self.profile, quote: self.quote, quoteAttachment: self.quoteAttachment, linkPreview: self.linkPreview, linkPreviewAttachment: self.linkPreviewAttachment, currentUserPublicKey: self.currentUserPublicKey, attachments: (attachments ?? self.attachments), reactionInfo: (reactionInfo ?? self.reactionInfo), cellType: self.cellType, authorName: self.authorName, senderName: self.senderName, canHaveProfile: self.canHaveProfile, shouldShowProfile: self.shouldShowProfile, shouldShowDateHeader: self.shouldShowDateHeader, containsOnlyEmoji: self.containsOnlyEmoji, glyphCount: self.glyphCount, previousVariant: self.previousVariant, positionInCluster: self.positionInCluster, isOnlyMessageInCluster: self.isOnlyMessageInCluster, isLast: self.isLast, isLastOutgoing: self.isLastOutgoing, currentUserBlindedPublicKey: self.currentUserBlindedPublicKey ) } public func withClusteringChanges( prevModel: MessageViewModel?, nextModel: MessageViewModel?, isLast: Bool, isLastOutgoing: Bool, currentUserBlindedPublicKey: String? ) -> MessageViewModel { let cellType: CellType = { guard self.isTypingIndicator != true else { return .typingIndicator } guard self.variant != .standardIncomingDeleted else { return .textOnlyMessage } guard let attachment: Attachment = self.attachments?.first else { return .textOnlyMessage } // The only case which currently supports multiple attachments is a 'mediaMessage' // (the album view) guard self.attachments?.count == 1 else { return .mediaMessage } // Quote and LinkPreview overload the 'attachments' array and use it for their // own purposes, otherwise check if the attachment is visual media guard self.quote == nil else { return .textOnlyMessage } guard self.linkPreview == nil else { return .textOnlyMessage } // Pending audio attachments won't have a duration if attachment.isAudio && ( ((attachment.duration ?? 0) > 0) || ( attachment.state != .downloaded && attachment.state != .uploaded ) ) { return .audio } if attachment.isVisualMedia { return .mediaMessage } return .genericAttachment }() let authorDisplayName: String = Profile.displayName( for: self.threadVariant, id: self.authorId, name: self.authorNameInternal, nickname: nil // Folded into 'authorName' within the Query ) let shouldShowDateBeforeThisModel: Bool = { guard self.isTypingIndicator != true else { return false } guard self.variant != .infoCall else { return true } // Always show on calls guard !self.variant.isInfoMessage else { return false } // Never show on info messages guard let prevModel: ViewModel = prevModel else { return true } return MessageViewModel.shouldShowDateBreak( between: prevModel.timestampMs, and: self.timestampMs ) }() let shouldShowDateBeforeNextModel: Bool = { // Should be nothing after a typing indicator guard self.isTypingIndicator != true else { return false } guard let nextModel: ViewModel = nextModel else { return false } return MessageViewModel.shouldShowDateBreak( between: self.timestampMs, and: nextModel.timestampMs ) }() let (positionInCluster, isOnlyMessageInCluster): (Position, Bool) = { let isFirstInCluster: Bool = ( prevModel == nil || shouldShowDateBeforeThisModel || ( self.variant == .standardOutgoing && prevModel?.variant != .standardOutgoing ) || ( ( self.variant == .standardIncoming || self.variant == .standardIncomingDeleted ) && ( prevModel?.variant != .standardIncoming && prevModel?.variant != .standardIncomingDeleted ) ) || self.authorId != prevModel?.authorId ) let isLastInCluster: Bool = ( nextModel == nil || shouldShowDateBeforeNextModel || ( self.variant == .standardOutgoing && nextModel?.variant != .standardOutgoing ) || ( ( self.variant == .standardIncoming || self.variant == .standardIncomingDeleted ) && ( nextModel?.variant != .standardIncoming && nextModel?.variant != .standardIncomingDeleted ) ) || self.authorId != nextModel?.authorId ) let isOnlyMessageInCluster: Bool = (isFirstInCluster && isLastInCluster) switch (isFirstInCluster, isLastInCluster) { case (true, true), (false, false): return (.middle, isOnlyMessageInCluster) case (true, false): return (.top, isOnlyMessageInCluster) case (false, true): return (.bottom, isOnlyMessageInCluster) } }() let isGroupThread: Bool = ( self.threadVariant == .community || self.threadVariant == .legacyGroup || self.threadVariant == .group ) return ViewModel( threadId: self.threadId, threadVariant: self.threadVariant, threadIsTrusted: self.threadIsTrusted, threadHasDisappearingMessagesEnabled: self.threadHasDisappearingMessagesEnabled, threadOpenGroupServer: self.threadOpenGroupServer, threadOpenGroupPublicKey: self.threadOpenGroupPublicKey, threadContactNameInternal: self.threadContactNameInternal, rowId: self.rowId, id: self.id, openGroupServerMessageId: self.openGroupServerMessageId, variant: self.variant, timestampMs: self.timestampMs, receivedAtTimestampMs: self.receivedAtTimestampMs, authorId: self.authorId, authorNameInternal: self.authorNameInternal, body: (!self.variant.isInfoMessage ? self.body : // Info messages might not have a body so we should use the 'previewText' value instead Interaction.previewText( variant: self.variant, body: self.body, threadContactDisplayName: Profile.displayName( for: self.threadVariant, id: self.threadId, name: self.threadContactNameInternal, nickname: nil // Folded into 'threadContactNameInternal' within the Query ), authorDisplayName: authorDisplayName, attachmentDescriptionInfo: self.attachments?.first.map { firstAttachment in Attachment.DescriptionInfo( id: firstAttachment.id, variant: firstAttachment.variant, contentType: firstAttachment.contentType, sourceFilename: firstAttachment.sourceFilename ) }, attachmentCount: self.attachments?.count, isOpenGroupInvitation: (self.linkPreview?.variant == .openGroupInvitation) ) ), rawBody: self.body, expiresStartedAtMs: self.expiresStartedAtMs, expiresInSeconds: self.expiresInSeconds, state: self.state, hasAtLeastOneReadReceipt: self.hasAtLeastOneReadReceipt, mostRecentFailureText: self.mostRecentFailureText, isSenderOpenGroupModerator: self.isSenderOpenGroupModerator, isTypingIndicator: self.isTypingIndicator, profile: self.profile, quote: self.quote, quoteAttachment: self.quoteAttachment, linkPreview: self.linkPreview, linkPreviewAttachment: self.linkPreviewAttachment, currentUserPublicKey: self.currentUserPublicKey, attachments: self.attachments, reactionInfo: self.reactionInfo, cellType: cellType, authorName: authorDisplayName, senderName: { // Only show for group threads guard isGroupThread else { return nil } // Only show for incoming messages guard self.variant == .standardIncoming || self.variant == .standardIncomingDeleted else { return nil } // Only if there is a date header or the senders are different guard shouldShowDateBeforeThisModel || self.authorId != prevModel?.authorId else { return nil } return authorDisplayName }(), canHaveProfile: ( // Only group threads and incoming messages isGroupThread && self.variant == .standardIncoming ), shouldShowProfile: ( // Only group threads isGroupThread && // Only incoming messages (self.variant == .standardIncoming || self.variant == .standardIncomingDeleted) && // Show if the next message has a different sender, isn't a standard message or has a "date break" ( self.authorId != nextModel?.authorId || (nextModel?.variant != .standardIncoming && nextModel?.variant != .standardIncomingDeleted) || shouldShowDateBeforeNextModel ) && // Need a profile to be able to show it self.profile != nil ), shouldShowDateHeader: shouldShowDateBeforeThisModel, containsOnlyEmoji: self.body?.containsOnlyEmoji, glyphCount: self.body?.glyphCount, previousVariant: prevModel?.variant, positionInCluster: positionInCluster, isOnlyMessageInCluster: isOnlyMessageInCluster, isLast: isLast, isLastOutgoing: isLastOutgoing, currentUserBlindedPublicKey: currentUserBlindedPublicKey ) } } // MARK: - AttachmentInteractionInfo public extension MessageViewModel { struct AttachmentInteractionInfo: FetchableRecordWithRowId, Decodable, Identifiable, Equatable, Comparable { public static let rowIdKey: SQL = SQL(stringLiteral: CodingKeys.rowId.stringValue) public static let attachmentKey: SQL = SQL(stringLiteral: CodingKeys.attachment.stringValue) public static let interactionAttachmentKey: SQL = SQL(stringLiteral: CodingKeys.interactionAttachment.stringValue) public static let attachmentString: String = CodingKeys.attachment.stringValue public static let interactionAttachmentString: String = CodingKeys.interactionAttachment.stringValue public let rowId: Int64 public let attachment: Attachment public let interactionAttachment: InteractionAttachment // MARK: - Identifiable public var id: String { "\(interactionAttachment.interactionId)-\(interactionAttachment.albumIndex)" } // MARK: - Comparable public static func < (lhs: AttachmentInteractionInfo, rhs: AttachmentInteractionInfo) -> Bool { return (lhs.interactionAttachment.albumIndex < rhs.interactionAttachment.albumIndex) } } } // MARK: - ReactionInfo public extension MessageViewModel { struct ReactionInfo: FetchableRecordWithRowId, Decodable, Identifiable, Equatable, Comparable, Hashable, Differentiable { public static let rowIdKey: SQL = SQL(stringLiteral: CodingKeys.rowId.stringValue) public static let reactionKey: SQL = SQL(stringLiteral: CodingKeys.reaction.stringValue) public static let profileKey: SQL = SQL(stringLiteral: CodingKeys.profile.stringValue) public static let reactionString: String = CodingKeys.reaction.stringValue public static let profileString: String = CodingKeys.profile.stringValue public let rowId: Int64 public let reaction: Reaction public let profile: Profile? // MARK: - Identifiable public var differenceIdentifier: String { return id } public var id: String { "\(reaction.emoji)-\(reaction.interactionId)-\(reaction.authorId)" } // MARK: - Comparable public static func < (lhs: ReactionInfo, rhs: ReactionInfo) -> Bool { return (lhs.reaction.sortId < rhs.reaction.sortId) } } } // MARK: - TypingIndicatorInfo public extension MessageViewModel { struct TypingIndicatorInfo: FetchableRecordWithRowId, Decodable, Identifiable, Equatable { public static let rowIdKey: SQL = SQL(stringLiteral: CodingKeys.rowId.stringValue) public static let threadIdKey: SQL = SQL(stringLiteral: CodingKeys.threadId.stringValue) public let rowId: Int64 public let threadId: String // MARK: - Identifiable public var id: String { threadId } } } // MARK: - Convenience Initialization public extension MessageViewModel { static let genericId: Int64 = -1 static let typingIndicatorId: Int64 = -2 /// This init method is only used for system-created cells or empty states init( variant: Interaction.Variant = .standardOutgoing, timestampMs: Int64 = Int64.max, receivedAtTimestampMs: Int64 = Int64.max, body: String? = nil, quote: Quote? = nil, cellType: CellType = .typingIndicator, isTypingIndicator: Bool? = nil, isLast: Bool = true, isLastOutgoing: Bool = false ) { self.threadId = "INVALID_THREAD_ID" self.threadVariant = .contact self.threadIsTrusted = false self.threadHasDisappearingMessagesEnabled = false self.threadOpenGroupServer = nil self.threadOpenGroupPublicKey = nil self.threadContactNameInternal = nil // Interaction Info let targetId: Int64 = { guard isTypingIndicator != true else { return MessageViewModel.typingIndicatorId } guard cellType != .dateHeader else { return -timestampMs } return MessageViewModel.genericId }() self.rowId = targetId self.id = targetId self.openGroupServerMessageId = nil self.variant = variant self.timestampMs = timestampMs self.receivedAtTimestampMs = receivedAtTimestampMs self.authorId = "" self.authorNameInternal = nil self.body = body self.rawBody = nil self.expiresStartedAtMs = nil self.expiresInSeconds = nil self.state = .sent self.hasAtLeastOneReadReceipt = false self.mostRecentFailureText = nil self.isSenderOpenGroupModerator = false self.isTypingIndicator = isTypingIndicator self.profile = nil self.quote = quote self.quoteAttachment = nil self.linkPreview = nil self.linkPreviewAttachment = nil self.currentUserPublicKey = "" self.attachments = nil self.reactionInfo = nil // Post-Query Processing Data self.cellType = cellType self.authorName = "" self.senderName = nil self.canHaveProfile = false self.shouldShowProfile = false self.shouldShowDateHeader = false self.containsOnlyEmoji = nil self.glyphCount = nil self.previousVariant = nil self.positionInCluster = .middle self.isOnlyMessageInCluster = true self.isLast = isLast self.isLastOutgoing = isLastOutgoing self.currentUserBlindedPublicKey = nil } /// This init method is only used for optimistic outgoing messages init( threadId: String, threadVariant: SessionThread.Variant, threadHasDisappearingMessagesEnabled: Bool, threadOpenGroupServer: String?, threadOpenGroupPublicKey: String?, threadContactNameInternal: String, timestampMs: Int64, receivedAtTimestampMs: Int64, authorId: String, authorNameInternal: String, body: String?, expiresStartedAtMs: Double?, expiresInSeconds: TimeInterval?, isSenderOpenGroupModerator: Bool, currentUserProfile: Profile, quote: Quote?, quoteAttachment: Attachment?, linkPreview: LinkPreview?, linkPreviewAttachment: Attachment?, attachments: [Attachment]? ) { self.threadId = threadId self.threadVariant = threadVariant self.threadIsTrusted = false self.threadHasDisappearingMessagesEnabled = threadHasDisappearingMessagesEnabled self.threadOpenGroupServer = threadOpenGroupServer self.threadOpenGroupPublicKey = threadOpenGroupPublicKey self.threadContactNameInternal = threadContactNameInternal // Interaction Info self.rowId = -1 self.id = -1 self.openGroupServerMessageId = nil self.variant = .standardOutgoing self.timestampMs = timestampMs self.receivedAtTimestampMs = receivedAtTimestampMs self.authorId = authorId self.authorNameInternal = authorNameInternal self.body = body self.rawBody = body self.expiresStartedAtMs = expiresStartedAtMs self.expiresInSeconds = expiresInSeconds self.state = .sending self.hasAtLeastOneReadReceipt = false self.mostRecentFailureText = nil self.isSenderOpenGroupModerator = isSenderOpenGroupModerator self.isTypingIndicator = false self.profile = currentUserProfile self.quote = quote self.quoteAttachment = quoteAttachment self.linkPreview = linkPreview self.linkPreviewAttachment = linkPreviewAttachment self.currentUserPublicKey = currentUserProfile.id self.attachments = attachments self.reactionInfo = nil // Post-Query Processing Data self.cellType = .textOnlyMessage self.authorName = "" self.senderName = nil self.canHaveProfile = false self.shouldShowProfile = false self.shouldShowDateHeader = false self.containsOnlyEmoji = nil self.glyphCount = nil self.previousVariant = nil self.positionInCluster = .middle self.isOnlyMessageInCluster = true self.isLast = false self.isLastOutgoing = false self.currentUserBlindedPublicKey = nil } } // MARK: - Convenience extension MessageViewModel { private static let maxMinutesBetweenTwoDateBreaks: Int = 5 /// Returns the difference in minutes, ignoring seconds /// /// If both dates are the same date, returns 0 /// If firstDate is one minute before secondDate, returns 1 /// /// **Note:** Assumes both dates use the "current" calendar private static func minutesFrom(_ firstDate: Date, to secondDate: Date) -> Int? { let calendar: Calendar = Calendar.current let components1: DateComponents = calendar.dateComponents( [.era, .year, .month, .day, .hour, .minute], from: firstDate ) let components2: DateComponents = calendar.dateComponents( [.era, .year, .month, .day, .hour, .minute], from: secondDate ) guard let date1: Date = calendar.date(from: components1), let date2: Date = calendar.date(from: components2) else { return nil } return calendar.dateComponents([.minute], from: date1, to: date2).minute } fileprivate static func shouldShowDateBreak(between timestamp1: Int64, and timestamp2: Int64) -> Bool { let date1: Date = Date(timeIntervalSince1970: (TimeInterval(timestamp1) / 1000)) let date2: Date = Date(timeIntervalSince1970: (TimeInterval(timestamp2) / 1000)) return ((minutesFrom(date1, to: date2) ?? 0) > maxMinutesBetweenTwoDateBreaks) } } // MARK: - ConversationVC // MARK: --MessageViewModel public extension MessageViewModel { static func filterSQL(threadId: String) -> SQL { let interaction: TypedTableAlias = TypedTableAlias() return SQL("\(interaction[.threadId]) = \(threadId)") } static let groupSQL: SQL = { let interaction: TypedTableAlias = TypedTableAlias() return SQL("GROUP BY \(interaction[.id])") }() static let orderSQL: SQL = { let interaction: TypedTableAlias = TypedTableAlias() return SQL("\(interaction[.timestampMs].desc)") }() static func baseQuery( userPublicKey: String, blindedPublicKey: String?, orderSQL: SQL, groupSQL: SQL? ) -> (([Int64]) -> AdaptedFetchRequest>) { return { rowIds -> AdaptedFetchRequest> in let interaction: TypedTableAlias = TypedTableAlias() let thread: TypedTableAlias = TypedTableAlias() let openGroup: TypedTableAlias = TypedTableAlias() let groupMember: TypedTableAlias = TypedTableAlias() let recipientState: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() let disappearingMessagesConfig: TypedTableAlias = TypedTableAlias() let profile: TypedTableAlias = TypedTableAlias() let quote: TypedTableAlias = TypedTableAlias() let linkPreview: TypedTableAlias = TypedTableAlias() let threadProfile: SQL = SQL(stringLiteral: "threadProfile") let quoteInteraction: SQL = SQL(stringLiteral: "quoteInteraction") let quoteInteractionAttachment: SQL = SQL(stringLiteral: "quoteInteractionAttachment") let readReceipt: SQL = SQL(stringLiteral: "readReceipt") let idColumn: SQL = SQL(stringLiteral: Interaction.Columns.id.name) let interactionBodyColumn: SQL = SQL(stringLiteral: Interaction.Columns.body.name) let profileIdColumn: SQL = SQL(stringLiteral: Profile.Columns.id.name) let nicknameColumn: SQL = SQL(stringLiteral: Profile.Columns.nickname.name) let nameColumn: SQL = SQL(stringLiteral: Profile.Columns.name.name) let quoteBodyColumn: SQL = SQL(stringLiteral: Quote.Columns.body.name) let quoteAttachmentIdColumn: SQL = SQL(stringLiteral: Quote.Columns.attachmentId.name) let readReceiptInteractionIdColumn: SQL = SQL(stringLiteral: RecipientState.Columns.interactionId.name) let readTimestampMsColumn: SQL = SQL(stringLiteral: RecipientState.Columns.readTimestampMs.name) let timestampMsColumn: SQL = SQL(stringLiteral: Interaction.Columns.timestampMs.name) let authorIdColumn: SQL = SQL(stringLiteral: Interaction.Columns.authorId.name) let attachmentIdColumn: SQL = SQL(stringLiteral: Attachment.Columns.id.name) let interactionAttachmentInteractionIdColumn: SQL = SQL(stringLiteral: InteractionAttachment.Columns.interactionId.name) let interactionAttachmentAttachmentIdColumn: SQL = SQL(stringLiteral: InteractionAttachment.Columns.attachmentId.name) let interactionAttachmentAlbumIndexColumn: SQL = SQL(stringLiteral: InteractionAttachment.Columns.albumIndex.name) let numColumnsBeforeLinkedRecords: Int = 22 let finalGroupSQL: SQL = (groupSQL ?? "") let request: SQLRequest = """ SELECT \(thread[.id]) AS \(ViewModel.threadIdKey), \(thread[.variant]) AS \(ViewModel.threadVariantKey), -- Default to 'true' for non-contact threads IFNULL(\(contact[.isTrusted]), true) AS \(ViewModel.threadIsTrustedKey), -- Default to 'false' when no contact exists IFNULL(\(disappearingMessagesConfig[.isEnabled]), false) AS \(ViewModel.threadHasDisappearingMessagesEnabledKey), \(openGroup[.server]) AS \(ViewModel.threadOpenGroupServerKey), \(openGroup[.publicKey]) AS \(ViewModel.threadOpenGroupPublicKeyKey), IFNULL(\(threadProfile).\(nicknameColumn), \(threadProfile).\(nameColumn)) AS \(ViewModel.threadContactNameInternalKey), \(interaction.alias[Column.rowID]) AS \(ViewModel.rowIdKey), \(interaction[.id]), \(interaction[.openGroupServerMessageId]), \(interaction[.variant]), \(interaction[.timestampMs]), \(interaction[.receivedAtTimestampMs]), \(interaction[.authorId]), IFNULL(\(profile[.nickname]), \(profile[.name])) AS \(ViewModel.authorNameInternalKey), \(interaction[.body]), \(interaction[.expiresStartedAtMs]), \(interaction[.expiresInSeconds]), -- Default to 'sending' assuming non-processed interaction when null IFNULL(MIN(\(recipientState[.state])), \(SQL("\(RecipientState.State.sending)"))) AS \(ViewModel.stateKey), (\(readReceipt).\(readTimestampMsColumn) IS NOT NULL) AS \(ViewModel.hasAtLeastOneReadReceiptKey), \(recipientState[.mostRecentFailureText]) AS \(ViewModel.mostRecentFailureTextKey), EXISTS ( SELECT 1 FROM \(GroupMember.self) WHERE ( \(groupMember[.groupId]) = \(interaction[.threadId]) AND \(groupMember[.profileId]) = \(interaction[.authorId]) AND \(SQL("\(thread[.variant]) = \(SessionThread.Variant.community)")) AND \(SQL("\(groupMember[.role]) IN \([GroupMember.Role.moderator, GroupMember.Role.admin])")) ) ) AS \(ViewModel.isSenderOpenGroupModeratorKey), \(ViewModel.profileKey).*, \(quote[.interactionId]), \(quote[.authorId]), \(quote[.timestampMs]), \(quoteInteraction).\(interactionBodyColumn) AS \(quoteBodyColumn), \(quoteInteractionAttachment).\(interactionAttachmentAttachmentIdColumn) AS \(quoteAttachmentIdColumn), \(ViewModel.quoteAttachmentKey).*, \(ViewModel.linkPreviewKey).*, \(ViewModel.linkPreviewAttachmentKey).*, \(SQL("\(userPublicKey)")) AS \(ViewModel.currentUserPublicKeyKey), -- All of the below properties are set in post-query processing but to prevent the -- query from crashing when decoding we need to provide default values \(CellType.textOnlyMessage) AS \(ViewModel.cellTypeKey), '' AS \(ViewModel.authorNameKey), false AS \(ViewModel.canHaveProfileKey), false AS \(ViewModel.shouldShowProfileKey), false AS \(ViewModel.shouldShowDateHeaderKey), \(Position.middle) AS \(ViewModel.positionInClusterKey), false AS \(ViewModel.isOnlyMessageInClusterKey), false AS \(ViewModel.isLastKey), false AS \(ViewModel.isLastOutgoingKey) FROM \(Interaction.self) JOIN \(SessionThread.self) ON \(thread[.id]) = \(interaction[.threadId]) LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(interaction[.threadId]) LEFT JOIN \(Profile.self) AS \(threadProfile) ON \(threadProfile).\(profileIdColumn) = \(interaction[.threadId]) LEFT JOIN \(DisappearingMessagesConfiguration.self) ON \(disappearingMessagesConfig[.threadId]) = \(interaction[.threadId]) LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(interaction[.threadId]) LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId]) LEFT JOIN \(Quote.self) ON \(quote[.interactionId]) = \(interaction[.id]) LEFT JOIN \(Interaction.self) AS \(quoteInteraction) ON ( \(quoteInteraction).\(timestampMsColumn) = \(quote[.timestampMs]) AND ( \(quoteInteraction).\(authorIdColumn) = \(quote[.authorId]) OR ( -- A users outgoing message is stored in some cases using their standard id -- but the quote will use their blinded id so handle that case \(quote[.authorId]) = \(blindedPublicKey ?? "''") AND \(quoteInteraction).\(authorIdColumn) = \(userPublicKey) ) ) ) LEFT JOIN \(InteractionAttachment.self) AS \(quoteInteractionAttachment) ON ( \(quoteInteractionAttachment).\(interactionAttachmentInteractionIdColumn) = \(quoteInteraction).\(idColumn) AND \(quoteInteractionAttachment).\(interactionAttachmentAlbumIndexColumn) = 0 ) LEFT JOIN \(Attachment.self) AS \(ViewModel.quoteAttachmentKey) ON \(ViewModel.quoteAttachmentKey).\(attachmentIdColumn) = \(quoteInteractionAttachment).\(interactionAttachmentAttachmentIdColumn) LEFT JOIN \(LinkPreview.self) ON ( \(linkPreview[.url]) = \(interaction[.linkPreviewUrl]) AND \(Interaction.linkPreviewFilterLiteral) ) LEFT JOIN \(Attachment.self) AS \(ViewModel.linkPreviewAttachmentKey) ON \(ViewModel.linkPreviewAttachmentKey).\(attachmentIdColumn) = \(linkPreview[.attachmentId]) LEFT JOIN \(RecipientState.self) ON ( -- Ignore 'skipped' states \(SQL("\(recipientState[.state]) != \(RecipientState.State.skipped)")) AND \(recipientState[.interactionId]) = \(interaction[.id]) ) LEFT JOIN \(RecipientState.self) AS \(readReceipt) ON ( \(readReceipt).\(readTimestampMsColumn) IS NOT NULL AND \(readReceipt).\(readReceiptInteractionIdColumn) = \(interaction[.id]) ) WHERE \(interaction.alias[Column.rowID]) IN \(rowIds) \(finalGroupSQL) ORDER BY \(orderSQL) """ return request.adapted { db in let adapters = try splittingRowAdapters(columnCounts: [ numColumnsBeforeLinkedRecords, Profile.numberOfSelectedColumns(db), Quote.numberOfSelectedColumns(db), Attachment.numberOfSelectedColumns(db), LinkPreview.numberOfSelectedColumns(db), Attachment.numberOfSelectedColumns(db) ]) return ScopeAdapter([ ViewModel.profileString: adapters[1], ViewModel.quoteString: adapters[2], ViewModel.quoteAttachmentString: adapters[3], ViewModel.linkPreviewString: adapters[4], ViewModel.linkPreviewAttachmentString: adapters[5] ]) } } } } // MARK: --AttachmentInteractionInfo public extension MessageViewModel.AttachmentInteractionInfo { static let baseQuery: ((SQL?) -> AdaptedFetchRequest>) = { return { additionalFilters -> AdaptedFetchRequest> in let attachment: TypedTableAlias = TypedTableAlias() let interactionAttachment: TypedTableAlias = TypedTableAlias() let finalFilterSQL: SQL = { guard let additionalFilters: SQL = additionalFilters else { return SQL(stringLiteral: "") } return """ WHERE \(additionalFilters) """ }() let numColumnsBeforeLinkedRecords: Int = 1 let request: SQLRequest = """ SELECT \(attachment.alias[Column.rowID]) AS \(AttachmentInteractionInfo.rowIdKey), \(AttachmentInteractionInfo.attachmentKey).*, \(AttachmentInteractionInfo.interactionAttachmentKey).* FROM \(Attachment.self) JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.attachmentId]) = \(attachment[.id]) \(finalFilterSQL) """ return request.adapted { db in let adapters = try splittingRowAdapters(columnCounts: [ numColumnsBeforeLinkedRecords, Attachment.numberOfSelectedColumns(db), InteractionAttachment.numberOfSelectedColumns(db) ]) return ScopeAdapter([ AttachmentInteractionInfo.attachmentString: adapters[1], AttachmentInteractionInfo.interactionAttachmentString: adapters[2] ]) } } }() static var joinToViewModelQuerySQL: SQL = { let interaction: TypedTableAlias = TypedTableAlias() let attachment: TypedTableAlias = TypedTableAlias() let interactionAttachment: TypedTableAlias = TypedTableAlias() return """ JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.interactionId]) = \(interaction[.id]) JOIN \(Attachment.self) ON \(attachment[.id]) = \(interactionAttachment[.attachmentId]) """ }() static func createAssociateDataClosure() -> (DataCache, DataCache) -> DataCache { return { dataCache, pagedDataCache -> DataCache in var updatedPagedDataCache: DataCache = pagedDataCache dataCache .values .grouped(by: \.interactionAttachment.interactionId) .forEach { (interactionId: Int64, attachments: [MessageViewModel.AttachmentInteractionInfo]) in guard let interactionRowId: Int64 = updatedPagedDataCache.lookup[interactionId], let dataToUpdate: ViewModel = updatedPagedDataCache.data[interactionRowId] else { return } updatedPagedDataCache = updatedPagedDataCache.upserting( dataToUpdate.with( attachments: attachments .sorted() .map { $0.attachment } ) ) } return updatedPagedDataCache } } } // MARK: --ReactionInfo public extension MessageViewModel.ReactionInfo { static let baseQuery: ((SQL?) -> AdaptedFetchRequest>) = { return { additionalFilters -> AdaptedFetchRequest> in let reaction: TypedTableAlias = TypedTableAlias() let profile: TypedTableAlias = TypedTableAlias() let finalFilterSQL: SQL = { guard let additionalFilters: SQL = additionalFilters else { return SQL(stringLiteral: "") } return """ WHERE \(additionalFilters) """ }() let numColumnsBeforeLinkedRecords: Int = 1 let request: SQLRequest = """ SELECT \(reaction.alias[Column.rowID]) AS \(ReactionInfo.rowIdKey), \(ReactionInfo.reactionKey).*, \(ReactionInfo.profileKey).* FROM \(Reaction.self) LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(reaction[.authorId]) \(finalFilterSQL) """ return request.adapted { db in let adapters = try splittingRowAdapters(columnCounts: [ numColumnsBeforeLinkedRecords, Reaction.numberOfSelectedColumns(db), Profile.numberOfSelectedColumns(db) ]) return ScopeAdapter([ ReactionInfo.reactionString: adapters[1], ReactionInfo.profileString: adapters[2] ]) } } }() static var joinToViewModelQuerySQL: SQL = { let interaction: TypedTableAlias = TypedTableAlias() let reaction: TypedTableAlias = TypedTableAlias() return """ JOIN \(Reaction.self) ON \(reaction[.interactionId]) = \(interaction[.id]) """ }() static func createAssociateDataClosure() -> (DataCache, DataCache) -> DataCache { return { dataCache, pagedDataCache -> DataCache in var updatedPagedDataCache: DataCache = pagedDataCache var pagedRowIdsWithNoReactions: Set = Set(pagedDataCache.data.keys) // Add any new reactions dataCache .values .grouped(by: \.reaction.interactionId) .forEach { (interactionId: Int64, reactionInfo: [MessageViewModel.ReactionInfo]) in guard let interactionRowId: Int64 = updatedPagedDataCache.lookup[interactionId], let dataToUpdate: ViewModel = updatedPagedDataCache.data[interactionRowId] else { return } updatedPagedDataCache = updatedPagedDataCache.upserting( dataToUpdate.with(reactionInfo: reactionInfo.sorted()) ) pagedRowIdsWithNoReactions.remove(interactionRowId) } // Remove any removed reactions updatedPagedDataCache = updatedPagedDataCache.upserting( items: pagedRowIdsWithNoReactions .compactMap { rowId -> ViewModel? in updatedPagedDataCache.data[rowId] } .filter { viewModel -> Bool in (viewModel.reactionInfo?.isEmpty == false) } .map { viewModel -> ViewModel in viewModel.with(reactionInfo: []) } ) return updatedPagedDataCache } } } // MARK: --TypingIndicatorInfo public extension MessageViewModel.TypingIndicatorInfo { static let baseQuery: ((SQL?) -> SQLRequest) = { return { additionalFilters -> SQLRequest in let threadTypingIndicator: TypedTableAlias = TypedTableAlias() let finalFilterSQL: SQL = { guard let additionalFilters: SQL = additionalFilters else { return SQL(stringLiteral: "") } return """ WHERE \(additionalFilters) """ }() let request: SQLRequest = """ SELECT \(threadTypingIndicator.alias[Column.rowID]) AS \(MessageViewModel.TypingIndicatorInfo.rowIdKey), \(threadTypingIndicator[.threadId]) AS \(MessageViewModel.TypingIndicatorInfo.threadIdKey) FROM \(ThreadTypingIndicator.self) \(finalFilterSQL) """ return request } }() static var joinToViewModelQuerySQL: SQL = { let interaction: TypedTableAlias = TypedTableAlias() let threadTypingIndicator: TypedTableAlias = TypedTableAlias() return """ JOIN \(ThreadTypingIndicator.self) ON \(threadTypingIndicator[.threadId]) = \(interaction[.threadId]) """ }() static func createAssociateDataClosure() -> (DataCache, DataCache) -> DataCache { return { dataCache, pagedDataCache -> DataCache in guard !dataCache.data.isEmpty else { return pagedDataCache.deleting(rowIds: [MessageViewModel.typingIndicatorId]) } return pagedDataCache .upserting(MessageViewModel(isTypingIndicator: true)) } } }