Merge branch 'dev' into switch-video-view

This commit is contained in:
Ryan Zhao 2023-02-20 10:02:18 +11:00
commit a14a99896b
61 changed files with 986 additions and 252 deletions

View file

@ -651,6 +651,8 @@
FD3C907127E445E500CD579F /* MessageReceiverDecryptionSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C907027E445E500CD579F /* MessageReceiverDecryptionSpec.swift */; }; FD3C907127E445E500CD579F /* MessageReceiverDecryptionSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C907027E445E500CD579F /* MessageReceiverDecryptionSpec.swift */; };
FD3E0C84283B5835002A425C /* SessionThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3E0C83283B5835002A425C /* SessionThreadViewModel.swift */; }; FD3E0C84283B5835002A425C /* SessionThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3E0C83283B5835002A425C /* SessionThreadViewModel.swift */; };
FD42F9A8285064B800A0C77D /* PushNotificationAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBDE255A581900E217F9 /* PushNotificationAPI.swift */; }; FD42F9A8285064B800A0C77D /* PushNotificationAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBDE255A581900E217F9 /* PushNotificationAPI.swift */; };
FD432432299C6933008A0213 /* _011_AddPendingReadReceipts.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD432431299C6933008A0213 /* _011_AddPendingReadReceipts.swift */; };
FD432434299C6985008A0213 /* PendingReadReceipt.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD432433299C6985008A0213 /* PendingReadReceipt.swift */; };
FD4B200E283492210034334B /* InsetLockableTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD4B200D283492210034334B /* InsetLockableTableView.swift */; }; FD4B200E283492210034334B /* InsetLockableTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD4B200D283492210034334B /* InsetLockableTableView.swift */; };
FD52090028AF6153006098F6 /* OWSBackgroundTask.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDC1B255A581F00E217F9 /* OWSBackgroundTask.m */; }; FD52090028AF6153006098F6 /* OWSBackgroundTask.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDC1B255A581F00E217F9 /* OWSBackgroundTask.m */; };
FD52090128AF61BA006098F6 /* OWSBackgroundTask.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB38255A580B00E217F9 /* OWSBackgroundTask.h */; settings = {ATTRIBUTES = (Public, ); }; }; FD52090128AF61BA006098F6 /* OWSBackgroundTask.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB38255A580B00E217F9 /* OWSBackgroundTask.h */; settings = {ATTRIBUTES = (Public, ); }; };
@ -1735,6 +1737,8 @@
FD3C907027E445E500CD579F /* MessageReceiverDecryptionSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReceiverDecryptionSpec.swift; sourceTree = "<group>"; }; FD3C907027E445E500CD579F /* MessageReceiverDecryptionSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReceiverDecryptionSpec.swift; sourceTree = "<group>"; };
FD3C907427E83AC200CD579F /* OpenGroupServerIdLookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupServerIdLookup.swift; sourceTree = "<group>"; }; FD3C907427E83AC200CD579F /* OpenGroupServerIdLookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupServerIdLookup.swift; sourceTree = "<group>"; };
FD3E0C83283B5835002A425C /* SessionThreadViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionThreadViewModel.swift; sourceTree = "<group>"; }; FD3E0C83283B5835002A425C /* SessionThreadViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionThreadViewModel.swift; sourceTree = "<group>"; };
FD432431299C6933008A0213 /* _011_AddPendingReadReceipts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _011_AddPendingReadReceipts.swift; sourceTree = "<group>"; };
FD432433299C6985008A0213 /* PendingReadReceipt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingReadReceipt.swift; sourceTree = "<group>"; };
FD4B200D283492210034334B /* InsetLockableTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsetLockableTableView.swift; sourceTree = "<group>"; }; FD4B200D283492210034334B /* InsetLockableTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsetLockableTableView.swift; sourceTree = "<group>"; };
FD52090228B4680F006098F6 /* RadioButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadioButton.swift; sourceTree = "<group>"; }; FD52090228B4680F006098F6 /* RadioButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadioButton.swift; sourceTree = "<group>"; };
FD52090428B4915F006098F6 /* PrivacySettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacySettingsViewModel.swift; sourceTree = "<group>"; }; FD52090428B4915F006098F6 /* PrivacySettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacySettingsViewModel.swift; sourceTree = "<group>"; };
@ -3524,6 +3528,7 @@
FDE77F6A280FEB28002CFC5D /* ControlMessageProcessRecord.swift */, FDE77F6A280FEB28002CFC5D /* ControlMessageProcessRecord.swift */,
FD5C7308285007920029977D /* BlindedIdLookup.swift */, FD5C7308285007920029977D /* BlindedIdLookup.swift */,
FD09B7E6288670FD00ED0B66 /* Reaction.swift */, FD09B7E6288670FD00ED0B66 /* Reaction.swift */,
FD432433299C6985008A0213 /* PendingReadReceipt.swift */,
); );
path = Models; path = Models;
sourceTree = "<group>"; sourceTree = "<group>";
@ -3541,6 +3546,7 @@
FD09B7E4288670BB00ED0B66 /* _008_EmojiReacts.swift */, FD09B7E4288670BB00ED0B66 /* _008_EmojiReacts.swift */,
7BAA7B6528D2DE4700AE1489 /* _009_OpenGroupPermission.swift */, 7BAA7B6528D2DE4700AE1489 /* _009_OpenGroupPermission.swift */,
FD7115F128C6CB3900B47552 /* _010_AddThreadIdToFTS.swift */, FD7115F128C6CB3900B47552 /* _010_AddThreadIdToFTS.swift */,
FD432431299C6933008A0213 /* _011_AddPendingReadReceipts.swift */,
); );
path = Migrations; path = Migrations;
sourceTree = "<group>"; sourceTree = "<group>";
@ -5490,6 +5496,7 @@
FD716E6428502DDD00C96BF4 /* CallManagerProtocol.swift in Sources */, FD716E6428502DDD00C96BF4 /* CallManagerProtocol.swift in Sources */,
FDC438C727BB6DF000C60D73 /* DirectMessage.swift in Sources */, FDC438C727BB6DF000C60D73 /* DirectMessage.swift in Sources */,
FDC4384F27B4804F00C60D73 /* Header.swift in Sources */, FDC4384F27B4804F00C60D73 /* Header.swift in Sources */,
FD432434299C6985008A0213 /* PendingReadReceipt.swift in Sources */,
FDC4381727B32EC700C60D73 /* Personalization.swift in Sources */, FDC4381727B32EC700C60D73 /* Personalization.swift in Sources */,
FD245C51285065CC00B966DD /* MessageReceiver.swift in Sources */, FD245C51285065CC00B966DD /* MessageReceiver.swift in Sources */,
FD245C652850665400B966DD /* ClosedGroupControlMessage.swift in Sources */, FD245C652850665400B966DD /* ClosedGroupControlMessage.swift in Sources */,
@ -5551,6 +5558,7 @@
FDC438C127BB4E6800C60D73 /* SMKDependencies.swift in Sources */, FDC438C127BB4E6800C60D73 /* SMKDependencies.swift in Sources */,
FDC4383827B3863200C60D73 /* VersionResponse.swift in Sources */, FDC4383827B3863200C60D73 /* VersionResponse.swift in Sources */,
B806ECA126C4A7E4008BDA44 /* WebRTCSession+UI.swift in Sources */, B806ECA126C4A7E4008BDA44 /* WebRTCSession+UI.swift in Sources */,
FD432432299C6933008A0213 /* _011_AddPendingReadReceipts.swift in Sources */,
7BCD116C27016062006330F1 /* WebRTCSession+DataChannel.swift in Sources */, 7BCD116C27016062006330F1 /* WebRTCSession+DataChannel.swift in Sources */,
FD5C72F9284F0E880029977D /* MessageReceiver+TypingIndicators.swift in Sources */, FD5C72F9284F0E880029977D /* MessageReceiver+TypingIndicators.swift in Sources */,
FD5C7303284F0FA50029977D /* MessageReceiver+Calls.swift in Sources */, FD5C7303284F0FA50029977D /* MessageReceiver+Calls.swift in Sources */,
@ -6032,7 +6040,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 391; CURRENT_PROJECT_VERSION = 392;
DEBUG_INFORMATION_FORMAT = dwarf; DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = SUQ8J2PCT7; DEVELOPMENT_TEAM = SUQ8J2PCT7;
FRAMEWORK_SEARCH_PATHS = "$(inherited)"; FRAMEWORK_SEARCH_PATHS = "$(inherited)";
@ -6057,7 +6065,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 2.2.6; MARKETING_VERSION = 2.2.7;
MTL_ENABLE_DEBUG_INFO = YES; MTL_ENABLE_DEBUG_INFO = YES;
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.ShareExtension"; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.ShareExtension";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@ -6105,7 +6113,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 391; CURRENT_PROJECT_VERSION = 392;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = SUQ8J2PCT7; DEVELOPMENT_TEAM = SUQ8J2PCT7;
ENABLE_NS_ASSERTIONS = NO; ENABLE_NS_ASSERTIONS = NO;
@ -6135,7 +6143,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 2.2.6; MARKETING_VERSION = 2.2.7;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.ShareExtension"; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.ShareExtension";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@ -6171,7 +6179,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 391; CURRENT_PROJECT_VERSION = 392;
DEBUG_INFORMATION_FORMAT = dwarf; DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = SUQ8J2PCT7; DEVELOPMENT_TEAM = SUQ8J2PCT7;
FRAMEWORK_SEARCH_PATHS = "$(inherited)"; FRAMEWORK_SEARCH_PATHS = "$(inherited)";
@ -6194,7 +6202,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 2.2.6; MARKETING_VERSION = 2.2.7;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.NotificationServiceExtension"; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.NotificationServiceExtension";
@ -6245,7 +6253,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 391; CURRENT_PROJECT_VERSION = 392;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = SUQ8J2PCT7; DEVELOPMENT_TEAM = SUQ8J2PCT7;
ENABLE_NS_ASSERTIONS = NO; ENABLE_NS_ASSERTIONS = NO;
@ -6273,7 +6281,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 2.2.6; MARKETING_VERSION = 2.2.7;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.NotificationServiceExtension"; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.NotificationServiceExtension";
@ -7173,7 +7181,7 @@
CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements; CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CURRENT_PROJECT_VERSION = 391; CURRENT_PROJECT_VERSION = 392;
DEVELOPMENT_TEAM = SUQ8J2PCT7; DEVELOPMENT_TEAM = SUQ8J2PCT7;
FRAMEWORK_SEARCH_PATHS = ( FRAMEWORK_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
@ -7212,7 +7220,7 @@
"$(SRCROOT)", "$(SRCROOT)",
); );
LLVM_LTO = NO; LLVM_LTO = NO;
MARKETING_VERSION = 2.2.6; MARKETING_VERSION = 2.2.7;
OTHER_LDFLAGS = "$(inherited)"; OTHER_LDFLAGS = "$(inherited)";
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger"; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger";
@ -7245,7 +7253,7 @@
CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements; CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CURRENT_PROJECT_VERSION = 391; CURRENT_PROJECT_VERSION = 392;
DEVELOPMENT_TEAM = SUQ8J2PCT7; DEVELOPMENT_TEAM = SUQ8J2PCT7;
FRAMEWORK_SEARCH_PATHS = ( FRAMEWORK_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
@ -7284,7 +7292,7 @@
"$(SRCROOT)", "$(SRCROOT)",
); );
LLVM_LTO = NO; LLVM_LTO = NO;
MARKETING_VERSION = 2.2.6; MARKETING_VERSION = 2.2.7;
OTHER_LDFLAGS = "$(inherited)"; OTHER_LDFLAGS = "$(inherited)";
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger"; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger";
PRODUCT_NAME = Session; PRODUCT_NAME = Session;

View file

