session-ios/SessionMessagingKit/Database/Models/Interaction.swift

701 lines
28 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import SessionUtilitiesKit
public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, MutablePersistableRecord, TableRecord, ColumnExpressible {
public static var databaseTableName: String { "interaction" }
internal static let threadForeignKey = ForeignKey([Columns.threadId], to: [SessionThread.Columns.id])
internal static let profileForeignKey = ForeignKey([Columns.authorId], to: [Profile.Columns.id])
internal static let linkPreviewForeignKey = ForeignKey(
[Columns.linkPreviewUrl],
to: [LinkPreview.Columns.url]
)
internal static let thread = belongsTo(SessionThread.self, using: threadForeignKey)
private static let profile = hasOne(Profile.self, using: profileForeignKey)
internal static let interactionAttachments = hasMany(
InteractionAttachment.self,
using: InteractionAttachment.interactionForeignKey
)
internal static let attachments = hasMany(
Attachment.self,
through: interactionAttachments,
using: InteractionAttachment.attachment
)
public static let quote = hasOne(Quote.self, using: Quote.interactionForeignKey)
internal static let linkPreview = hasOne(LinkPreview.self, using: LinkPreview.interactionForeignKey)
private static let recipientStates = hasMany(RecipientState.self, using: RecipientState.interactionForeignKey)
public typealias Columns = CodingKeys
public enum CodingKeys: String, CodingKey, ColumnExpression {
case id
case serverHash
case threadId
case authorId
case variant
case body
case timestampMs
case receivedAtTimestampMs
case wasRead
case expiresInSeconds
case expiresStartedAtMs
case linkPreviewUrl
// Open Group specific properties
case openGroupServerMessageId
case openGroupWhisperMods
case openGroupWhisperTo
}
public enum Variant: Int, Codable, DatabaseValueConvertible {
case standardIncoming
case standardOutgoing
case standardIncomingDeleted
// Info Message Types (spacing the values out to make it easier to extend)
case infoClosedGroupCreated = 1000
case infoClosedGroupUpdated
case infoClosedGroupCurrentUserLeft
case infoDisappearingMessagesUpdate = 2000
case infoScreenshotNotification = 3000
case infoMediaSavedNotification
case infoMessageRequestAccepted = 4000
}
/// The `id` value is auto incremented by the database, if the `Interaction` hasn't been inserted into
/// the database yet this value will be `nil`
public var id: Int64? = nil
/// The hash returned by the server when this message was created on the server
///
/// **Note:** This will only be populated for `standardIncoming`/`standardOutgoing` interactions
/// from either `contact` or `closedGroup` threads
public let serverHash: String?
/// The id of the thread that this interaction belongs to (used to expose the `thread` variable)
public let threadId: String
/// The id of the user who sent the interaction, also used to expose the `profile` variable)
public let authorId: String
/// The type of interaction
public let variant: Variant
/// The body of this interaction
public let body: String?
/// When the interaction was created in milliseconds since epoch
///
/// **Note:** This value will be `0` if it hasn't been set yet
public let timestampMs: Int64
/// When the interaction was received in milliseconds since epoch
///
/// **Note:** This value will be `0` if it hasn't been set yet
public let receivedAtTimestampMs: Int64
/// A flag indicating whether the interaction has been read (this is a flag rather than a timestamp because
/// we couldnt know if a read timestamp is accurate)
///
/// **Note:** This flag is not applicable to standardOutgoing or standardIncomingDeleted interactions
public let wasRead: Bool
/// The number of seconds until this message should expire
public let expiresInSeconds: TimeInterval?
/// The timestamp in milliseconds since 1970 at which this messages expiration timer started counting
/// down (this is stored in order to allow the `expiresInSeconds` value to be updated before a
/// message has expired)
public let expiresStartedAtMs: Double?
/// This value is the url for the link preview for this interaction
///
/// **Note:** This is also used for open group invitations
public let linkPreviewUrl: String?
// Open Group specific properties
/// The `openGroupServerMessageId` value will only be set for messages from SOGS
public let openGroupServerMessageId: Int64?
/// This flag indicates whether this interaction is a whisper to the mods of an Open Group
public let openGroupWhisperMods: Bool
/// This value is the id of the user within an Open Group who is the target of this whisper interaction
public let openGroupWhisperTo: String?
// MARK: - Relationships
public var thread: QueryInterfaceRequest<SessionThread> {
request(for: Interaction.thread)
}
public var profile: QueryInterfaceRequest<Profile> {
request(for: Interaction.profile)
}
/// Depending on the data associated to this interaction this array will represent different things, these
/// cases are mutually exclusive:
///
/// **Quote:** The thumbnails associated to the `Quote`
/// **LinkPreview:** The thumbnails associated to the `LinkPreview`
/// **Other:** The files directly attached to the interaction
public var attachments: QueryInterfaceRequest<Attachment> {
request(for: Interaction.attachments)
}
public var quote: QueryInterfaceRequest<Quote> {
request(for: Interaction.quote)
}
public var linkPreview: QueryInterfaceRequest<LinkPreview> {
let linkPreviewAlias: TableAlias = TableAlias()
return LinkPreview
.aliased(linkPreviewAlias)
.joining(
required: LinkPreview.interactions
.filter(literal: [
"(ROUND((\(Interaction.Columns.timestampMs) / 1000 / 100000) - 0.5) * 100000)",
"=",
"\(linkPreviewAlias[LinkPreview.Columns.timestamp])"
].joined(separator: " "))
.limit(1) // Avoid joining to multiple interactions
)
.limit(1) // Avoid joining to multiple interactions
}
public var recipientStates: QueryInterfaceRequest<RecipientState> {
request(for: Interaction.recipientStates)
}
// MARK: - Initialization
internal init(
id: Int64? = nil,
serverHash: String?,
threadId: String,
authorId: String,
variant: Variant,
body: String?,
timestampMs: Int64,
receivedAtTimestampMs: Int64,
wasRead: Bool,
expiresInSeconds: TimeInterval?,
expiresStartedAtMs: Double?,
linkPreviewUrl: String?,
openGroupServerMessageId: Int64?,
openGroupWhisperMods: Bool,
openGroupWhisperTo: String?
) {
self.id = id
self.serverHash = serverHash
self.threadId = threadId
self.authorId = authorId
self.variant = variant
self.body = body
self.timestampMs = timestampMs
self.receivedAtTimestampMs = receivedAtTimestampMs
self.wasRead = wasRead
self.expiresInSeconds = expiresInSeconds
self.expiresStartedAtMs = expiresStartedAtMs
self.linkPreviewUrl = linkPreviewUrl
self.openGroupServerMessageId = openGroupServerMessageId
self.openGroupWhisperMods = openGroupWhisperMods
self.openGroupWhisperTo = openGroupWhisperTo
}
public init(
serverHash: String? = nil,
threadId: String,
authorId: String,
variant: Variant,
body: String? = nil,
timestampMs: Int64 = 0,
wasRead: Bool = false,
expiresInSeconds: TimeInterval? = nil,
expiresStartedAtMs: Double? = nil,
linkPreviewUrl: String? = nil,
openGroupServerMessageId: Int64? = nil,
openGroupWhisperMods: Bool = false,
openGroupWhisperTo: String? = nil
) throws {
self.serverHash = serverHash
self.threadId = threadId
self.authorId = authorId
self.variant = variant
self.body = body
self.timestampMs = timestampMs
self.receivedAtTimestampMs = {
switch variant {
case .standardIncoming, .standardOutgoing: return Int64(Date().timeIntervalSince1970 * 1000)
/// For TSInteractions which are not `standardIncoming` and `standardOutgoing` use the `timestampMs` value
default: return timestampMs
}
}()
self.wasRead = wasRead
self.expiresInSeconds = expiresInSeconds
self.expiresStartedAtMs = expiresStartedAtMs
self.linkPreviewUrl = linkPreviewUrl
self.openGroupServerMessageId = openGroupServerMessageId
self.openGroupWhisperMods = openGroupWhisperMods
self.openGroupWhisperTo = openGroupWhisperTo
}
// MARK: - Custom Database Interaction
public mutating func insert(_ db: Database) throws {
try performInsert(db)
// Since we need to do additional logic upon insert we can just set the 'id' value
// here directly instead of in the 'didInsert' method (if you look at the docs the
// 'db.lastInsertedRowID' value is the row id of the newly inserted row which the
// interaction uses as it's id)
let interactionId: Int64 = db.lastInsertedRowID
self.id = interactionId
guard let thread: SessionThread = try? SessionThread.fetchOne(db, id: threadId) else {
SNLog("Inserted an interaction but couldn't find it's associated thead")
return
}
switch variant {
case .standardOutgoing:
// New outgoing messages should immediately determine their recipient list
// from current thread state
switch thread.variant {
case .contact:
try RecipientState(
interactionId: interactionId,
recipientId: threadId, // Will be the contact id
state: .sending
).insert(db)
case .closedGroup:
guard
let closedGroup: ClosedGroup = try? thread.closedGroup.fetchOne(db),
let members: [GroupMember] = try? closedGroup.members.fetchAll(db)
else {
SNLog("Inserted an interaction but couldn't find it's associated thread members")
return
}
try members.forEach { member in
try RecipientState(
interactionId: interactionId,
recipientId: member.profileId,
state: .sending
).insert(db)
}
case .openGroup:
// Since we use the 'RecipientState' type to manage the message state
// we need to ensure we have a state for all threads; so for open groups
// we just use the open group id as the 'recipientId' value
try RecipientState(
interactionId: interactionId,
recipientId: threadId, // Will be the open group id
state: .sending
).insert(db)
}
default: break
}
}
public func delete(_ db: Database) throws -> Bool {
// If we have a LinkPreview then check if this is the only interaction that has it
// and delete the LinkPreview if so
if linkPreviewUrl != nil {
let interactionAlias: TableAlias = TableAlias()
let numInteractions: Int? = try? Interaction
.aliased(interactionAlias)
.joining(
required: Interaction.linkPreview
.filter(literal: [
"(ROUND((\(interactionAlias[Columns.timestampMs]) / 1000 / 100000) - 0.5) * 100000)",
"=",
"\(LinkPreview.Columns.timestamp)"
].joined(separator: " "))
)
.fetchCount(db)
let tmp = try linkPreview.fetchAll(db)
if numInteractions == 1 {
try linkPreview.deleteAll(db)
}
}
// Delete any jobs associated to this interaction
try Job
.filter(Job.Columns.interactionId == id)
.deleteAll(db)
return try performDelete(db)
}
}
// MARK: - Mutation
public extension Interaction {
func with(
serverHash: String? = nil,
authorId: String? = nil,
timestampMs: Int64? = nil,
wasRead: Bool? = nil,
expiresInSeconds: TimeInterval? = nil,
expiresStartedAtMs: Double? = nil,
openGroupServerMessageId: Int64? = nil
) -> Interaction {
return Interaction(
id: id,
serverHash: (serverHash ?? self.serverHash),
threadId: threadId,
authorId: (authorId ?? self.authorId),
variant: variant,
body: body,
timestampMs: (timestampMs ?? self.timestampMs),
receivedAtTimestampMs: receivedAtTimestampMs,
wasRead: (wasRead ?? self.wasRead),
expiresInSeconds: (expiresInSeconds ?? self.expiresInSeconds),
expiresStartedAtMs: (expiresStartedAtMs ?? self.expiresStartedAtMs),
linkPreviewUrl: linkPreviewUrl,
openGroupServerMessageId: (openGroupServerMessageId ?? self.openGroupServerMessageId),
openGroupWhisperMods: openGroupWhisperMods,
openGroupWhisperTo: openGroupWhisperTo
)
}
}
// MARK: - GRDB Interactions
public extension Interaction {
/// Immutable version of the `markAsRead(_:includingOlder:trySendReadReceipt:)` function
func markingAsRead(_ db: Database, includingOlder: Bool, trySendReadReceipt: Bool) throws -> Interaction {
var updatedInteraction: Interaction = self
try updatedInteraction.markAsRead(db, includingOlder: includingOlder, trySendReadReceipt: trySendReadReceipt)
return updatedInteraction
}
/// This will update the `wasRead` state the the interaction
///
/// - Parameters
/// - includingOlder: Setting this to `true` will updated the `wasRead` flag for all older interactions as well
/// - trySendReadReceipt: Setting this to `true` will schedule a `ReadReceiptJob`
mutating func markAsRead(_ db: Database, includingOlder: Bool, trySendReadReceipt: Bool) throws {
// Once all of the below is done schedule the jobs
func scheduleJobs(interactionIds: [Int64]) {
// Add the 'DisappearingMessagesJob' if needed - this will update any expiring
// messages `expiresStartedAtMs` values
JobRunner.upsert(
db,
job: Job(
variant: .disappearingMessages,
details: DisappearingMessagesJob.updateNextRunIfNeeded(
db,
interactionIds: interactionIds,
startedAtMs: (Date().timeIntervalSince1970 * 1000)
)
)
)
// If we want to send read receipts then try to add the 'SendReadReceiptsJob'
if trySendReadReceipt {
JobRunner.upsert(
db,
job: SendReadReceiptsJob.createOrUpdateIfNeeded(
db,
threadId: threadId,
interactionIds: interactionIds
)
)
}
}
// If we aren't including older interactions then update and save the current one
guard includingOlder else {
let updatedInteraction: Interaction = try self
.with(wasRead: true)
.saved(db)
guard let id: Int64 = updatedInteraction.id else { throw GRDBStorageError.objectNotFound }
scheduleJobs(interactionIds: [id])
return
}
// Need an id in order to continue
guard let id: Int64 = self.id else { throw GRDBStorageError.objectNotFound }
let interactionQuery = Interaction
.filter(Columns.threadId == threadId)
.filter(Columns.id <= id)
// The `wasRead` flag doesn't apply to `standardOutgoing` or `standardIncomingDeleted`
.filter(Columns.variant != Variant.standardOutgoing && Columns.variant != Variant.standardIncomingDeleted)
// Update the `wasRead` flag to true
try interactionQuery.updateAll(db, Columns.wasRead.set(to: true))
// Retrieve the interaction ids we want to update
scheduleJobs(
interactionIds: try Int64.fetchAll(
db,
interactionQuery.select(Interaction.Columns.id)
)
)
}
static func markAsRead(_ db: Database, recipientId: String, timestampMsValues: [Double], readTimestampMs: Double) throws {
guard db[.areReadReceiptsEnabled] == true else { return }
try RecipientState
.filter(RecipientState.Columns.recipientId == recipientId)
.joining(
required: RecipientState.interaction
.filter(Columns.variant == Variant.standardOutgoing)
.filter(timestampMsValues.contains(Columns.timestampMs))
)
.updateAll(
db,
RecipientState.Columns.readTimestampMs.set(to: readTimestampMs),
RecipientState.Columns.state.set(to: RecipientState.State.sent)
)
}
}
// MARK: - Convenience
public extension Interaction {
static let oversizeTextMessageSizeThreshold: UInt = (2 * 1024)
// MARK: - Variables
var isExpiringMessage: Bool {
guard variant == .standardIncoming || variant == .standardOutgoing else { return false }
return (expiresInSeconds ?? 0 > 0)
}
var openGroupWhisper: Bool { return (openGroupWhisperMods || (openGroupWhisperTo != nil)) }
var notificationIdentifiers: [String] {
[
notificationIdentifier(isBackgroundPoll: true),
notificationIdentifier(isBackgroundPoll: false)
]
}
// MARK: - Functions
func notificationIdentifier(isBackgroundPoll: Bool) -> String {
// When the app is in the background we want the notifications to be grouped to prevent spam
guard isBackgroundPoll else { return threadId }
return "\(threadId)-\(id ?? 0)"
}
func markingAsDeleted() -> Interaction {
return Interaction(
id: id,
serverHash: nil,
threadId: threadId,
authorId: authorId,
variant: .standardIncomingDeleted,
body: nil,
timestampMs: timestampMs,
receivedAtTimestampMs: receivedAtTimestampMs,
wasRead: wasRead,
expiresInSeconds: expiresInSeconds,
expiresStartedAtMs: expiresStartedAtMs,
linkPreviewUrl: linkPreviewUrl,
openGroupServerMessageId: openGroupServerMessageId,
openGroupWhisperMods: openGroupWhisperMods,
openGroupWhisperTo: openGroupWhisperTo
)
}
func isUserMentioned(_ db: Database) -> Bool {
guard variant == .standardIncoming else { return false }
let userPublicKey: String = getUserHexEncodedPublicKey(db)
return (
(
body != nil &&
(body ?? "").contains("@\(userPublicKey)")
) || (
(try? quote.fetchOne(db))?.authorId == userPublicKey
)
)
}
func previewText(_ db: Database) -> String {
switch variant {
case .standardIncomingDeleted: return ""
case .standardIncoming, .standardOutgoing:
struct AttachmentDescriptionInfo: Decodable, FetchableRecord {
let id: String
let variant: Attachment.Variant
let contentType: String
let sourceFilename: String?
}
var bodyDescription: String?
if let body: String = self.body, !body.isEmpty {
bodyDescription = body
}
if bodyDescription == nil {
struct AttachmentBodyInfo: Decodable, FetchableRecord {
let id: String
let variant: Attachment.Variant
let contentType: String
let sourceFilename: String?
}
let maybeTextInfo: AttachmentDescriptionInfo? = try? AttachmentDescriptionInfo
.fetchOne(
db,
attachments
.select(
Attachment.Columns.id,
Attachment.Columns.state,
Attachment.Columns.variant,
Attachment.Columns.contentType,
Attachment.Columns.sourceFilename
)
.filter(Attachment.Columns.contentType == OWSMimeTypeOversizeTextMessage)
.filter(Attachment.Columns.state == Attachment.State.downloaded)
)
if
let textInfo: AttachmentDescriptionInfo = maybeTextInfo,
let filePath: String = Attachment.originalFilePath(
id: textInfo.id,
mimeType: textInfo.contentType,
sourceFilename: textInfo.sourceFilename
),
let data: Data = try? Data(contentsOf: URL(fileURLWithPath: filePath)),
let dataString: String = String(data: data, encoding: .utf8)
{
bodyDescription = dataString.filterForDisplay
}
}
let attachmentDescription: String? = try? AttachmentDescriptionInfo
.fetchOne(
db,
attachments
.select(
Attachment.Columns.id,
Attachment.Columns.variant,
Attachment.Columns.contentType,
Attachment.Columns.sourceFilename
)
.filter(Attachment.Columns.contentType != OWSMimeTypeOversizeTextMessage)
)
.map { info -> String in
Attachment.description(
for: info.variant,
contentType: info.contentType,
sourceFilename: info.sourceFilename
)
}
if
let attachmentDescription: String = attachmentDescription,
let bodyDescription: String = bodyDescription,
!attachmentDescription.isEmpty,
!bodyDescription.isEmpty
{
if CurrentAppContext().isRTL {
return "\(bodyDescription): \(attachmentDescription)"
}
return "\(attachmentDescription): \(bodyDescription)"
}
if let bodyDescription: String = bodyDescription, !bodyDescription.isEmpty {
return bodyDescription
}
if let attachmentDescription: String = attachmentDescription, !attachmentDescription.isEmpty {
return attachmentDescription
}
if let linkPreview: LinkPreview = try? linkPreview.fetchOne(db), linkPreview.variant == .openGroupInvitation {
return "😎 Open group invitation"
}
// TODO: We should do better here
return ""
case .infoMediaSavedNotification:
// Note: This should only occur in 'contact' threads so the `threadId`
// is the contact id
let displayName: String = Profile.displayName(id: threadId)
// TODO: Use referencedAttachmentTimestamp to tell the user * which * media was saved
return String(format: "media_saved".localized(), displayName)
case .infoScreenshotNotification:
// Note: This should only occur in 'contact' threads so the `threadId`
// is the contact id
let displayName: String = Profile.displayName(id: threadId)
return String(format: "screenshot_taken".localized(), displayName)
case .infoClosedGroupCreated: return "GROUP_CREATED".localized()
case .infoClosedGroupCurrentUserLeft: return "GROUP_YOU_LEFT".localized()
case .infoClosedGroupUpdated: return (body ?? "GROUP_UPDATED".localized())
case .infoMessageRequestAccepted: return (body ?? "MESSAGE_REQUESTS_ACCEPTED".localized())
case .infoDisappearingMessagesUpdate:
// TODO: We should do better here
return (body ?? "")
}
}
func state(_ db: Database) throws -> RecipientState.State {
let states: [RecipientState.State] = try RecipientState.State
.fetchAll(
db,
recipientStates
.select(RecipientState.Columns.state)
)
var hasFailed: Bool = false
for state in states {
switch state {
// If there are any "sending" recipients, consider this message "sending"
case .sending: return .sending
case .failed:
hasFailed = true
break
default: break
}
}
// If there are any "failed" recipients, consider this message "failed"
guard !hasFailed else { return .failed }
// Otherwise, consider the message "sent"
//
// Note: This includes messages with no recipients
return .sent
}
}