mirror of
https://github.com/oxen-io/session-ios.git
synced 2023-12-13 21:30:14 +01:00
Merge branch 'dev' into switch-video-view
This commit is contained in:
commit
a14a99896b
|
@ -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;
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 = (
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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.";
|
||||||
|
|
|
@ -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.";
|
||||||
|
|
|
@ -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.";
|
||||||
|
|
|
@ -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.";
|
||||||
|
|
|
@ -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.";
|
||||||
|
|
|
@ -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.";
|
||||||
|
|
|
@ -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.";
|
||||||
|
|
|
@ -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.";
|
||||||
|
|
|
@ -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.";
|
||||||
|
|
|
@ -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.";
|
||||||
|
|
|
@ -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.";
|
||||||
|
|
|
@ -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.";
|
||||||
|
|
|
@ -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.";
|
||||||
|
|
|
@ -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.";
|
||||||
|
|
|
@ -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.";
|
||||||
|
|
|
@ -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.";
|
||||||
|
|
|
@ -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.";
|
||||||
|
|
|
@ -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.";
|
||||||
|
|
|
@ -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.";
|
||||||
|
|
|
@ -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.";
|
||||||
|
|
|
@ -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.";
|
||||||
|
|
|
@ -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.";
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
44
SessionMessagingKit/Database/Models/PendingReadReceipt.swift
Normal file
44
SessionMessagingKit/Database/Models/PendingReadReceipt.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -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)")
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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])
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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"
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue