mirror of
https://github.com/lux-tel/session-ios.git
synced 2026-10-30 20:38:32 +00:00
• Added logic to handle non-originating accounts • Added explicit SessionPro errors • Moved profile pro decisions into the SessionProManager to consolidate the logic • Resolved a bunch of TODOs • Fixed an issue where a invalid pro key would prevent messages from decoding
771 lines
33 KiB
Swift
771 lines
33 KiB
Swift
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
|
|
|
import Foundation
|
|
import GRDB
|
|
import SessionNetworkingKit
|
|
import SessionUtilitiesKit
|
|
|
|
extension MessageReceiver {
|
|
public typealias InsertedInteractionInfo = (
|
|
threadId: String,
|
|
threadVariant: SessionThread.Variant,
|
|
interactionId: Int64,
|
|
interactionVariant: Interaction.Variant?,
|
|
wasRead: Bool,
|
|
numPreviousInteractionsForMessageRequest: Int
|
|
)
|
|
|
|
internal static func handleVisibleMessage(
|
|
_ db: ObservingDatabase,
|
|
threadId: String,
|
|
threadVariant: SessionThread.Variant,
|
|
message: VisibleMessage,
|
|
decodedMessage: DecodedMessage,
|
|
serverExpirationTimestamp: TimeInterval?,
|
|
suppressNotifications: Bool,
|
|
currentUserSessionIds: Set<String>,
|
|
using dependencies: Dependencies
|
|
) throws -> InsertedInteractionInfo {
|
|
let isMainAppActive: Bool = dependencies[defaults: .appGroup, key: .isMainAppActive]
|
|
|
|
// Update profile if needed
|
|
if let profile = message.profile {
|
|
try Profile.updateIfNeeded(
|
|
db,
|
|
publicKey: decodedMessage.sender.hexString,
|
|
displayNameUpdate: .contactUpdate(profile.displayName),
|
|
displayPictureUpdate: .contactUpdateTo(profile, fallback: .contactRemove),
|
|
blocksCommunityMessageRequests: .set(to: profile.blocksCommunityMessageRequests),
|
|
proUpdate: .contactUpdate(Profile.ProState(decodedMessage.decodedPro)),
|
|
profileUpdateTimestamp: profile.updateTimestampSeconds,
|
|
currentUserSessionIds: currentUserSessionIds,
|
|
using: dependencies
|
|
)
|
|
}
|
|
|
|
switch threadVariant {
|
|
case .contact: break // Always continue
|
|
|
|
case .community:
|
|
// Only process visible messages for communities if they have an existing thread
|
|
guard (try? SessionThread.exists(db, id: threadId)) == true else {
|
|
throw MessageError.messageRequiresThreadToExistButThreadDoesNotExist
|
|
}
|
|
|
|
case .legacyGroup, .group:
|
|
// Only process visible messages for groups if they have a ClosedGroup record
|
|
guard (try? ClosedGroup.exists(db, id: threadId)) == true else {
|
|
throw MessageError.messageRequiresThreadToExistButThreadDoesNotExist
|
|
}
|
|
}
|
|
|
|
// Store the message variant so we can run variant-specific behaviours
|
|
let userSessionId: SessionId = dependencies[cache: .general].sessionId
|
|
let thread: SessionThread = try SessionThread.upsert(
|
|
db,
|
|
id: threadId,
|
|
variant: threadVariant,
|
|
values: SessionThread.TargetValues(
|
|
creationDateTimestamp: .useExistingOrSetTo(
|
|
TimeInterval(Double(decodedMessage.sentTimestampMs) / 1000)
|
|
),
|
|
shouldBeVisible: .useExisting
|
|
),
|
|
using: dependencies
|
|
)
|
|
let openGroupUrlInfo: LibSession.OpenGroupUrlInfo? = {
|
|
guard threadVariant == .community else { return nil }
|
|
|
|
return try? LibSession.OpenGroupUrlInfo.fetchOne(db, id: threadId)
|
|
}()
|
|
let variant: Interaction.Variant = try {
|
|
guard let openGroupUrlInfo: LibSession.OpenGroupUrlInfo = openGroupUrlInfo else {
|
|
return (decodedMessage.sender == userSessionId ?
|
|
.standardOutgoing :
|
|
.standardIncoming
|
|
)
|
|
}
|
|
|
|
// Need to check if the blinded id matches for open groups
|
|
switch decodedMessage.sender.prefix {
|
|
case .blinded15, .blinded25:
|
|
guard
|
|
dependencies[singleton: .crypto].verify(
|
|
.sessionId(
|
|
userSessionId.hexString,
|
|
matchesBlindedId: decodedMessage.sender.hexString,
|
|
serverPublicKey: openGroupUrlInfo.publicKey
|
|
)
|
|
)
|
|
else { return .standardIncoming }
|
|
|
|
return .standardOutgoing
|
|
|
|
case .standard, .unblinded:
|
|
return (decodedMessage.sender == userSessionId ?
|
|
.standardOutgoing :
|
|
.standardIncoming
|
|
)
|
|
|
|
case .group, .versionBlinded07:
|
|
Log.info(.messageReceiver, "Ignoring message with invalid sender.")
|
|
throw MessageError.invalidSender
|
|
}
|
|
}()
|
|
let generateCurrentUserSessionIds: () -> Set<String> = {
|
|
guard threadVariant == .community else { return [userSessionId.hexString] }
|
|
|
|
let openGroupCapabilityInfo = try? LibSession.OpenGroupCapabilityInfo
|
|
.fetchOne(db, id: threadId)
|
|
|
|
return Set([
|
|
userSessionId,
|
|
SessionThread.getCurrentUserBlindedSessionId(
|
|
threadId: threadId,
|
|
threadVariant: threadVariant,
|
|
blindingPrefix: .blinded15,
|
|
openGroupCapabilityInfo: openGroupCapabilityInfo,
|
|
using: dependencies
|
|
),
|
|
SessionThread.getCurrentUserBlindedSessionId(
|
|
threadId: threadId,
|
|
threadVariant: threadVariant,
|
|
blindingPrefix: .blinded25,
|
|
openGroupCapabilityInfo: openGroupCapabilityInfo,
|
|
using: dependencies
|
|
)
|
|
].compactMap { $0 }.map { $0.hexString })
|
|
}
|
|
|
|
// Handle emoji reacts first (otherwise it's essentially an invalid message)
|
|
if let interactionId: Int64 = try handleEmojiReactIfNeeded(
|
|
db,
|
|
thread: thread,
|
|
message: message,
|
|
decodedMessage: decodedMessage,
|
|
openGroupUrlInfo: openGroupUrlInfo,
|
|
currentUserSessionIds: generateCurrentUserSessionIds(),
|
|
suppressNotifications: suppressNotifications,
|
|
using: dependencies
|
|
) {
|
|
return (threadId, threadVariant, interactionId, nil, true, 0)
|
|
}
|
|
// Try to insert the interaction
|
|
//
|
|
// Note: There are now a number of unique constraints on the database which
|
|
// prevent the ability to insert duplicate interactions at a database level
|
|
// so we don't need to check for the existance of a message beforehand anymore
|
|
let interaction: Interaction
|
|
|
|
// Auto-mark sent messages or messages older than the 'lastReadTimestampMs' as read
|
|
let wasRead: Bool = (
|
|
variant == .standardOutgoing ||
|
|
dependencies.mutate(cache: .libSession) { cache in
|
|
cache.timestampAlreadyRead(
|
|
threadId: thread.id,
|
|
threadVariant: thread.variant,
|
|
timestampMs: decodedMessage.sentTimestampMs,
|
|
openGroupUrlInfo: openGroupUrlInfo
|
|
)
|
|
}
|
|
)
|
|
let messageExpirationInfo: Message.MessageExpirationInfo = Message.getMessageExpirationInfo(
|
|
threadVariant: thread.variant,
|
|
wasRead: wasRead,
|
|
serverExpirationTimestamp: serverExpirationTimestamp,
|
|
expiresInSeconds: message.expiresInSeconds,
|
|
expiresStartedAtMs: message.expiresStartedAtMs,
|
|
using: dependencies
|
|
)
|
|
do {
|
|
let processedMessageBody: String? = processMessageBody(
|
|
message.text,
|
|
decodedMessage: decodedMessage,
|
|
threadVariant: thread.variant,
|
|
dependencies: dependencies
|
|
)
|
|
|
|
interaction = try Interaction(
|
|
serverHash: message.serverHash, // Keep track of server hash
|
|
threadId: thread.id,
|
|
threadVariant: thread.variant,
|
|
authorId: decodedMessage.sender.hexString,
|
|
variant: variant,
|
|
body: processedMessageBody,
|
|
timestampMs: Int64(decodedMessage.sentTimestampMs),
|
|
wasRead: wasRead,
|
|
hasMention: Interaction.isUserMentioned(
|
|
db,
|
|
threadId: thread.id,
|
|
body: processedMessageBody,
|
|
quoteAuthorId: message.quote?.authorId,
|
|
using: dependencies
|
|
),
|
|
expiresInSeconds: messageExpirationInfo.expiresInSeconds,
|
|
expiresStartedAtMs: messageExpirationInfo.expiresStartedAtMs,
|
|
// OpenGroupInvitations are stored as LinkPreview's in the database
|
|
linkPreviewUrl: (message.linkPreview?.url ?? message.openGroupInvitation?.url),
|
|
// Keep track of the open group server message ID ↔ message ID relationship
|
|
openGroupServerMessageId: message.openGroupServerMessageId.map { Int64($0) },
|
|
openGroupWhisper: message.openGroupWhisper,
|
|
openGroupWhisperMods: message.openGroupWhisperMods,
|
|
openGroupWhisperTo: message.openGroupWhisperTo,
|
|
// If we received an outgoing message then we can assume the interaction has already
|
|
// been sent, otherwise we should just use whatever the default state is
|
|
state: (variant == .standardOutgoing ? .sent : nil),
|
|
proMessageFeatures: (message.proMessageFeatures ?? .none),
|
|
proProfileFeatures: (message.proProfileFeatures ?? .none),
|
|
using: dependencies
|
|
).inserted(db)
|
|
}
|
|
catch {
|
|
switch error {
|
|
case DatabaseError.SQLITE_CONSTRAINT_UNIQUE:
|
|
guard
|
|
variant == .standardOutgoing,
|
|
let existingInteractionId: Int64 = try? Interaction
|
|
.select(.id)
|
|
.filter(Interaction.Columns.threadId == thread.id)
|
|
.filter(Interaction.Columns.timestampMs == decodedMessage.sentTimestampMs)
|
|
.filter(Interaction.Columns.variant == variant)
|
|
.filter(Interaction.Columns.authorId == decodedMessage.sender.hexString)
|
|
.asRequest(of: Int64.self)
|
|
.fetchOne(db)
|
|
else { break }
|
|
|
|
// If we receive an outgoing message that already exists in the database
|
|
// then we still need to update the recipient and read states for the
|
|
// message (even if we don't need to do anything else)
|
|
try updateRecipientAndReadStatesForOutgoingInteraction(
|
|
db,
|
|
thread: thread,
|
|
interactionId: existingInteractionId,
|
|
messageSentTimestampMs: decodedMessage.sentTimestampMs,
|
|
variant: variant,
|
|
syncTarget: message.syncTarget,
|
|
using: dependencies
|
|
)
|
|
|
|
Message.getExpirationForOutgoingDisappearingMessages(
|
|
db,
|
|
threadId: threadId,
|
|
threadVariant: threadVariant,
|
|
variant: variant,
|
|
serverHash: message.serverHash,
|
|
expireInSeconds: message.expiresInSeconds,
|
|
using: dependencies
|
|
)
|
|
|
|
default: break
|
|
}
|
|
|
|
throw error
|
|
}
|
|
|
|
guard let interactionId: Int64 = interaction.id else { throw StorageError.failedToSave }
|
|
|
|
// Update and recipient and read states as needed
|
|
try updateRecipientAndReadStatesForOutgoingInteraction(
|
|
db,
|
|
thread: thread,
|
|
interactionId: interactionId,
|
|
messageSentTimestampMs: decodedMessage.sentTimestampMs,
|
|
variant: variant,
|
|
syncTarget: message.syncTarget,
|
|
using: dependencies
|
|
)
|
|
|
|
if messageExpirationInfo.shouldUpdateExpiry {
|
|
Message.updateExpiryForDisappearAfterReadMessages(
|
|
db,
|
|
threadId: threadId,
|
|
threadVariant: threadVariant,
|
|
serverHash: message.serverHash,
|
|
expiresInSeconds: message.expiresInSeconds,
|
|
expiresStartedAtMs: message.expiresStartedAtMs,
|
|
using: dependencies
|
|
)
|
|
}
|
|
|
|
Message.getExpirationForOutgoingDisappearingMessages(
|
|
db,
|
|
threadId: threadId,
|
|
threadVariant: threadVariant,
|
|
variant: variant,
|
|
serverHash: message.serverHash,
|
|
expireInSeconds: message.expiresInSeconds,
|
|
using: dependencies
|
|
)
|
|
|
|
// Parse & persist attachments
|
|
let proto: SNProtoContent = try decodedMessage.decodeProtoContent()
|
|
var attachments: [Attachment] = []
|
|
|
|
if
|
|
let protoAttachments: [SNProtoAttachmentPointer] = proto.dataMessage?.attachments,
|
|
!protoAttachments.isEmpty
|
|
{
|
|
attachments = try protoAttachments
|
|
.compactMap { proto -> Attachment? in
|
|
let attachment: Attachment = Attachment(proto: proto)
|
|
|
|
// Attachments on received messages must have a 'downloadUrl' otherwise
|
|
// they are invalid and we can ignore them
|
|
return (attachment.downloadUrl != nil ? attachment : nil)
|
|
}
|
|
.enumerated()
|
|
.map { index, attachment in
|
|
let savedAttachment: Attachment = try attachment.upserted(db)
|
|
|
|
// Link the attachment to the interaction and add to the id lookup
|
|
try InteractionAttachment(
|
|
albumIndex: index,
|
|
interactionId: interactionId,
|
|
attachmentId: savedAttachment.id
|
|
).insert(db)
|
|
|
|
return savedAttachment
|
|
}
|
|
|
|
message.attachmentIds = attachments.map { $0.id }
|
|
}
|
|
|
|
// Persist quote if needed
|
|
if let quote: VisibleMessage.VMQuote = message.quote {
|
|
try? Quote(
|
|
interactionId: interactionId,
|
|
authorId: quote.authorId,
|
|
timestampMs: Int64(quote.timestamp)
|
|
).insert(db)
|
|
}
|
|
|
|
// Parse link preview if needed
|
|
var linkPreviewAttachmentId: String?
|
|
if let linkPreview: VisibleMessage.VMLinkPreview = message.linkPreview {
|
|
let linkPreview: LinkPreview? = try? LinkPreview(
|
|
db,
|
|
linkPreview: linkPreview,
|
|
sentTimestampMs: decodedMessage.sentTimestampMs
|
|
)
|
|
_ = try? linkPreview?.upserted(db)
|
|
|
|
linkPreviewAttachmentId = linkPreview?.attachmentId
|
|
}
|
|
|
|
// Open group invitations are stored as LinkPreview values so create one if needed
|
|
if
|
|
let openGroupInvitationUrl: String = message.openGroupInvitation?.url,
|
|
let openGroupInvitationName: String = message.openGroupInvitation?.name
|
|
{
|
|
try LinkPreview(
|
|
url: openGroupInvitationUrl,
|
|
timestamp: LinkPreview.timestampFor(sentTimestampMs: decodedMessage.sentTimestampMs),
|
|
variant: .openGroupInvitation,
|
|
title: openGroupInvitationName,
|
|
using: dependencies
|
|
).upsert(db)
|
|
}
|
|
|
|
// Start attachment downloads if needed (ie. trusted contact or group thread)
|
|
// FIXME: Replace this to check the `autoDownloadAttachments` flag we are adding to threads
|
|
let isContactTrusted: Bool = ((try? Contact.fetchOne(db, id: decodedMessage.sender.hexString))?.isTrusted ?? false)
|
|
|
|
if isContactTrusted || thread.variant != .contact {
|
|
attachments
|
|
.map { $0.id }
|
|
.appending(linkPreviewAttachmentId)
|
|
.forEach { attachmentId in
|
|
dependencies[singleton: .jobRunner].add(
|
|
db,
|
|
job: Job(
|
|
variant: .attachmentDownload,
|
|
threadId: thread.id,
|
|
interactionId: interactionId,
|
|
details: AttachmentDownloadJob.Details(
|
|
attachmentId: attachmentId
|
|
)
|
|
),
|
|
canStartJob: isMainAppActive
|
|
)
|
|
}
|
|
}
|
|
|
|
// Cancel any typing indicators if needed
|
|
if isMainAppActive {
|
|
Task {
|
|
await dependencies[singleton: .typingIndicators].didStopTyping(
|
|
threadId: thread.id,
|
|
direction: .incoming
|
|
)
|
|
}
|
|
}
|
|
|
|
// Update the contact's approval status of the current user if needed (if we are getting messages from
|
|
// them outside of a group then we can assume they have approved the current user)
|
|
//
|
|
// Note: This is to resolve a rare edge-case where a conversation was started with a user on an old
|
|
// version of the app and their message request approval state was set via a migration rather than
|
|
// by using the approval process
|
|
switch thread.variant {
|
|
case .contact:
|
|
try MessageReceiver.updateContactApprovalStatusIfNeeded(
|
|
db,
|
|
senderSessionId: decodedMessage.sender.hexString,
|
|
threadId: thread.id,
|
|
using: dependencies
|
|
)
|
|
|
|
case .group:
|
|
try MessageReceiver.updateMemberApprovalStatusIfNeeded(
|
|
db,
|
|
senderSessionId: decodedMessage.sender.hexString,
|
|
groupSessionIdHexString: thread.id,
|
|
profile: nil, // Don't update the profile in this case
|
|
using: dependencies
|
|
)
|
|
|
|
default: break
|
|
}
|
|
|
|
// Notify the user if needed
|
|
guard
|
|
!suppressNotifications &&
|
|
variant == .standardIncoming &&
|
|
!interaction.wasRead
|
|
else { return (threadId, threadVariant, interactionId, variant, interaction.wasRead, 0) }
|
|
|
|
let isMessageRequest: Bool = dependencies.mutate(cache: .libSession) { cache in
|
|
cache.isMessageRequest(
|
|
threadId: threadId,
|
|
threadVariant: threadVariant
|
|
)
|
|
}
|
|
let numPreviousInteractionsForMessageRequest: Int = {
|
|
guard isMessageRequest else { return 0 }
|
|
|
|
switch interaction.serverHash {
|
|
case .some(let serverHash):
|
|
return (try? Interaction
|
|
.filter(Interaction.Columns.threadId == threadId)
|
|
.filter(Interaction.Columns.serverHash != serverHash)
|
|
.fetchCount(db))
|
|
.defaulting(to: 0)
|
|
|
|
case .none:
|
|
return (try? Interaction
|
|
.filter(Interaction.Columns.threadId == threadId)
|
|
.filter(Interaction.Columns.timestampMs != interaction.timestampMs)
|
|
.fetchCount(db))
|
|
.defaulting(to: 0)
|
|
}
|
|
}()
|
|
|
|
try? dependencies[singleton: .notificationsManager].notifyUser(
|
|
cat: .messageReceiver,
|
|
message: message,
|
|
threadId: threadId,
|
|
threadVariant: threadVariant,
|
|
interactionIdentifier: (interaction.serverHash ?? "\(interactionId)"),
|
|
interactionVariant: interaction.variant,
|
|
attachmentDescriptionInfo: attachments.map { $0.descriptionInfo },
|
|
openGroupUrlInfo: openGroupUrlInfo,
|
|
applicationState: (isMainAppActive ? .active : .background),
|
|
extensionBaseUnreadCount: nil,
|
|
currentUserSessionIds: generateCurrentUserSessionIds(),
|
|
displayNameRetriever: { sessionId, _ in
|
|
Profile.displayNameNoFallback(
|
|
db,
|
|
id: sessionId,
|
|
threadVariant: threadVariant
|
|
)
|
|
},
|
|
groupNameRetriever: { threadId, threadVariant in
|
|
switch threadVariant {
|
|
case .group:
|
|
let groupId: SessionId = SessionId(.group, hex: threadId)
|
|
return dependencies.mutate(cache: .libSession) { cache in
|
|
cache.groupName(groupSessionId: groupId)
|
|
}
|
|
|
|
case .community:
|
|
return try? OpenGroup
|
|
.select(.name)
|
|
.filter(id: threadId)
|
|
.asRequest(of: String.self)
|
|
.fetchOne(db)
|
|
|
|
default: return nil
|
|
}
|
|
},
|
|
shouldShowForMessageRequest: {
|
|
// We only want to show a notification for the first interaction in the thread
|
|
return (numPreviousInteractionsForMessageRequest == 0)
|
|
}
|
|
)
|
|
|
|
return (
|
|
threadId,
|
|
threadVariant,
|
|
interactionId,
|
|
variant,
|
|
interaction.wasRead,
|
|
numPreviousInteractionsForMessageRequest
|
|
)
|
|
}
|
|
|
|
private static func handleEmojiReactIfNeeded(
|
|
_ db: ObservingDatabase,
|
|
thread: SessionThread,
|
|
message: VisibleMessage,
|
|
decodedMessage: DecodedMessage,
|
|
openGroupUrlInfo: LibSession.OpenGroupUrlInfo?,
|
|
currentUserSessionIds: Set<String>,
|
|
suppressNotifications: Bool,
|
|
using dependencies: Dependencies
|
|
) throws -> Int64? {
|
|
guard let vmReaction: VisibleMessage.VMReaction = message.reaction else { return nil }
|
|
|
|
// Since we have database access here make sure the original message for this reaction exists
|
|
// before handling it or showing a notification
|
|
let maybeInteractionId: Int64? = try? Interaction
|
|
.select(.id)
|
|
.filter(Interaction.Columns.threadId == thread.id)
|
|
.filter(Interaction.Columns.timestampMs == vmReaction.timestamp)
|
|
.filter(Interaction.Columns.authorId == vmReaction.publicKey)
|
|
.filter(Interaction.Columns.variant != Interaction.Variant.standardIncomingDeleted)
|
|
.filter(Interaction.Columns.state != Interaction.State.deleted)
|
|
.asRequest(of: Int64.self)
|
|
.fetchOne(db)
|
|
|
|
guard let interactionId: Int64 = maybeInteractionId else {
|
|
throw StorageError.objectNotFound
|
|
}
|
|
|
|
let sortId = Reaction.getSortId(
|
|
db,
|
|
interactionId: interactionId,
|
|
emoji: vmReaction.emoji
|
|
)
|
|
|
|
switch vmReaction.kind {
|
|
case .react:
|
|
// Determine whether the app is active based on the prefs rather than the UIApplication state to avoid
|
|
// requiring main-thread execution
|
|
let isMainAppActive: Bool = dependencies[defaults: .appGroup, key: .isMainAppActive]
|
|
let userSessionId: SessionId = dependencies[cache: .general].sessionId
|
|
try Reaction(
|
|
interactionId: interactionId,
|
|
serverHash: message.serverHash,
|
|
timestampMs: Int64(decodedMessage.sentTimestampMs),
|
|
authorId: decodedMessage.sender.hexString,
|
|
emoji: vmReaction.emoji,
|
|
count: 1,
|
|
sortId: sortId
|
|
).insert(db)
|
|
|
|
// Notify of reaction event
|
|
db.addReactionEvent(
|
|
id: db.lastInsertedRowID,
|
|
messageId: interactionId,
|
|
change: .added(vmReaction.emoji)
|
|
)
|
|
|
|
let timestampAlreadyRead: Bool = dependencies.mutate(cache: .libSession) { cache in
|
|
cache.timestampAlreadyRead(
|
|
threadId: thread.id,
|
|
threadVariant: thread.variant,
|
|
timestampMs: decodedMessage.sentTimestampMs,
|
|
openGroupUrlInfo: openGroupUrlInfo
|
|
)
|
|
}
|
|
|
|
// Don't notify if the reaction was added before the lastest read timestamp for
|
|
// the conversation or the reaction is for the sender's own message
|
|
if
|
|
!suppressNotifications &&
|
|
decodedMessage.sender != userSessionId &&
|
|
!timestampAlreadyRead &&
|
|
vmReaction.publicKey != decodedMessage.sender.hexString
|
|
{
|
|
try? dependencies[singleton: .notificationsManager].notifyUser(
|
|
cat: .messageReceiver,
|
|
message: message,
|
|
threadId: thread.id,
|
|
threadVariant: thread.variant,
|
|
interactionIdentifier: (message.serverHash ?? "\(interactionId)"),
|
|
interactionVariant: .standardIncoming,
|
|
attachmentDescriptionInfo: nil,
|
|
openGroupUrlInfo: openGroupUrlInfo,
|
|
applicationState: (isMainAppActive ? .active : .background),
|
|
extensionBaseUnreadCount: nil,
|
|
currentUserSessionIds: currentUserSessionIds,
|
|
displayNameRetriever: { sessionId, _ in
|
|
Profile.displayNameNoFallback(
|
|
db,
|
|
id: sessionId,
|
|
threadVariant: thread.variant
|
|
)
|
|
},
|
|
groupNameRetriever: { threadId, threadVariant in
|
|
switch threadVariant {
|
|
case .group:
|
|
let groupId: SessionId = SessionId(.group, hex: threadId)
|
|
return dependencies.mutate(cache: .libSession) { cache in
|
|
cache.groupName(groupSessionId: groupId)
|
|
}
|
|
|
|
case .community:
|
|
return try? OpenGroup
|
|
.select(.name)
|
|
.filter(id: threadId)
|
|
.asRequest(of: String.self)
|
|
.fetchOne(db)
|
|
|
|
default: return nil
|
|
}
|
|
},
|
|
shouldShowForMessageRequest: { false }
|
|
)
|
|
}
|
|
|
|
case .remove:
|
|
let rowIds: [Int64] = try Reaction
|
|
.select(Column.rowID)
|
|
.filter(Reaction.Columns.interactionId == interactionId)
|
|
.filter(Reaction.Columns.authorId == decodedMessage.sender.hexString)
|
|
.filter(Reaction.Columns.emoji == vmReaction.emoji)
|
|
.asRequest(of: Int64.self)
|
|
.fetchAll(db)
|
|
try Reaction
|
|
.filter(Reaction.Columns.interactionId == interactionId)
|
|
.filter(Reaction.Columns.authorId == decodedMessage.sender.hexString)
|
|
.filter(Reaction.Columns.emoji == vmReaction.emoji)
|
|
.deleteAll(db)
|
|
|
|
// Notify of reaction event
|
|
rowIds.forEach {
|
|
db.addReactionEvent(
|
|
id: $0,
|
|
messageId: interactionId,
|
|
change: .removed(vmReaction.emoji)
|
|
)
|
|
}
|
|
}
|
|
|
|
return interactionId
|
|
}
|
|
|
|
private static func updateRecipientAndReadStatesForOutgoingInteraction(
|
|
_ db: ObservingDatabase,
|
|
thread: SessionThread,
|
|
interactionId: Int64,
|
|
messageSentTimestampMs: UInt64,
|
|
variant: Interaction.Variant,
|
|
syncTarget: String?,
|
|
using dependencies: Dependencies
|
|
) throws {
|
|
guard variant == .standardOutgoing else { return }
|
|
|
|
// Immediately update any existing outgoing message 'State' records to be 'sent' (can
|
|
// also remove the failure text as it's redundant if the message is in the sent state)
|
|
if (try? Interaction.select(.state).filter(id: interactionId).asRequest(of: Interaction.State.self).fetchOne(db)) != .sent {
|
|
_ = try? Interaction
|
|
.filter(id: interactionId)
|
|
.updateAll(
|
|
db,
|
|
Interaction.Columns.state.set(to: Interaction.State.sent),
|
|
Interaction.Columns.mostRecentFailureText.set(to: nil)
|
|
)
|
|
db.addMessageEvent(id: interactionId, threadId: thread.id, type: .updated(.state(.sent)))
|
|
}
|
|
|
|
// For outgoing messages mark all older interactions as read (the user should have seen
|
|
// them if they send a message - also avoids a situation where the user has "phantom"
|
|
// unread messages that they need to scroll back to before they become marked as read)
|
|
try Interaction.markAsRead(
|
|
db,
|
|
interactionId: interactionId,
|
|
threadId: thread.id,
|
|
threadVariant: thread.variant,
|
|
includingOlder: true,
|
|
trySendReadReceipt: false,
|
|
using: dependencies
|
|
)
|
|
|
|
// Process any PendingReadReceipt values
|
|
let maybePendingReadReceipt: PendingReadReceipt? = try PendingReadReceipt
|
|
.filter(PendingReadReceipt.Columns.threadId == thread.id)
|
|
.filter(PendingReadReceipt.Columns.interactionTimestampMs == messageSentTimestampMs)
|
|
.fetchOne(db)
|
|
|
|
if let pendingReadReceipt: PendingReadReceipt = maybePendingReadReceipt {
|
|
try Interaction.markAsRecipientRead(
|
|
db,
|
|
threadId: thread.id,
|
|
timestampMsValues: [pendingReadReceipt.interactionTimestampMs],
|
|
readTimestampMs: pendingReadReceipt.readTimestampMs,
|
|
using: dependencies
|
|
)
|
|
|
|
_ = try pendingReadReceipt.delete(db)
|
|
}
|
|
}
|
|
|
|
private static func processMessageBody(
|
|
_ text: String?,
|
|
decodedMessage: DecodedMessage,
|
|
threadVariant: SessionThread.Variant,
|
|
dependencies: Dependencies
|
|
) -> String? {
|
|
guard let text: String = text else { return nil }
|
|
|
|
/// Extract the features used for the message
|
|
let info: SessionPro.FeaturesForMessage = dependencies[singleton: .sessionProManager].messageFeatures(for: text)
|
|
let proStatus: SessionPro.DecodedStatus? = dependencies[singleton: .sessionProManager].proStatus(
|
|
for: decodedMessage.decodedPro?.proProof,
|
|
verifyPubkey: {
|
|
switch threadVariant {
|
|
case .community: return Array(Data(hex: Network.SessionPro.serverEdPublicKey))
|
|
default: return decodedMessage.senderEd25519Pubkey
|
|
}
|
|
}(),
|
|
atTimestampMs: decodedMessage.sentTimestampMs
|
|
)
|
|
|
|
/// Check if the message is too long
|
|
guard
|
|
info.status == .exceedsCharacterLimit || (
|
|
proStatus != .valid &&
|
|
info.features.contains(.largerCharacterLimit)
|
|
)
|
|
else { return text }
|
|
|
|
// FIXME: Replace this with a libSession-based truncation solution
|
|
let utf16View = text.utf16
|
|
let characterLimit: Int = (proStatus == .valid ?
|
|
SessionPro.ProCharacterLimit :
|
|
SessionPro.CharacterLimit
|
|
)
|
|
|
|
guard utf16View.count > characterLimit else { return text }
|
|
|
|
// Get the index at the maxUnits position in UTF16
|
|
let endUTF16Index = utf16View.index(utf16View.startIndex, offsetBy: characterLimit)
|
|
|
|
// Try converting that UTF16 index back to a String.Index
|
|
if let endIndex = String.Index(endUTF16Index, within: text) {
|
|
return String(text[..<endIndex])
|
|
} else {
|
|
// Fallback: safely step back until there is a valid boundary
|
|
var adjustedIndex = endUTF16Index
|
|
while adjustedIndex > utf16View.startIndex {
|
|
adjustedIndex = utf16View.index(before: adjustedIndex)
|
|
if let validIndex = String.Index(adjustedIndex, within: text) {
|
|
return String(text[..<validIndex])
|
|
}
|
|
}
|
|
return text // If all else fails, return original string
|
|
}
|
|
}
|
|
}
|