mirror of
https://github.com/oxen-io/session-ios.git
synced 2023-12-13 21:30:14 +01:00
8ff542405c
Updated the migrations to indicate progress (Potential to base progress for the "processing" sections on the file size of the legacy database) Updated the JobRunner to properly support concurrent queues for sending/receiving (other queues are still serial) Added the typing indicator logic into the ConversationVC Put code into SUKLegacy for connecting to the YDB database Fixed a couple of minor UI bugs with the GalleryRailView Updated the media gallery selection screen to use the appropriate system theme colouring (was painful to randomly swap from dark mode to like for one screen...) Added an alert for when the database migration fails Deleted the legacy migrations (manually applying any unapplied changes as part of the YDB to GRDB migration process)
773 lines
35 KiB
Swift
773 lines
35 KiB
Swift
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
|
|
|
import Foundation
|
|
import GRDB
|
|
import DifferenceKit
|
|
import SessionUtilitiesKit
|
|
|
|
fileprivate typealias ViewModel = MessageViewModel
|
|
fileprivate typealias AttachmentInteractionInfo = MessageViewModel.AttachmentInteractionInfo
|
|
fileprivate typealias TypingIndicatorInfo = MessageViewModel.TypingIndicatorInfo
|
|
|
|
public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, Hashable, Identifiable, Differentiable {
|
|
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 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 cellTypeKey: SQL = SQL(stringLiteral: CodingKeys.cellType.stringValue)
|
|
public static let authorNameKey: SQL = SQL(stringLiteral: CodingKeys.authorName.stringValue)
|
|
public static let shouldShowProfileKey: SQL = SQL(stringLiteral: CodingKeys.shouldShowProfile.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 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 Position: Int, Decodable, Equatable, Hashable, DatabaseValueConvertible {
|
|
case top
|
|
case middle
|
|
case bottom
|
|
}
|
|
|
|
public enum CellType: Int, Decodable, Equatable, Hashable, DatabaseValueConvertible {
|
|
case textOnlyMessage
|
|
case mediaMessage
|
|
case audio
|
|
case genericAttachment
|
|
case typingIndicator
|
|
}
|
|
|
|
public var differenceIdentifier: Int64 { id }
|
|
|
|
// Thread Info
|
|
|
|
public let threadVariant: SessionThread.Variant
|
|
public let threadIsTrusted: Bool
|
|
public let threadHasDisappearingMessagesEnabled: Bool
|
|
|
|
// Interaction Info
|
|
|
|
public let rowId: Int64
|
|
public let id: Int64
|
|
public let variant: Interaction.Variant
|
|
public let timestampMs: Int64
|
|
public let authorId: String
|
|
private let authorNameInternal: String?
|
|
public let body: String?
|
|
public let expiresStartedAtMs: Double?
|
|
public let expiresInSeconds: TimeInterval?
|
|
|
|
public let state: RecipientState.State
|
|
public let hasAtLeastOneReadReceipt: Bool
|
|
public let mostRecentFailureText: String?
|
|
public let isTypingIndicator: Bool
|
|
public let isSenderOpenGroupModerator: Bool
|
|
public let profile: Profile?
|
|
public let quote: Quote?
|
|
public let quoteAttachment: Attachment?
|
|
public let linkPreview: LinkPreview?
|
|
public let linkPreviewAttachment: Attachment?
|
|
|
|
// Post-Query Processing Data
|
|
|
|
/// This value includes the associated attachments
|
|
public let attachments: [Attachment]?
|
|
|
|
/// 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
|
|
public let senderName: String?
|
|
|
|
/// A flag indicating whether the profile view should be displayed
|
|
public let shouldShowProfile: Bool
|
|
|
|
/// This value will be used to populate the date header, if it's null then the header will be hidden
|
|
public let dateForUI: Date?
|
|
|
|
/// 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
|
|
|
|
// MARK: - Mutation
|
|
|
|
public func with(attachments: [Attachment]) -> MessageViewModel {
|
|
return MessageViewModel(
|
|
threadVariant: self.threadVariant,
|
|
threadIsTrusted: self.threadIsTrusted,
|
|
threadHasDisappearingMessagesEnabled: self.threadHasDisappearingMessagesEnabled,
|
|
rowId: self.rowId,
|
|
id: self.id,
|
|
variant: self.variant,
|
|
timestampMs: self.timestampMs,
|
|
authorId: self.authorId,
|
|
authorNameInternal: self.authorNameInternal,
|
|
body: self.body,
|
|
expiresStartedAtMs: self.expiresStartedAtMs,
|
|
expiresInSeconds: self.expiresInSeconds,
|
|
state: self.state,
|
|
hasAtLeastOneReadReceipt: self.hasAtLeastOneReadReceipt,
|
|
mostRecentFailureText: self.mostRecentFailureText,
|
|
isTypingIndicator: self.isTypingIndicator,
|
|
isSenderOpenGroupModerator: self.isSenderOpenGroupModerator,
|
|
profile: self.profile,
|
|
quote: self.quote,
|
|
quoteAttachment: self.quoteAttachment,
|
|
linkPreview: self.linkPreview,
|
|
linkPreviewAttachment: self.linkPreviewAttachment,
|
|
attachments: attachments,
|
|
cellType: self.cellType,
|
|
authorName: self.authorName,
|
|
senderName: self.senderName,
|
|
shouldShowProfile: self.shouldShowProfile,
|
|
dateForUI: self.dateForUI,
|
|
containsOnlyEmoji: self.containsOnlyEmoji,
|
|
glyphCount: self.glyphCount,
|
|
previousVariant: self.previousVariant,
|
|
positionInCluster: self.positionInCluster,
|
|
isOnlyMessageInCluster: self.isOnlyMessageInCluster,
|
|
isLast: self.isLast
|
|
)
|
|
}
|
|
|
|
public func withClusteringChanges(
|
|
prevModel: MessageViewModel?,
|
|
nextModel: MessageViewModel?,
|
|
isLast: Bool
|
|
) -> MessageViewModel {
|
|
let cellType: CellType = {
|
|
guard !self.isTypingIndicator 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 shouldShowDateOnThisModel: Bool = {
|
|
guard !self.isTypingIndicator else { return false }
|
|
guard let prevModel: ViewModel = prevModel else { return true }
|
|
|
|
return MessageViewModel.shouldShowDateBreak(
|
|
between: prevModel.timestampMs,
|
|
and: self.timestampMs
|
|
)
|
|
}()
|
|
let shouldShowDateOnNextModel: Bool = {
|
|
// Should be nothing after a typing indicator
|
|
guard !self.isTypingIndicator 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 ||
|
|
shouldShowDateOnThisModel || (
|
|
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 ||
|
|
shouldShowDateOnNextModel || (
|
|
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)
|
|
}
|
|
}()
|
|
|
|
return ViewModel(
|
|
threadVariant: self.threadVariant,
|
|
threadIsTrusted: self.threadIsTrusted,
|
|
threadHasDisappearingMessagesEnabled: self.threadHasDisappearingMessagesEnabled,
|
|
rowId: self.rowId,
|
|
id: self.id,
|
|
variant: self.variant,
|
|
timestampMs: self.timestampMs,
|
|
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,
|
|
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)
|
|
)
|
|
),
|
|
expiresStartedAtMs: self.expiresStartedAtMs,
|
|
expiresInSeconds: self.expiresInSeconds,
|
|
state: self.state,
|
|
hasAtLeastOneReadReceipt: self.hasAtLeastOneReadReceipt,
|
|
mostRecentFailureText: self.mostRecentFailureText,
|
|
isTypingIndicator: self.isTypingIndicator,
|
|
isSenderOpenGroupModerator: self.isSenderOpenGroupModerator,
|
|
profile: self.profile,
|
|
quote: self.quote,
|
|
quoteAttachment: self.quoteAttachment,
|
|
linkPreview: self.linkPreview,
|
|
linkPreviewAttachment: self.linkPreviewAttachment,
|
|
attachments: self.attachments,
|
|
cellType: cellType,
|
|
authorName: authorDisplayName,
|
|
senderName: {
|
|
// Only show for group threads
|
|
guard self.threadVariant == .openGroup || self.threadVariant == .closedGroup else {
|
|
return nil
|
|
}
|
|
|
|
// Only if there is a date header or the senders are different
|
|
guard shouldShowDateOnThisModel || self.authorId != prevModel?.authorId else {
|
|
return nil
|
|
}
|
|
|
|
return authorDisplayName
|
|
}(),
|
|
shouldShowProfile: (
|
|
// Only group threads
|
|
(self.threadVariant == .openGroup || self.threadVariant == .closedGroup) &&
|
|
|
|
// Only incoming messages
|
|
(self.variant == .standardIncoming || self.variant == .standardIncomingDeleted) &&
|
|
|
|
// Show if the next message has a different sender or has a "date break"
|
|
(
|
|
self.authorId != nextModel?.authorId ||
|
|
shouldShowDateOnNextModel
|
|
) &&
|
|
|
|
// Need a profile to be able to show it
|
|
self.profile != nil
|
|
),
|
|
dateForUI: (shouldShowDateOnThisModel ?
|
|
Date(timeIntervalSince1970: (TimeInterval(self.timestampMs) / 1000)) :
|
|
nil
|
|
),
|
|
containsOnlyEmoji: self.body?.containsOnlyEmoji,
|
|
glyphCount: self.body?.glyphCount,
|
|
previousVariant: prevModel?.variant,
|
|
positionInCluster: positionInCluster,
|
|
isOnlyMessageInCluster: isOnlyMessageInCluster,
|
|
isLast: isLast
|
|
)
|
|
}
|
|
}
|
|
|
|
// 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: - 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 {
|
|
public static let genericId: Int64 = -2
|
|
public static let typingIndicatorId: Int64 = -2
|
|
|
|
// Note: This init method is only used system-created cells or empty states
|
|
init(isTypingIndicator: Bool = false) {
|
|
self.threadVariant = .contact
|
|
self.threadIsTrusted = false
|
|
self.threadHasDisappearingMessagesEnabled = false
|
|
|
|
// Interaction Info
|
|
|
|
let targetId: Int64 = (isTypingIndicator ? MessageViewModel.typingIndicatorId : MessageViewModel.genericId)
|
|
self.rowId = targetId
|
|
self.id = targetId
|
|
self.variant = .standardOutgoing
|
|
self.timestampMs = Int64.max
|
|
self.authorId = ""
|
|
self.authorNameInternal = nil
|
|
self.body = nil
|
|
self.expiresStartedAtMs = nil
|
|
self.expiresInSeconds = nil
|
|
|
|
self.state = .sent
|
|
self.hasAtLeastOneReadReceipt = false
|
|
self.mostRecentFailureText = nil
|
|
self.isTypingIndicator = isTypingIndicator
|
|
self.isSenderOpenGroupModerator = false
|
|
self.profile = nil
|
|
self.quote = nil
|
|
self.quoteAttachment = nil
|
|
self.linkPreview = nil
|
|
self.linkPreviewAttachment = nil
|
|
|
|
// Post-Query Processing Data
|
|
|
|
self.attachments = nil
|
|
self.cellType = .typingIndicator
|
|
self.authorName = ""
|
|
self.senderName = nil
|
|
self.shouldShowProfile = false
|
|
self.dateForUI = nil
|
|
self.containsOnlyEmoji = nil
|
|
self.glyphCount = nil
|
|
self.previousVariant = nil
|
|
self.positionInCluster = .middle
|
|
self.isOnlyMessageInCluster = true
|
|
self.isLast = true
|
|
}
|
|
}
|
|
|
|
// 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<Interaction> = TypedTableAlias()
|
|
|
|
return SQL("\(interaction[.threadId]) = \(threadId)")
|
|
}
|
|
|
|
static let orderSQL: SQL = {
|
|
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
|
|
|
return SQL("\(interaction[.timestampMs].desc)")
|
|
}()
|
|
|
|
static func baseQuery(orderSQL: SQL, baseFilterSQL: SQL) -> ((SQL?, SQL?) -> AdaptedFetchRequest<SQLRequest<MessageViewModel>>) {
|
|
return { additionalFilters, limitSQL -> AdaptedFetchRequest<SQLRequest<ViewModel>> in
|
|
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
|
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
|
|
let typingIndicator: TypedTableAlias<ThreadTypingIndicator> = TypedTableAlias()
|
|
let contact: TypedTableAlias<Contact> = TypedTableAlias()
|
|
let disappearingMessagesConfig: TypedTableAlias<DisappearingMessagesConfiguration> = TypedTableAlias()
|
|
let profile: TypedTableAlias<Profile> = TypedTableAlias()
|
|
let quote: TypedTableAlias<Quote> = TypedTableAlias()
|
|
let linkPreview: TypedTableAlias<LinkPreview> = TypedTableAlias()
|
|
|
|
let interactionStateTableLiteral: SQL = SQL(stringLiteral: "interactionState")
|
|
let interactionStateInteractionIdColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.interactionId.name)
|
|
let interactionStateStateColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.state.name)
|
|
let interactionStateMostRecentFailureTextColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.mostRecentFailureText.name)
|
|
let readReceiptTableLiteral: SQL = SQL(stringLiteral: "readReceipt")
|
|
let readReceiptReadTimestampMsColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.readTimestampMs.name)
|
|
let attachmentIdColumnLiteral: SQL = SQL(stringLiteral: Attachment.Columns.id.name)
|
|
|
|
let finalFilterSQL: SQL = {
|
|
guard let additionalFilters: SQL = additionalFilters else {
|
|
return """
|
|
WHERE \(baseFilterSQL)
|
|
"""
|
|
}
|
|
|
|
return """
|
|
WHERE (
|
|
\(baseFilterSQL) AND
|
|
\(additionalFilters)
|
|
)
|
|
"""
|
|
}()
|
|
let finalLimitSQL: SQL = (limitSQL ?? SQL(stringLiteral: ""))
|
|
let numColumnsBeforeLinkedRecords: Int = 17
|
|
let request: SQLRequest<ViewModel> = """
|
|
SELECT
|
|
\(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),
|
|
|
|
\(interaction.alias[Column.rowID]) AS \(ViewModel.rowIdKey),
|
|
\(interaction[.id]),
|
|
\(interaction[.variant]),
|
|
\(interaction[.timestampMs]),
|
|
\(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(\(interactionStateTableLiteral).\(interactionStateStateColumnLiteral), \(SQL("\(RecipientState.State.sending)"))) AS \(ViewModel.stateKey),
|
|
(\(readReceiptTableLiteral).\(readReceiptReadTimestampMsColumnLiteral) IS NOT NULL) AS \(ViewModel.hasAtLeastOneReadReceiptKey),
|
|
\(interactionStateTableLiteral).\(interactionStateMostRecentFailureTextColumnLiteral) AS \(ViewModel.mostRecentFailureTextKey),
|
|
|
|
(\(typingIndicator[.threadId]) IS NOT NULL) AS \(ViewModel.isTypingIndicatorKey),
|
|
false AS \(ViewModel.isSenderOpenGroupModeratorKey),
|
|
|
|
\(ViewModel.profileKey).*,
|
|
\(ViewModel.quoteKey).*,
|
|
\(ViewModel.quoteAttachmentKey).*,
|
|
\(ViewModel.linkPreviewKey).*,
|
|
\(ViewModel.linkPreviewAttachmentKey).*,
|
|
|
|
-- 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.shouldShowProfileKey),
|
|
\(Position.middle) AS \(ViewModel.positionInClusterKey),
|
|
false AS \(ViewModel.isOnlyMessageInClusterKey),
|
|
false AS \(ViewModel.isLastKey)
|
|
|
|
FROM \(Interaction.self)
|
|
JOIN \(SessionThread.self) ON \(thread[.id]) = \(interaction[.threadId])
|
|
LEFT JOIN \(ThreadTypingIndicator.self) ON \(typingIndicator[.threadId]) = \(thread[.id])
|
|
LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(interaction[.threadId])
|
|
LEFT JOIN \(DisappearingMessagesConfiguration.self) ON \(disappearingMessagesConfig[.threadId]) = \(interaction[.threadId])
|
|
LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId])
|
|
LEFT JOIN \(Quote.self) ON \(quote[.interactionId]) = \(interaction[.id])
|
|
LEFT JOIN \(Attachment.self) AS \(ViewModel.quoteAttachmentKey) ON \(ViewModel.quoteAttachmentKey).\(attachmentIdColumnLiteral) = \(quote[.attachmentId])
|
|
LEFT JOIN \(LinkPreview.self) ON (
|
|
\(linkPreview[.url]) = \(interaction[.linkPreviewUrl]) AND
|
|
\(Interaction.linkPreviewFilterLiteral)
|
|
)
|
|
LEFT JOIN \(Attachment.self) AS \(ViewModel.linkPreviewAttachmentKey) ON \(ViewModel.linkPreviewAttachmentKey).\(attachmentIdColumnLiteral) = \(linkPreview[.attachmentId])
|
|
LEFT JOIN (
|
|
\(RecipientState.selectInteractionState(
|
|
tableLiteral: interactionStateTableLiteral,
|
|
idColumnLiteral: interactionStateInteractionIdColumnLiteral
|
|
))
|
|
) AS \(interactionStateTableLiteral) ON \(interactionStateTableLiteral).\(interactionStateInteractionIdColumnLiteral) = \(interaction[.id])
|
|
LEFT JOIN \(RecipientState.self) AS \(readReceiptTableLiteral) ON (
|
|
\(readReceiptTableLiteral).\(readReceiptReadTimestampMsColumnLiteral) IS NOT NULL AND
|
|
\(interaction[.id]) = \(readReceiptTableLiteral).\(interactionStateInteractionIdColumnLiteral)
|
|
)
|
|
\(finalFilterSQL)
|
|
ORDER BY \(orderSQL)
|
|
\(finalLimitSQL)
|
|
"""
|
|
|
|
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<SQLRequest<MessageViewModel.AttachmentInteractionInfo>>) = {
|
|
return { additionalFilters -> AdaptedFetchRequest<SQLRequest<AttachmentInteractionInfo>> in
|
|
let attachment: TypedTableAlias<Attachment> = TypedTableAlias()
|
|
let interactionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias()
|
|
|
|
let finalFilterSQL: SQL = {
|
|
guard let additionalFilters: SQL = additionalFilters else {
|
|
return SQL(stringLiteral: "")
|
|
}
|
|
|
|
return """
|
|
WHERE \(additionalFilters)
|
|
"""
|
|
}()
|
|
let numColumnsBeforeLinkedRecords: Int = 1
|
|
let request: SQLRequest<AttachmentInteractionInfo> = """
|
|
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<Interaction> = TypedTableAlias()
|
|
let attachment: TypedTableAlias<Attachment> = TypedTableAlias()
|
|
let interactionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias()
|
|
|
|
return """
|
|
JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.attachmentId]) = \(attachment[.id])
|
|
JOIN \(Interaction.self) ON
|
|
\(interaction[.id]) = \(interactionAttachment[.interactionId])
|
|
"""
|
|
}()
|
|
|
|
static var groupViewModelQuerySQL: SQL = {
|
|
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
|
|
|
return "\(interaction[.id])"
|
|
}()
|
|
|
|
static func createAssociateDataClosure() -> (DataCache<MessageViewModel.AttachmentInteractionInfo>, DataCache<MessageViewModel>) -> DataCache<MessageViewModel> {
|
|
return { dataCache, pagedDataCache -> DataCache<MessageViewModel> in
|
|
var updatedPagedDataCache: DataCache<MessageViewModel> = 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: --TypingIndicatorInfo
|
|
|
|
public extension MessageViewModel.TypingIndicatorInfo {
|
|
static let baseQuery: ((SQL?) -> SQLRequest<MessageViewModel.TypingIndicatorInfo>) = {
|
|
return { additionalFilters -> SQLRequest<TypingIndicatorInfo> in
|
|
let threadTypingIndicator: TypedTableAlias<ThreadTypingIndicator> = TypedTableAlias()
|
|
let finalFilterSQL: SQL = {
|
|
guard let additionalFilters: SQL = additionalFilters else {
|
|
return SQL(stringLiteral: "")
|
|
}
|
|
|
|
return """
|
|
WHERE \(additionalFilters)
|
|
"""
|
|
}()
|
|
let request: SQLRequest<MessageViewModel.TypingIndicatorInfo> = """
|
|
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<Interaction> = TypedTableAlias()
|
|
let threadTypingIndicator: TypedTableAlias<ThreadTypingIndicator> = TypedTableAlias()
|
|
|
|
return """
|
|
JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(threadTypingIndicator[.threadId])
|
|
"""
|
|
}()
|
|
|
|
static func createAssociateDataClosure() -> (DataCache<MessageViewModel.TypingIndicatorInfo>, DataCache<MessageViewModel>) -> DataCache<MessageViewModel> {
|
|
return { dataCache, pagedDataCache -> DataCache<MessageViewModel> in
|
|
guard !dataCache.data.isEmpty else {
|
|
return pagedDataCache.deleting(rowIds: [MessageViewModel.typingIndicatorId])
|
|
}
|
|
|
|
return pagedDataCache
|
|
.upserting(MessageViewModel(isTypingIndicator: true))
|
|
}
|
|
}
|
|
}
|