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:
Morgan Pretty 2023-02-21 11:11:06 +11:00
commit 742c4a161f
69 changed files with 1178 additions and 328 deletions

View File

@ -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;

View File

@ -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)

View File

@ -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(

View File

@ -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()

View File

@ -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

View File

@ -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
})
}

View File

@ -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

View File

@ -1,6 +1,6 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import UIKit
import Combine
import Reachability
import SignalUtilitiesKit

View File

@ -3,6 +3,7 @@
import Foundation
import Combine
import CoreServices
import SignalUtilitiesKit
import SessionUtilitiesKit
// There's no UTI type for webp!

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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
)

View File

@ -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))
}
}

View File

@ -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))
}
}

View File

@ -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]
}

View File

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

View File

@ -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
]
]
)

View File

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

View File

@ -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

View File

@ -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)
}
}

View File

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

View File

@ -40,6 +40,8 @@ public struct RecipientState: Codable, Equatable, FetchableRecord, PersistableRe
case failed
case 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)
)
}
}

View File

@ -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()

View File

@ -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

View File

@ -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)")
}

View File

@ -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 {

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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

View File

@ -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
)
)

View File

@ -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

View File

@ -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

View File

@ -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)
}
}
}

View File

@ -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)
}
}
}

View File

@ -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)

View File

@ -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
}
}()

View File

@ -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
)
)
)
}
}
}
}

View File

@ -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

View File

@ -39,6 +39,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable,
public static let positionInClusterKey: SQL = SQL(stringLiteral: CodingKeys.positionInCluster.stringValue)
public static let 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])

View File

@ -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

View File

@ -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"

View File

@ -628,6 +628,8 @@ public final class SnodeAPI {
sodium: sodium.wrappedValue,
userX25519PublicKey: userX25519PublicKey
)
return (info, response)
}
.retry(maxRetryCount)
.eraseToAnyPublisher()

View File

@ -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,

View File

@ -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,

View File

@ -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

View File

@ -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,

View File

@ -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,

View File

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

View File

@ -44,6 +44,7 @@ public enum SNUserDefaults {
case lastOpenGroupImageUpdate
case lastOpen
case lastGarbageCollection
case lastPushNotificationSync
}
public enum Double: Swift.String {