@ -34,6 +34,17 @@ extension ContextMenuVC {
} }
// MARK: - Actions // MARK: - Actions
static func retry(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
return Action(
icon: UIImage(systemName: "arrow.triangle.2.circlepath"),
title: (cellViewModel.state == .failedToSync ?
"context_menu_resync".localized() :
"context_menu_resend".localized()
),
accessibilityLabel: (cellViewModel.state == .failedToSync ? "Resync message" : "Resend message")
) { delegate?.retry(cellViewModel) }
}
static func reply(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { static func reply(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
return Action( return Action(
@ -127,6 +138,14 @@ extension ContextMenuVC {
case .standardOutgoing, .standardIncoming: break case .standardOutgoing, .standardIncoming: break
} }
let canRetry: Bool = (
cellViewModel.variant == .standardOutgoing && (
cellViewModel.state == .failed || (
cellViewModel.threadVariant == .contact &&
cellViewModel.state == .failedToSync
)
)
)
let canReply: Bool = ( let canReply: Bool = (
cellViewModel.variant != .standardOutgoing || ( cellViewModel.variant != .standardOutgoing || (
cellViewModel.state != .failed && cellViewModel.state != .failed &&
@ -182,6 +201,7 @@ extension ContextMenuVC {
}() }()
let generatedActions: [Action] = [ let generatedActions: [Action] = [
(canRetry ? Action.retry(cellViewModel, delegate) : nil),
(canReply ? Action.reply(cellViewModel, delegate) : nil), (canReply ? Action.reply(cellViewModel, delegate) : nil),
(canCopy ? Action.copy(cellViewModel, delegate) : nil), (canCopy ? Action.copy(cellViewModel, delegate) : nil),
(canSave ? Action.save(cellViewModel, delegate) : nil), (canSave ? Action.save(cellViewModel, delegate) : nil),
@ -203,6 +223,7 @@ extension ContextMenuVC {
// MARK: - Delegate // MARK: - Delegate
protocol ContextMenuActionDelegate { protocol ContextMenuActionDelegate {
func retry(_ cellViewModel: MessageViewModel)
func reply(_ cellViewModel: MessageViewModel) func reply(_ cellViewModel: MessageViewModel)
func copy(_ cellViewModel: MessageViewModel) func copy(_ cellViewModel: MessageViewModel)
func copySessionID(_ cellViewModel: MessageViewModel) func copySessionID(_ cellViewModel: MessageViewModel)

View file

@ -7,6 +7,7 @@ import PhotosUI
import Sodium import Sodium
import PromiseKit import PromiseKit
import GRDB import GRDB
import SessionUIKit
import SessionMessagingKit import SessionMessagingKit
import SessionUtilitiesKit import SessionUtilitiesKit
import SignalUtilitiesKit import SignalUtilitiesKit
@ -200,6 +201,30 @@ extension ConversationVC:
// MARK: - ExpandingAttachmentsButtonDelegate // MARK: - ExpandingAttachmentsButtonDelegate
func handleGIFButtonTapped() { func handleGIFButtonTapped() {
guard Storage.shared[.isGiphyEnabled] else {
let modal: ConfirmationModal = ConfirmationModal(
info: ConfirmationModal.Info(
title: "GIPHY_PERMISSION_TITLE".localized(),
explanation: "GIPHY_PERMISSION_MESSAGE".localized(),
confirmTitle: "continue_2".localized()
) { [weak self] _ in
Storage.shared.writeAsync(
updates: { db in
db[.isGiphyEnabled] = true
},
completion: { _, _ in
DispatchQueue.main.async {
self?.handleGIFButtonTapped()
}
}
)
}
)
present(modal, animated: true, completion: nil)
return
}
let gifVC = GifPickerViewController() let gifVC = GifPickerViewController()
gifVC.delegate = self gifVC.delegate = self
@ -829,7 +854,7 @@ extension ConversationVC:
} }
func handleItemTapped(_ cellViewModel: MessageViewModel, gestureRecognizer: UITapGestureRecognizer) { func handleItemTapped(_ cellViewModel: MessageViewModel, gestureRecognizer: UITapGestureRecognizer) {
guard cellViewModel.variant != .standardOutgoing || cellViewModel.state != .failed else { guard cellViewModel.variant != .standardOutgoing || (cellViewModel.state != .failed && cellViewModel.state != .failedToSync) else {
// Show the failed message sheet // Show the failed message sheet
showFailedMessageSheet(for: cellViewModel) showFailedMessageSheet(for: cellViewModel)
return return
@ -1451,30 +1476,34 @@ extension ConversationVC:
// MARK: --action handling // MARK: --action handling
func showFailedMessageSheet(for cellViewModel: MessageViewModel) { func showFailedMessageSheet(for cellViewModel: MessageViewModel) {
let sheet = UIAlertController(title: cellViewModel.mostRecentFailureText, message: nil, preferredStyle: .actionSheet) let sheet = UIAlertController(
sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil)) title: (cellViewModel.state == .failedToSync ?
sheet.addAction(UIAlertAction(title: "Delete", style: .destructive, handler: { _ in "MESSAGE_DELIVERY_FAILED_SYNC_TITLE".localized() :
Storage.shared.writeAsync { db in "MESSAGE_DELIVERY_FAILED_TITLE".localized()
try Interaction ),
.filter(id: cellViewModel.id) message: cellViewModel.mostRecentFailureText,
.deleteAll(db) preferredStyle: .actionSheet
} )
})) sheet.addAction(UIAlertAction(title: "TXT_CANCEL_TITLE".localized(), style: .cancel, handler: nil))
sheet.addAction(UIAlertAction(title: "Resend", style: .default, handler: { _ in
Storage.shared.writeAsync { [weak self] db in if cellViewModel.state != .failedToSync {
guard sheet.addAction(UIAlertAction(title: "TXT_DELETE_TITLE".localized(), style: .destructive, handler: { _ in
let threadId: String = self?.viewModel.threadData.threadId, Storage.shared.writeAsync { db in
let interaction: Interaction = try? Interaction.fetchOne(db, id: cellViewModel.id), try Interaction
let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) .filter(id: cellViewModel.id)
else { return } .deleteAll(db)
}
try MessageSender.send( }))
db, }
interaction: interaction,
in: thread sheet.addAction(UIAlertAction(
) title: (cellViewModel.state == .failedToSync ?
} "context_menu_resync".localized() :
})) "context_menu_resend".localized()
),
style: .default,
handler: { [weak self] _ in self?.retry(cellViewModel) }
))
// HACK: Extracting this info from the error string is pretty dodgy // HACK: Extracting this info from the error string is pretty dodgy
let prefix: String = "HTTP request failed at destination (Service node " let prefix: String = "HTTP request failed at destination (Service node "
@ -1558,6 +1587,23 @@ extension ConversationVC:
} }
// MARK: - ContextMenuActionDelegate // MARK: - ContextMenuActionDelegate
func retry(_ cellViewModel: MessageViewModel) {
Storage.shared.writeAsync { [weak self] db in
guard
let threadId: String = self?.viewModel.threadData.threadId,
let interaction: Interaction = try? Interaction.fetchOne(db, id: cellViewModel.id),
let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId)
else { return }
try MessageSender.send(
db,
interaction: interaction,
in: thread,
isSyncMessage: (cellViewModel.state == .failedToSync)
)
}
}
func reply(_ cellViewModel: MessageViewModel) { func reply(_ cellViewModel: MessageViewModel) {
let maybeQuoteDraft: QuotedReplyModel? = QuotedReplyModel.quotedReplyForSending( let maybeQuoteDraft: QuotedReplyModel? = QuotedReplyModel.quotedReplyForSending(
@ -1827,10 +1873,16 @@ extension ConversationVC:
}) })
actionSheet.addAction(UIAlertAction( actionSheet.addAction(UIAlertAction(
title: (cellViewModel.threadVariant == .closedGroup ? title: {
"delete_message_for_everyone".localized() : switch cellViewModel.threadVariant {
String(format: "delete_message_for_me_and_recipient".localized(), threadName) case .closedGroup: return "delete_message_for_everyone".localized()
), default:
return (cellViewModel.threadId == userPublicKey ?
"delete_message_for_me_and_my_devices".localized() :
String(format: "delete_message_for_me_and_recipient".localized(), threadName)
)
}
}(),
style: .destructive style: .destructive
) { [weak self] _ in ) { [weak self] _ in
deleteRemotely( deleteRemotely(

View file

@ -206,7 +206,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
), ),
PagedData.ObservedChanges( PagedData.ObservedChanges(
table: RecipientState.self, table: RecipientState.self,
columns: [.state, .mostRecentFailureText], columns: [.state, .readTimestampMs, .mostRecentFailureText],
joinToPagedType: { joinToPagedType: {
let interaction: TypedTableAlias<Interaction> = TypedTableAlias() let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let recipientState: TypedTableAlias<RecipientState> = TypedTableAlias() let recipientState: TypedTableAlias<RecipientState> = TypedTableAlias()
@ -304,6 +304,15 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
index == (sortedData.count - 1) && index == (sortedData.count - 1) &&
pageInfo.pageOffset == 0 pageInfo.pageOffset == 0
), ),
isLastOutgoing: (
cellViewModel.id == sortedData
.filter {
$0.authorId == threadData.currentUserPublicKey ||
$0.authorId == threadData.currentUserBlindedPublicKey
}
.last?
.id
),
currentUserBlindedPublicKey: threadData.currentUserBlindedPublicKey currentUserBlindedPublicKey: threadData.currentUserBlindedPublicKey
) )
} }

View file

@ -3,6 +3,7 @@
import UIKit import UIKit
import SessionUIKit import SessionUIKit
import SessionMessagingKit import SessionMessagingKit
import SessionUtilitiesKit
final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, MentionSelectionViewDelegate { final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, MentionSelectionViewDelegate {
// MARK: - Variables // MARK: - Variables
@ -404,8 +405,11 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
func handleInputViewButtonLongPressBegan(_ inputViewButton: InputViewButton?) { func handleInputViewButtonLongPressBegan(_ inputViewButton: InputViewButton?) {
guard inputViewButton == voiceMessageButton else { return } guard inputViewButton == voiceMessageButton else { return }
delegate?.startVoiceMessageRecording() // Note: The 'showVoiceMessageUI' call MUST come before triggering 'startVoiceMessageRecording'
// because if something goes wrong it'll trigger `hideVoiceMessageUI` and we don't want it to
// end up in a state with the input content hidden
showVoiceMessageUI() showVoiceMessageUI()
delegate?.startVoiceMessageRecording()
} }
func handleInputViewButtonLongPressMoved(_ inputViewButton: InputViewButton, with touch: UITouch?) { func handleInputViewButtonLongPressMoved(_ inputViewButton: InputViewButton, with touch: UITouch?) {
@ -466,9 +470,9 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
UIView.animate(withDuration: 0.25, animations: { UIView.animate(withDuration: 0.25, animations: {
allOtherViews.forEach { $0.alpha = 1 } allOtherViews.forEach { $0.alpha = 1 }
self.voiceMessageRecordingView?.alpha = 0 self.voiceMessageRecordingView?.alpha = 0
}, completion: { _ in }, completion: { [weak self] _ in
self.voiceMessageRecordingView?.removeFromSuperview() self?.voiceMessageRecordingView?.removeFromSuperview()
self.voiceMessageRecordingView = nil self?.voiceMessageRecordingView = nil
}) })
} }

View file

@ -431,7 +431,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
cellViewModel.variant == .infoCall || cellViewModel.variant == .infoCall ||
( (
cellViewModel.state == .sent && cellViewModel.state == .sent &&
!cellViewModel.isLast !cellViewModel.isLastOutgoing
) )
) )
messageStatusLabelPaddingView.isHidden = ( messageStatusLabelPaddingView.isHidden = (

View file

@ -2,7 +2,7 @@
// Copyright (c) 2019 Open Whisper Systems. All rights reserved. // Copyright (c) 2019 Open Whisper Systems. All rights reserved.
// //
import Foundation import UIKit
import Reachability import Reachability
import SignalUtilitiesKit import SignalUtilitiesKit
import PromiseKit import PromiseKit

View file

@ -6,6 +6,8 @@ import AFNetworking
import Foundation import Foundation
import PromiseKit import PromiseKit
import CoreServices import CoreServices
import SignalUtilitiesKit
import SessionUtilitiesKit
// There's no UTI type for webp! // There's no UTI type for webp!
enum GiphyFormat { enum GiphyFormat {

View file

@ -600,3 +600,12 @@
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent"; "MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
"MESSAGE_DELIVERY_STATUS_READ" = "Read"; "MESSAGE_DELIVERY_STATUS_READ" = "Read";
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send"; "MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
"MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync";
"MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing";
"MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message";
"MESSAGE_DELIVERY_FAILED_SYNC_TITLE" = "Failed to sync message to your other devices";
"delete_message_for_me_and_my_devices" = "Delete from all of my devices";
"context_menu_resend" = "Resend";
"context_menu_resync" = "Resync";
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";

View file

@ -600,3 +600,12 @@
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent"; "MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
"MESSAGE_DELIVERY_STATUS_READ" = "Read"; "MESSAGE_DELIVERY_STATUS_READ" = "Read";
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send"; "MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
"MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync";
"MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing";
"MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message";
"MESSAGE_DELIVERY_FAILED_SYNC_TITLE" = "Failed to sync message to your other devices";
"delete_message_for_me_and_my_devices" = "Delete from all of my devices";
"context_menu_resend" = "Resend";
"context_menu_resync" = "Resync";
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";

View file

@ -600,3 +600,12 @@
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent"; "MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
"MESSAGE_DELIVERY_STATUS_READ" = "Read"; "MESSAGE_DELIVERY_STATUS_READ" = "Read";
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send"; "MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
"MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync";
"MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing";
"MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message";
"MESSAGE_DELIVERY_FAILED_SYNC_TITLE" = "Failed to sync message to your other devices";
"delete_message_for_me_and_my_devices" = "Delete from all of my devices";
"context_menu_resend" = "Resend";
"context_menu_resync" = "Resync";
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";

View file

@ -600,3 +600,12 @@
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent"; "MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
"MESSAGE_DELIVERY_STATUS_READ" = "Read"; "MESSAGE_DELIVERY_STATUS_READ" = "Read";
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send"; "MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
"MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync";
"MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing";
"MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message";
"MESSAGE_DELIVERY_FAILED_SYNC_TITLE" = "Failed to sync message to your other devices";
"delete_message_for_me_and_my_devices" = "Delete from all of my devices";
"context_menu_resend" = "Resend";
"context_menu_resync" = "Resync";
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";

View file

@ -600,3 +600,12 @@
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent"; "MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
"MESSAGE_DELIVERY_STATUS_READ" = "Read"; "MESSAGE_DELIVERY_STATUS_READ" = "Read";
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send"; "MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
"MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync";
"MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing";
"MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message";
"MESSAGE_DELIVERY_FAILED_SYNC_TITLE" = "Failed to sync message to your other devices";
"delete_message_for_me_and_my_devices" = "Delete from all of my devices";
"context_menu_resend" = "Resend";
"context_menu_resync" = "Resync";
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";

View file

@ -600,3 +600,12 @@
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent"; "MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
"MESSAGE_DELIVERY_STATUS_READ" = "Read"; "MESSAGE_DELIVERY_STATUS_READ" = "Read";
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send"; "MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
"MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync";
"MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing";
"MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message";
"MESSAGE_DELIVERY_FAILED_SYNC_TITLE" = "Failed to sync message to your other devices";
"delete_message_for_me_and_my_devices" = "Delete from all of my devices";
"context_menu_resend" = "Resend";
"context_menu_resync" = "Resync";
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";

View file

@ -600,3 +600,12 @@
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent"; "MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
"MESSAGE_DELIVERY_STATUS_READ" = "Read"; "MESSAGE_DELIVERY_STATUS_READ" = "Read";
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send"; "MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
"MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync";
"MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing";
"MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message";
"MESSAGE_DELIVERY_FAILED_SYNC_TITLE" = "Failed to sync message to your other devices";
"delete_message_for_me_and_my_devices" = "Delete from all of my devices";
"context_menu_resend" = "Resend";
"context_menu_resync" = "Resync";
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";

View file

@ -600,3 +600,12 @@
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent"; "MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
"MESSAGE_DELIVERY_STATUS_READ" = "Read"; "MESSAGE_DELIVERY_STATUS_READ" = "Read";
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send"; "MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
"MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync";
"MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing";
"MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message";
"MESSAGE_DELIVERY_FAILED_SYNC_TITLE" = "Failed to sync message to your other devices";
"delete_message_for_me_and_my_devices" = "Delete from all of my devices";
"context_menu_resend" = "Resend";
"context_menu_resync" = "Resync";
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";

View file

@ -600,3 +600,12 @@
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent"; "MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
"MESSAGE_DELIVERY_STATUS_READ" = "Read"; "MESSAGE_DELIVERY_STATUS_READ" = "Read";
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send"; "MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
"MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync";
"MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing";
"MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message";
"MESSAGE_DELIVERY_FAILED_SYNC_TITLE" = "Failed to sync message to your other devices";
"delete_message_for_me_and_my_devices" = "Delete from all of my devices";
"context_menu_resend" = "Resend";
"context_menu_resync" = "Resync";
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";

View file

@ -600,3 +600,12 @@
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent"; "MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
"MESSAGE_DELIVERY_STATUS_READ" = "Read"; "MESSAGE_DELIVERY_STATUS_READ" = "Read";
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send"; "MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
"MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync";
"MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing";
"MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message";
"MESSAGE_DELIVERY_FAILED_SYNC_TITLE" = "Failed to sync message to your other devices";
"delete_message_for_me_and_my_devices" = "Delete from all of my devices";
"context_menu_resend" = "Resend";
"context_menu_resync" = "Resync";
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";

View file

@ -600,3 +600,12 @@
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent"; "MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
"MESSAGE_DELIVERY_STATUS_READ" = "Read"; "MESSAGE_DELIVERY_STATUS_READ" = "Read";
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send"; "MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
"MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync";
"MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing";
"MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message";
"MESSAGE_DELIVERY_FAILED_SYNC_TITLE" = "Failed to sync message to your other devices";
"delete_message_for_me_and_my_devices" = "Delete from all of my devices";
"context_menu_resend" = "Resend";
"context_menu_resync" = "Resync";
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";

View file

@ -600,3 +600,12 @@
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent"; "MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
"MESSAGE_DELIVERY_STATUS_READ" = "Read"; "MESSAGE_DELIVERY_STATUS_READ" = "Read";
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send"; "MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
"MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync";
"MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing";
"MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message";
"MESSAGE_DELIVERY_FAILED_SYNC_TITLE" = "Failed to sync message to your other devices";
"delete_message_for_me_and_my_devices" = "Delete from all of my devices";
"context_menu_resend" = "Resend";
"context_menu_resync" = "Resync";
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";

View file

@ -600,3 +600,12 @@
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent"; "MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
"MESSAGE_DELIVERY_STATUS_READ" = "Read"; "MESSAGE_DELIVERY_STATUS_READ" = "Read";
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send"; "MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
"MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync";
"MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing";
"MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message";
"MESSAGE_DELIVERY_FAILED_SYNC_TITLE" = "Failed to sync message to your other devices";
"delete_message_for_me_and_my_devices" = "Delete from all of my devices";
"context_menu_resend" = "Resend";
"context_menu_resync" = "Resync";
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";

View file

@ -600,3 +600,12 @@
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent"; "MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
"MESSAGE_DELIVERY_STATUS_READ" = "Read"; "MESSAGE_DELIVERY_STATUS_READ" = "Read";
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send"; "MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
"MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync";
"MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing";
"MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message";
"MESSAGE_DELIVERY_FAILED_SYNC_TITLE" = "Failed to sync message to your other devices";
"delete_message_for_me_and_my_devices" = "Delete from all of my devices";
"context_menu_resend" = "Resend";
"context_menu_resync" = "Resync";
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";

View file

@ -600,3 +600,12 @@
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent"; "MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
"MESSAGE_DELIVERY_STATUS_READ" = "Read"; "MESSAGE_DELIVERY_STATUS_READ" = "Read";
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send"; "MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
"MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync";
"MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing";
"MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message";
"MESSAGE_DELIVERY_FAILED_SYNC_TITLE" = "Failed to sync message to your other devices";
"delete_message_for_me_and_my_devices" = "Delete from all of my devices";
"context_menu_resend" = "Resend";
"context_menu_resync" = "Resync";
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";

View file

@ -600,3 +600,12 @@
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent"; "MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
"MESSAGE_DELIVERY_STATUS_READ" = "Read"; "MESSAGE_DELIVERY_STATUS_READ" = "Read";
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send"; "MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
"MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync";
"MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing";
"MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message";
"MESSAGE_DELIVERY_FAILED_SYNC_TITLE" = "Failed to sync message to your other devices";
"delete_message_for_me_and_my_devices" = "Delete from all of my devices";
"context_menu_resend" = "Resend";
"context_menu_resync" = "Resync";
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";

View file

@ -600,3 +600,12 @@
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent"; "MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
"MESSAGE_DELIVERY_STATUS_READ" = "Read"; "MESSAGE_DELIVERY_STATUS_READ" = "Read";
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send"; "MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
"MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync";
"MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing";
"MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message";
"MESSAGE_DELIVERY_FAILED_SYNC_TITLE" = "Failed to sync message to your other devices";
"delete_message_for_me_and_my_devices" = "Delete from all of my devices";
"context_menu_resend" = "Resend";
"context_menu_resync" = "Resync";
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";

View file

@ -600,3 +600,12 @@
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent"; "MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
"MESSAGE_DELIVERY_STATUS_READ" = "Read"; "MESSAGE_DELIVERY_STATUS_READ" = "Read";
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send"; "MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
"MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync";
"MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing";
"MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message";
"MESSAGE_DELIVERY_FAILED_SYNC_TITLE" = "Failed to sync message to your other devices";
"delete_message_for_me_and_my_devices" = "Delete from all of my devices";
"context_menu_resend" = "Resend";
"context_menu_resync" = "Resync";
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";

View file

@ -600,3 +600,12 @@
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent"; "MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
"MESSAGE_DELIVERY_STATUS_READ" = "Read"; "MESSAGE_DELIVERY_STATUS_READ" = "Read";
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send"; "MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
"MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync";
"MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing";
"MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message";
"MESSAGE_DELIVERY_FAILED_SYNC_TITLE" = "Failed to sync message to your other devices";
"delete_message_for_me_and_my_devices" = "Delete from all of my devices";
"context_menu_resend" = "Resend";
"context_menu_resync" = "Resync";
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";

View file

@ -600,3 +600,12 @@
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent"; "MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
"MESSAGE_DELIVERY_STATUS_READ" = "Read"; "MESSAGE_DELIVERY_STATUS_READ" = "Read";
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send"; "MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
"MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync";
"MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing";
"MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message";
"MESSAGE_DELIVERY_FAILED_SYNC_TITLE" = "Failed to sync message to your other devices";
"delete_message_for_me_and_my_devices" = "Delete from all of my devices";
"context_menu_resend" = "Resend";
"context_menu_resync" = "Resync";
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";

View file

@ -600,3 +600,12 @@
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent"; "MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
"MESSAGE_DELIVERY_STATUS_READ" = "Read"; "MESSAGE_DELIVERY_STATUS_READ" = "Read";
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send"; "MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
"MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync";
"MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing";
"MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message";
"MESSAGE_DELIVERY_FAILED_SYNC_TITLE" = "Failed to sync message to your other devices";
"delete_message_for_me_and_my_devices" = "Delete from all of my devices";
"context_menu_resend" = "Resend";
"context_menu_resync" = "Resync";
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";

View file

@ -600,3 +600,12 @@
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent"; "MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
"MESSAGE_DELIVERY_STATUS_READ" = "Read"; "MESSAGE_DELIVERY_STATUS_READ" = "Read";
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send"; "MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
"MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync";
"MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing";
"MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message";
"MESSAGE_DELIVERY_FAILED_SYNC_TITLE" = "Failed to sync message to your other devices";
"delete_message_for_me_and_my_devices" = "Delete from all of my devices";
"context_menu_resend" = "Resend";
"context_menu_resync" = "Resync";
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";

View file

@ -5,6 +5,8 @@
import Foundation import Foundation
import UserNotifications import UserNotifications
import PromiseKit import PromiseKit
import SignalCoreKit
import SignalUtilitiesKit
import SessionMessagingKit import SessionMessagingKit
class UserNotificationConfig { class UserNotificationConfig {

View file

@ -3,6 +3,7 @@
import UIKit import UIKit
import Reachability import Reachability
import SessionUIKit import SessionUIKit
import SessionSnodeKit
final class PathStatusView: UIView { final class PathStatusView: UIView {
enum Size { enum Size {

View file

@ -1,9 +1,11 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit import UIKit
import Reachability
import NVActivityIndicatorView import NVActivityIndicatorView
import SessionMessagingKit import SessionMessagingKit
import SessionUIKit import SessionUIKit
import SessionSnodeKit
final class PathVC: BaseVC { final class PathVC: BaseVC {
public static let dotSize: CGFloat = 8 public static let dotSize: CGFloat = 8
@ -239,6 +241,7 @@ private final class LineView: UIView {
private var dotViewWidthConstraint: NSLayoutConstraint! private var dotViewWidthConstraint: NSLayoutConstraint!
private var dotViewHeightConstraint: NSLayoutConstraint! private var dotViewHeightConstraint: NSLayoutConstraint!
private var dotViewAnimationTimer: Timer! private var dotViewAnimationTimer: Timer!
private let reachability: Reachability = Reachability.forInternetConnection()
enum Location { enum Location {
case top, middle, bottom case top, middle, bottom
@ -273,6 +276,7 @@ private final class LineView: UIView {
super.init(frame: CGRect.zero) super.init(frame: CGRect.zero)
setUpViewHierarchy() setUpViewHierarchy()
registerObservers()
} }
override init(frame: CGRect) { override init(frame: CGRect) {
@ -283,6 +287,12 @@ private final class LineView: UIView {
preconditionFailure("Use init(location:dotAnimationStartDelay:dotAnimationRepeatInterval:) instead.") preconditionFailure("Use init(location:dotAnimationStartDelay:dotAnimationRepeatInterval:) instead.")
} }
deinit {
NotificationCenter.default.removeObserver(self)
dotViewAnimationTimer?.invalidate()
}
private func setUpViewHierarchy() { private func setUpViewHierarchy() {
let lineView = UIView() let lineView = UIView()
lineView.set(.width, to: Values.separatorThickness) lineView.set(.width, to: Values.separatorThickness)
@ -315,10 +325,33 @@ private final class LineView: UIView {
self?.animate() self?.animate()
} }
} }
switch (reachability.isReachable(), OnionRequestAPI.paths.isEmpty) {
case (false, _): setStatus(to: .error)
case (true, true): setStatus(to: .connecting)
case (true, false): setStatus(to: .connected)
}
} }
deinit { private func registerObservers() {
dotViewAnimationTimer?.invalidate() NotificationCenter.default.addObserver(
self,
selector: #selector(handleBuildingPathsNotification),
name: .buildingPaths,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handlePathsBuiltNotification),
name: .pathsBuilt,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(reachabilityChanged),
name: .reachabilityChanged,
object: nil
)
} }
private func animate() { private func animate() {
@ -340,4 +373,41 @@ private final class LineView: UIView {
self?.dotView.transform = CGAffineTransform.scale(1) self?.dotView.transform = CGAffineTransform.scale(1)
} }
} }
private func setStatus(to status: PathStatusView.Status) {
dotView.themeBackgroundColor = status.themeColor
dotView.layer.themeShadowColor = status.themeColor
}
@objc private func handleBuildingPathsNotification() {
guard reachability.isReachable() else {
setStatus(to: .error)
return
}
setStatus(to: .connecting)
}
@objc private func handlePathsBuiltNotification() {
guard reachability.isReachable() else {
setStatus(to: .error)
return
}
setStatus(to: .connected)
}
@objc private func reachabilityChanged() {
guard Thread.isMainThread else {
DispatchQueue.main.async { [weak self] in self?.reachabilityChanged() }
return
}
guard reachability.isReachable() else {
setStatus(to: .error)
return
}
setStatus(to: (!OnionRequestAPI.paths.isEmpty ? .connected : .connecting))
}
} }

View file

@ -4,6 +4,7 @@ import UIKit
import AVFoundation import AVFoundation
import Curve25519Kit import Curve25519Kit
import SessionUIKit import SessionUIKit
import SessionMessagingKit
import SessionUtilitiesKit import SessionUtilitiesKit
final class QRCodeVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControllerDelegate, QRScannerDelegate { final class QRCodeVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControllerDelegate, QRScannerDelegate {

View file

@ -24,7 +24,8 @@ public enum SNMessagingKit { // Just to make the external API nice
[ [
_008_EmojiReacts.self, _008_EmojiReacts.self,
_009_OpenGroupPermission.self, _009_OpenGroupPermission.self,
_010_AddThreadIdToFTS.self _010_AddThreadIdToFTS.self,
_011_AddPendingReadReceipts.self
] ]
] ]
) )

View file

@ -0,0 +1,41 @@
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import SessionUtilitiesKit
/// This migration adds a table to track pending read receipts (it's possible to receive a read receipt message before getting the original
/// message due to how one-to-one conversations work, by storing pending read receipts we should be able to prevent this case)
enum _011_AddPendingReadReceipts: Migration {
static let target: TargetMigrations.Identifier = .messagingKit
static let identifier: String = "AddPendingReadReceipts"
static let needsConfigSync: Bool = false
static let minExpectedRunDuration: TimeInterval = 0.1
static func migrate(_ db: Database) throws {
// Can't actually alter a virtual table in SQLite so we need to drop and recreate it,
// luckily this is actually pretty quick
if try db.tableExists(Interaction.fullTextSearchTableName) {
try db.drop(table: Interaction.fullTextSearchTableName)
try db.dropFTS5SynchronizationTriggers(forTable: Interaction.fullTextSearchTableName)
}
try db.create(table: PendingReadReceipt.self) { t in
t.column(.threadId, .text)
.notNull()
.indexed() // Quicker querying
.references(SessionThread.self, onDelete: .cascade) // Delete if Thread deleted
t.column(.interactionTimestampMs, .integer)
.notNull()
.indexed() // Quicker querying
t.column(.readTimestampMs, .integer)
.notNull()
t.column(.serverExpirationTimestamp, .double)
.notNull()
t.primaryKey([.threadId, .interactionTimestampMs])
}
Storage.update(progress: 1, for: self, in: target) // In case this is the last migration
}
}

View file

@ -450,26 +450,33 @@ public extension Interaction {
trySendReadReceipt: Bool trySendReadReceipt: Bool
) throws { ) throws {
guard let interactionId: Int64 = interactionId else { return } guard let interactionId: Int64 = interactionId else { return }
struct InteractionReadInfo: Decodable, FetchableRecord {
let id: Int64
let variant: Interaction.Variant
let timestampMs: Int64
let wasRead: Bool
}
// Once all of the below is done schedule the jobs // Once all of the below is done schedule the jobs
func scheduleJobs(interactionIds: [Int64]) { func scheduleJobs(interactionInfo: [InteractionReadInfo]) {
// Add the 'DisappearingMessagesJob' if needed - this will update any expiring // Add the 'DisappearingMessagesJob' if needed - this will update any expiring
// messages `expiresStartedAtMs` values // messages `expiresStartedAtMs` values
JobRunner.upsert( JobRunner.upsert(
db, db,
job: DisappearingMessagesJob.updateNextRunIfNeeded( job: DisappearingMessagesJob.updateNextRunIfNeeded(
db, db,
interactionIds: interactionIds, interactionIds: interactionInfo.map { $0.id },
startedAtMs: TimeInterval(SnodeAPI.currentOffsetTimestampMs()) startedAtMs: TimeInterval(SnodeAPI.currentOffsetTimestampMs())
) )
) )
// Clear out any notifications for the interactions we mark as read // Clear out any notifications for the interactions we mark as read
Environment.shared?.notificationsManager.wrappedValue?.cancelNotifications( Environment.shared?.notificationsManager.wrappedValue?.cancelNotifications(
identifiers: interactionIds identifiers: interactionInfo
.map { interactionId in .map { interactionInfo in
Interaction.notificationIdentifier( Interaction.notificationIdentifier(
for: interactionId, for: interactionInfo.id,
threadId: threadId, threadId: threadId,
shouldGroupMessagesForThread: false shouldGroupMessagesForThread: false
) )
@ -482,43 +489,54 @@ public extension Interaction {
) )
// If we want to send read receipts and it's a contact thread then try to add the // If we want to send read receipts and it's a contact thread then try to add the
// 'SendReadReceiptsJob' // 'SendReadReceiptsJob' for and unread messages that weren't outgoing
if trySendReadReceipt && threadVariant == .contact { if trySendReadReceipt && threadVariant == .contact {
JobRunner.upsert( JobRunner.upsert(
db, db,
job: SendReadReceiptsJob.createOrUpdateIfNeeded( job: SendReadReceiptsJob.createOrUpdateIfNeeded(
db, db,
threadId: threadId, threadId: threadId,
interactionIds: interactionIds interactionIds: interactionInfo
.filter { !$0.wasRead && $0.variant != .standardOutgoing }
.map { $0.id }
) )
) )
} }
} }
// If we aren't including older interactions then update and save the current one
struct InteractionReadInfo: Decodable, FetchableRecord {
let timestampMs: Int64
let wasRead: Bool
}
// Since there is no guarantee on the order messages are inserted into the database // Since there is no guarantee on the order messages are inserted into the database
// fetch the timestamp for the interaction and set everything before that as read // fetch the timestamp for the interaction and set everything before that as read
let maybeInteractionInfo: InteractionReadInfo? = try Interaction let maybeInteractionInfo: InteractionReadInfo? = try Interaction
.select(.timestampMs, .wasRead) .select(.id, .variant, .timestampMs, .wasRead)
.filter(id: interactionId) .filter(id: interactionId)
.asRequest(of: InteractionReadInfo.self) .asRequest(of: InteractionReadInfo.self)
.fetchOne(db) .fetchOne(db)
// If we aren't including older interactions then update and save the current one
guard includingOlder, let interactionInfo: InteractionReadInfo = maybeInteractionInfo else { guard includingOlder, let interactionInfo: InteractionReadInfo = maybeInteractionInfo else {
// Only mark as read and trigger the subsequent jobs if the interaction is // Only mark as read and trigger the subsequent jobs if the interaction is
// actually not read (no point updating and triggering db changes otherwise) // actually not read (no point updating and triggering db changes otherwise)
guard maybeInteractionInfo?.wasRead == false else { return } guard
maybeInteractionInfo?.wasRead == false,
let variant: Variant = try Interaction
.filter(id: interactionId)
.select(.variant)
.asRequest(of: Variant.self)
.fetchOne(db)
else { return }
_ = try Interaction _ = try Interaction
.filter(id: interactionId) .filter(id: interactionId)
.updateAll(db, Columns.wasRead.set(to: true)) .updateAll(db, Columns.wasRead.set(to: true))
scheduleJobs(interactionIds: [interactionId]) scheduleJobs(interactionInfo: [
InteractionReadInfo(
id: interactionId,
variant: variant,
timestampMs: 0,
wasRead: false
)
])
return return
} }
@ -526,16 +544,16 @@ public extension Interaction {
.filter(Interaction.Columns.threadId == threadId) .filter(Interaction.Columns.threadId == threadId)
.filter(Interaction.Columns.timestampMs <= interactionInfo.timestampMs) .filter(Interaction.Columns.timestampMs <= interactionInfo.timestampMs)
.filter(Interaction.Columns.wasRead == false) .filter(Interaction.Columns.wasRead == false)
let interactionIdsToMarkAsRead: [Int64] = try interactionQuery let interactionInfoToMarkAsRead: [InteractionReadInfo] = try interactionQuery
.select(.id) .select(.id, .variant, .timestampMs, .wasRead)
.asRequest(of: Int64.self) .asRequest(of: InteractionReadInfo.self)
.fetchAll(db) .fetchAll(db)
// If there are no other interactions to mark as read then just schedule the jobs // If there are no other interactions to mark as read then just schedule the jobs
// for this interaction (need to ensure the disapeparing messages run for sync'ed // for this interaction (need to ensure the disapeparing messages run for sync'ed
// outgoing messages which will always have 'wasRead' as false) // outgoing messages which will always have 'wasRead' as false)
guard !interactionIdsToMarkAsRead.isEmpty else { guard !interactionInfoToMarkAsRead.isEmpty else {
scheduleJobs(interactionIds: [interactionId]) scheduleJobs(interactionInfo: [interactionInfo])
return return
} }
@ -543,27 +561,71 @@ public extension Interaction {
try interactionQuery.updateAll(db, Columns.wasRead.set(to: true)) try interactionQuery.updateAll(db, Columns.wasRead.set(to: true))
// Retrieve the interaction ids we want to update // Retrieve the interaction ids we want to update
scheduleJobs(interactionIds: interactionIdsToMarkAsRead) scheduleJobs(interactionInfo: interactionInfoToMarkAsRead)
} }
/// This method flags sent messages as read for the specified recipients /// This method flags sent messages as read for the specified recipients
/// ///
/// **Note:** This method won't update the 'wasRead' flag (it will be updated via the above method) /// **Note:** This method won't update the 'wasRead' flag (it will be updated via the above method)
static func markAsRead(_ db: Database, recipientId: String, timestampMsValues: [Double], readTimestampMs: Double) throws { @discardableResult static func markAsRead(
guard db[.areReadReceiptsEnabled] == true else { return } _ db: Database,
recipientId: String,
timestampMsValues: [Int64],
readTimestampMs: Int64
) throws -> Set<Int64> {
guard db[.areReadReceiptsEnabled] == true else { return [] }
try RecipientState // Update the read state
let rowIds: [Int64] = try RecipientState
.select(Column.rowID)
.filter(RecipientState.Columns.recipientId == recipientId) .filter(RecipientState.Columns.recipientId == recipientId)
.joining( .joining(
required: RecipientState.interaction required: RecipientState.interaction
.filter(Columns.variant == Variant.standardOutgoing)
.filter(timestampMsValues.contains(Columns.timestampMs)) .filter(timestampMsValues.contains(Columns.timestampMs))
.filter(Columns.variant == Variant.standardOutgoing)
) )
.asRequest(of: Int64.self)
.fetchAll(db)
// If there were no 'rowIds' then no need to run the below queries, all of the timestamps
// and for pending read receipts
guard !rowIds.isEmpty else { return timestampMsValues.asSet() }
// Update the 'readTimestampMs' if it doesn't match (need to do this to prevent
// the UI update from being triggered for a redundant update)
try RecipientState
.filter(rowIds.contains(Column.rowID))
.filter(RecipientState.Columns.readTimestampMs == nil)
.updateAll(
db,
RecipientState.Columns.readTimestampMs.set(to: readTimestampMs)
)
// If the message still appeared to be sending then mark it as sent
try RecipientState
.filter(rowIds.contains(Column.rowID))
.filter(RecipientState.Columns.state == RecipientState.State.sending)
.updateAll( .updateAll(
db, db,
RecipientState.Columns.readTimestampMs.set(to: readTimestampMs),
RecipientState.Columns.state.set(to: RecipientState.State.sent) RecipientState.Columns.state.set(to: RecipientState.State.sent)
) )
// Retrieve the set of timestamps which were updated
let timestampsUpdated: Set<Int64> = try Interaction
.select(Columns.timestampMs)
.filter(timestampMsValues.contains(Columns.timestampMs))
.filter(Columns.variant == Variant.standardOutgoing)
.joining(
required: Interaction.recipientStates
.filter(rowIds.contains(Column.rowID))
)
.asRequest(of: Int64.self)
.fetchSet(db)
// Return the timestamps which weren't updated
return timestampMsValues
.asSet()
.subtracting(timestampsUpdated)
} }
} }

View file

@ -0,0 +1,44 @@
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import SessionUtilitiesKit
public struct PendingReadReceipt: Codable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible {
public static var databaseTableName: String { "pendingReadReceipt" }
public static let threadForeignKey = ForeignKey([Columns.threadId], to: [SessionThread.Columns.id])
public typealias Columns = CodingKeys
public enum CodingKeys: String, CodingKey, ColumnExpression {
case threadId
case interactionTimestampMs
case readTimestampMs
case serverExpirationTimestamp
}
/// The id for the thread this ReadReceipt belongs to
public let threadId: String
/// The timestamp in milliseconds since epoch for the interaction this read receipt relates to
public let interactionTimestampMs: Int64
/// The timestamp in milliseconds since epoch that the interaction this read receipt relates to was read
public let readTimestampMs: Int64
/// The timestamp for when this message will expire on the server (will be used for garbage collection)
public let serverExpirationTimestamp: TimeInterval
// MARK: - Initialization
public init(
threadId: String,
interactionTimestampMs: Int64,
readTimestampMs: Int64,
serverExpirationTimestamp: TimeInterval
) {
self.threadId = threadId
self.interactionTimestampMs = interactionTimestampMs
self.readTimestampMs = readTimestampMs
self.serverExpirationTimestamp = serverExpirationTimestamp
}
}

View file

@ -40,6 +40,8 @@ public struct RecipientState: Codable, Equatable, FetchableRecord, PersistableRe
case failed case failed
case skipped case skipped
case sent case sent
case failedToSync // One-to-one Only
case syncing // One-to-one Only
func message(hasAttachments: Bool, hasAtLeastOneReadReceipt: Bool) -> String { func message(hasAttachments: Bool, hasAtLeastOneReadReceipt: Bool) -> String {
switch self { switch self {
@ -58,6 +60,9 @@ public struct RecipientState: Codable, Equatable, FetchableRecord, PersistableRe
} }
return "MESSAGE_STATUS_READ".localized() return "MESSAGE_STATUS_READ".localized()
case .failedToSync: return "MESSAGE_DELIVERY_STATUS_FAILED_SYNC".localized()
case .syncing: return "MESSAGE_DELIVERY_STATUS_SYNCING".localized()
default: default:
owsFailDebug("Message has unexpected status: \(self).") owsFailDebug("Message has unexpected status: \(self).")
@ -96,6 +101,21 @@ public struct RecipientState: Codable, Equatable, FetchableRecord, PersistableRe
"MESSAGE_DELIVERY_STATUS_FAILED".localized(), "MESSAGE_DELIVERY_STATUS_FAILED".localized(),
.danger .danger
) )
case (.failedToSync, _):
return (
UIImage(systemName: "exclamationmark.triangle"),
"MESSAGE_DELIVERY_STATUS_FAILED_SYNC".localized(),
.warning
)
case (.syncing, _):
return (
UIImage(systemName: "ellipsis.circle"),
"MESSAGE_DELIVERY_STATUS_SYNCING".localized(),
.warning
)
} }
} }
} }
@ -148,21 +168,3 @@ public struct RecipientState: Codable, Equatable, FetchableRecord, PersistableRe
self.mostRecentFailureText = mostRecentFailureText self.mostRecentFailureText = mostRecentFailureText
} }
} }
// MARK: - Mutation
public extension RecipientState {
func with(
state: State? = nil,
readTimestampMs: Int64? = nil,
mostRecentFailureText: String? = nil
) -> RecipientState {
return RecipientState(
interactionId: interactionId,
recipientId: recipientId,
state: (state ?? self.state),
readTimestampMs: (readTimestampMs ?? self.readTimestampMs),
mostRecentFailureText: (mostRecentFailureText ?? self.mostRecentFailureText)
)
}
}

View file

@ -19,12 +19,16 @@ public enum FailedMessageSendsJob: JobExecutor {
) { ) {
// Update all 'sending' message states to 'failed' // Update all 'sending' message states to 'failed'
Storage.shared.write { db in Storage.shared.write { db in
let changeCount: Int = try RecipientState let sendChangeCount: Int = try RecipientState
.filter(RecipientState.Columns.state == RecipientState.State.sending) .filter(RecipientState.Columns.state == RecipientState.State.sending)
.updateAll(db, RecipientState.Columns.state.set(to: RecipientState.State.failed)) .updateAll(db, RecipientState.Columns.state.set(to: RecipientState.State.failed))
let syncChangeCount: Int = try RecipientState
.filter(RecipientState.Columns.state == RecipientState.State.syncing)
.updateAll(db, RecipientState.Columns.state.set(to: RecipientState.State.failedToSync))
let attachmentChangeCount: Int = try Attachment let attachmentChangeCount: Int = try Attachment
.filter(Attachment.Columns.state == Attachment.State.uploading) .filter(Attachment.Columns.state == Attachment.State.uploading)
.updateAll(db, Attachment.Columns.state.set(to: Attachment.State.failedUpload)) .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.failedUpload))
let changeCount: Int = (sendChangeCount + syncChangeCount)
SNLog("Marked \(changeCount) message\(changeCount == 1 ? "" : "s") as failed (\(attachmentChangeCount) upload\(attachmentChangeCount == 1 ? "" : "s") cancelled)") SNLog("Marked \(changeCount) message\(changeCount == 1 ? "" : "s") as failed (\(attachmentChangeCount) upload\(attachmentChangeCount == 1 ? "" : "s") cancelled)")
} }

View file

@ -41,7 +41,7 @@ public enum GarbageCollectionJob: JobExecutor {
/// are shown) /// are shown)
let lastGarbageCollection: Date = UserDefaults.standard[.lastGarbageCollection] let lastGarbageCollection: Date = UserDefaults.standard[.lastGarbageCollection]
.defaulting(to: Date.distantPast) .defaulting(to: Date.distantPast)
let finalTypesToCollection: Set<Types> = { let finalTypesToCollect: Set<Types> = {
guard guard
job.behaviour != .recurringOnActive || job.behaviour != .recurringOnActive ||
Date().timeIntervalSince(lastGarbageCollection) > (23 * 60 * 60) Date().timeIntervalSince(lastGarbageCollection) > (23 * 60 * 60)
@ -60,20 +60,20 @@ public enum GarbageCollectionJob: JobExecutor {
Storage.shared.writeAsync( Storage.shared.writeAsync(
updates: { db in updates: { db in
/// Remove any typing indicators /// Remove any typing indicators
if finalTypesToCollection.contains(.threadTypingIndicators) { if finalTypesToCollect.contains(.threadTypingIndicators) {
_ = try ThreadTypingIndicator _ = try ThreadTypingIndicator
.deleteAll(db) .deleteAll(db)
} }
/// Remove any expired controlMessageProcessRecords /// Remove any expired controlMessageProcessRecords
if finalTypesToCollection.contains(.expiredControlMessageProcessRecords) { if finalTypesToCollect.contains(.expiredControlMessageProcessRecords) {
_ = try ControlMessageProcessRecord _ = try ControlMessageProcessRecord
.filter(ControlMessageProcessRecord.Columns.serverExpirationTimestamp <= timestampNow) .filter(ControlMessageProcessRecord.Columns.serverExpirationTimestamp <= timestampNow)
.deleteAll(db) .deleteAll(db)
} }
/// Remove any old open group messages - open group messages which are older than six months /// Remove any old open group messages - open group messages which are older than six months
if finalTypesToCollection.contains(.oldOpenGroupMessages) && db[.trimOpenGroupMessagesOlderThanSixMonths] { if finalTypesToCollect.contains(.oldOpenGroupMessages) && db[.trimOpenGroupMessagesOlderThanSixMonths] {
let interaction: TypedTableAlias<Interaction> = TypedTableAlias() let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let thread: TypedTableAlias<SessionThread> = TypedTableAlias() let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
let threadIdLiteral: SQL = SQL(stringLiteral: Interaction.Columns.threadId.name) let threadIdLiteral: SQL = SQL(stringLiteral: Interaction.Columns.threadId.name)
@ -104,7 +104,7 @@ public enum GarbageCollectionJob: JobExecutor {
} }
/// Orphaned jobs - jobs which have had their threads or interactions removed /// Orphaned jobs - jobs which have had their threads or interactions removed
if finalTypesToCollection.contains(.orphanedJobs) { if finalTypesToCollect.contains(.orphanedJobs) {
let job: TypedTableAlias<Job> = TypedTableAlias() let job: TypedTableAlias<Job> = TypedTableAlias()
let thread: TypedTableAlias<SessionThread> = TypedTableAlias() let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
let interaction: TypedTableAlias<Interaction> = TypedTableAlias() let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
@ -130,7 +130,7 @@ public enum GarbageCollectionJob: JobExecutor {
} }
/// Orphaned link previews - link previews which have no interactions with matching url & rounded timestamps /// Orphaned link previews - link previews which have no interactions with matching url & rounded timestamps
if finalTypesToCollection.contains(.orphanedLinkPreviews) { if finalTypesToCollect.contains(.orphanedLinkPreviews) {
let linkPreview: TypedTableAlias<LinkPreview> = TypedTableAlias() let linkPreview: TypedTableAlias<LinkPreview> = TypedTableAlias()
let interaction: TypedTableAlias<Interaction> = TypedTableAlias() let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
@ -150,7 +150,7 @@ public enum GarbageCollectionJob: JobExecutor {
/// Orphaned open groups - open groups which are no longer associated to a thread (except for the session-run ones for which /// Orphaned open groups - open groups which are no longer associated to a thread (except for the session-run ones for which
/// we want cached image data even if the user isn't in the group) /// we want cached image data even if the user isn't in the group)
if finalTypesToCollection.contains(.orphanedOpenGroups) { if finalTypesToCollect.contains(.orphanedOpenGroups) {
let openGroup: TypedTableAlias<OpenGroup> = TypedTableAlias() let openGroup: TypedTableAlias<OpenGroup> = TypedTableAlias()
let thread: TypedTableAlias<SessionThread> = TypedTableAlias() let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
@ -169,7 +169,7 @@ public enum GarbageCollectionJob: JobExecutor {
} }
/// Orphaned open group capabilities - capabilities which have no existing open groups with the same server /// Orphaned open group capabilities - capabilities which have no existing open groups with the same server
if finalTypesToCollection.contains(.orphanedOpenGroupCapabilities) { if finalTypesToCollect.contains(.orphanedOpenGroupCapabilities) {
let capability: TypedTableAlias<Capability> = TypedTableAlias() let capability: TypedTableAlias<Capability> = TypedTableAlias()
let openGroup: TypedTableAlias<OpenGroup> = TypedTableAlias() let openGroup: TypedTableAlias<OpenGroup> = TypedTableAlias()
@ -185,7 +185,7 @@ public enum GarbageCollectionJob: JobExecutor {
} }
/// Orphaned blinded id lookups - lookups which have no existing threads or approval/block settings for either blinded/un-blinded id /// Orphaned blinded id lookups - lookups which have no existing threads or approval/block settings for either blinded/un-blinded id
if finalTypesToCollection.contains(.orphanedBlindedIdLookups) { if finalTypesToCollect.contains(.orphanedBlindedIdLookups) {
let blindedIdLookup: TypedTableAlias<BlindedIdLookup> = TypedTableAlias() let blindedIdLookup: TypedTableAlias<BlindedIdLookup> = TypedTableAlias()
let thread: TypedTableAlias<SessionThread> = TypedTableAlias() let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
let contact: TypedTableAlias<Contact> = TypedTableAlias() let contact: TypedTableAlias<Contact> = TypedTableAlias()
@ -213,7 +213,7 @@ public enum GarbageCollectionJob: JobExecutor {
/// Approved blinded contact records - once a blinded contact has been approved there is no need to keep the blinded /// Approved blinded contact records - once a blinded contact has been approved there is no need to keep the blinded
/// contact record around anymore /// contact record around anymore
if finalTypesToCollection.contains(.approvedBlindedContactRecords) { if finalTypesToCollect.contains(.approvedBlindedContactRecords) {
let contact: TypedTableAlias<Contact> = TypedTableAlias() let contact: TypedTableAlias<Contact> = TypedTableAlias()
let blindedIdLookup: TypedTableAlias<BlindedIdLookup> = TypedTableAlias() let blindedIdLookup: TypedTableAlias<BlindedIdLookup> = TypedTableAlias()
@ -232,7 +232,7 @@ public enum GarbageCollectionJob: JobExecutor {
} }
/// Orphaned attachments - attachments which have no related interactions, quotes or link previews /// Orphaned attachments - attachments which have no related interactions, quotes or link previews
if finalTypesToCollection.contains(.orphanedAttachments) { if finalTypesToCollect.contains(.orphanedAttachments) {
let attachment: TypedTableAlias<Attachment> = TypedTableAlias() let attachment: TypedTableAlias<Attachment> = TypedTableAlias()
let quote: TypedTableAlias<Quote> = TypedTableAlias() let quote: TypedTableAlias<Quote> = TypedTableAlias()
let linkPreview: TypedTableAlias<LinkPreview> = TypedTableAlias() let linkPreview: TypedTableAlias<LinkPreview> = TypedTableAlias()
@ -255,7 +255,7 @@ public enum GarbageCollectionJob: JobExecutor {
""") """)
} }
if finalTypesToCollection.contains(.orphanedProfiles) { if finalTypesToCollect.contains(.orphanedProfiles) {
let profile: TypedTableAlias<Profile> = TypedTableAlias() let profile: TypedTableAlias<Profile> = TypedTableAlias()
let thread: TypedTableAlias<SessionThread> = TypedTableAlias() let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
let interaction: TypedTableAlias<Interaction> = TypedTableAlias() let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
@ -289,6 +289,12 @@ public enum GarbageCollectionJob: JobExecutor {
) )
""") """)
} }
if finalTypesToCollect.contains(.expiredPendingReadReceipts) {
_ = try PendingReadReceipt
.filter(PendingReadReceipt.Columns.serverExpirationTimestamp <= timestampNow)
.deleteAll(db)
}
}, },
completion: { _, _ in completion: { _, _ in
// Dispatch async so we can swap from the write queue to a read one (we are done writing) // Dispatch async so we can swap from the write queue to a read one (we are done writing)
@ -304,7 +310,7 @@ public enum GarbageCollectionJob: JobExecutor {
var profileAvatarFilenames: Set<String> = [] var profileAvatarFilenames: Set<String> = []
/// Orphaned attachment files - attachment files which don't have an associated record in the database /// Orphaned attachment files - attachment files which don't have an associated record in the database
if finalTypesToCollection.contains(.orphanedAttachmentFiles) { if finalTypesToCollect.contains(.orphanedAttachmentFiles) {
/// **Note:** Thumbnails are stored in the `NSCachesDirectory` directory which should be automatically manage /// **Note:** Thumbnails are stored in the `NSCachesDirectory` directory which should be automatically manage
/// it's own garbage collection so we can just ignore it according to the various comments in the following stack overflow /// it's own garbage collection so we can just ignore it according to the various comments in the following stack overflow
/// post, the directory will be cleared during app updates as well as if the system is running low on memory (if the app isn't running) /// post, the directory will be cleared during app updates as well as if the system is running low on memory (if the app isn't running)
@ -317,7 +323,7 @@ public enum GarbageCollectionJob: JobExecutor {
} }
/// Orphaned profile avatar files - profile avatar files which don't have an associated record in the database /// Orphaned profile avatar files - profile avatar files which don't have an associated record in the database
if finalTypesToCollection.contains(.orphanedProfileAvatars) { if finalTypesToCollect.contains(.orphanedProfileAvatars) {
profileAvatarFilenames = try Profile profileAvatarFilenames = try Profile
.select(.profilePictureFileName) .select(.profilePictureFileName)
.filter(Profile.Columns.profilePictureFileName != nil) .filter(Profile.Columns.profilePictureFileName != nil)
@ -340,7 +346,7 @@ public enum GarbageCollectionJob: JobExecutor {
var deletionErrors: [Error] = [] var deletionErrors: [Error] = []
// Orphaned attachment files (actual deletion) // Orphaned attachment files (actual deletion)
if finalTypesToCollection.contains(.orphanedAttachmentFiles) { if finalTypesToCollect.contains(.orphanedAttachmentFiles) {
// Note: Looks like in order to recursively look through files we need to use the // Note: Looks like in order to recursively look through files we need to use the
// enumerator method // enumerator method
let fileEnumerator = FileManager.default.enumerator( let fileEnumerator = FileManager.default.enumerator(
@ -384,7 +390,7 @@ public enum GarbageCollectionJob: JobExecutor {
} }
// Orphaned profile avatar files (actual deletion) // Orphaned profile avatar files (actual deletion)
if finalTypesToCollection.contains(.orphanedProfileAvatars) { if finalTypesToCollect.contains(.orphanedProfileAvatars) {
let allAvatarProfileFilenames: Set<String> = (try? FileManager.default let allAvatarProfileFilenames: Set<String> = (try? FileManager.default
.contentsOfDirectory(atPath: ProfileManager.sharedDataProfileAvatarsDirPath)) .contentsOfDirectory(atPath: ProfileManager.sharedDataProfileAvatarsDirPath))
.defaulting(to: []) .defaulting(to: [])
@ -442,6 +448,7 @@ extension GarbageCollectionJob {
case orphanedAttachments case orphanedAttachments
case orphanedAttachmentFiles case orphanedAttachmentFiles
case orphanedProfileAvatars case orphanedProfileAvatars
case expiredPendingReadReceipts
} }
public struct Details: Codable { public struct Details: Codable {

View file

@ -36,6 +36,7 @@ public enum MessageReceiveJob: JobExecutor {
try MessageReceiver.handle( try MessageReceiver.handle(
db, db,
message: messageInfo.message, message: messageInfo.message,
serverExpirationTimestamp: messageInfo.serverExpirationTimestamp,
associatedWithProto: try SNProtoContent.parseData(messageInfo.serializedProtoData), associatedWithProto: try SNProtoContent.parseData(messageInfo.serializedProtoData),
openGroupId: nil openGroupId: nil
) )
@ -88,7 +89,7 @@ public enum MessageReceiveJob: JobExecutor {
failure(updatedJob, error, true) failure(updatedJob, error, true)
case .some(let error): case .some(let error):
failure(updatedJob, error, false) // TODO: Confirm the 'noKeyPair' errors here aren't an issue failure(updatedJob, error, false)
case .none: case .none:
success(updatedJob, false) success(updatedJob, false)
@ -104,30 +105,36 @@ extension MessageReceiveJob {
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case message case message
case variant case variant
case serverExpirationTimestamp
case serializedProtoData case serializedProtoData
} }
public let message: Message public let message: Message
public let variant: Message.Variant public let variant: Message.Variant
public let serverExpirationTimestamp: TimeInterval?
public let serializedProtoData: Data public let serializedProtoData: Data
public init( public init(
message: Message, message: Message,
variant: Message.Variant, variant: Message.Variant,
serverExpirationTimestamp: TimeInterval?,
proto: SNProtoContent proto: SNProtoContent
) throws { ) throws {
self.message = message self.message = message
self.variant = variant self.variant = variant
self.serverExpirationTimestamp = serverExpirationTimestamp
self.serializedProtoData = try proto.serializedData() self.serializedProtoData = try proto.serializedData()
} }
private init( private init(
message: Message, message: Message,
variant: Message.Variant, variant: Message.Variant,
serverExpirationTimestamp: TimeInterval?,
serializedProtoData: Data serializedProtoData: Data
) { ) {
self.message = message self.message = message
self.variant = variant self.variant = variant
self.serverExpirationTimestamp = serverExpirationTimestamp
self.serializedProtoData = serializedProtoData self.serializedProtoData = serializedProtoData
} }
@ -144,6 +151,7 @@ extension MessageReceiveJob {
self = MessageInfo( self = MessageInfo(
message: try variant.decode(from: container, forKey: .message), message: try variant.decode(from: container, forKey: .message),
variant: variant, variant: variant,
serverExpirationTimestamp: try? container.decode(TimeInterval.self, forKey: .serverExpirationTimestamp),
serializedProtoData: try container.decode(Data.self, forKey: .serializedProtoData) serializedProtoData: try container.decode(Data.self, forKey: .serializedProtoData)
) )
} }
@ -158,6 +166,7 @@ extension MessageReceiveJob {
try container.encode(message, forKey: .message) try container.encode(message, forKey: .message)
try container.encode(variant, forKey: .variant) try container.encode(variant, forKey: .variant)
try container.encodeIfPresent(serverExpirationTimestamp, forKey: .serverExpirationTimestamp)
try container.encode(serializedProtoData, forKey: .serializedProtoData) try container.encode(serializedProtoData, forKey: .serializedProtoData)
} }
} }

View file

@ -167,7 +167,8 @@ public enum MessageSendJob: JobExecutor {
message: details.message, message: details.message,
to: details.destination to: details.destination
.with(fileIds: messageFileIds), .with(fileIds: messageFileIds),
interactionId: job.interactionId interactionId: job.interactionId,
isSyncMessage: (details.isSyncMessage == true)
) )
} }
.done(on: queue) { _ in success(job, false) } .done(on: queue) { _ in success(job, false) }
@ -213,21 +214,25 @@ extension MessageSendJob {
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case destination case destination
case message case message
case isSyncMessage
case variant case variant
} }
public let destination: Message.Destination public let destination: Message.Destination
public let message: Message public let message: Message
public let isSyncMessage: Bool?
public let variant: Message.Variant? public let variant: Message.Variant?
// MARK: - Initialization // MARK: - Initialization
public init( public init(
destination: Message.Destination, destination: Message.Destination,
message: Message message: Message,
isSyncMessage: Bool? = nil
) { ) {
self.destination = destination self.destination = destination
self.message = message self.message = message
self.isSyncMessage = isSyncMessage
self.variant = Message.Variant(from: message) self.variant = Message.Variant(from: message)
} }
@ -243,7 +248,8 @@ extension MessageSendJob {
self = Details( self = Details(
destination: try container.decode(Message.Destination.self, forKey: .destination), destination: try container.decode(Message.Destination.self, forKey: .destination),
message: try variant.decode(from: container, forKey: .message) message: try variant.decode(from: container, forKey: .message),
isSyncMessage: try? container.decode(Bool.self, forKey: .isSyncMessage)
) )
} }
@ -257,6 +263,7 @@ extension MessageSendJob {
try container.encode(destination, forKey: .destination) try container.encode(destination, forKey: .destination)
try container.encode(message, forKey: .message) try container.encode(message, forKey: .message)
try container.encodeIfPresent(isSyncMessage, forKey: .isSyncMessage)
try container.encode(variant, forKey: .variant) try container.encode(variant, forKey: .variant)
} }
} }

View file

@ -43,7 +43,8 @@ public enum SendReadReceiptsJob: JobExecutor {
timestamps: details.timestampMsValues.map { UInt64($0) } timestamps: details.timestampMsValues.map { UInt64($0) }
), ),
to: details.destination, to: details.destination,
interactionId: nil interactionId: nil,
isSyncMessage: false
) )
} }
.done(on: queue) { .done(on: queue) {
@ -104,6 +105,7 @@ public extension SendReadReceiptsJob {
/// ensure that is done correctly beforehand /// ensure that is done correctly beforehand
@discardableResult static func createOrUpdateIfNeeded(_ db: Database, threadId: String, interactionIds: [Int64]) -> Job? { @discardableResult static func createOrUpdateIfNeeded(_ db: Database, threadId: String, interactionIds: [Int64]) -> Job? {
guard db[.areReadReceiptsEnabled] == true else { return nil } guard db[.areReadReceiptsEnabled] == true else { return nil }
guard !interactionIds.isEmpty else { return nil }
// Retrieve the timestampMs values for the specified interactions // Retrieve the timestampMs values for the specified interactions
let timestampMsValues: [Int64] = (try? Interaction let timestampMsValues: [Int64] = (try? Interaction

View file

@ -178,6 +178,11 @@ public extension Message {
static func shouldSync(message: Message) -> Bool { static func shouldSync(message: Message) -> Bool {
switch message { switch message {
case is VisibleMessage: return true
case is ExpirationTimerUpdate: return true
case is ConfigurationMessage: return true
case is UnsendRequest: return true
case let controlMessage as ClosedGroupControlMessage: case let controlMessage as ClosedGroupControlMessage:
switch controlMessage.kind { switch controlMessage.kind {
case .new: return true case .new: return true
@ -189,9 +194,7 @@ public extension Message {
case .answer, .endCall: return true case .answer, .endCall: return true
default: return false default: return false
} }
case is ConfigurationMessage: return true
case is UnsendRequest: return true
default: return false default: return false
} }
} }
@ -544,6 +547,7 @@ public extension Message {
try MessageReceiveJob.Details.MessageInfo( try MessageReceiveJob.Details.MessageInfo(
message: message, message: message,
variant: variant, variant: variant,
serverExpirationTimestamp: serverExpirationTimestamp,
proto: proto proto: proto
) )
) )

View file

@ -576,6 +576,7 @@ public final class OpenGroupManager: NSObject {
try MessageReceiver.handle( try MessageReceiver.handle(
db, db,
message: messageInfo.message, message: messageInfo.message,
serverExpirationTimestamp: messageInfo.serverExpirationTimestamp,
associatedWithProto: try SNProtoContent.parseData(messageInfo.serializedProtoData), associatedWithProto: try SNProtoContent.parseData(messageInfo.serializedProtoData),
openGroupId: openGroup.id, openGroupId: openGroup.id,
dependencies: dependencies dependencies: dependencies
@ -739,6 +740,7 @@ public final class OpenGroupManager: NSObject {
try MessageReceiver.handle( try MessageReceiver.handle(
db, db,
message: messageInfo.message, message: messageInfo.message,
serverExpirationTimestamp: messageInfo.serverExpirationTimestamp,
associatedWithProto: try SNProtoContent.parseData(messageInfo.serializedProtoData), associatedWithProto: try SNProtoContent.parseData(messageInfo.serializedProtoData),
openGroupId: nil, // Intentionally nil as they are technically not open group messages openGroupId: nil, // Intentionally nil as they are technically not open group messages
dependencies: dependencies dependencies: dependencies

View file

@ -4,16 +4,32 @@ import Foundation
import GRDB import GRDB
extension MessageReceiver { extension MessageReceiver {
internal static func handleReadReceipt(_ db: Database, message: ReadReceipt) throws { internal static func handleReadReceipt(
_ db: Database,
message: ReadReceipt,
serverExpirationTimestamp: TimeInterval?
) throws {
guard let sender: String = message.sender else { return } guard let sender: String = message.sender else { return }
guard let timestampMsValues: [Double] = message.timestamps?.map({ Double($0) }) else { return } guard let timestampMsValues: [Int64] = message.timestamps?.map({ Int64($0) }) else { return }
guard let readTimestampMs: Double = message.receivedTimestamp.map({ Double($0) }) else { return } guard let readTimestampMs: Int64 = message.receivedTimestamp.map({ Int64($0) }) else { return }
try Interaction.markAsRead( let pendingTimestampMs: Set<Int64> = try Interaction.markAsRead(
db, db,
recipientId: sender, recipientId: sender,
timestampMsValues: timestampMsValues, timestampMsValues: timestampMsValues,
readTimestampMs: readTimestampMs readTimestampMs: readTimestampMs
) )
guard !pendingTimestampMs.isEmpty else { return }
// We have some pending read receipts so store them in the database
try pendingTimestampMs.forEach { timestampMs in
try PendingReadReceipt(
threadId: sender,
interactionTimestampMs: timestampMs,
readTimestampMs: readTimestampMs,
serverExpirationTimestamp: (serverExpirationTimestamp ?? 0)
).save(db)
}
} }
} }

View file

@ -161,6 +161,7 @@ extension MessageReceiver {
db, db,
thread: thread, thread: thread,
interactionId: existingInteractionId, interactionId: existingInteractionId,
messageSentTimestamp: messageSentTimestamp,
variant: variant, variant: variant,
syncTarget: message.syncTarget syncTarget: message.syncTarget
) )
@ -178,6 +179,7 @@ extension MessageReceiver {
db, db,
thread: thread, thread: thread,
interactionId: interactionId, interactionId: interactionId,
messageSentTimestamp: messageSentTimestamp,
variant: variant, variant: variant,
syncTarget: message.syncTarget syncTarget: message.syncTarget
) )
@ -363,11 +365,19 @@ extension MessageReceiver {
_ db: Database, _ db: Database,
thread: SessionThread, thread: SessionThread,
interactionId: Int64, interactionId: Int64,
messageSentTimestamp: TimeInterval,
variant: Interaction.Variant, variant: Interaction.Variant,
syncTarget: String? syncTarget: String?
) throws { ) throws {
guard variant == .standardOutgoing else { return } guard variant == .standardOutgoing else { return }
// Immediately update any existing outgoing message 'RecipientState' records to be 'sent'
_ = try? RecipientState
.filter(RecipientState.Columns.interactionId == interactionId)
.filter(RecipientState.Columns.state != RecipientState.State.sent)
.updateAll(db, RecipientState.Columns.state.set(to: RecipientState.State.sent))
// Create any addiitonal 'RecipientState' records as needed
switch thread.variant { switch thread.variant {
case .contact: case .contact:
if let syncTarget: String = syncTarget { if let syncTarget: String = syncTarget {
@ -409,5 +419,22 @@ extension MessageReceiver {
includingOlder: true, includingOlder: true,
trySendReadReceipt: true trySendReadReceipt: true
) )
// Process any PendingReadReceipt values
let maybePendingReadReceipt: PendingReadReceipt? = try PendingReadReceipt
.filter(PendingReadReceipt.Columns.threadId == thread.id)
.filter(PendingReadReceipt.Columns.interactionTimestampMs == Int64(messageSentTimestamp * 1000))
.fetchOne(db)
if let pendingReadReceipt: PendingReadReceipt = maybePendingReadReceipt {
try Interaction.markAsRead(
db,
recipientId: thread.id,
timestampMsValues: [pendingReadReceipt.interactionTimestampMs],
readTimestampMs: pendingReadReceipt.readTimestampMs
)
_ = try pendingReadReceipt.delete(db)
}
} }
} }

View file

@ -179,13 +179,18 @@ public enum MessageReceiver {
public static func handle( public static func handle(
_ db: Database, _ db: Database,
message: Message, message: Message,
serverExpirationTimestamp: TimeInterval?,
associatedWithProto proto: SNProtoContent, associatedWithProto proto: SNProtoContent,
openGroupId: String?, openGroupId: String?,
dependencies: SMKDependencies = SMKDependencies() dependencies: SMKDependencies = SMKDependencies()
) throws { ) throws {
switch message { switch message {
case let message as ReadReceipt: case let message as ReadReceipt:
try MessageReceiver.handleReadReceipt(db, message: message) try MessageReceiver.handleReadReceipt(
db,
message: message,
serverExpirationTimestamp: serverExpirationTimestamp
)
case let message as TypingIndicator: case let message as TypingIndicator:
try MessageReceiver.handleTypingIndicator(db, message: message) try MessageReceiver.handleTypingIndicator(db, message: message)

View file

@ -9,7 +9,7 @@ extension MessageSender {
// MARK: - Durable // MARK: - Durable
public static func send(_ db: Database, interaction: Interaction, with attachments: [SignalAttachment], in thread: SessionThread) throws { public static func send(_ db: Database, interaction: Interaction, with attachments: [SignalAttachment], in thread: SessionThread, isSyncMessage: Bool = false) throws {
guard let interactionId: Int64 = interaction.id else { throw StorageError.objectNotSaved } guard let interactionId: Int64 = interaction.id else { throw StorageError.objectNotSaved }
try prep(db, signalAttachments: attachments, for: interactionId) try prep(db, signalAttachments: attachments, for: interactionId)
@ -18,11 +18,12 @@ extension MessageSender {
message: VisibleMessage.from(db, interaction: interaction), message: VisibleMessage.from(db, interaction: interaction),
threadId: thread.id, threadId: thread.id,
interactionId: interactionId, interactionId: interactionId,
to: try Message.Destination.from(db, thread: thread) to: try Message.Destination.from(db, thread: thread),
isSyncMessage: isSyncMessage
) )
} }
public static func send(_ db: Database, interaction: Interaction, in thread: SessionThread) throws { public static func send(_ db: Database, interaction: Interaction, in thread: SessionThread, isSyncMessage: Bool = false) throws {
// Only 'VisibleMessage' types can be sent via this method // Only 'VisibleMessage' types can be sent via this method
guard interaction.variant == .standardOutgoing else { throw MessageSenderError.invalidMessage } guard interaction.variant == .standardOutgoing else { throw MessageSenderError.invalidMessage }
guard let interactionId: Int64 = interaction.id else { throw StorageError.objectNotSaved } guard let interactionId: Int64 = interaction.id else { throw StorageError.objectNotSaved }
@ -32,21 +33,37 @@ extension MessageSender {
message: VisibleMessage.from(db, interaction: interaction), message: VisibleMessage.from(db, interaction: interaction),
threadId: thread.id, threadId: thread.id,
interactionId: interactionId, interactionId: interactionId,
to: try Message.Destination.from(db, thread: thread) to: try Message.Destination.from(db, thread: thread),
isSyncMessage: isSyncMessage
) )
} }
public static func send(_ db: Database, message: Message, interactionId: Int64?, in thread: SessionThread) throws { public static func send(_ db: Database, message: Message, interactionId: Int64?, in thread: SessionThread, isSyncMessage: Bool = false) throws {
send( send(
db, db,
message: message, message: message,
threadId: thread.id, threadId: thread.id,
interactionId: interactionId, interactionId: interactionId,
to: try Message.Destination.from(db, thread: thread) to: try Message.Destination.from(db, thread: thread),
isSyncMessage: isSyncMessage
) )
} }
public static func send(_ db: Database, message: Message, threadId: String?, interactionId: Int64?, to destination: Message.Destination) { public static func send(_ db: Database, message: Message, threadId: String?, interactionId: Int64?, to destination: Message.Destination, isSyncMessage: Bool = false) {
// If it's a sync message then we need to make some slight tweaks before sending so use the proper
// sync message sending process instead of the standard process
guard !isSyncMessage else {
scheduleSyncMessageIfNeeded(
db,
message: message,
destination: destination,
threadId: threadId,
interactionId: interactionId,
isAlreadySyncMessage: false
)
return
}
JobRunner.add( JobRunner.add(
db, db,
job: Job( job: Job(
@ -55,7 +72,8 @@ extension MessageSender {
interactionId: interactionId, interactionId: interactionId,
details: MessageSendJob.Details( details: MessageSendJob.Details(
destination: destination, destination: destination,
message: message message: message,
isSyncMessage: isSyncMessage
) )
) )
) )
@ -179,7 +197,8 @@ extension MessageSender {
message: message, message: message,
to: destination to: destination
.with(fileIds: fileIds), .with(fileIds: fileIds),
interactionId: interactionId interactionId: interactionId,
isSyncMessage: false
) )
} }
} }
@ -200,7 +219,7 @@ extension MessageSender {
if forceSyncNow { if forceSyncNow {
try MessageSender try MessageSender
.sendImmediate(db, message: configurationMessage, to: destination, interactionId: nil) .sendImmediate(db, message: configurationMessage, to: destination, interactionId: nil, isSyncMessage: false)
.done { seal.fulfill(()) } .done { seal.fulfill(()) }
.catch { _ in seal.reject(StorageError.generic) } .catch { _ in seal.reject(StorageError.generic) }
.retainUntilComplete() .retainUntilComplete()

View file

@ -42,10 +42,16 @@ public final class MessageSender {
// MARK: - Convenience // MARK: - Convenience
public static func sendImmediate(_ db: Database, message: Message, to destination: Message.Destination, interactionId: Int64?) throws -> Promise<Void> { public static func sendImmediate(
_ db: Database,
message: Message,
to destination: Message.Destination,
interactionId: Int64?,
isSyncMessage: Bool
) throws -> Promise<Void> {
switch destination { switch destination {
case .contact, .closedGroup: case .contact, .closedGroup:
return try sendToSnodeDestination(db, message: message, to: destination, interactionId: interactionId) return try sendToSnodeDestination(db, message: message, to: destination, interactionId: interactionId, isSyncMessage: isSyncMessage)
case .openGroup: case .openGroup:
return sendToOpenGroupDestination(db, message: message, to: destination, interactionId: interactionId) return sendToOpenGroupDestination(db, message: message, to: destination, interactionId: interactionId)
@ -65,7 +71,7 @@ public final class MessageSender {
isSyncMessage: Bool = false isSyncMessage: Bool = false
) throws -> Promise<Void> { ) throws -> Promise<Void> {
let (promise, seal) = Promise<Void>.pending() let (promise, seal) = Promise<Void>.pending()
let userPublicKey: String = getUserHexEncodedPublicKey(db) let currentUserPublicKey: String = getUserHexEncodedPublicKey(db)
let messageSendTimestamp: Int64 = SnodeAPI.currentOffsetTimestampMs() let messageSendTimestamp: Int64 = SnodeAPI.currentOffsetTimestampMs()
// Set the timestamp, sender and recipient // Set the timestamp, sender and recipient
@ -73,7 +79,7 @@ public final class MessageSender {
message.sentTimestamp ?? // Visible messages will already have their sent timestamp set message.sentTimestamp ?? // Visible messages will already have their sent timestamp set
UInt64(messageSendTimestamp) UInt64(messageSendTimestamp)
) )
message.sender = userPublicKey message.sender = currentUserPublicKey
message.recipient = { message.recipient = {
switch destination { switch destination {
case .contact(let publicKey): return publicKey case .contact(let publicKey): return publicKey
@ -84,7 +90,7 @@ public final class MessageSender {
// Set the failure handler (need it here already for precondition failure handling) // Set the failure handler (need it here already for precondition failure handling)
func handleFailure(_ db: Database, with error: MessageSenderError) { func handleFailure(_ db: Database, with error: MessageSenderError) {
MessageSender.handleFailedMessageSend(db, message: message, with: error, interactionId: interactionId) MessageSender.handleFailedMessageSend(db, message: message, with: error, interactionId: interactionId, isSyncMessage: isSyncMessage)
seal.reject(error) seal.reject(error)
} }
@ -94,21 +100,8 @@ public final class MessageSender {
return promise return promise
} }
// Stop here if this is a self-send, unless we should sync the message
let isSelfSend: Bool = (message.recipient == userPublicKey)
guard
!isSelfSend ||
isSyncMessage ||
Message.shouldSync(message: message)
else {
try MessageSender.handleSuccessfulMessageSend(db, message: message, to: destination, interactionId: interactionId)
seal.fulfill(())
return promise
}
// Attach the user's profile if needed // Attach the user's profile if needed
if var messageWithProfile: MessageWithProfile = message as? MessageWithProfile { if !isSyncMessage, var messageWithProfile: MessageWithProfile = message as? MessageWithProfile {
let profile: Profile = Profile.fetchOrCreateCurrentUser(db) let profile: Profile = Profile.fetchOrCreateCurrentUser(db)
if let profileKey: Data = profile.profileEncryptionKey?.keyData, let profilePictureUrl: String = profile.profilePictureUrl { if let profileKey: Data = profile.profileEncryptionKey?.keyData, let profilePictureUrl: String = profile.profilePictureUrl {
@ -123,6 +116,9 @@ public final class MessageSender {
} }
} }
// Perform any pre-send actions
handleMessageWillSend(db, message: message, interactionId: interactionId, isSyncMessage: isSyncMessage)
// Convert it to protobuf // Convert it to protobuf
guard let proto = message.toProto(db) else { guard let proto = message.toProto(db) else {
handleFailure(db, with: .protoConversionFailed) handleFailure(db, with: .protoConversionFailed)
@ -233,6 +229,9 @@ public final class MessageSender {
) )
let shouldNotify: Bool = { let shouldNotify: Bool = {
// Don't send a notification when sending messages in 'Note to Self'
guard message.recipient != currentUserPublicKey else { return false }
switch message { switch message {
case is VisibleMessage, is UnsendRequest: return !isSyncMessage case is VisibleMessage, is UnsendRequest: return !isSyncMessage
case let callMessage as CallMessage: case let callMessage as CallMessage:
@ -402,6 +401,9 @@ public final class MessageSender {
return promise return promise
} }
// Perform any pre-send actions
handleMessageWillSend(db, message: message, interactionId: interactionId)
// Convert it to protobuf // Convert it to protobuf
guard let proto = message.toProto(db) else { guard let proto = message.toProto(db) else {
handleFailure(db, with: .protoConversionFailed) handleFailure(db, with: .protoConversionFailed)
@ -465,7 +467,7 @@ public final class MessageSender {
dependencies: SMKDependencies = SMKDependencies() dependencies: SMKDependencies = SMKDependencies()
) -> Promise<Void> { ) -> Promise<Void> {
let (promise, seal) = Promise<Void>.pending() let (promise, seal) = Promise<Void>.pending()
let userPublicKey: String = getUserHexEncodedPublicKey(db, dependencies: dependencies) let currentUserPublicKey: String = getUserHexEncodedPublicKey(db, dependencies: dependencies)
guard case .openGroupInbox(let server, let openGroupPublicKey, let recipientBlindedPublicKey) = destination else { guard case .openGroupInbox(let server, let openGroupPublicKey, let recipientBlindedPublicKey) = destination else {
preconditionFailure() preconditionFailure()
@ -476,7 +478,7 @@ public final class MessageSender {
message.sentTimestamp = UInt64(SnodeAPI.currentOffsetTimestampMs()) message.sentTimestamp = UInt64(SnodeAPI.currentOffsetTimestampMs())
} }
message.sender = userPublicKey message.sender = currentUserPublicKey
message.recipient = recipientBlindedPublicKey message.recipient = recipientBlindedPublicKey
// Set the failure handler (need it here already for precondition failure handling) // Set the failure handler (need it here already for precondition failure handling)
@ -501,6 +503,9 @@ public final class MessageSender {
} }
} }
// Perform any pre-send actions
handleMessageWillSend(db, message: message, interactionId: interactionId)
// Convert it to protobuf // Convert it to protobuf
guard let proto = message.toProto(db) else { guard let proto = message.toProto(db) else {
handleFailure(db, with: .protoConversionFailed) handleFailure(db, with: .protoConversionFailed)
@ -569,6 +574,32 @@ public final class MessageSender {
// MARK: Success & Failure Handling // MARK: Success & Failure Handling
public static func handleMessageWillSend(
_ db: Database,
message: Message,
interactionId: Int64?,
isSyncMessage: Bool = false
) {
// If the message was a reaction then we don't want to do anything to the original
// interaction (which the 'interactionId' is pointing to
guard (message as? VisibleMessage)?.reaction == nil else { return }
// Mark messages as "sending"/"syncing" if needed (this is for retries)
_ = try? RecipientState
.filter(RecipientState.Columns.interactionId == interactionId)
.filter(isSyncMessage ?
RecipientState.Columns.state == RecipientState.State.failedToSync :
RecipientState.Columns.state == RecipientState.State.failed
)
.updateAll(
db,
RecipientState.Columns.state.set(to: isSyncMessage ?
RecipientState.State.syncing :
RecipientState.State.sending
)
)
}
private static func handleSuccessfulMessageSend( private static func handleSuccessfulMessageSend(
_ db: Database, _ db: Database,
message: Message, message: Message,
@ -578,7 +609,7 @@ public final class MessageSender {
isSyncMessage: Bool = false isSyncMessage: Bool = false
) throws { ) throws {
// If the message was a reaction then we want to update the reaction instead of the original // If the message was a reaction then we want to update the reaction instead of the original
// interaciton (which the 'interactionId' is pointing to // interaction (which the 'interactionId' is pointing to
if let visibleMessage: VisibleMessage = message as? VisibleMessage, let reaction: VisibleMessage.VMReaction = visibleMessage.reaction { if let visibleMessage: VisibleMessage = message as? VisibleMessage, let reaction: VisibleMessage.VMReaction = visibleMessage.reaction {
try Reaction try Reaction
.filter(Reaction.Columns.interactionId == interactionId) .filter(Reaction.Columns.interactionId == interactionId)
@ -610,6 +641,7 @@ public final class MessageSender {
// Mark the message as sent // Mark the message as sent
try interaction.recipientStates try interaction.recipientStates
.filter(RecipientState.Columns.state != RecipientState.State.sent)
.updateAll(db, RecipientState.Columns.state.set(to: RecipientState.State.sent)) .updateAll(db, RecipientState.Columns.state.set(to: RecipientState.State.sent))
// Start the disappearing messages timer if needed // Start the disappearing messages timer if needed
@ -624,18 +656,20 @@ public final class MessageSender {
} }
} }
let threadId: String = {
switch destination {
case .contact(let publicKey): return publicKey
case .closedGroup(let groupPublicKey): return groupPublicKey
case .openGroup(let roomToken, let server, _, _, _):
return OpenGroup.idFor(roomToken: roomToken, server: server)
case .openGroupInbox(_, _, let blindedPublicKey): return blindedPublicKey
}
}()
// Prevent ControlMessages from being handled multiple times if not supported // Prevent ControlMessages from being handled multiple times if not supported
try? ControlMessageProcessRecord( try? ControlMessageProcessRecord(
threadId: { threadId: threadId,
switch destination {
case .contact(let publicKey): return publicKey
case .closedGroup(let groupPublicKey): return groupPublicKey
case .openGroup(let roomToken, let server, _, _, _):
return OpenGroup.idFor(roomToken: roomToken, server: server)
case .openGroupInbox(_, _, let blindedPublicKey): return blindedPublicKey
}
}(),
message: message, message: message,
serverExpirationTimestamp: ( serverExpirationTimestamp: (
(TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000) + (TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000) +
@ -643,35 +677,27 @@ public final class MessageSender {
) )
)?.insert(db) )?.insert(db)
// Sync the message if: // Sync the message if needed
// it's a visible message or an expiration timer update scheduleSyncMessageIfNeeded(
// the destination was a contact db,
// we didn't sync it already message: message,
let userPublicKey = getUserHexEncodedPublicKey(db) destination: destination,
if case .contact(let publicKey) = destination, !isSyncMessage { threadId: threadId,
if let message = message as? VisibleMessage { message.syncTarget = publicKey } interactionId: interactionId,
if let message = message as? ExpirationTimerUpdate { message.syncTarget = publicKey } isAlreadySyncMessage: isSyncMessage
)
// FIXME: Make this a job
try sendToSnodeDestination(
db,
message: message,
to: .contact(publicKey: userPublicKey),
interactionId: interactionId,
isSyncMessage: true
).retainUntilComplete()
}
} }
public static func handleFailedMessageSend( public static func handleFailedMessageSend(
_ db: Database, _ db: Database,
message: Message, message: Message,
with error: MessageSenderError, with error: MessageSenderError,
interactionId: Int64? interactionId: Int64?,
isSyncMessage: Bool = false
) { ) {
// TODO: Revert the local database change // TODO: Revert the local database change
// If the message was a reaction then we don't want to do anything to the original // If the message was a reaction then we don't want to do anything to the original
// interaciton (which the 'interactionId' is pointing to // interaction (which the 'interactionId' is pointing to
guard (message as? VisibleMessage)?.reaction == nil else { return } guard (message as? VisibleMessage)?.reaction == nil else { return }
// Check if we need to mark any "sending" recipients as "failed" // Check if we need to mark any "sending" recipients as "failed"
@ -682,7 +708,12 @@ public final class MessageSender {
let rowIds: [Int64] = (try? RecipientState let rowIds: [Int64] = (try? RecipientState
.select(Column.rowID) .select(Column.rowID)
.filter(RecipientState.Columns.interactionId == interactionId) .filter(RecipientState.Columns.interactionId == interactionId)
.filter(RecipientState.Columns.state == RecipientState.State.sending) .filter(!isSyncMessage ?
RecipientState.Columns.state == RecipientState.State.sending : (
RecipientState.Columns.state == RecipientState.State.syncing ||
RecipientState.Columns.state == RecipientState.State.sent
)
)
.asRequest(of: Int64.self) .asRequest(of: Int64.self)
.fetchAll(db)) .fetchAll(db))
.defaulting(to: []) .defaulting(to: [])
@ -697,7 +728,9 @@ public final class MessageSender {
.filter(rowIds.contains(Column.rowID)) .filter(rowIds.contains(Column.rowID))
.updateAll( .updateAll(
db, db,
RecipientState.Columns.state.set(to: RecipientState.State.failed), RecipientState.Columns.state.set(
to: (isSyncMessage ? RecipientState.State.failedToSync : RecipientState.State.failed)
),
RecipientState.Columns.mostRecentFailureText.set(to: error.localizedDescription) RecipientState.Columns.mostRecentFailureText.set(to: error.localizedDescription)
) )
} }
@ -719,6 +752,45 @@ public final class MessageSender {
return nil return nil
} }
public static func scheduleSyncMessageIfNeeded(
_ db: Database,
message: Message,
destination: Message.Destination,
threadId: String?,
interactionId: Int64?,
isAlreadySyncMessage: Bool
) {
// Sync the message if it's not a sync message, wasn't already sent to the current user and
// it's a message type which should be synced
let currentUserPublicKey = getUserHexEncodedPublicKey(db)
if
case .contact(let publicKey) = destination,
!isAlreadySyncMessage,
publicKey != currentUserPublicKey,
Message.shouldSync(message: message)
{
if let message = message as? VisibleMessage { message.syncTarget = publicKey }
if let message = message as? ExpirationTimerUpdate { message.syncTarget = publicKey }
Storage.shared.write { db in
JobRunner.add(
db,
job: Job(
variant: .messageSend,
threadId: threadId,
interactionId: interactionId,
details: MessageSendJob.Details(
destination: .contact(publicKey: currentUserPublicKey),
message: message,
isSyncMessage: true
)
)
)
}
}
}
} }
// MARK: - Objective-C Support // MARK: - Objective-C Support

View file

@ -29,75 +29,98 @@ public extension MentionInfo {
let profile: TypedTableAlias<Profile> = TypedTableAlias() let profile: TypedTableAlias<Profile> = TypedTableAlias()
let interaction: TypedTableAlias<Interaction> = TypedTableAlias() let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let openGroup: TypedTableAlias<OpenGroup> = TypedTableAlias() let openGroup: TypedTableAlias<OpenGroup> = TypedTableAlias()
let groupMember: TypedTableAlias<GroupMember> = TypedTableAlias()
let prefixLiteral: SQL = SQL(stringLiteral: "\(targetPrefix.rawValue)%") let prefixLiteral: SQL = SQL(stringLiteral: "\(targetPrefix.rawValue)%")
let profileFullTextSearch: SQL = SQL(stringLiteral: Profile.fullTextSearchTableName) let profileFullTextSearch: SQL = SQL(stringLiteral: Profile.fullTextSearchTableName)
/// **Note:** The `\(MentionInfo.profileKey).*` value **MUST** be first /// The query needs to differ depending on the thread variant because the behaviour should be different:
let limitSQL: SQL? = (threadVariant == .openGroup ? SQL("LIMIT 20") : nil) ///
/// **Contact:** We should show the profile directly (filtered out if the pattern doesn't match)
/// **Closed Group:** We should show all profiles within the group, filtered by the pattern
/// **Open Group:** We should show only the 20 most recent profiles which match the pattern
let request: SQLRequest<MentionInfo> = { let request: SQLRequest<MentionInfo> = {
guard let pattern: FTS5Pattern = pattern else { let hasValidPattern: Bool = (pattern != nil && pattern?.rawPattern != "\"\"*")
let finalLimitSQL: SQL = (limitSQL ?? "") let targetJoin: SQL = {
guard hasValidPattern else { return "FROM \(Profile.self)" }
return """ return """
SELECT FROM \(profileFullTextSearch)
\(Profile.self).*, JOIN \(Profile.self) ON (
MAX(\(interaction[.timestampMs])), -- Want the newest interaction (for sorting) \(Profile.self).rowid = \(profileFullTextSearch).rowid AND
\(SQL("\(threadVariant) AS \(MentionInfo.threadVariantKey)")),
\(openGroup[.server]) AS \(MentionInfo.openGroupServerKey),
\(openGroup[.roomToken]) AS \(MentionInfo.openGroupRoomTokenKey)
FROM \(Profile.self)
JOIN \(Interaction.self) ON (
\(SQL("\(interaction[.threadId]) = \(threadId)")) AND
\(interaction[.authorId]) = \(profile[.id])
)
LEFT JOIN \(OpenGroup.self) ON \(SQL("\(openGroup[.threadId]) = \(threadId)"))
WHERE (
\(SQL("\(profile[.id]) != \(userPublicKey)")) AND ( \(SQL("\(profile[.id]) != \(userPublicKey)")) AND (
\(SQL("\(threadVariant) != \(SessionThread.Variant.openGroup)")) OR \(SQL("\(threadVariant) != \(SessionThread.Variant.openGroup)")) OR
\(SQL("\(profile[.id]) LIKE '\(prefixLiteral)'")) \(SQL("\(profile[.id]) LIKE '\(prefixLiteral)'"))
) )
) )
GROUP BY \(profile[.id])
ORDER BY \(interaction[.timestampMs].desc)
\(finalLimitSQL)
""" """
} }()
let targetWhere: SQL = {
// If we do have a search patern then use FTS guard let pattern: FTS5Pattern = pattern, pattern.rawPattern != "\"\"*" else {
let matchLiteral: SQL = SQL(stringLiteral: "\(Profile.Columns.nickname.name):\(pattern.rawPattern) OR \(Profile.Columns.name.name):\(pattern.rawPattern)") return """
let finalLimitSQL: SQL = (limitSQL ?? "") WHERE (
\(SQL("\(profile[.id]) != \(userPublicKey)")) AND (
return """ \(SQL("\(threadVariant) != \(SessionThread.Variant.openGroup)")) OR
SELECT \(SQL("\(profile[.id]) LIKE '\(prefixLiteral)'"))
\(Profile.self).*, )
MAX(\(interaction[.timestampMs])), -- Want the newest interaction (for sorting) )
\(SQL("\(threadVariant) AS \(MentionInfo.threadVariantKey)")), """
\(openGroup[.server]) AS \(MentionInfo.openGroupServerKey), }
\(openGroup[.roomToken]) AS \(MentionInfo.openGroupRoomTokenKey)
FROM \(profileFullTextSearch) let matchLiteral: SQL = SQL(stringLiteral: "\(Profile.Columns.nickname.name):\(pattern.rawPattern) OR \(Profile.Columns.name.name):\(pattern.rawPattern)")
JOIN \(Profile.self) ON (
\(Profile.self).rowid = \(profileFullTextSearch).rowid AND return "WHERE \(profileFullTextSearch) MATCH '\(matchLiteral)'"
\(SQL("\(profile[.id]) != \(userPublicKey)")) AND ( }()
\(SQL("\(threadVariant) != \(SessionThread.Variant.openGroup)")) OR
\(SQL("\(profile[.id]) LIKE '\(prefixLiteral)'"))
)
)
JOIN \(Interaction.self) ON (
\(SQL("\(interaction[.threadId]) = \(threadId)")) AND
\(interaction[.authorId]) = \(profile[.id])
)
LEFT JOIN \(OpenGroup.self) ON \(SQL("\(openGroup[.threadId]) = \(threadId)"))
WHERE \(profileFullTextSearch) MATCH '\(matchLiteral)' switch threadVariant {
GROUP BY \(profile[.id]) case .contact:
ORDER BY \(interaction[.timestampMs].desc) return SQLRequest("""
\(finalLimitSQL) SELECT
""" \(Profile.self).*,
\(SQL("\(threadVariant) AS \(MentionInfo.threadVariantKey)"))
\(targetJoin)
\(targetWhere) AND \(SQL("\(profile[.id]) = \(threadId)"))
""")
case .closedGroup:
return SQLRequest("""
SELECT
\(Profile.self).*,
\(SQL("\(threadVariant) AS \(MentionInfo.threadVariantKey)"))
\(targetJoin)
JOIN \(GroupMember.self) ON (
\(SQL("\(groupMember[.groupId]) = \(threadId)")) AND
\(groupMember[.profileId]) = \(profile[.id]) AND
\(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)"))
)
\(targetWhere)
GROUP BY \(profile[.id])
ORDER BY IFNULL(\(profile[.nickname]), \(profile[.name])) ASC
""")
case .openGroup:
return SQLRequest("""
SELECT
\(Profile.self).*,
MAX(\(interaction[.timestampMs])), -- Want the newest interaction (for sorting)
\(SQL("\(threadVariant) AS \(MentionInfo.threadVariantKey)")),
\(openGroup[.server]) AS \(MentionInfo.openGroupServerKey),
\(openGroup[.roomToken]) AS \(MentionInfo.openGroupRoomTokenKey)
\(targetJoin)
JOIN \(Interaction.self) ON (
\(SQL("\(interaction[.threadId]) = \(threadId)")) AND
\(interaction[.authorId]) = \(profile[.id])
)
JOIN \(OpenGroup.self) ON \(SQL("\(openGroup[.threadId]) = \(threadId)"))
\(targetWhere)
GROUP BY \(profile[.id])
ORDER BY \(interaction[.timestampMs].desc)
LIMIT 20
""")
}
}() }()
return request.adapted { db in return request.adapted { db in

View file

@ -39,6 +39,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable,
public static let positionInClusterKey: SQL = SQL(stringLiteral: CodingKeys.positionInCluster.stringValue) public static let positionInClusterKey: SQL = SQL(stringLiteral: CodingKeys.positionInCluster.stringValue)
public static let isOnlyMessageInClusterKey: SQL = SQL(stringLiteral: CodingKeys.isOnlyMessageInCluster.stringValue) public static let isOnlyMessageInClusterKey: SQL = SQL(stringLiteral: CodingKeys.isOnlyMessageInCluster.stringValue)
public static let isLastKey: SQL = SQL(stringLiteral: CodingKeys.isLast.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 profileString: String = CodingKeys.profile.stringValue
public static let quoteString: String = CodingKeys.quote.stringValue public static let quoteString: String = CodingKeys.quote.stringValue
@ -140,6 +141,8 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable,
/// This value indicates whether this is the last message in the thread /// This value indicates whether this is the last message in the thread
public let isLast: Bool public let isLast: Bool
public let isLastOutgoing: Bool
/// This is the users blinded key (will only be set for messages within open groups) /// This is the users blinded key (will only be set for messages within open groups)
public let currentUserBlindedPublicKey: String? public let currentUserBlindedPublicKey: String?
@ -191,6 +194,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable,
positionInCluster: self.positionInCluster, positionInCluster: self.positionInCluster,
isOnlyMessageInCluster: self.isOnlyMessageInCluster, isOnlyMessageInCluster: self.isOnlyMessageInCluster,
isLast: self.isLast, isLast: self.isLast,
isLastOutgoing: self.isLastOutgoing,
currentUserBlindedPublicKey: self.currentUserBlindedPublicKey currentUserBlindedPublicKey: self.currentUserBlindedPublicKey
) )
} }
@ -199,6 +203,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable,
prevModel: MessageViewModel?, prevModel: MessageViewModel?,
nextModel: MessageViewModel?, nextModel: MessageViewModel?,
isLast: Bool, isLast: Bool,
isLastOutgoing: Bool,
currentUserBlindedPublicKey: String? currentUserBlindedPublicKey: String?
) -> MessageViewModel { ) -> MessageViewModel {
let cellType: CellType = { let cellType: CellType = {
@ -403,6 +408,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable,
positionInCluster: positionInCluster, positionInCluster: positionInCluster,
isOnlyMessageInCluster: isOnlyMessageInCluster, isOnlyMessageInCluster: isOnlyMessageInCluster,
isLast: isLast, isLast: isLast,
isLastOutgoing: isLastOutgoing,
currentUserBlindedPublicKey: currentUserBlindedPublicKey currentUserBlindedPublicKey: currentUserBlindedPublicKey
) )
} }
@ -498,7 +504,8 @@ public extension MessageViewModel {
quote: Quote? = nil, quote: Quote? = nil,
cellType: CellType = .typingIndicator, cellType: CellType = .typingIndicator,
isTypingIndicator: Bool? = nil, isTypingIndicator: Bool? = nil,
isLast: Bool = true isLast: Bool = true,
isLastOutgoing: Bool = false
) { ) {
self.threadId = "INVALID_THREAD_ID" self.threadId = "INVALID_THREAD_ID"
self.threadVariant = .contact self.threadVariant = .contact
@ -554,6 +561,7 @@ public extension MessageViewModel {
self.positionInCluster = .middle self.positionInCluster = .middle
self.isOnlyMessageInCluster = true self.isOnlyMessageInCluster = true
self.isLast = isLast self.isLast = isLast
self.isLastOutgoing = isLastOutgoing
self.currentUserBlindedPublicKey = nil self.currentUserBlindedPublicKey = nil
} }
} }
@ -700,7 +708,8 @@ public extension MessageViewModel {
false AS \(ViewModel.shouldShowDateHeaderKey), false AS \(ViewModel.shouldShowDateHeaderKey),
\(Position.middle) AS \(ViewModel.positionInClusterKey), \(Position.middle) AS \(ViewModel.positionInClusterKey),
false AS \(ViewModel.isOnlyMessageInClusterKey), false AS \(ViewModel.isOnlyMessageInClusterKey),
false AS \(ViewModel.isLastKey) false AS \(ViewModel.isLastKey),
false AS \(ViewModel.isLastOutgoingKey)
FROM \(Interaction.self) FROM \(Interaction.self)
JOIN \(SessionThread.self) ON \(thread[.id]) = \(interaction[.threadId]) JOIN \(SessionThread.self) ON \(thread[.id]) = \(interaction[.threadId])

View file

@ -1607,15 +1607,14 @@ public extension SessionThreadViewModel {
\(SQL("\(thread[.variant]) != \(SessionThread.Variant.contact)")) OR \(SQL("\(thread[.variant]) != \(SessionThread.Variant.contact)")) OR
\(SQL("\(thread[.id]) = \(userPublicKey)")) OR \(SQL("\(thread[.id]) = \(userPublicKey)")) OR
\(contact[.isApproved]) = true \(contact[.isApproved]) = true
) AND (
-- Only show the 'Note to Self' thread if it has an interaction
\(SQL("\(thread[.id]) != \(userPublicKey)")) OR
\(interaction[.id]) IS NOT NULL
) )
-- Always show the 'Note to Self' thread when sharing
OR \(SQL("\(thread[.id]) = \(userPublicKey)"))
) )
GROUP BY \(thread[.id]) GROUP BY \(thread[.id])
ORDER BY IFNULL(\(interaction[.timestampMs]), (\(thread[.creationDateTimestamp]) * 1000)) DESC -- 'Note to Self', then by most recent message
ORDER BY \(SQL("\(thread[.id]) = \(userPublicKey)")) DESC, IFNULL(\(interaction[.timestampMs]), (\(thread[.creationDateTimestamp]) * 1000)) DESC
""" """
return request.adapted { db in return request.adapted { db in

View file

@ -33,6 +33,11 @@ public extension Setting.BoolKey {
/// **Note:** Link Previews are only enabled for HTTPS urls /// **Note:** Link Previews are only enabled for HTTPS urls
static let areLinkPreviewsEnabled: Setting.BoolKey = "areLinkPreviewsEnabled" static let areLinkPreviewsEnabled: Setting.BoolKey = "areLinkPreviewsEnabled"
/// Controls whether Giphy search is enabled
///
/// **Note:** Link Previews are only enabled for HTTPS urls
static let isGiphyEnabled: Setting.BoolKey = "isGiphyEnabled"
/// Controls whether Calls are enabled /// Controls whether Calls are enabled
static let areCallsEnabled: Setting.BoolKey = "areCallsEnabled" static let areCallsEnabled: Setting.BoolKey = "areCallsEnabled"

View file

@ -10,6 +10,7 @@ internal enum Theme_ClassicDark: ThemeColors {
.clear: .clear, .clear: .clear,
.primary: .primary, .primary: .primary,
.defaultPrimary: Theme.PrimaryColor.green.color, .defaultPrimary: Theme.PrimaryColor.green.color,
.warning: .warning,
.danger: .dangerDark, .danger: .dangerDark,
.disabled: .disabledDark, .disabled: .disabledDark,
.backgroundPrimary: .classicDark0, .backgroundPrimary: .classicDark0,

View file

@ -10,6 +10,7 @@ internal enum Theme_ClassicLight: ThemeColors {
.clear: .clear, .clear: .clear,
.primary: .primary, .primary: .primary,
.defaultPrimary: Theme.PrimaryColor.green.color, .defaultPrimary: Theme.PrimaryColor.green.color,
.warning: .warning,
.danger: .dangerLight, .danger: .dangerLight,
.disabled: .disabledLight, .disabled: .disabledLight,
.backgroundPrimary: .classicLight6, .backgroundPrimary: .classicLight6,

View file

@ -41,6 +41,7 @@ public extension Theme {
// MARK: - Standard Theme Colors // MARK: - Standard Theme Colors
internal extension UIColor { internal extension UIColor {
static let warning: UIColor = #colorLiteral(red: 0.9882352941, green: 0.6941176471, blue: 0.3490196078, alpha: 1) // #FCB159
static let dangerDark: UIColor = #colorLiteral(red: 1, green: 0.2274509804, blue: 0.2274509804, alpha: 1) // #FF3A3A static let dangerDark: UIColor = #colorLiteral(red: 1, green: 0.2274509804, blue: 0.2274509804, alpha: 1) // #FF3A3A
static let dangerLight: UIColor = #colorLiteral(red: 0.8823529412, green: 0.1764705882, blue: 0.09803921569, alpha: 1) // #E12D19 static let dangerLight: UIColor = #colorLiteral(red: 0.8823529412, green: 0.1764705882, blue: 0.09803921569, alpha: 1) // #E12D19
static let disabledDark: UIColor = #colorLiteral(red: 0.631372549, green: 0.6352941176, blue: 0.631372549, alpha: 1) // #A1A2A1 static let disabledDark: UIColor = #colorLiteral(red: 0.631372549, green: 0.6352941176, blue: 0.631372549, alpha: 1) // #A1A2A1

View file

@ -10,6 +10,7 @@ internal enum Theme_OceanDark: ThemeColors {
.clear: .clear, .clear: .clear,
.primary: .primary, .primary: .primary,
.defaultPrimary: Theme.PrimaryColor.blue.color, .defaultPrimary: Theme.PrimaryColor.blue.color,
.warning: .warning,
.danger: .dangerDark, .danger: .dangerDark,
.disabled: .disabledDark, .disabled: .disabledDark,
.backgroundPrimary: .oceanDark2, .backgroundPrimary: .oceanDark2,

View file

@ -10,6 +10,7 @@ internal enum Theme_OceanLight: ThemeColors {
.clear: .clear, .clear: .clear,
.primary: .primary, .primary: .primary,
.defaultPrimary: Theme.PrimaryColor.blue.color, .defaultPrimary: Theme.PrimaryColor.blue.color,
.warning: .warning,
.danger: .dangerLight, .danger: .dangerLight,
.disabled: .disabledLight, .disabled: .disabledLight,
.backgroundPrimary: .oceanLight7, .backgroundPrimary: .oceanLight7,

View file

@ -98,6 +98,7 @@ public indirect enum ThemeValue: Hashable {
case clear case clear
case primary case primary
case defaultPrimary case defaultPrimary
case warning
case danger case danger
case disabled case disabled
case backgroundPrimary case backgroundPrimary