Merge remote-tracking branch 'upstream/dev' into feature/updated-user-config-handling
# Conflicts: # Session.xcodeproj/project.pbxproj # Session/Conversations/ConversationVC+Interaction.swift # Session/Conversations/ConversationViewModel.swift # Session/Media Viewing & Editing/GIFs/GifPickerViewController.swift # Session/Media Viewing & Editing/GIFs/GiphyAPI.swift # Session/Meta/Translations/de.lproj/Localizable.strings # Session/Meta/Translations/en.lproj/Localizable.strings # Session/Meta/Translations/es.lproj/Localizable.strings # Session/Meta/Translations/fa.lproj/Localizable.strings # Session/Meta/Translations/fi.lproj/Localizable.strings # Session/Meta/Translations/fr.lproj/Localizable.strings # Session/Meta/Translations/hi.lproj/Localizable.strings # Session/Meta/Translations/hr.lproj/Localizable.strings # Session/Meta/Translations/id-ID.lproj/Localizable.strings # Session/Meta/Translations/it.lproj/Localizable.strings # Session/Meta/Translations/ja.lproj/Localizable.strings # Session/Meta/Translations/nl.lproj/Localizable.strings # Session/Meta/Translations/pl.lproj/Localizable.strings # Session/Meta/Translations/pt_BR.lproj/Localizable.strings # Session/Meta/Translations/ru.lproj/Localizable.strings # Session/Meta/Translations/si.lproj/Localizable.strings # Session/Meta/Translations/sk.lproj/Localizable.strings # Session/Meta/Translations/sv.lproj/Localizable.strings # Session/Meta/Translations/th.lproj/Localizable.strings # Session/Meta/Translations/vi-VN.lproj/Localizable.strings # Session/Meta/Translations/zh-Hant.lproj/Localizable.strings # Session/Meta/Translations/zh_CN.lproj/Localizable.strings # Session/Notifications/AppNotifications.swift # Session/Notifications/SyncPushTokensJob.swift # Session/Notifications/UserNotificationsAdaptee.swift # SessionMessagingKit/Configuration.swift # SessionMessagingKit/Database/Models/Interaction.swift # SessionMessagingKit/Database/Models/SessionThread.swift # SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift # SessionMessagingKit/Jobs/Types/MessageSendJob.swift # SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift # SessionMessagingKit/Messages/Message.swift # SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ReadReceipts.swift # SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift # SessionMessagingKit/Sending & Receiving/MessageSender.swift # SessionMessagingKit/Shared Models/MentionInfo.swift
This commit is contained in:
commit
742c4a161f
|
@ -632,6 +632,8 @@
|
|||
FD3E0C84283B5835002A425C /* SessionThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3E0C83283B5835002A425C /* SessionThreadViewModel.swift */; };
|
||||
FD42F9A8285064B800A0C77D /* PushNotificationAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBDE255A581900E217F9 /* PushNotificationAPI.swift */; };
|
||||
FD4324302999F0BC008A0213 /* ValidatableResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD43242F2999F0BC008A0213 /* ValidatableResponse.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 */; };
|
||||
FD432437299DEA38008A0213 /* TypeConversion+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD432436299DEA38008A0213 /* TypeConversion+Utilities.swift */; };
|
||||
FD43EE9D297A5190009C87C5 /* SessionUtil+Groups.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD43EE9C297A5190009C87C5 /* SessionUtil+Groups.swift */; };
|
||||
FD43EE9F297E2EE0009C87C5 /* SessionUtil+ConvoInfoVolatile.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD43EE9E297E2EE0009C87C5 /* SessionUtil+ConvoInfoVolatile.swift */; };
|
||||
|
@ -734,7 +736,7 @@
|
|||
FD87DD0428B8727D00AF0F98 /* Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD87DD0328B8727D00AF0F98 /* Configuration.swift */; };
|
||||
FD8ECF7929340F7200C0D1BB /* libsession-util.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = FD8ECF7829340F7100C0D1BB /* libsession-util.xcframework */; };
|
||||
FD8ECF7B29340FFD00C0D1BB /* SessionUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD8ECF7A29340FFD00C0D1BB /* SessionUtil.swift */; };
|
||||
FD8ECF7D2934293A00C0D1BB /* _011_SharedUtilChanges.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD8ECF7C2934293A00C0D1BB /* _011_SharedUtilChanges.swift */; };
|
||||
FD8ECF7D2934293A00C0D1BB /* _012_SharedUtilChanges.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD8ECF7C2934293A00C0D1BB /* _012_SharedUtilChanges.swift */; };
|
||||
FD8ECF7F2934298100C0D1BB /* SharedConfigDump.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD8ECF7E2934298100C0D1BB /* SharedConfigDump.swift */; };
|
||||
FD8ECF822934387A00C0D1BB /* ConfigUserProfileSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD8ECF812934387A00C0D1BB /* ConfigUserProfileSpec.swift */; };
|
||||
FD8ECF892935AB7200C0D1BB /* SessionUtilError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD8ECF882935AB7200C0D1BB /* SessionUtilError.swift */; };
|
||||
|
@ -1760,6 +1762,8 @@
|
|||
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>"; };
|
||||
FD43242F2999F0BC008A0213 /* ValidatableResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValidatableResponse.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>"; };
|
||||
FD432436299DEA38008A0213 /* TypeConversion+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TypeConversion+Utilities.swift"; sourceTree = "<group>"; };
|
||||
FD43EE9C297A5190009C87C5 /* SessionUtil+Groups.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionUtil+Groups.swift"; sourceTree = "<group>"; };
|
||||
FD43EE9E297E2EE0009C87C5 /* SessionUtil+ConvoInfoVolatile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionUtil+ConvoInfoVolatile.swift"; sourceTree = "<group>"; };
|
||||
|
@ -1857,7 +1861,7 @@
|
|||
FD87DD0328B8727D00AF0F98 /* Configuration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Configuration.swift; sourceTree = "<group>"; };
|
||||
FD8ECF7829340F7100C0D1BB /* libsession-util.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = "libsession-util.xcframework"; sourceTree = "<group>"; };
|
||||
FD8ECF7A29340FFD00C0D1BB /* SessionUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionUtil.swift; sourceTree = "<group>"; };
|
||||
FD8ECF7C2934293A00C0D1BB /* _011_SharedUtilChanges.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _011_SharedUtilChanges.swift; sourceTree = "<group>"; };
|
||||
FD8ECF7C2934293A00C0D1BB /* _012_SharedUtilChanges.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _012_SharedUtilChanges.swift; sourceTree = "<group>"; };
|
||||
FD8ECF7E2934298100C0D1BB /* SharedConfigDump.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedConfigDump.swift; sourceTree = "<group>"; };
|
||||
FD8ECF812934387A00C0D1BB /* ConfigUserProfileSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigUserProfileSpec.swift; sourceTree = "<group>"; };
|
||||
FD8ECF882935AB7200C0D1BB /* SessionUtilError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionUtilError.swift; sourceTree = "<group>"; };
|
||||
|
@ -3597,6 +3601,7 @@
|
|||
FD5C7308285007920029977D /* BlindedIdLookup.swift */,
|
||||
FD09B7E6288670FD00ED0B66 /* Reaction.swift */,
|
||||
FD8ECF7E2934298100C0D1BB /* SharedConfigDump.swift */,
|
||||
FD432433299C6985008A0213 /* PendingReadReceipt.swift */,
|
||||
);
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
|
@ -3614,7 +3619,8 @@
|
|||
FD09B7E4288670BB00ED0B66 /* _008_EmojiReacts.swift */,
|
||||
7BAA7B6528D2DE4700AE1489 /* _009_OpenGroupPermission.swift */,
|
||||
FD7115F128C6CB3900B47552 /* _010_AddThreadIdToFTS.swift */,
|
||||
FD8ECF7C2934293A00C0D1BB /* _011_SharedUtilChanges.swift */,
|
||||
FD432431299C6933008A0213 /* _011_AddPendingReadReceipts.swift */,
|
||||
FD8ECF7C2934293A00C0D1BB /* _012_SharedUtilChanges.swift */,
|
||||
);
|
||||
path = Migrations;
|
||||
sourceTree = "<group>";
|
||||
|
@ -5720,7 +5726,7 @@
|
|||
FD43EE9F297E2EE0009C87C5 /* SessionUtil+ConvoInfoVolatile.swift in Sources */,
|
||||
B8EB20EE2640F28000773E52 /* VisibleMessage+OpenGroupInvitation.swift in Sources */,
|
||||
FDF8487F29405994007DCAE5 /* HTTPHeader+OpenGroup.swift in Sources */,
|
||||
FD8ECF7D2934293A00C0D1BB /* _011_SharedUtilChanges.swift in Sources */,
|
||||
FD8ECF7D2934293A00C0D1BB /* _012_SharedUtilChanges.swift in Sources */,
|
||||
FD17D7A227F40F0500122BE0 /* _001_InitialSetupMigration.swift in Sources */,
|
||||
FD245C5D2850660F00B966DD /* OWSAudioPlayer.m in Sources */,
|
||||
FDF0B7582807F368004C14C5 /* MessageReceiverError.swift in Sources */,
|
||||
|
@ -5737,6 +5743,7 @@
|
|||
FD7115F228C6CB3900B47552 /* _010_AddThreadIdToFTS.swift in Sources */,
|
||||
FD716E6428502DDD00C96BF4 /* CallManagerProtocol.swift in Sources */,
|
||||
FDC438C727BB6DF000C60D73 /* DirectMessage.swift in Sources */,
|
||||
FD432434299C6985008A0213 /* PendingReadReceipt.swift in Sources */,
|
||||
FDC4381727B32EC700C60D73 /* Personalization.swift in Sources */,
|
||||
FD245C51285065CC00B966DD /* MessageReceiver.swift in Sources */,
|
||||
FD245C652850665400B966DD /* ClosedGroupControlMessage.swift in Sources */,
|
||||
|
@ -5801,6 +5808,7 @@
|
|||
FDC438C127BB4E6800C60D73 /* SMKDependencies.swift in Sources */,
|
||||
FDC4383827B3863200C60D73 /* VersionResponse.swift in Sources */,
|
||||
B806ECA126C4A7E4008BDA44 /* WebRTCSession+UI.swift in Sources */,
|
||||
FD432432299C6933008A0213 /* _011_AddPendingReadReceipts.swift in Sources */,
|
||||
7BCD116C27016062006330F1 /* WebRTCSession+DataChannel.swift in Sources */,
|
||||
FD5C72F9284F0E880029977D /* MessageReceiver+TypingIndicators.swift in Sources */,
|
||||
FD5C7303284F0FA50029977D /* MessageReceiver+Calls.swift in Sources */,
|
||||
|
@ -6283,7 +6291,7 @@
|
|||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 389;
|
||||
CURRENT_PROJECT_VERSION = 392;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = SUQ8J2PCT7;
|
||||
FRAMEWORK_SEARCH_PATHS = "$(inherited)";
|
||||
|
@ -6307,7 +6315,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.2.4;
|
||||
MARKETING_VERSION = 2.2.7;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.ShareExtension";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
@ -6355,7 +6363,7 @@
|
|||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 389;
|
||||
CURRENT_PROJECT_VERSION = 392;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = SUQ8J2PCT7;
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
|
@ -6384,7 +6392,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.2.4;
|
||||
MARKETING_VERSION = 2.2.7;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.ShareExtension";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
@ -6420,7 +6428,7 @@
|
|||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 389;
|
||||
CURRENT_PROJECT_VERSION = 392;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = SUQ8J2PCT7;
|
||||
FRAMEWORK_SEARCH_PATHS = "$(inherited)";
|
||||
|
@ -6443,7 +6451,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.2.4;
|
||||
MARKETING_VERSION = 2.2.7;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.NotificationServiceExtension";
|
||||
|
@ -6494,7 +6502,7 @@
|
|||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 389;
|
||||
CURRENT_PROJECT_VERSION = 392;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = SUQ8J2PCT7;
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
|
@ -6522,7 +6530,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.2.4;
|
||||
MARKETING_VERSION = 2.2.7;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.NotificationServiceExtension";
|
||||
|
@ -7406,7 +7414,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CURRENT_PROJECT_VERSION = 389;
|
||||
CURRENT_PROJECT_VERSION = 392;
|
||||
DEVELOPMENT_TEAM = SUQ8J2PCT7;
|
||||
FRAMEWORK_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
|
@ -7444,7 +7452,7 @@
|
|||
"$(SRCROOT)",
|
||||
);
|
||||
LLVM_LTO = NO;
|
||||
MARKETING_VERSION = 2.2.4;
|
||||
MARKETING_VERSION = 2.2.7;
|
||||
OTHER_LDFLAGS = "$(inherited)";
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger";
|
||||
|
@ -7477,7 +7485,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CURRENT_PROJECT_VERSION = 389;
|
||||
CURRENT_PROJECT_VERSION = 392;
|
||||
DEVELOPMENT_TEAM = SUQ8J2PCT7;
|
||||
FRAMEWORK_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
|
@ -7515,7 +7523,7 @@
|
|||
"$(SRCROOT)",
|
||||
);
|
||||
LLVM_LTO = NO;
|
||||
MARKETING_VERSION = 2.2.4;
|
||||
MARKETING_VERSION = 2.2.7;
|
||||
OTHER_LDFLAGS = "$(inherited)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger";
|
||||
PRODUCT_NAME = Session;
|
||||
|
|
|
@ -34,6 +34,17 @@ extension ContextMenuVC {
|
|||
}
|
||||
|
||||
// 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 {
|
||||
return Action(
|
||||
|
@ -110,6 +121,8 @@ extension ContextMenuVC {
|
|||
static func actions(
|
||||
for cellViewModel: MessageViewModel,
|
||||
recentEmojis: [EmojiWithSkinTones],
|
||||
currentUserPublicKey: String,
|
||||
currentUserBlindedPublicKey: String?,
|
||||
currentUserIsOpenGroupModerator: Bool,
|
||||
currentThreadIsMessageRequest: Bool,
|
||||
delegate: ContextMenuActionDelegate?
|
||||
|
@ -125,6 +138,14 @@ extension ContextMenuVC {
|
|||
case .standardOutgoing, .standardIncoming: break
|
||||
}
|
||||
|
||||
let canRetry: Bool = (
|
||||
cellViewModel.variant == .standardOutgoing && (
|
||||
cellViewModel.state == .failed || (
|
||||
cellViewModel.threadVariant == .contact &&
|
||||
cellViewModel.state == .failedToSync
|
||||
)
|
||||
)
|
||||
)
|
||||
let canReply: Bool = (
|
||||
cellViewModel.variant != .standardOutgoing || (
|
||||
cellViewModel.state != .failed &&
|
||||
|
@ -163,6 +184,8 @@ extension ContextMenuVC {
|
|||
let canDelete: Bool = (
|
||||
cellViewModel.threadVariant != .community ||
|
||||
currentUserIsOpenGroupModerator ||
|
||||
cellViewModel.authorId == currentUserPublicKey ||
|
||||
cellViewModel.authorId == currentUserBlindedPublicKey ||
|
||||
cellViewModel.state == .failed
|
||||
)
|
||||
let canBan: Bool = (
|
||||
|
@ -178,6 +201,7 @@ extension ContextMenuVC {
|
|||
}()
|
||||
|
||||
let generatedActions: [Action] = [
|
||||
(canRetry ? Action.retry(cellViewModel, delegate) : nil),
|
||||
(canReply ? Action.reply(cellViewModel, delegate) : nil),
|
||||
(canCopy ? Action.copy(cellViewModel, delegate) : nil),
|
||||
(canSave ? Action.save(cellViewModel, delegate) : nil),
|
||||
|
@ -199,6 +223,7 @@ extension ContextMenuVC {
|
|||
// MARK: - Delegate
|
||||
|
||||
protocol ContextMenuActionDelegate {
|
||||
func retry(_ cellViewModel: MessageViewModel)
|
||||
func reply(_ cellViewModel: MessageViewModel)
|
||||
func copy(_ cellViewModel: MessageViewModel)
|
||||
func copySessionID(_ cellViewModel: MessageViewModel)
|
||||
|
|
|
@ -7,6 +7,7 @@ import Photos
|
|||
import PhotosUI
|
||||
import Sodium
|
||||
import GRDB
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
import SignalUtilitiesKit
|
||||
|
@ -200,6 +201,30 @@ extension ConversationVC:
|
|||
// MARK: - ExpandingAttachmentsButtonDelegate
|
||||
|
||||
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()
|
||||
gifVC.delegate = self
|
||||
|
||||
|
@ -434,10 +459,17 @@ extension ConversationVC:
|
|||
.filter(id: threadId)
|
||||
.updateAll(db, SessionThread.Columns.shouldBeVisible.set(to: true))
|
||||
|
||||
let authorId: String = {
|
||||
if let blindedId = self?.viewModel.threadData.currentUserBlindedPublicKey {
|
||||
return blindedId
|
||||
}
|
||||
return self?.viewModel.threadData.currentUserPublicKey ?? getUserHexEncodedPublicKey(db)
|
||||
}()
|
||||
|
||||
// Create the interaction
|
||||
let interaction: Interaction = try Interaction(
|
||||
threadId: threadId,
|
||||
authorId: getUserHexEncodedPublicKey(db),
|
||||
authorId: authorId,
|
||||
variant: .standardOutgoing,
|
||||
body: text,
|
||||
timestampMs: sentTimestampMs,
|
||||
|
@ -789,6 +821,8 @@ extension ConversationVC:
|
|||
let actions: [ContextMenuVC.Action] = ContextMenuVC.actions(
|
||||
for: cellViewModel,
|
||||
recentEmojis: (self.viewModel.threadData.recentReactionEmoji ?? []).compactMap { EmojiWithSkinTones(rawValue: $0) },
|
||||
currentUserPublicKey: self.viewModel.threadData.currentUserPublicKey,
|
||||
currentUserBlindedPublicKey: self.viewModel.threadData.currentUserBlindedPublicKey,
|
||||
currentUserIsOpenGroupModerator: OpenGroupManager.isUserModeratorOrAdmin(
|
||||
self.viewModel.threadData.currentUserPublicKey,
|
||||
for: self.viewModel.threadData.openGroupRoomToken,
|
||||
|
@ -839,7 +873,7 @@ extension ConversationVC:
|
|||
}
|
||||
|
||||
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
|
||||
showFailedMessageSheet(for: cellViewModel)
|
||||
return
|
||||
|
@ -1474,30 +1508,34 @@ extension ConversationVC:
|
|||
// MARK: --action handling
|
||||
|
||||
func showFailedMessageSheet(for cellViewModel: MessageViewModel) {
|
||||
let sheet = UIAlertController(title: cellViewModel.mostRecentFailureText, message: nil, preferredStyle: .actionSheet)
|
||||
sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil))
|
||||
sheet.addAction(UIAlertAction(title: "Delete", style: .destructive, handler: { _ in
|
||||
Storage.shared.writeAsync { db in
|
||||
try Interaction
|
||||
.filter(id: cellViewModel.id)
|
||||
.deleteAll(db)
|
||||
}
|
||||
}))
|
||||
sheet.addAction(UIAlertAction(title: "Resend", style: .default, handler: { _ in
|
||||
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
|
||||
)
|
||||
}
|
||||
}))
|
||||
let sheet = UIAlertController(
|
||||
title: (cellViewModel.state == .failedToSync ?
|
||||
"MESSAGE_DELIVERY_FAILED_SYNC_TITLE".localized() :
|
||||
"MESSAGE_DELIVERY_FAILED_TITLE".localized()
|
||||
),
|
||||
message: cellViewModel.mostRecentFailureText,
|
||||
preferredStyle: .actionSheet
|
||||
)
|
||||
sheet.addAction(UIAlertAction(title: "TXT_CANCEL_TITLE".localized(), style: .cancel, handler: nil))
|
||||
|
||||
if cellViewModel.state != .failedToSync {
|
||||
sheet.addAction(UIAlertAction(title: "TXT_DELETE_TITLE".localized(), style: .destructive, handler: { _ in
|
||||
Storage.shared.writeAsync { db in
|
||||
try Interaction
|
||||
.filter(id: cellViewModel.id)
|
||||
.deleteAll(db)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
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
|
||||
let prefix: String = "HTTP request failed at destination (Service node "
|
||||
|
@ -1581,6 +1619,23 @@ extension ConversationVC:
|
|||
}
|
||||
|
||||
// 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) {
|
||||
let maybeQuoteDraft: QuotedReplyModel? = QuotedReplyModel.quotedReplyForSending(
|
||||
|
@ -1855,10 +1910,16 @@ extension ConversationVC:
|
|||
})
|
||||
|
||||
actionSheet.addAction(UIAlertAction(
|
||||
title: (cellViewModel.threadVariant == .legacyGroup || cellViewModel.threadVariant == .group ?
|
||||
"delete_message_for_everyone".localized() :
|
||||
String(format: "delete_message_for_me_and_recipient".localized(), threadName)
|
||||
),
|
||||
title: {
|
||||
switch cellViewModel.threadVariant {
|
||||
case .legacyGroup, .group: 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
|
||||
) { [weak self] _ in
|
||||
deleteRemotely(
|
||||
|
|
|
@ -733,11 +733,17 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
|
|||
|
||||
// Store the 'sentMessageBeforeUpdate' state locally
|
||||
let didSendMessageBeforeUpdate: Bool = self.viewModel.sentMessageBeforeUpdate
|
||||
let wasOnlyUpdates: Bool = (
|
||||
changeset.count == 1 &&
|
||||
changeset[0].elementUpdated.count == changeset[0].changeCount
|
||||
)
|
||||
self.viewModel.sentMessageBeforeUpdate = false
|
||||
|
||||
// When sending a message we want to reload the UI instantly (with any form of animation the message
|
||||
// sending feels somewhat unresponsive but an instant update feels snappy)
|
||||
guard !didSendMessageBeforeUpdate else {
|
||||
// When sending a message, or if there were only cell updates (ie. read status changes) we want to
|
||||
// reload the UI instantly (with any form of animation the message sending feels somewhat unresponsive
|
||||
// but an instant update feels snappy and without the instant update there is some overlap of the read
|
||||
// status text change even though there shouldn't be any animations)
|
||||
guard !didSendMessageBeforeUpdate && !wasOnlyUpdates else {
|
||||
self.viewModel.updateInteractionData(updatedData)
|
||||
self.tableView.reloadData()
|
||||
|
||||
|
|
|
@ -84,7 +84,11 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
// distinct stutter)
|
||||
self.pagedDataObserver = self.setupPagedObserver(
|
||||
for: threadId,
|
||||
userPublicKey: getUserHexEncodedPublicKey()
|
||||
userPublicKey: getUserHexEncodedPublicKey(),
|
||||
blindedPublicKey: SessionThread.getUserHexEncodedBlindedKey(
|
||||
threadId: threadId,
|
||||
threadVariant: threadVariant
|
||||
)
|
||||
)
|
||||
|
||||
// Run the initial query on a background thread so we don't block the push transition
|
||||
|
@ -118,6 +122,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
}
|
||||
)
|
||||
)
|
||||
.populatingCurrentUserBlindedKey()
|
||||
|
||||
/// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise
|
||||
/// performance https://github.com/groue/GRDB.swift#valueobservation-performance
|
||||
|
@ -133,7 +138,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
|
||||
private func setupObservableThreadData(for threadId: String) -> ValueObservation<ValueReducers.RemoveDuplicates<ValueReducers.Fetch<SessionThreadViewModel?>>> {
|
||||
return ValueObservation
|
||||
.trackingConstantRegion { db -> SessionThreadViewModel? in
|
||||
.trackingConstantRegion { [weak self] db -> SessionThreadViewModel? in
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||
let recentReactionEmoji: [String] = try Emoji.getRecent(db, withDefaultEmoji: true)
|
||||
let threadViewModel: SessionThreadViewModel? = try SessionThreadViewModel
|
||||
|
@ -142,6 +147,11 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
|
||||
return threadViewModel
|
||||
.map { $0.with(recentReactionEmoji: recentReactionEmoji) }
|
||||
.map { viewModel -> SessionThreadViewModel in
|
||||
viewModel.populatingCurrentUserBlindedKey(
|
||||
currentUserBlindedPublicKeyForThisThread: self?.threadData.currentUserBlindedPublicKey
|
||||
)
|
||||
}
|
||||
}
|
||||
.removeDuplicates()
|
||||
}
|
||||
|
@ -169,7 +179,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
private func setupPagedObserver(for threadId: String, userPublicKey: String) -> PagedDatabaseObserver<Interaction, MessageViewModel> {
|
||||
private func setupPagedObserver(for threadId: String, userPublicKey: String, blindedPublicKey: String?) -> PagedDatabaseObserver<Interaction, MessageViewModel> {
|
||||
return PagedDatabaseObserver(
|
||||
pagedTable: Interaction.self,
|
||||
pageSize: ConversationViewModel.pageSize,
|
||||
|
@ -200,13 +210,24 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
|
||||
return SQL("JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId])")
|
||||
}()
|
||||
)
|
||||
),
|
||||
PagedData.ObservedChanges(
|
||||
table: RecipientState.self,
|
||||
columns: [.state, .readTimestampMs, .mostRecentFailureText],
|
||||
joinToPagedType: {
|
||||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||
let recipientState: TypedTableAlias<RecipientState> = TypedTableAlias()
|
||||
|
||||
return SQL("LEFT JOIN \(RecipientState.self) ON \(recipientState[.interactionId]) = \(interaction[.id])")
|
||||
}()
|
||||
),
|
||||
],
|
||||
filterSQL: MessageViewModel.filterSQL(threadId: threadId),
|
||||
groupSQL: MessageViewModel.groupSQL,
|
||||
orderSQL: MessageViewModel.orderSQL,
|
||||
dataQuery: MessageViewModel.baseQuery(
|
||||
userPublicKey: userPublicKey,
|
||||
blindedPublicKey: blindedPublicKey,
|
||||
orderSQL: MessageViewModel.orderSQL,
|
||||
groupSQL: MessageViewModel.groupSQL
|
||||
),
|
||||
|
@ -294,6 +315,15 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
index == (sortedData.count - 1) &&
|
||||
pageInfo.pageOffset == 0
|
||||
),
|
||||
isLastOutgoing: (
|
||||
cellViewModel.id == sortedData
|
||||
.filter {
|
||||
$0.authorId == threadData.currentUserPublicKey ||
|
||||
$0.authorId == threadData.currentUserBlindedPublicKey
|
||||
}
|
||||
.last?
|
||||
.id
|
||||
),
|
||||
currentUserBlindedPublicKey: threadData.currentUserBlindedPublicKey
|
||||
)
|
||||
}
|
||||
|
@ -437,7 +467,8 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
self.observableThreadData = self.setupObservableThreadData(for: updatedThreadId)
|
||||
self.pagedDataObserver = self.setupPagedObserver(
|
||||
for: updatedThreadId,
|
||||
userPublicKey: getUserHexEncodedPublicKey()
|
||||
userPublicKey: getUserHexEncodedPublicKey(),
|
||||
blindedPublicKey: nil
|
||||
)
|
||||
|
||||
// Try load everything up to the initial visible message, fallback to just the initial page of messages
|
||||
|
|
|
@ -4,6 +4,7 @@ import UIKit
|
|||
import Combine
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, MentionSelectionViewDelegate {
|
||||
// MARK: - Variables
|
||||
|
@ -413,8 +414,11 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
|
|||
func handleInputViewButtonLongPressBegan(_ inputViewButton: InputViewButton?) {
|
||||
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()
|
||||
delegate?.startVoiceMessageRecording()
|
||||
}
|
||||
|
||||
func handleInputViewButtonLongPressMoved(_ inputViewButton: InputViewButton, with touch: UITouch?) {
|
||||
|
@ -475,9 +479,9 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
|
|||
UIView.animate(withDuration: 0.25, animations: {
|
||||
allOtherViews.forEach { $0.alpha = 1 }
|
||||
self.voiceMessageRecordingView?.alpha = 0
|
||||
}, completion: { _ in
|
||||
self.voiceMessageRecordingView?.removeFromSuperview()
|
||||
self.voiceMessageRecordingView = nil
|
||||
}, completion: { [weak self] _ in
|
||||
self?.voiceMessageRecordingView?.removeFromSuperview()
|
||||
self?.voiceMessageRecordingView = nil
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -165,6 +165,8 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
|||
|
||||
return result
|
||||
}()
|
||||
|
||||
internal lazy var messageStatusLabelPaddingView: UIView = UIView()
|
||||
|
||||
// MARK: - Settings
|
||||
|
||||
|
@ -252,6 +254,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
|||
|
||||
underBubbleStackView.addArrangedSubview(reactionContainerView)
|
||||
underBubbleStackView.addArrangedSubview(messageStatusContainerView)
|
||||
underBubbleStackView.addArrangedSubview(messageStatusLabelPaddingView)
|
||||
|
||||
messageStatusContainerView.addSubview(messageStatusLabel)
|
||||
messageStatusContainerView.addSubview(messageStatusImageView)
|
||||
|
@ -267,6 +270,8 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
|||
messageStatusLabel.center(.vertical, in: messageStatusContainerView)
|
||||
messageStatusLabel.pin(.leading, to: .leading, of: messageStatusContainerView)
|
||||
messageStatusLabel.pin(.trailing, to: .leading, of: messageStatusImageView, withInset: -2)
|
||||
messageStatusLabelPaddingView.pin(.leading, to: .leading, of: messageStatusContainerView)
|
||||
messageStatusLabelPaddingView.pin(.trailing, to: .trailing, of: messageStatusContainerView)
|
||||
}
|
||||
|
||||
override func setUpGestureRecognizers() {
|
||||
|
@ -432,9 +437,13 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
|||
cellViewModel.variant == .infoCall ||
|
||||
(
|
||||
cellViewModel.state == .sent &&
|
||||
!cellViewModel.isLast
|
||||
!cellViewModel.isLastOutgoing
|
||||
)
|
||||
)
|
||||
messageStatusLabelPaddingView.isHidden = (
|
||||
messageStatusContainerView.isHidden ||
|
||||
cellViewModel.isLast
|
||||
)
|
||||
|
||||
// Set the height of the underBubbleStackView to 0 if it has no content (need to do this
|
||||
// otherwise it can randomly stretch)
|
||||
|
@ -1129,11 +1138,15 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
|||
return [:]
|
||||
}
|
||||
|
||||
// Note: The 'String.count' value is based on actual character counts whereas
|
||||
// NSAttributedString and NSRange are both based on UTF-16 encoded lengths, so
|
||||
// in order to avoid strings which contain emojis breaking strings which end
|
||||
// with URLs we need to use the 'String.utf16.count' value when creating the range
|
||||
return detector
|
||||
.matches(
|
||||
in: attributedText.string,
|
||||
options: [],
|
||||
range: NSRange(location: 0, length: attributedText.string.count)
|
||||
range: NSRange(location: 0, length: attributedText.string.utf16.count)
|
||||
)
|
||||
.reduce(into: [:]) { result, match in
|
||||
guard
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import Combine
|
||||
import Reachability
|
||||
import SignalUtilitiesKit
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
import Foundation
|
||||
import Combine
|
||||
import CoreServices
|
||||
import SignalUtilitiesKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
// There's no UTI type for webp!
|
||||
|
|
|
@ -600,6 +600,15 @@
|
|||
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
|
||||
"MESSAGE_DELIVERY_STATUS_READ" = "Read";
|
||||
"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.";
|
||||
"REMOVE_AVATAR" = "Remove";
|
||||
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
|
||||
"MARK_AS_READ" = "Mark Read";
|
||||
|
|
|
@ -600,6 +600,15 @@
|
|||
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
|
||||
"MESSAGE_DELIVERY_STATUS_READ" = "Read";
|
||||
"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.";
|
||||
"REMOVE_AVATAR" = "Remove";
|
||||
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
|
||||
"MARK_AS_READ" = "Mark Read";
|
||||
|
|
|
@ -600,6 +600,15 @@
|
|||
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
|
||||
"MESSAGE_DELIVERY_STATUS_READ" = "Read";
|
||||
"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.";
|
||||
"REMOVE_AVATAR" = "Remove";
|
||||
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
|
||||
"MARK_AS_READ" = "Mark Read";
|
||||
|
|
|
@ -600,6 +600,15 @@
|
|||
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
|
||||
"MESSAGE_DELIVERY_STATUS_READ" = "Read";
|
||||
"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.";
|
||||
"REMOVE_AVATAR" = "Remove";
|
||||
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
|
||||
"MARK_AS_READ" = "Mark Read";
|
||||
|
|
|
@ -600,6 +600,15 @@
|
|||
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
|
||||
"MESSAGE_DELIVERY_STATUS_READ" = "Read";
|
||||
"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.";
|
||||
"REMOVE_AVATAR" = "Remove";
|
||||
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
|
||||
"MARK_AS_READ" = "Mark Read";
|
||||
|
|
|
@ -600,6 +600,15 @@
|
|||
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
|
||||
"MESSAGE_DELIVERY_STATUS_READ" = "Read";
|
||||
"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.";
|
||||
"REMOVE_AVATAR" = "Remove";
|
||||
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
|
||||
"MARK_AS_READ" = "Mark Read";
|
||||
|
|
|
@ -600,6 +600,15 @@
|
|||
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
|
||||
"MESSAGE_DELIVERY_STATUS_READ" = "Read";
|
||||
"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.";
|
||||
"REMOVE_AVATAR" = "Remove";
|
||||
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
|
||||
"MARK_AS_READ" = "Mark Read";
|
||||
|
|
|
@ -600,6 +600,15 @@
|
|||
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
|
||||
"MESSAGE_DELIVERY_STATUS_READ" = "Read";
|
||||
"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.";
|
||||
"REMOVE_AVATAR" = "Remove";
|
||||
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
|
||||
"MARK_AS_READ" = "Mark Read";
|
||||
|
|
|
@ -600,6 +600,15 @@
|
|||
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
|
||||
"MESSAGE_DELIVERY_STATUS_READ" = "Read";
|
||||
"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.";
|
||||
"REMOVE_AVATAR" = "Remove";
|
||||
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
|
||||
"MARK_AS_READ" = "Mark Read";
|
||||
|
|
|
@ -600,6 +600,15 @@
|
|||
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
|
||||
"MESSAGE_DELIVERY_STATUS_READ" = "Read";
|
||||
"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.";
|
||||
"REMOVE_AVATAR" = "Remove";
|
||||
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
|
||||
"MARK_AS_READ" = "Mark Read";
|
||||
|
|
|
@ -600,6 +600,15 @@
|
|||
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
|
||||
"MESSAGE_DELIVERY_STATUS_READ" = "Read";
|
||||
"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.";
|
||||
"REMOVE_AVATAR" = "Remove";
|
||||
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
|
||||
"MARK_AS_READ" = "Mark Read";
|
||||
|
|
|
@ -600,6 +600,15 @@
|
|||
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
|
||||
"MESSAGE_DELIVERY_STATUS_READ" = "Read";
|
||||
"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.";
|
||||
"REMOVE_AVATAR" = "Remove";
|
||||
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
|
||||
"MARK_AS_READ" = "Mark Read";
|
||||
|
|
|
@ -600,6 +600,15 @@
|
|||
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
|
||||
"MESSAGE_DELIVERY_STATUS_READ" = "Read";
|
||||
"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.";
|
||||
"REMOVE_AVATAR" = "Remove";
|
||||
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
|
||||
"MARK_AS_READ" = "Mark Read";
|
||||
|
|
|
@ -600,6 +600,15 @@
|
|||
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
|
||||
"MESSAGE_DELIVERY_STATUS_READ" = "Read";
|
||||
"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.";
|
||||
"REMOVE_AVATAR" = "Remove";
|
||||
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
|
||||
"MARK_AS_READ" = "Mark Read";
|
||||
|
|
|
@ -600,6 +600,15 @@
|
|||
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
|
||||
"MESSAGE_DELIVERY_STATUS_READ" = "Read";
|
||||
"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.";
|
||||
"REMOVE_AVATAR" = "Remove";
|
||||
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
|
||||
"MARK_AS_READ" = "Mark Read";
|
||||
|
|
|
@ -600,6 +600,15 @@
|
|||
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
|
||||
"MESSAGE_DELIVERY_STATUS_READ" = "Read";
|
||||
"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.";
|
||||
"REMOVE_AVATAR" = "Remove";
|
||||
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
|
||||
"MARK_AS_READ" = "Mark Read";
|
||||
|
|
|
@ -600,6 +600,15 @@
|
|||
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
|
||||
"MESSAGE_DELIVERY_STATUS_READ" = "Read";
|
||||
"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.";
|
||||
"REMOVE_AVATAR" = "Remove";
|
||||
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
|
||||
"MARK_AS_READ" = "Mark Read";
|
||||
|
|
|
@ -600,6 +600,15 @@
|
|||
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
|
||||
"MESSAGE_DELIVERY_STATUS_READ" = "Read";
|
||||
"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.";
|
||||
"REMOVE_AVATAR" = "Remove";
|
||||
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
|
||||
"MARK_AS_READ" = "Mark Read";
|
||||
|
|
|
@ -600,6 +600,15 @@
|
|||
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
|
||||
"MESSAGE_DELIVERY_STATUS_READ" = "Read";
|
||||
"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.";
|
||||
"REMOVE_AVATAR" = "Remove";
|
||||
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
|
||||
"MARK_AS_READ" = "Mark Read";
|
||||
|
|
|
@ -600,6 +600,15 @@
|
|||
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
|
||||
"MESSAGE_DELIVERY_STATUS_READ" = "Read";
|
||||
"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.";
|
||||
"REMOVE_AVATAR" = "Remove";
|
||||
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
|
||||
"MARK_AS_READ" = "Mark Read";
|
||||
|
|
|
@ -600,6 +600,15 @@
|
|||
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
|
||||
"MESSAGE_DELIVERY_STATUS_READ" = "Read";
|
||||
"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.";
|
||||
"REMOVE_AVATAR" = "Remove";
|
||||
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
|
||||
"MARK_AS_READ" = "Mark Read";
|
||||
|
|
|
@ -600,6 +600,15 @@
|
|||
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
|
||||
"MESSAGE_DELIVERY_STATUS_READ" = "Read";
|
||||
"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.";
|
||||
"REMOVE_AVATAR" = "Remove";
|
||||
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
|
||||
"MARK_AS_READ" = "Mark Read";
|
||||
|
|
|
@ -12,6 +12,7 @@ public enum SyncPushTokensJob: JobExecutor {
|
|||
public static let maxFailureCount: Int = -1
|
||||
public static let requiresThreadId: Bool = false
|
||||
public static let requiresInteractionId: Bool = false
|
||||
private static let maxFrequency: TimeInterval = (12 * 60 * 60)
|
||||
|
||||
public static func run(
|
||||
_ job: Job,
|
||||
|
@ -35,50 +36,48 @@ public enum SyncPushTokensJob: JobExecutor {
|
|||
return
|
||||
}
|
||||
|
||||
// Push tokens don't normally change while the app is launched, so checking once during launch is
|
||||
// usually sufficient, but e.g. on iOS11, users who have disabled "Allow Notifications" and disabled
|
||||
// "Background App Refresh" will not be able to obtain an APN token. Enabling those settings does not
|
||||
// restart the app, so we check every activation for users who haven't yet registered.
|
||||
guard job.behaviour != .recurringOnActive || !UIApplication.shared.isRegisteredForRemoteNotifications else {
|
||||
// Push tokens don't normally change while the app is launched, so you would assume checking once
|
||||
// during launch is sufficient, but e.g. on iOS11, users who have disabled "Allow Notifications"
|
||||
// and disabled "Background App Refresh" will not be able to obtain an APN token. Enabling those
|
||||
// settings does not restart the app, so we check every activation for users who haven't yet
|
||||
// registered.
|
||||
//
|
||||
// It's also possible for a device to successfully register for push notifications but fail to
|
||||
// register with Session
|
||||
//
|
||||
// Due to the above we want to re-register at least once every ~12 hours to ensure users will
|
||||
// continue to receive push notifications
|
||||
//
|
||||
// In addition to this if we are custom running the job (eg. by toggling the push notification
|
||||
// setting) then we should run regardless of the other settings so users have a mechanism to force
|
||||
// the registration to run
|
||||
let lastPushNotificationSync: Date = UserDefaults.standard[.lastPushNotificationSync]
|
||||
.defaulting(to: Date.distantPast)
|
||||
|
||||
guard
|
||||
job.behaviour == .runOnce ||
|
||||
!UIApplication.shared.isRegisteredForRemoteNotifications ||
|
||||
Date().timeIntervalSince(lastPushNotificationSync) >= SyncPushTokensJob.maxFrequency
|
||||
else {
|
||||
deferred(job) // Don't need to do anything if push notifications are already registered
|
||||
return
|
||||
}
|
||||
|
||||
Logger.info("Retrying remote notification registration since user hasn't registered yet.")
|
||||
|
||||
// Determine if we want to upload only if stale (Note: This should default to true, and be true if
|
||||
// 'details' isn't provided)
|
||||
let uploadOnlyIfStale: Bool = ((try? JSONDecoder().decode(Details.self, from: job.details ?? Data()))?.uploadOnlyIfStale ?? true)
|
||||
|
||||
// Get the app version info (used to determine if we want to update the push tokens)
|
||||
let lastAppVersion: String? = AppVersion.sharedInstance().lastAppVersion
|
||||
let currentAppVersion: String? = AppVersion.sharedInstance().currentAppVersion
|
||||
Logger.info("Re-registering for remote notifications.")
|
||||
|
||||
// Perform device registration
|
||||
PushRegistrationManager.shared.requestPushTokens()
|
||||
.subscribe(on: queue)
|
||||
.flatMap { (pushToken: String, voipToken: String) -> AnyPublisher<Void, Error> in
|
||||
let lastPushToken: String? = Storage.shared[.lastRecordedPushToken]
|
||||
let lastVoipToken: String? = Storage.shared[.lastRecordedVoipToken]
|
||||
let shouldUploadTokens: Bool = (
|
||||
!uploadOnlyIfStale || (
|
||||
lastPushToken != pushToken ||
|
||||
lastVoipToken != voipToken
|
||||
) ||
|
||||
lastAppVersion != currentAppVersion
|
||||
)
|
||||
|
||||
guard shouldUploadTokens else {
|
||||
return Just(())
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
return Deferred {
|
||||
Future<Void, Error> { resolver in
|
||||
SyncPushTokensJob.registerForPushNotifications(
|
||||
pushToken: pushToken,
|
||||
voipToken: voipToken,
|
||||
isForcedUpdate: shouldUploadTokens,
|
||||
isForcedUpdate: true,
|
||||
success: { resolver(Result.success(())) },
|
||||
failure: { resolver(Result.failure($0)) }
|
||||
)
|
||||
|
@ -110,6 +109,7 @@ public enum SyncPushTokensJob: JobExecutor {
|
|||
public static func run(uploadOnlyIfStale: Bool) {
|
||||
guard let job: Job = Job(
|
||||
variant: .syncPushTokens,
|
||||
behaviour: .runOnce,
|
||||
details: SyncPushTokensJob.Details(
|
||||
uploadOnlyIfStale: uploadOnlyIfStale
|
||||
)
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import Reachability
|
||||
import SessionUIKit
|
||||
import SessionSnodeKit
|
||||
|
||||
final class PathStatusView: UIView {
|
||||
enum Size {
|
||||
|
@ -42,6 +44,7 @@ final class PathStatusView: UIView {
|
|||
// MARK: - Initialization
|
||||
|
||||
private let size: Size
|
||||
private let reachability: Reachability = Reachability.forInternetConnection()
|
||||
|
||||
init(size: Size = .small) {
|
||||
self.size = size
|
||||
|
@ -73,15 +76,34 @@ final class PathStatusView: UIView {
|
|||
self.set(.width, to: self.size.pointSize)
|
||||
self.set(.height, to: self.size.pointSize)
|
||||
|
||||
setStatus(to: (!OnionRequestAPI.paths.isEmpty ? .connected : .connecting))
|
||||
switch (reachability.isReachable(), OnionRequestAPI.paths.isEmpty) {
|
||||
case (false, _): setStatus(to: .error)
|
||||
case (true, true): setStatus(to: .connecting)
|
||||
case (true, false): setStatus(to: .connected)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Functions
|
||||
|
||||
private func registerObservers() {
|
||||
let notificationCenter = NotificationCenter.default
|
||||
notificationCenter.addObserver(self, selector: #selector(handleBuildingPathsNotification), name: .buildingPaths, object: nil)
|
||||
notificationCenter.addObserver(self, selector: #selector(handlePathsBuiltNotification), name: .pathsBuilt, object: nil)
|
||||
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 setStatus(to status: Status) {
|
||||
|
@ -102,10 +124,34 @@ final class PathStatusView: UIView {
|
|||
}
|
||||
|
||||
@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))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import Reachability
|
||||
import NVActivityIndicatorView
|
||||
import SessionMessagingKit
|
||||
import SessionUIKit
|
||||
import SessionSnodeKit
|
||||
|
||||
final class PathVC: BaseVC {
|
||||
public static let dotSize: CGFloat = 8
|
||||
|
@ -239,6 +241,7 @@ private final class LineView: UIView {
|
|||
private var dotViewWidthConstraint: NSLayoutConstraint!
|
||||
private var dotViewHeightConstraint: NSLayoutConstraint!
|
||||
private var dotViewAnimationTimer: Timer!
|
||||
private let reachability: Reachability = Reachability.forInternetConnection()
|
||||
|
||||
enum Location {
|
||||
case top, middle, bottom
|
||||
|
@ -273,6 +276,7 @@ private final class LineView: UIView {
|
|||
super.init(frame: CGRect.zero)
|
||||
|
||||
setUpViewHierarchy()
|
||||
registerObservers()
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
|
@ -283,6 +287,12 @@ private final class LineView: UIView {
|
|||
preconditionFailure("Use init(location:dotAnimationStartDelay:dotAnimationRepeatInterval:) instead.")
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
|
||||
dotViewAnimationTimer?.invalidate()
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
let lineView = UIView()
|
||||
lineView.set(.width, to: Values.separatorThickness)
|
||||
|
@ -315,10 +325,33 @@ private final class LineView: UIView {
|
|||
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 {
|
||||
dotViewAnimationTimer?.invalidate()
|
||||
|
||||
private func registerObservers() {
|
||||
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() {
|
||||
|
@ -340,4 +373,41 @@ private final class LineView: UIView {
|
|||
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))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
import Foundation
|
||||
import Combine
|
||||
import GRDB
|
||||
import LocalAuthentication
|
||||
import DifferenceKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
|
@ -96,7 +97,23 @@ class PrivacySettingsViewModel: SessionTableViewModel<PrivacySettingsViewModel.N
|
|||
title: "PRIVACY_SCREEN_SECURITY_LOCK_SESSION_TITLE".localized(),
|
||||
subtitle: "PRIVACY_SCREEN_SECURITY_LOCK_SESSION_DESCRIPTION".localized(),
|
||||
rightAccessory: .toggle(.settingBool(key: .isScreenLockEnabled)),
|
||||
onTap: {
|
||||
onTap: { [weak self] in
|
||||
// Make sure the device has a passcode set before allowing screen lock to
|
||||
// be enabled (Note: This will always return true on a simulator)
|
||||
guard LAContext().canEvaluatePolicy(.deviceOwnerAuthentication, error: nil) else {
|
||||
self?.transitionToScreen(
|
||||
ConfirmationModal(
|
||||
info: ConfirmationModal.Info(
|
||||
title: "SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_AVAILABLE".localized(),
|
||||
cancelTitle: "BUTTON_OK".localized(),
|
||||
cancelStyle: .alert_text
|
||||
)
|
||||
),
|
||||
transitionType: .present
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
Storage.shared.write { db in
|
||||
db[.isScreenLockEnabled] = !db[.isScreenLockEnabled]
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
import UIKit
|
||||
import AVFoundation
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
final class QRCodeVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControllerDelegate, QRScannerDelegate {
|
||||
|
|
|
@ -27,7 +27,8 @@ public enum SNMessagingKit { // Just to make the external API nice
|
|||
_010_AddThreadIdToFTS.self
|
||||
], // Add job priorities
|
||||
[
|
||||
_011_SharedUtilChanges.self
|
||||
_011_AddPendingReadReceipts.self,
|
||||
_012_SharedUtilChanges.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
|
||||
}
|
||||
}
|
|
@ -7,7 +7,7 @@ import SessionUtilitiesKit
|
|||
|
||||
/// This migration recreates the interaction FTS table and adds the threadId so we can do a performant in-conversation
|
||||
/// searh (currently it's much slower than the global search)
|
||||
enum _011_SharedUtilChanges: Migration {
|
||||
enum _012_SharedUtilChanges: Migration {
|
||||
static let target: TargetMigrations.Identifier = .messagingKit
|
||||
static let identifier: String = "SharedUtilChanges"
|
||||
static let needsConfigSync: Bool = true
|
|
@ -454,13 +454,20 @@ public extension Interaction {
|
|||
trySendReadReceipt: Bool
|
||||
) throws {
|
||||
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
|
||||
func scheduleJobs(
|
||||
_ db: Database,
|
||||
threadId: String,
|
||||
threadVariant: SessionThread.Variant,
|
||||
interactionIds: [Int64],
|
||||
interactionInfo: [InteractionReadInfo],
|
||||
lastReadTimestampMs: Int64
|
||||
) throws {
|
||||
// Update the last read timestamp if needed
|
||||
|
@ -477,17 +484,17 @@ public extension Interaction {
|
|||
db,
|
||||
job: DisappearingMessagesJob.updateNextRunIfNeeded(
|
||||
db,
|
||||
interactionIds: interactionIds,
|
||||
interactionIds: interactionInfo.map { $0.id },
|
||||
startedAtMs: TimeInterval(SnodeAPI.currentOffsetTimestampMs())
|
||||
)
|
||||
)
|
||||
|
||||
// Clear out any notifications for the interactions we mark as read
|
||||
Environment.shared?.notificationsManager.wrappedValue?.cancelNotifications(
|
||||
identifiers: interactionIds
|
||||
.map { interactionId in
|
||||
identifiers: interactionInfo
|
||||
.map { interactionInfo in
|
||||
Interaction.notificationIdentifier(
|
||||
for: interactionId,
|
||||
for: interactionInfo.id,
|
||||
threadId: threadId,
|
||||
shouldGroupMessagesForThread: false
|
||||
)
|
||||
|
@ -499,39 +506,42 @@ public extension Interaction {
|
|||
))
|
||||
)
|
||||
|
||||
// If we want to send read receipts then try to add the 'SendReadReceiptsJob'
|
||||
if trySendReadReceipt {
|
||||
// If we want to send read receipts and it's a contact thread then try to add the
|
||||
// 'SendReadReceiptsJob' for and unread messages that weren't outgoing
|
||||
if trySendReadReceipt && threadVariant == .contact {
|
||||
JobRunner.upsert(
|
||||
db,
|
||||
job: SendReadReceiptsJob.createOrUpdateIfNeeded(
|
||||
db,
|
||||
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
|
||||
// fetch the timestamp for the interaction and set everything before that as read
|
||||
let maybeInteractionInfo: InteractionReadInfo? = try Interaction
|
||||
.select(.timestampMs, .wasRead)
|
||||
.select(.id, .variant, .timestampMs, .wasRead)
|
||||
.filter(id: interactionId)
|
||||
.asRequest(of: InteractionReadInfo.self)
|
||||
.fetchOne(db)
|
||||
|
||||
// If we aren't including older interactions then update and save the current one
|
||||
guard includingOlder, let interactionInfo: InteractionReadInfo = maybeInteractionInfo else {
|
||||
// Only mark as read and trigger the subsequent jobs if the interaction is
|
||||
// actually not read (no point updating and triggering db changes otherwise)
|
||||
guard
|
||||
maybeInteractionInfo?.wasRead == false,
|
||||
let timestampMs: Int64 = maybeInteractionInfo?.timestampMs
|
||||
let timestampMs: Int64 = maybeInteractionInfo?.timestampMs,
|
||||
let variant: Variant = try Interaction
|
||||
.filter(id: interactionId)
|
||||
.select(.variant)
|
||||
.asRequest(of: Variant.self)
|
||||
.fetchOne(db)
|
||||
else { return }
|
||||
|
||||
_ = try Interaction
|
||||
|
@ -542,7 +552,14 @@ public extension Interaction {
|
|||
db,
|
||||
threadId: threadId,
|
||||
threadVariant: threadVariant,
|
||||
interactionIds: [interactionId],
|
||||
interactionInfo: [
|
||||
InteractionReadInfo(
|
||||
id: interactionId,
|
||||
variant: variant,
|
||||
timestampMs: 0,
|
||||
wasRead: false
|
||||
)
|
||||
],
|
||||
lastReadTimestampMs: timestampMs
|
||||
)
|
||||
return
|
||||
|
@ -552,20 +569,20 @@ public extension Interaction {
|
|||
.filter(Interaction.Columns.threadId == threadId)
|
||||
.filter(Interaction.Columns.timestampMs <= interactionInfo.timestampMs)
|
||||
.filter(Interaction.Columns.wasRead == false)
|
||||
let interactionIdsToMarkAsRead: [Int64] = try interactionQuery
|
||||
.select(.id)
|
||||
.asRequest(of: Int64.self)
|
||||
let interactionInfoToMarkAsRead: [InteractionReadInfo] = try interactionQuery
|
||||
.select(.id, .variant, .timestampMs, .wasRead)
|
||||
.asRequest(of: InteractionReadInfo.self)
|
||||
.fetchAll(db)
|
||||
|
||||
// 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
|
||||
// outgoing messages which will always have 'wasRead' as false)
|
||||
guard !interactionIdsToMarkAsRead.isEmpty else {
|
||||
guard !interactionInfoToMarkAsRead.isEmpty else {
|
||||
try scheduleJobs(
|
||||
db,
|
||||
threadId: threadId,
|
||||
threadVariant: threadVariant,
|
||||
interactionIds: [interactionId],
|
||||
interactionInfo: [interactionInfo],
|
||||
lastReadTimestampMs: interactionInfo.timestampMs
|
||||
)
|
||||
return
|
||||
|
@ -579,7 +596,7 @@ public extension Interaction {
|
|||
db,
|
||||
threadId: threadId,
|
||||
threadVariant: threadVariant,
|
||||
interactionIds: interactionIdsToMarkAsRead,
|
||||
interactionInfo: interactionInfoToMarkAsRead,
|
||||
lastReadTimestampMs: interactionInfo.timestampMs
|
||||
)
|
||||
}
|
||||
|
@ -587,21 +604,65 @@ public extension Interaction {
|
|||
/// 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)
|
||||
static func markAsRecipientRead(_ db: Database, recipientId: String, timestampMsValues: [Double], readTimestampMs: Double) throws {
|
||||
guard db[.areReadReceiptsEnabled] == true else { return }
|
||||
@discardableResult static func markAsRead(
|
||||
_ 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)
|
||||
.joining(
|
||||
required: RecipientState.interaction
|
||||
.filter(Columns.variant == Variant.standardOutgoing)
|
||||
.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(
|
||||
db,
|
||||
RecipientState.Columns.readTimestampMs.set(to: readTimestampMs),
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 skipped
|
||||
case sent
|
||||
case failedToSync // One-to-one Only
|
||||
case syncing // One-to-one Only
|
||||
|
||||
func message(hasAttachments: Bool, hasAtLeastOneReadReceipt: Bool) -> String {
|
||||
switch self {
|
||||
|
@ -58,6 +60,9 @@ public struct RecipientState: Codable, Equatable, FetchableRecord, PersistableRe
|
|||
}
|
||||
|
||||
return "MESSAGE_STATUS_READ".localized()
|
||||
|
||||
case .failedToSync: return "MESSAGE_DELIVERY_STATUS_FAILED_SYNC".localized()
|
||||
case .syncing: return "MESSAGE_DELIVERY_STATUS_SYNCING".localized()
|
||||
|
||||
default:
|
||||
owsFailDebug("Message has unexpected status: \(self).")
|
||||
|
@ -96,6 +101,21 @@ public struct RecipientState: Codable, Equatable, FetchableRecord, PersistableRe
|
|||
"MESSAGE_DELIVERY_STATUS_FAILED".localized(),
|
||||
.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
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -330,18 +330,31 @@ public extension SessionThread {
|
|||
) -> String? {
|
||||
guard
|
||||
threadVariant == .community,
|
||||
let blindingInfo: (edkeyPair: Box.KeyPair?, publicKey: String?) = Storage.shared.read({ db in
|
||||
let blindingInfo: (edkeyPair: Box.KeyPair?, publicKey: String?, capabilities: Set<Capability.Variant>) = Storage.shared.read({ db in
|
||||
struct OpenGroupInfo: Decodable, FetchableRecord {
|
||||
let publicKey: String?
|
||||
let server: String?
|
||||
}
|
||||
let openGroupInfo: OpenGroupInfo? = try OpenGroup
|
||||
.filter(id: threadId)
|
||||
.select(.publicKey, .server)
|
||||
.asRequest(of: OpenGroupInfo.self)
|
||||
.fetchOne(db)
|
||||
|
||||
return (
|
||||
Identity.fetchUserEd25519KeyPair(db),
|
||||
try OpenGroup
|
||||
.filter(id: threadId)
|
||||
.select(.publicKey)
|
||||
.asRequest(of: String.self)
|
||||
.fetchOne(db)
|
||||
openGroupInfo?.publicKey,
|
||||
(try? Capability
|
||||
.select(.variant)
|
||||
.filter(Capability.Columns.openGroupServer == openGroupInfo?.server?.lowercased())
|
||||
.asRequest(of: Capability.Variant.self)
|
||||
.fetchSet(db))
|
||||
.defaulting(to: [])
|
||||
)
|
||||
}),
|
||||
let userEdKeyPair: Box.KeyPair = blindingInfo.edkeyPair,
|
||||
let publicKey: String = blindingInfo.publicKey
|
||||
let publicKey: String = blindingInfo.publicKey,
|
||||
blindingInfo.capabilities.isEmpty || blindingInfo.capabilities.contains(.blind)
|
||||
else { return nil }
|
||||
|
||||
let sodium: Sodium = Sodium()
|
||||
|
|
|
@ -13,7 +13,10 @@ public enum FileServerAPI {
|
|||
public static let oldServerPublicKey = "7cb31905b55cd5580c686911debf672577b3fb0bff81df4ce2d5c4cb3a7aaa69"
|
||||
public static let server = "http://filev2.getsession.org"
|
||||
public static let serverPublicKey = "da21e1d886c6fbaea313f75298bd64aab03a97ce985b46bb2dad9f2089c8ee59"
|
||||
public static let maxFileSize = (10 * 1024 * 1024) // 10 MB
|
||||
|
||||
/// **Note:** The max file size is 10,000,000 bytes (rather than 10MiB which would be `(10 * 1024 * 1024)`), 10,000,000
|
||||
/// exactly will be fine but a single byte more will result in an error
|
||||
public static let maxFileSize = 10_000_000
|
||||
|
||||
/// Standard timeout is 10 seconds which is a little too short fir file upload/download with slightly larger files
|
||||
public static let fileTimeout: TimeInterval = 30
|
||||
|
|
|
@ -19,12 +19,16 @@ public enum FailedMessageSendsJob: JobExecutor {
|
|||
) {
|
||||
// Update all 'sending' message states to 'failed'
|
||||
Storage.shared.write { db in
|
||||
let changeCount: Int = try RecipientState
|
||||
let sendChangeCount: Int = try RecipientState
|
||||
.filter(RecipientState.Columns.state == RecipientState.State.sending)
|
||||
.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
|
||||
.filter(Attachment.Columns.state == Attachment.State.uploading)
|
||||
.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)")
|
||||
}
|
||||
|
|
|
@ -40,7 +40,7 @@ public enum GarbageCollectionJob: JobExecutor {
|
|||
/// are shown)
|
||||
let lastGarbageCollection: Date = UserDefaults.standard[.lastGarbageCollection]
|
||||
.defaulting(to: Date.distantPast)
|
||||
let finalTypesToCollection: Set<Types> = {
|
||||
let finalTypesToCollect: Set<Types> = {
|
||||
guard
|
||||
job.behaviour != .recurringOnActive ||
|
||||
Date().timeIntervalSince(lastGarbageCollection) > (23 * 60 * 60)
|
||||
|
@ -59,20 +59,20 @@ public enum GarbageCollectionJob: JobExecutor {
|
|||
Storage.shared.writeAsync(
|
||||
updates: { db in
|
||||
/// Remove any typing indicators
|
||||
if finalTypesToCollection.contains(.threadTypingIndicators) {
|
||||
if finalTypesToCollect.contains(.threadTypingIndicators) {
|
||||
_ = try ThreadTypingIndicator
|
||||
.deleteAll(db)
|
||||
}
|
||||
|
||||
/// Remove any expired controlMessageProcessRecords
|
||||
if finalTypesToCollection.contains(.expiredControlMessageProcessRecords) {
|
||||
if finalTypesToCollect.contains(.expiredControlMessageProcessRecords) {
|
||||
_ = try ControlMessageProcessRecord
|
||||
.filter(ControlMessageProcessRecord.Columns.serverExpirationTimestamp <= timestampNow)
|
||||
.deleteAll(db)
|
||||
}
|
||||
|
||||
/// 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 thread: TypedTableAlias<SessionThread> = TypedTableAlias()
|
||||
let threadIdLiteral: SQL = SQL(stringLiteral: Interaction.Columns.threadId.name)
|
||||
|
@ -103,7 +103,7 @@ public enum GarbageCollectionJob: JobExecutor {
|
|||
}
|
||||
|
||||
/// 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 thread: TypedTableAlias<SessionThread> = TypedTableAlias()
|
||||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||
|
@ -129,7 +129,7 @@ public enum GarbageCollectionJob: JobExecutor {
|
|||
}
|
||||
|
||||
/// 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 interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||
|
||||
|
@ -149,7 +149,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
|
||||
/// 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 thread: TypedTableAlias<SessionThread> = TypedTableAlias()
|
||||
|
||||
|
@ -168,7 +168,7 @@ public enum GarbageCollectionJob: JobExecutor {
|
|||
}
|
||||
|
||||
/// 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 openGroup: TypedTableAlias<OpenGroup> = TypedTableAlias()
|
||||
|
||||
|
@ -184,7 +184,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
|
||||
if finalTypesToCollection.contains(.orphanedBlindedIdLookups) {
|
||||
if finalTypesToCollect.contains(.orphanedBlindedIdLookups) {
|
||||
let blindedIdLookup: TypedTableAlias<BlindedIdLookup> = TypedTableAlias()
|
||||
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
|
||||
let contact: TypedTableAlias<Contact> = TypedTableAlias()
|
||||
|
@ -212,7 +212,7 @@ public enum GarbageCollectionJob: JobExecutor {
|
|||
|
||||
/// Approved blinded contact records - once a blinded contact has been approved there is no need to keep the blinded
|
||||
/// contact record around anymore
|
||||
if finalTypesToCollection.contains(.approvedBlindedContactRecords) {
|
||||
if finalTypesToCollect.contains(.approvedBlindedContactRecords) {
|
||||
let contact: TypedTableAlias<Contact> = TypedTableAlias()
|
||||
let blindedIdLookup: TypedTableAlias<BlindedIdLookup> = TypedTableAlias()
|
||||
|
||||
|
@ -231,7 +231,7 @@ public enum GarbageCollectionJob: JobExecutor {
|
|||
}
|
||||
|
||||
/// 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 quote: TypedTableAlias<Quote> = TypedTableAlias()
|
||||
let linkPreview: TypedTableAlias<LinkPreview> = TypedTableAlias()
|
||||
|
@ -254,7 +254,7 @@ public enum GarbageCollectionJob: JobExecutor {
|
|||
""")
|
||||
}
|
||||
|
||||
if finalTypesToCollection.contains(.orphanedProfiles) {
|
||||
if finalTypesToCollect.contains(.orphanedProfiles) {
|
||||
let profile: TypedTableAlias<Profile> = TypedTableAlias()
|
||||
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
|
||||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||
|
@ -288,6 +288,12 @@ public enum GarbageCollectionJob: JobExecutor {
|
|||
)
|
||||
""")
|
||||
}
|
||||
|
||||
if finalTypesToCollect.contains(.expiredPendingReadReceipts) {
|
||||
_ = try PendingReadReceipt
|
||||
.filter(PendingReadReceipt.Columns.serverExpirationTimestamp <= timestampNow)
|
||||
.deleteAll(db)
|
||||
}
|
||||
},
|
||||
completion: { _, _ in
|
||||
// Dispatch async so we can swap from the write queue to a read one (we are done writing)
|
||||
|
@ -303,7 +309,7 @@ public enum GarbageCollectionJob: JobExecutor {
|
|||
var profileAvatarFilenames: Set<String> = []
|
||||
|
||||
/// 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
|
||||
/// 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)
|
||||
|
@ -316,7 +322,7 @@ public enum GarbageCollectionJob: JobExecutor {
|
|||
}
|
||||
|
||||
/// 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
|
||||
.select(.profilePictureFileName)
|
||||
.filter(Profile.Columns.profilePictureFileName != nil)
|
||||
|
@ -339,7 +345,7 @@ public enum GarbageCollectionJob: JobExecutor {
|
|||
var deletionErrors: [Error] = []
|
||||
|
||||
// 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
|
||||
// enumerator method
|
||||
let fileEnumerator = FileManager.default.enumerator(
|
||||
|
@ -383,7 +389,7 @@ public enum GarbageCollectionJob: JobExecutor {
|
|||
}
|
||||
|
||||
// Orphaned profile avatar files (actual deletion)
|
||||
if finalTypesToCollection.contains(.orphanedProfileAvatars) {
|
||||
if finalTypesToCollect.contains(.orphanedProfileAvatars) {
|
||||
let allAvatarProfileFilenames: Set<String> = (try? FileManager.default
|
||||
.contentsOfDirectory(atPath: ProfileManager.sharedDataProfileAvatarsDirPath))
|
||||
.defaulting(to: [])
|
||||
|
@ -441,6 +447,7 @@ extension GarbageCollectionJob {
|
|||
case orphanedAttachments
|
||||
case orphanedAttachmentFiles
|
||||
case orphanedProfileAvatars
|
||||
case expiredPendingReadReceipts
|
||||
}
|
||||
|
||||
public struct Details: Codable {
|
||||
|
|
|
@ -60,6 +60,7 @@ public enum MessageReceiveJob: JobExecutor {
|
|||
try MessageReceiver.handle(
|
||||
db,
|
||||
message: messageInfo.message,
|
||||
serverExpirationTimestamp: messageInfo.serverExpirationTimestamp,
|
||||
associatedWithProto: protoContent,
|
||||
openGroupId: nil
|
||||
)
|
||||
|
@ -130,30 +131,36 @@ extension MessageReceiveJob {
|
|||
private enum CodingKeys: String, CodingKey {
|
||||
case message
|
||||
case variant
|
||||
case serverExpirationTimestamp
|
||||
case serializedProtoData
|
||||
}
|
||||
|
||||
public let message: Message
|
||||
public let variant: Message.Variant
|
||||
public let serverExpirationTimestamp: TimeInterval?
|
||||
public let serializedProtoData: Data
|
||||
|
||||
public init(
|
||||
message: Message,
|
||||
variant: Message.Variant,
|
||||
serverExpirationTimestamp: TimeInterval?,
|
||||
proto: SNProtoContent
|
||||
) throws {
|
||||
self.message = message
|
||||
self.variant = variant
|
||||
self.serverExpirationTimestamp = serverExpirationTimestamp
|
||||
self.serializedProtoData = try proto.serializedData()
|
||||
}
|
||||
|
||||
private init(
|
||||
message: Message,
|
||||
variant: Message.Variant,
|
||||
serverExpirationTimestamp: TimeInterval?,
|
||||
serializedProtoData: Data
|
||||
) {
|
||||
self.message = message
|
||||
self.variant = variant
|
||||
self.serverExpirationTimestamp = serverExpirationTimestamp
|
||||
self.serializedProtoData = serializedProtoData
|
||||
}
|
||||
|
||||
|
@ -170,6 +177,7 @@ extension MessageReceiveJob {
|
|||
self = MessageInfo(
|
||||
message: try variant.decode(from: container, forKey: .message),
|
||||
variant: variant,
|
||||
serverExpirationTimestamp: try? container.decode(TimeInterval.self, forKey: .serverExpirationTimestamp),
|
||||
serializedProtoData: try container.decode(Data.self, forKey: .serializedProtoData)
|
||||
)
|
||||
}
|
||||
|
@ -184,6 +192,7 @@ extension MessageReceiveJob {
|
|||
|
||||
try container.encode(message, forKey: .message)
|
||||
try container.encode(variant, forKey: .variant)
|
||||
try container.encodeIfPresent(serverExpirationTimestamp, forKey: .serverExpirationTimestamp)
|
||||
try container.encode(serializedProtoData, forKey: .serializedProtoData)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -223,21 +223,25 @@ extension MessageSendJob {
|
|||
private enum CodingKeys: String, CodingKey {
|
||||
case destination
|
||||
case message
|
||||
case isSyncMessage
|
||||
case variant
|
||||
}
|
||||
|
||||
public let destination: Message.Destination
|
||||
public let message: Message
|
||||
public let isSyncMessage: Bool?
|
||||
public let variant: Message.Variant?
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
public init(
|
||||
destination: Message.Destination,
|
||||
message: Message
|
||||
message: Message,
|
||||
isSyncMessage: Bool? = nil
|
||||
) {
|
||||
self.destination = destination
|
||||
self.message = message
|
||||
self.isSyncMessage = isSyncMessage
|
||||
self.variant = Message.Variant(from: message)
|
||||
}
|
||||
|
||||
|
@ -253,7 +257,8 @@ extension MessageSendJob {
|
|||
|
||||
self = Details(
|
||||
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)
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -267,6 +272,7 @@ extension MessageSendJob {
|
|||
|
||||
try container.encode(destination, forKey: .destination)
|
||||
try container.encode(message, forKey: .message)
|
||||
try container.encodeIfPresent(isSyncMessage, forKey: .isSyncMessage)
|
||||
try container.encode(variant, forKey: .variant)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -43,7 +43,8 @@ public enum SendReadReceiptsJob: JobExecutor {
|
|||
timestamps: details.timestampMsValues.map { UInt64($0) }
|
||||
),
|
||||
to: details.destination,
|
||||
interactionId: nil
|
||||
interactionId: nil,
|
||||
isSyncMessage: false
|
||||
)
|
||||
}
|
||||
.flatMap { MessageSender.sendImmediate(preparedSendData: $0) }
|
||||
|
@ -104,33 +105,25 @@ extension SendReadReceiptsJob {
|
|||
// MARK: - Convenience
|
||||
|
||||
public extension SendReadReceiptsJob {
|
||||
/// This method upserts a 'sendReadReceipts' job to include the timestamps for the specified `interactionIds`
|
||||
///
|
||||
/// **Note:** This method assumes that the provided `interactionIds` are valid and won't filter out any invalid ids so
|
||||
/// ensure that is done correctly beforehand
|
||||
@discardableResult static func createOrUpdateIfNeeded(_ db: Database, threadId: String, interactionIds: [Int64]) -> Job? {
|
||||
guard db[.areReadReceiptsEnabled] == true else { return nil }
|
||||
guard !interactionIds.isEmpty else { return nil }
|
||||
|
||||
// Retrieve the timestampMs values for the specified interactions
|
||||
let maybeTimestampMsValues: [Int64]? = try? Int64.fetchAll(
|
||||
db,
|
||||
Interaction
|
||||
.select(.timestampMs)
|
||||
.filter(interactionIds.contains(Interaction.Columns.id))
|
||||
// Only `standardIncoming` incoming interactions should have read receipts sent
|
||||
.filter(Interaction.Columns.variant == Interaction.Variant.standardIncoming)
|
||||
.filter(Interaction.Columns.wasRead == false) // Only send for unread messages
|
||||
.joining(
|
||||
// Don't send read receipts in group threads
|
||||
required: Interaction.thread
|
||||
.filter(SessionThread.Columns.variant != SessionThread.Variant.legacyGroup)
|
||||
.filter(SessionThread.Columns.variant != SessionThread.Variant.group)
|
||||
.filter(SessionThread.Columns.variant != SessionThread.Variant.community)
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
let timestampMsValues: [Int64] = (try? Interaction
|
||||
.select(.timestampMs)
|
||||
.filter(interactionIds.contains(Interaction.Columns.id))
|
||||
.distinct()
|
||||
.asRequest(of: Int64.self)
|
||||
.fetchAll(db))
|
||||
.defaulting(to: [])
|
||||
|
||||
// If there are no timestamp values then do nothing
|
||||
guard
|
||||
let timestampMsValues: [Int64] = maybeTimestampMsValues,
|
||||
!timestampMsValues.isEmpty
|
||||
else { return nil }
|
||||
guard !timestampMsValues.isEmpty else { return nil }
|
||||
|
||||
// Try to get an existing job (if there is one that's not running)
|
||||
if
|
||||
|
|
|
@ -171,6 +171,11 @@ public extension Message {
|
|||
|
||||
static func shouldSync(message: Message) -> Bool {
|
||||
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:
|
||||
switch controlMessage.kind {
|
||||
case .new: return true
|
||||
|
@ -182,9 +187,7 @@ public extension Message {
|
|||
case .answer, .endCall: return true
|
||||
default: return false
|
||||
}
|
||||
|
||||
case is ConfigurationMessage, is SharedConfigMessage: return true
|
||||
case is UnsendRequest: return true
|
||||
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
@ -537,6 +540,7 @@ public extension Message {
|
|||
try MessageReceiveJob.Details.MessageInfo(
|
||||
message: message,
|
||||
variant: variant,
|
||||
serverExpirationTimestamp: serverExpirationTimestamp,
|
||||
proto: proto
|
||||
)
|
||||
)
|
||||
|
|
|
@ -44,6 +44,7 @@ public final class VisibleMessage: Message {
|
|||
// MARK: - Initialization
|
||||
|
||||
public init(
|
||||
sender: String? = nil,
|
||||
sentTimestamp: UInt64? = nil,
|
||||
recipient: String? = nil,
|
||||
groupPublicKey: String? = nil,
|
||||
|
@ -68,6 +69,7 @@ public final class VisibleMessage: Message {
|
|||
super.init(
|
||||
sentTimestamp: sentTimestamp,
|
||||
recipient: recipient,
|
||||
sender: sender,
|
||||
groupPublicKey: groupPublicKey
|
||||
)
|
||||
}
|
||||
|
@ -214,6 +216,7 @@ public extension VisibleMessage {
|
|||
let linkPreview: LinkPreview? = try? interaction.linkPreview.fetchOne(db)
|
||||
|
||||
return VisibleMessage(
|
||||
sender: interaction.authorId,
|
||||
sentTimestamp: UInt64(interaction.timestampMs),
|
||||
recipient: (try? interaction.recipientStates.fetchOne(db))?.recipientId,
|
||||
groupPublicKey: try? interaction.thread
|
||||
|
|
|
@ -606,6 +606,7 @@ public final class OpenGroupManager {
|
|||
try MessageReceiver.handle(
|
||||
db,
|
||||
message: messageInfo.message,
|
||||
serverExpirationTimestamp: messageInfo.serverExpirationTimestamp,
|
||||
associatedWithProto: try SNProtoContent.parseData(messageInfo.serializedProtoData),
|
||||
openGroupId: openGroup.id,
|
||||
dependencies: dependencies
|
||||
|
@ -769,6 +770,7 @@ public final class OpenGroupManager {
|
|||
try MessageReceiver.handle(
|
||||
db,
|
||||
message: messageInfo.message,
|
||||
serverExpirationTimestamp: messageInfo.serverExpirationTimestamp,
|
||||
associatedWithProto: try SNProtoContent.parseData(messageInfo.serializedProtoData),
|
||||
openGroupId: nil, // Intentionally nil as they are technically not open group messages
|
||||
dependencies: dependencies
|
||||
|
|
|
@ -4,16 +4,32 @@ import Foundation
|
|||
import GRDB
|
||||
|
||||
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 timestampMsValues: [Double] = message.timestamps?.map({ Double($0) }) else { return }
|
||||
guard let readTimestampMs: Double = message.receivedTimestamp.map({ Double($0) }) else { return }
|
||||
guard let timestampMsValues: [Int64] = message.timestamps?.map({ Int64($0) }) else { return }
|
||||
guard let readTimestampMs: Int64 = message.receivedTimestamp.map({ Int64($0) }) else { return }
|
||||
|
||||
try Interaction.markAsRecipientRead(
|
||||
let pendingTimestampMs: Set<Int64> = try Interaction.markAsRead(
|
||||
db,
|
||||
recipientId: sender,
|
||||
timestampMsValues: timestampMsValues,
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -177,6 +177,7 @@ extension MessageReceiver {
|
|||
db,
|
||||
thread: thread,
|
||||
interactionId: existingInteractionId,
|
||||
messageSentTimestamp: messageSentTimestamp,
|
||||
variant: variant,
|
||||
syncTarget: message.syncTarget
|
||||
)
|
||||
|
@ -194,6 +195,7 @@ extension MessageReceiver {
|
|||
db,
|
||||
thread: thread,
|
||||
interactionId: interactionId,
|
||||
messageSentTimestamp: messageSentTimestamp,
|
||||
variant: variant,
|
||||
syncTarget: message.syncTarget
|
||||
)
|
||||
|
@ -378,11 +380,19 @@ extension MessageReceiver {
|
|||
_ db: Database,
|
||||
thread: SessionThread,
|
||||
interactionId: Int64,
|
||||
messageSentTimestamp: TimeInterval,
|
||||
variant: Interaction.Variant,
|
||||
syncTarget: String?
|
||||
) throws {
|
||||
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 {
|
||||
case .contact:
|
||||
if let syncTarget: String = syncTarget {
|
||||
|
@ -424,5 +434,22 @@ extension MessageReceiver {
|
|||
includingOlder: 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -180,13 +180,18 @@ public enum MessageReceiver {
|
|||
public static func handle(
|
||||
_ db: Database,
|
||||
message: Message,
|
||||
serverExpirationTimestamp: TimeInterval?,
|
||||
associatedWithProto proto: SNProtoContent,
|
||||
openGroupId: String?,
|
||||
dependencies: SMKDependencies = SMKDependencies()
|
||||
) throws {
|
||||
switch message {
|
||||
case let message as ReadReceipt:
|
||||
try MessageReceiver.handleReadReceipt(db, message: message)
|
||||
try MessageReceiver.handleReadReceipt(
|
||||
db,
|
||||
message: message,
|
||||
serverExpirationTimestamp: serverExpirationTimestamp
|
||||
)
|
||||
|
||||
case let message as TypingIndicator:
|
||||
try MessageReceiver.handleTypingIndicator(db, message: message)
|
||||
|
|
|
@ -9,7 +9,7 @@ extension MessageSender {
|
|||
|
||||
// MARK: - Durable
|
||||
|
||||
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
|
||||
guard interaction.variant == .standardOutgoing else { throw MessageSenderError.invalidMessage }
|
||||
guard let interactionId: Int64 = interaction.id else { throw StorageError.objectNotSaved }
|
||||
|
@ -19,21 +19,37 @@ extension MessageSender {
|
|||
message: VisibleMessage.from(db, interaction: interaction),
|
||||
threadId: thread.id,
|
||||
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(
|
||||
db,
|
||||
message: message,
|
||||
threadId: thread.id,
|
||||
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(
|
||||
db,
|
||||
job: Job(
|
||||
|
@ -42,7 +58,8 @@ extension MessageSender {
|
|||
interactionId: interactionId,
|
||||
details: MessageSendJob.Details(
|
||||
destination: destination,
|
||||
message: message
|
||||
message: message,
|
||||
isSyncMessage: isSyncMessage
|
||||
)
|
||||
)
|
||||
)
|
||||
|
@ -90,7 +107,7 @@ extension MessageSender {
|
|||
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
|
||||
}
|
||||
}()
|
||||
|
|
|
@ -149,7 +149,7 @@ public final class MessageSender {
|
|||
using dependencies: SMKDependencies = SMKDependencies()
|
||||
) throws -> PreparedSendData {
|
||||
// Common logic for all destinations
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey(db, dependencies: dependencies)
|
||||
let currentUserPublicKey: String = getUserHexEncodedPublicKey(db, dependencies: dependencies)
|
||||
let messageSendTimestamp: Int64 = SnodeAPI.currentOffsetTimestampMs()
|
||||
let updatedMessage: Message = message
|
||||
|
||||
|
@ -166,8 +166,9 @@ public final class MessageSender {
|
|||
message: updatedMessage,
|
||||
to: destination,
|
||||
interactionId: interactionId,
|
||||
userPublicKey: userPublicKey,
|
||||
userPublicKey: currentUserPublicKey,
|
||||
messageSendTimestamp: messageSendTimestamp,
|
||||
isSyncMessage: isSyncMessage,
|
||||
using: dependencies
|
||||
)
|
||||
|
||||
|
@ -187,7 +188,7 @@ public final class MessageSender {
|
|||
message: message,
|
||||
to: destination,
|
||||
interactionId: interactionId,
|
||||
userPublicKey: userPublicKey,
|
||||
userPublicKey: currentUserPublicKey,
|
||||
messageSendTimestamp: messageSendTimestamp,
|
||||
using: dependencies
|
||||
)
|
||||
|
@ -224,20 +225,10 @@ public final class MessageSender {
|
|||
)
|
||||
}
|
||||
|
||||
// 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, using: dependencies)
|
||||
return PreparedSendData()
|
||||
}
|
||||
|
||||
// Attach the user's profile if needed (no need to do so for 'Note to Self' or sync messages as they
|
||||
// will be managed by the user config handling
|
||||
let isSelfSend: Bool = (message.recipient == userPublicKey)
|
||||
|
||||
if !isSelfSend, !isSyncMessage, var messageWithProfile: MessageWithProfile = message as? MessageWithProfile {
|
||||
let profile: Profile = Profile.fetchOrCreateCurrentUser(db)
|
||||
|
||||
|
@ -253,6 +244,9 @@ public final class MessageSender {
|
|||
}
|
||||
}
|
||||
|
||||
// Perform any pre-send actions
|
||||
handleMessageWillSend(db, message: message, interactionId: interactionId, isSyncMessage: isSyncMessage)
|
||||
|
||||
// Convert it to protobuf
|
||||
guard let proto = message.toProto(db) else {
|
||||
throw MessageSender.handleFailedMessageSend(
|
||||
|
@ -455,6 +449,9 @@ public final class MessageSender {
|
|||
)
|
||||
}
|
||||
|
||||
// Perform any pre-send actions
|
||||
handleMessageWillSend(db, message: message, interactionId: interactionId)
|
||||
|
||||
// Convert it to protobuf
|
||||
guard let proto = message.toProto(db) else {
|
||||
throw MessageSender.handleFailedMessageSend(
|
||||
|
@ -523,6 +520,9 @@ public final class MessageSender {
|
|||
}
|
||||
}
|
||||
|
||||
// Perform any pre-send actions
|
||||
handleMessageWillSend(db, message: message, interactionId: interactionId)
|
||||
|
||||
// Convert it to protobuf
|
||||
guard let proto = message.toProto(db) else {
|
||||
throw MessageSender.handleFailedMessageSend(
|
||||
|
@ -638,9 +638,6 @@ public final class MessageSender {
|
|||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
let isMainAppActive: Bool = (UserDefaults.sharedLokiProject?[.isMainAppActive])
|
||||
.defaulting(to: false)
|
||||
|
||||
return SnodeAPI
|
||||
.sendMessage(
|
||||
snodeMessage,
|
||||
|
@ -691,6 +688,9 @@ public final class MessageSender {
|
|||
return ()
|
||||
}
|
||||
.flatMap { _ -> AnyPublisher<Bool, Error> in
|
||||
let isMainAppActive: Bool = (UserDefaults.sharedLokiProject?[.isMainAppActive])
|
||||
.defaulting(to: false)
|
||||
|
||||
guard shouldNotify && !isMainAppActive else {
|
||||
return Just(true)
|
||||
.setFailureType(to: Error.self)
|
||||
|
@ -885,6 +885,32 @@ public final class MessageSender {
|
|||
|
||||
// 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(
|
||||
_ db: Database,
|
||||
message: Message,
|
||||
|
@ -895,7 +921,7 @@ public final class MessageSender {
|
|||
using dependencies: SMKDependencies = SMKDependencies()
|
||||
) throws {
|
||||
// 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 {
|
||||
try Reaction
|
||||
.filter(Reaction.Columns.interactionId == interactionId)
|
||||
|
@ -914,7 +940,6 @@ public final class MessageSender {
|
|||
// real message has no use when we delete a message. It is OK to let it be.
|
||||
try interaction.with(
|
||||
serverHash: message.serverHash,
|
||||
|
||||
// Track the open group server message ID and update server timestamp (use server
|
||||
// timestamp for open group messages otherwise the quote messages may not be able
|
||||
// to be found by the timestamp on other devices
|
||||
|
@ -927,6 +952,7 @@ public final class MessageSender {
|
|||
|
||||
// Mark the message as sent
|
||||
try interaction.recipientStates
|
||||
.filter(RecipientState.Columns.state != RecipientState.State.sent)
|
||||
.updateAll(db, RecipientState.Columns.state.set(to: RecipientState.State.sent))
|
||||
|
||||
// Start the disappearing messages timer if needed
|
||||
|
@ -941,49 +967,36 @@ 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
|
||||
try? ControlMessageProcessRecord(
|
||||
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
|
||||
}
|
||||
}(),
|
||||
threadId: threadId,
|
||||
message: message,
|
||||
serverExpirationTimestamp: (
|
||||
(TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000) +
|
||||
ControlMessageProcessRecord.defaultExpirationSeconds
|
||||
)
|
||||
)?.insert(db)
|
||||
|
||||
// Sync the message if:
|
||||
// • it's a visible message or an expiration timer update
|
||||
// • the destination was a contact
|
||||
// • we didn't sync it already
|
||||
let userPublicKey = getUserHexEncodedPublicKey(db)
|
||||
if case .contact(let publicKey) = destination, !isSyncMessage {
|
||||
if let message = message as? VisibleMessage { message.syncTarget = publicKey }
|
||||
if let message = message as? ExpirationTimerUpdate { message.syncTarget = publicKey }
|
||||
|
||||
MessageSender
|
||||
.sendToSnodeDestination(
|
||||
data: try prepareSendToSnodeDestination(
|
||||
db,
|
||||
message: message,
|
||||
to: .contact(publicKey: userPublicKey),
|
||||
interactionId: interactionId,
|
||||
userPublicKey: userPublicKey,
|
||||
messageSendTimestamp: SnodeAPI.currentOffsetTimestampMs(),
|
||||
isSyncMessage: true
|
||||
),
|
||||
using: dependencies
|
||||
)
|
||||
.sinkUntilComplete()
|
||||
}
|
||||
|
||||
// Sync the message if needed
|
||||
scheduleSyncMessageIfNeeded(
|
||||
db,
|
||||
message: message,
|
||||
destination: destination,
|
||||
threadId: threadId,
|
||||
interactionId: interactionId,
|
||||
isAlreadySyncMessage: isSyncMessage
|
||||
)
|
||||
}
|
||||
|
||||
@discardableResult private static func handleFailedMessageSend(
|
||||
|
@ -991,6 +1004,7 @@ public final class MessageSender {
|
|||
message: Message,
|
||||
with error: MessageSenderError,
|
||||
interactionId: Int64?,
|
||||
isSyncMessage: Bool = false,
|
||||
using dependencies: SMKDependencies = SMKDependencies()
|
||||
) -> Error {
|
||||
// TODO: Revert the local database change
|
||||
|
@ -1006,7 +1020,12 @@ public final class MessageSender {
|
|||
let rowIds: [Int64] = (try? RecipientState
|
||||
.select(Column.rowID)
|
||||
.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)
|
||||
.fetchAll(db))
|
||||
.defaulting(to: [])
|
||||
|
@ -1021,7 +1040,9 @@ public final class MessageSender {
|
|||
.filter(rowIds.contains(Column.rowID))
|
||||
.updateAll(
|
||||
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)
|
||||
)
|
||||
}
|
||||
|
@ -1045,4 +1066,43 @@ public final class MessageSender {
|
|||
|
||||
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
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -29,75 +29,98 @@ public extension MentionInfo {
|
|||
let profile: TypedTableAlias<Profile> = TypedTableAlias()
|
||||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||
let openGroup: TypedTableAlias<OpenGroup> = TypedTableAlias()
|
||||
let groupMember: TypedTableAlias<GroupMember> = TypedTableAlias()
|
||||
|
||||
let prefixLiteral: SQL = SQL(stringLiteral: "\(targetPrefix.rawValue)%")
|
||||
let profileFullTextSearch: SQL = SQL(stringLiteral: Profile.fullTextSearchTableName)
|
||||
|
||||
/// **Note:** The `\(MentionInfo.profileKey).*` value **MUST** be first
|
||||
let limitSQL: SQL? = (threadVariant == .community ? SQL("LIMIT 20") : nil)
|
||||
|
||||
/// The query needs to differ depending on the thread variant because the behaviour should be different:
|
||||
///
|
||||
/// **Contact:** We should show the profile directly (filtered out if the pattern doesn't match)
|
||||
/// **Group:** We should show all profiles within the group, filtered by the pattern
|
||||
/// **Community:** We should show only the 20 most recent profiles which match the pattern
|
||||
let request: SQLRequest<MentionInfo> = {
|
||||
guard let pattern: FTS5Pattern = pattern else {
|
||||
let finalLimitSQL: SQL = (limitSQL ?? "")
|
||||
let hasValidPattern: Bool = (pattern != nil && pattern?.rawPattern != "\"\"*")
|
||||
let targetJoin: SQL = {
|
||||
guard hasValidPattern else { return "FROM \(Profile.self)" }
|
||||
|
||||
return """
|
||||
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)
|
||||
|
||||
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 (
|
||||
FROM \(profileFullTextSearch)
|
||||
JOIN \(Profile.self) ON (
|
||||
\(Profile.self).rowid = \(profileFullTextSearch).rowid AND
|
||||
\(SQL("\(profile[.id]) != \(userPublicKey)")) AND (
|
||||
\(SQL("\(threadVariant) != \(SessionThread.Variant.community)")) OR
|
||||
\(SQL("\(profile[.id]) LIKE '\(prefixLiteral)'"))
|
||||
)
|
||||
)
|
||||
GROUP BY \(profile[.id])
|
||||
ORDER BY \(interaction[.timestampMs].desc)
|
||||
\(finalLimitSQL)
|
||||
"""
|
||||
}
|
||||
|
||||
// If we do have a search patern then use FTS
|
||||
let matchLiteral: SQL = SQL(stringLiteral: "\(Profile.Columns.nickname.name):\(pattern.rawPattern) OR \(Profile.Columns.name.name):\(pattern.rawPattern)")
|
||||
let finalLimitSQL: SQL = (limitSQL ?? "")
|
||||
|
||||
return """
|
||||
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)
|
||||
}()
|
||||
let targetWhere: SQL = {
|
||||
guard let pattern: FTS5Pattern = pattern, pattern.rawPattern != "\"\"*" else {
|
||||
return """
|
||||
WHERE (
|
||||
\(SQL("\(profile[.id]) != \(userPublicKey)")) AND (
|
||||
\(SQL("\(threadVariant) != \(SessionThread.Variant.community)")) OR
|
||||
\(SQL("\(profile[.id]) LIKE '\(prefixLiteral)'"))
|
||||
)
|
||||
)
|
||||
"""
|
||||
}
|
||||
|
||||
FROM \(profileFullTextSearch)
|
||||
JOIN \(Profile.self) ON (
|
||||
\(Profile.self).rowid = \(profileFullTextSearch).rowid AND
|
||||
\(SQL("\(profile[.id]) != \(userPublicKey)")) AND (
|
||||
\(SQL("\(threadVariant) != \(SessionThread.Variant.community)")) 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)"))
|
||||
let matchLiteral: SQL = SQL(stringLiteral: "\(Profile.Columns.nickname.name):\(pattern.rawPattern) OR \(Profile.Columns.name.name):\(pattern.rawPattern)")
|
||||
|
||||
return "WHERE \(profileFullTextSearch) MATCH '\(matchLiteral)'"
|
||||
}()
|
||||
|
||||
WHERE \(profileFullTextSearch) MATCH '\(matchLiteral)'
|
||||
GROUP BY \(profile[.id])
|
||||
ORDER BY \(interaction[.timestampMs].desc)
|
||||
\(finalLimitSQL)
|
||||
"""
|
||||
switch threadVariant {
|
||||
case .contact:
|
||||
return SQLRequest("""
|
||||
SELECT
|
||||
\(Profile.self).*,
|
||||
\(SQL("\(threadVariant) AS \(MentionInfo.threadVariantKey)"))
|
||||
|
||||
\(targetJoin)
|
||||
\(targetWhere) AND \(SQL("\(profile[.id]) = \(threadId)"))
|
||||
""")
|
||||
|
||||
case .legacyGroup, .group:
|
||||
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 .community:
|
||||
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
|
||||
|
|
|
@ -39,6 +39,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable,
|
|||
public static let positionInClusterKey: SQL = SQL(stringLiteral: CodingKeys.positionInCluster.stringValue)
|
||||
public static let isOnlyMessageInClusterKey: SQL = SQL(stringLiteral: CodingKeys.isOnlyMessageInCluster.stringValue)
|
||||
public static let isLastKey: SQL = SQL(stringLiteral: CodingKeys.isLast.stringValue)
|
||||
public static let isLastOutgoingKey: SQL = SQL(stringLiteral: CodingKeys.isLastOutgoing.stringValue)
|
||||
|
||||
public static let profileString: String = CodingKeys.profile.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
|
||||
public let isLast: Bool
|
||||
|
||||
public let isLastOutgoing: Bool
|
||||
|
||||
/// This is the users blinded key (will only be set for messages within open groups)
|
||||
public let currentUserBlindedPublicKey: String?
|
||||
|
||||
|
@ -191,6 +194,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable,
|
|||
positionInCluster: self.positionInCluster,
|
||||
isOnlyMessageInCluster: self.isOnlyMessageInCluster,
|
||||
isLast: self.isLast,
|
||||
isLastOutgoing: self.isLastOutgoing,
|
||||
currentUserBlindedPublicKey: self.currentUserBlindedPublicKey
|
||||
)
|
||||
}
|
||||
|
@ -199,6 +203,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable,
|
|||
prevModel: MessageViewModel?,
|
||||
nextModel: MessageViewModel?,
|
||||
isLast: Bool,
|
||||
isLastOutgoing: Bool,
|
||||
currentUserBlindedPublicKey: String?
|
||||
) -> MessageViewModel {
|
||||
let cellType: CellType = {
|
||||
|
@ -406,6 +411,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable,
|
|||
positionInCluster: positionInCluster,
|
||||
isOnlyMessageInCluster: isOnlyMessageInCluster,
|
||||
isLast: isLast,
|
||||
isLastOutgoing: isLastOutgoing,
|
||||
currentUserBlindedPublicKey: currentUserBlindedPublicKey
|
||||
)
|
||||
}
|
||||
|
@ -501,7 +507,8 @@ public extension MessageViewModel {
|
|||
quote: Quote? = nil,
|
||||
cellType: CellType = .typingIndicator,
|
||||
isTypingIndicator: Bool? = nil,
|
||||
isLast: Bool = true
|
||||
isLast: Bool = true,
|
||||
isLastOutgoing: Bool = false
|
||||
) {
|
||||
self.threadId = "INVALID_THREAD_ID"
|
||||
self.threadVariant = .contact
|
||||
|
@ -557,6 +564,7 @@ public extension MessageViewModel {
|
|||
self.positionInCluster = .middle
|
||||
self.isOnlyMessageInCluster = true
|
||||
self.isLast = isLast
|
||||
self.isLastOutgoing = isLastOutgoing
|
||||
self.currentUserBlindedPublicKey = nil
|
||||
}
|
||||
}
|
||||
|
@ -624,6 +632,7 @@ public extension MessageViewModel {
|
|||
|
||||
static func baseQuery(
|
||||
userPublicKey: String,
|
||||
blindedPublicKey: String?,
|
||||
orderSQL: SQL,
|
||||
groupSQL: SQL?
|
||||
) -> (([Int64]) -> AdaptedFetchRequest<SQLRequest<MessageViewModel>>) {
|
||||
|
@ -703,7 +712,8 @@ public extension MessageViewModel {
|
|||
false AS \(ViewModel.shouldShowDateHeaderKey),
|
||||
\(Position.middle) AS \(ViewModel.positionInClusterKey),
|
||||
false AS \(ViewModel.isOnlyMessageInClusterKey),
|
||||
false AS \(ViewModel.isLastKey)
|
||||
false AS \(ViewModel.isLastKey),
|
||||
false AS \(ViewModel.isLastOutgoingKey)
|
||||
|
||||
FROM \(Interaction.self)
|
||||
JOIN \(SessionThread.self) ON \(thread[.id]) = \(interaction[.threadId])
|
||||
|
@ -720,7 +730,12 @@ public extension MessageViewModel {
|
|||
\(interactionAttachment[.attachmentId]) AS \(Quote.Columns.attachmentId)
|
||||
FROM \(Quote.self)
|
||||
LEFT JOIN \(Interaction.self) ON (
|
||||
\(quote[.authorId]) = \(interaction[.authorId]) AND
|
||||
(
|
||||
\(quote[.authorId]) = \(interaction[.authorId]) OR (
|
||||
\(quote[.authorId]) = \(blindedPublicKey ?? "") AND
|
||||
\(userPublicKey) = \(interaction[.authorId])
|
||||
)
|
||||
) AND
|
||||
\(quote[.timestampMs]) = \(interaction[.timestampMs])
|
||||
)
|
||||
LEFT JOIN \(InteractionAttachment.self) ON \(interaction[.id]) = \(interactionAttachment[.interactionId])
|
||||
|
|
|
@ -1708,15 +1708,14 @@ public extension SessionThreadViewModel {
|
|||
\(SQL("\(thread[.variant]) != \(SessionThread.Variant.contact)")) OR
|
||||
\(SQL("\(thread[.id]) = \(userPublicKey)")) OR
|
||||
\(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])
|
||||
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
|
||||
|
|
|
@ -33,6 +33,11 @@ public extension Setting.BoolKey {
|
|||
/// **Note:** Link Previews are only enabled for HTTPS urls
|
||||
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
|
||||
static let areCallsEnabled: Setting.BoolKey = "areCallsEnabled"
|
||||
|
||||
|
|
|
@ -628,6 +628,8 @@ public final class SnodeAPI {
|
|||
sodium: sodium.wrappedValue,
|
||||
userX25519PublicKey: userX25519PublicKey
|
||||
)
|
||||
|
||||
return (info, response)
|
||||
}
|
||||
.retry(maxRetryCount)
|
||||
.eraseToAnyPublisher()
|
||||
|
|
|
@ -10,6 +10,7 @@ internal enum Theme_ClassicDark: ThemeColors {
|
|||
.clear: .clear,
|
||||
.primary: .primary,
|
||||
.defaultPrimary: Theme.PrimaryColor.green.color,
|
||||
.warning: .warning,
|
||||
.danger: .dangerDark,
|
||||
.disabled: .disabledDark,
|
||||
.backgroundPrimary: .classicDark0,
|
||||
|
|
|
@ -10,6 +10,7 @@ internal enum Theme_ClassicLight: ThemeColors {
|
|||
.clear: .clear,
|
||||
.primary: .primary,
|
||||
.defaultPrimary: Theme.PrimaryColor.green.color,
|
||||
.warning: .warning,
|
||||
.danger: .dangerLight,
|
||||
.disabled: .disabledLight,
|
||||
.backgroundPrimary: .classicLight6,
|
||||
|
|
|
@ -41,6 +41,7 @@ public extension Theme {
|
|||
// MARK: - Standard Theme Colors
|
||||
|
||||
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 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
|
||||
|
|
|
@ -10,6 +10,7 @@ internal enum Theme_OceanDark: ThemeColors {
|
|||
.clear: .clear,
|
||||
.primary: .primary,
|
||||
.defaultPrimary: Theme.PrimaryColor.blue.color,
|
||||
.warning: .warning,
|
||||
.danger: .dangerDark,
|
||||
.disabled: .disabledDark,
|
||||
.backgroundPrimary: .oceanDark2,
|
||||
|
|
|
@ -10,6 +10,7 @@ internal enum Theme_OceanLight: ThemeColors {
|
|||
.clear: .clear,
|
||||
.primary: .primary,
|
||||
.defaultPrimary: Theme.PrimaryColor.blue.color,
|
||||
.warning: .warning,
|
||||
.danger: .dangerLight,
|
||||
.disabled: .disabledLight,
|
||||
.backgroundPrimary: .oceanLight7,
|
||||
|
|
|
@ -98,6 +98,7 @@ public indirect enum ThemeValue: Hashable {
|
|||
case clear
|
||||
case primary
|
||||
case defaultPrimary
|
||||
case warning
|
||||
case danger
|
||||
case disabled
|
||||
case backgroundPrimary
|
||||
|
|
|
@ -44,6 +44,7 @@ public enum SNUserDefaults {
|
|||
case lastOpenGroupImageUpdate
|
||||
case lastOpen
|
||||
case lastGarbageCollection
|
||||
case lastPushNotificationSync
|
||||
}
|
||||
|
||||
public enum Double: Swift.String {
|
||||
|
|
Loading…
Reference in New Issue