Fixed a number of issues found during internal testing
Added copy for an unrecoverable startup case Added some additional logs to better debug ValueObservation query errors Increased the pageSize to 20 on iPad devices (to prevent it immediately loading a second page) Cleaned up a bunch of threading logic (try to avoid overriding subscribe/receive threads specified at subscription) Consolidated the 'sendMessage' and 'sendAttachments' functions Updated the various frameworks to use 'DAWRF with DSYM' to allow for better debugging during debug mode (at the cost of a longer build time) Updated the logic to optimistically insert messages when sending to avoid any database write delays Updated the logic to avoid sending notifications for messages which are already marked as read by the config Fixed an issue where multiple paths could incorrectly get built at the same time in some cases Fixed an issue where other job queues could be started before the blockingQueue finishes Fixed a potential bug with the snode version comparison (was just a string comparison which would fail when getting to double-digit values) Fixed a bug where you couldn't remove the last reaction on a message Fixed the broken media message zoom animations Fixed a bug where the last message read in a conversation wouldn't be correctly detected as already read Fixed a bug where the QuoteView had no line limits (resulting in the '@You' mention background highlight being incorrectly positioned in the quote preview) Fixed a bug where a large number of configSyncJobs could be scheduled (only one would run at a time but this could result in performance impacts)
This commit is contained in:
parent
5db254303a
commit
53a5db0ea5
|
@ -1 +1 @@
|
|||
Subproject commit 49c78682a6f4546c8773113f3e201244f0b1e65a
|
||||
Subproject commit e0b994201a016cc5bf9065526a0ceb4291f60d5a
|
|
@ -578,6 +578,10 @@
|
|||
FD245C6B2850667400B966DD /* VisibleMessage+Profile.swift in Sources */ = {isa = PBXBuildFile; fileRef = C300A5B12554AF9800555489 /* VisibleMessage+Profile.swift */; };
|
||||
FD245C6C2850669200B966DD /* MessageReceiveJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A31225574F5200338F3E /* MessageReceiveJob.swift */; };
|
||||
FD245C6D285066A400B966DD /* NotifyPushServerJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A32E2557549C00338F3E /* NotifyPushServerJob.swift */; };
|
||||
FD29598B2A43BB8100888A17 /* GetStatsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD29598A2A43BB8100888A17 /* GetStatsResponse.swift */; };
|
||||
FD29598D2A43BC0B00888A17 /* Version.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD29598C2A43BC0B00888A17 /* Version.swift */; };
|
||||
FD2959902A43BE5F00888A17 /* VersionSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD29598F2A43BE5F00888A17 /* VersionSpec.swift */; };
|
||||
FD2959922A4417A900888A17 /* PreparedSendData.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2959912A4417A900888A17 /* PreparedSendData.swift */; };
|
||||
FD2AAAED28ED3E1000A49611 /* MockGeneralCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFD645C27F273F300808CA1 /* MockGeneralCache.swift */; };
|
||||
FD2AAAEE28ED3E1100A49611 /* MockGeneralCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFD645C27F273F300808CA1 /* MockGeneralCache.swift */; };
|
||||
FD2AAAF028ED57B500A49611 /* SynchronousStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2AAAEF28ED57B500A49611 /* SynchronousStorage.swift */; };
|
||||
|
@ -1281,7 +1285,6 @@
|
|||
7BC707F127290ACB002817AD /* SessionCallManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionCallManager.swift; sourceTree = "<group>"; };
|
||||
7BCD116B27016062006330F1 /* WebRTCSession+DataChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WebRTCSession+DataChannel.swift"; sourceTree = "<group>"; };
|
||||
7BD477A727EC39F5004E2822 /* Atomic.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Atomic.swift; sourceTree = "<group>"; };
|
||||
7BD477A927F15F24004E2822 /* OpenGroupServerIdLookup.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OpenGroupServerIdLookup.swift; sourceTree = "<group>"; };
|
||||
7BDCFC0424206E7300641C39 /* SessionNotificationServiceExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = SessionNotificationServiceExtension.entitlements; sourceTree = "<group>"; };
|
||||
7BDCFC07242186E700641C39 /* NotificationServiceExtensionContext.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationServiceExtensionContext.swift; sourceTree = "<group>"; };
|
||||
7BFA8AE22831D0D4001876F3 /* ContextMenuVC+EmojiReactsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContextMenuVC+EmojiReactsView.swift"; sourceTree = "<group>"; };
|
||||
|
@ -1725,6 +1728,10 @@
|
|||
FD23EA6028ED0B260058676E /* CombineExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombineExtensions.swift; sourceTree = "<group>"; };
|
||||
FD245C612850664300B966DD /* Configuration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Configuration.swift; sourceTree = "<group>"; };
|
||||
FD28A4F527EAD44C00FF65E7 /* Storage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storage.swift; sourceTree = "<group>"; };
|
||||
FD29598A2A43BB8100888A17 /* GetStatsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetStatsResponse.swift; sourceTree = "<group>"; };
|
||||
FD29598C2A43BC0B00888A17 /* Version.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Version.swift; sourceTree = "<group>"; };
|
||||
FD29598F2A43BE5F00888A17 /* VersionSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionSpec.swift; sourceTree = "<group>"; };
|
||||
FD2959912A4417A900888A17 /* PreparedSendData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreparedSendData.swift; sourceTree = "<group>"; };
|
||||
FD2AAAEF28ED57B500A49611 /* SynchronousStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SynchronousStorage.swift; sourceTree = "<group>"; };
|
||||
FD2B4AFA29429D1000AB4848 /* ConfigContactsSpec.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConfigContactsSpec.swift; sourceTree = "<group>"; };
|
||||
FD2B4AFC294688D000AB4848 /* SessionUtil+Contacts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionUtil+Contacts.swift"; sourceTree = "<group>"; };
|
||||
|
@ -3217,7 +3224,6 @@
|
|||
FDC4381827B34EAD00C60D73 /* Models */,
|
||||
FDC4380727B31D3A00C60D73 /* Types */,
|
||||
FD3C907427E83AC200CD579F /* OpenGroupServerIdLookup.swift */,
|
||||
7BD477A927F15F24004E2822 /* OpenGroupServerIdLookup.swift */,
|
||||
B88FA7B726045D100049422F /* OpenGroupAPI.swift */,
|
||||
C3DB66AB260ACA42001EFC55 /* OpenGroupManager.swift */,
|
||||
);
|
||||
|
@ -3609,6 +3615,7 @@
|
|||
FD8ECF93293856AF00C0D1BB /* Randomness.swift */,
|
||||
C33FDB38255A580B00E217F9 /* OWSBackgroundTask.h */,
|
||||
C33FDC1B255A581F00E217F9 /* OWSBackgroundTask.m */,
|
||||
FD29598C2A43BC0B00888A17 /* Version.swift */,
|
||||
);
|
||||
path = Utilities;
|
||||
sourceTree = "<group>";
|
||||
|
@ -3784,6 +3791,14 @@
|
|||
path = Utilities;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FD29598E2A43BE5400888A17 /* Utilities */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
FD29598F2A43BE5F00888A17 /* VersionSpec.swift */,
|
||||
);
|
||||
path = Utilities;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FD2B4B022949886900AB4848 /* Database */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -4033,6 +4048,7 @@
|
|||
FD37EA1228AB3F60003AE748 /* Database */,
|
||||
FD83B9B927CF20A5005E1583 /* General */,
|
||||
FD9B30F1293EA0AF008DEE3E /* Networking */,
|
||||
FD29598E2A43BE5400888A17 /* Utilities */,
|
||||
);
|
||||
path = SessionUtilitiesKitTests;
|
||||
sourceTree = "<group>";
|
||||
|
@ -4167,6 +4183,7 @@
|
|||
FDC4381F27B36ADC00C60D73 /* SOGSEndpoint.swift */,
|
||||
FDC4380827B31D4E00C60D73 /* OpenGroupAPIError.swift */,
|
||||
FDC4381627B32EC700C60D73 /* Personalization.swift */,
|
||||
FD2959912A4417A900888A17 /* PreparedSendData.swift */,
|
||||
FDC4381427B329CE00C60D73 /* NonceGenerator.swift */,
|
||||
FDC438C227BB512200C60D73 /* SodiumProtocols.swift */,
|
||||
);
|
||||
|
@ -4395,6 +4412,7 @@
|
|||
FDF848A629405C5A007DCAE5 /* ONSResolveRequest.swift */,
|
||||
FDF8489E29405C5A007DCAE5 /* ONSResolveResponse.swift */,
|
||||
FDF8489C29405C5A007DCAE5 /* GetServiceNodesRequest.swift */,
|
||||
FD29598A2A43BB8100888A17 /* GetStatsResponse.swift */,
|
||||
);
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
|
@ -5580,6 +5598,7 @@
|
|||
FDF848CC29405C5B007DCAE5 /* SnodeReceivedMessage.swift in Sources */,
|
||||
FDF848C129405C5A007DCAE5 /* UpdateExpiryRequest.swift in Sources */,
|
||||
FDF848C729405C5B007DCAE5 /* SendMessageResponse.swift in Sources */,
|
||||
FD29598B2A43BB8100888A17 /* GetStatsResponse.swift in Sources */,
|
||||
FDF848CA29405C5B007DCAE5 /* DeleteAllBeforeRequest.swift in Sources */,
|
||||
FDF848D229405C5B007DCAE5 /* LegacyGetMessagesRequest.swift in Sources */,
|
||||
FDF848CB29405C5B007DCAE5 /* SnodePoolResponse.swift in Sources */,
|
||||
|
@ -5696,6 +5715,7 @@
|
|||
C3D9E4F4256778AF0040E4F3 /* NSData+Image.m in Sources */,
|
||||
FDF222092818D2B0000A4995 /* NSAttributedString+Utilities.swift in Sources */,
|
||||
C32C5E0C256DDAFA003C73A2 /* NSRegularExpression+SSK.swift in Sources */,
|
||||
FD29598D2A43BC0B00888A17 /* Version.swift in Sources */,
|
||||
FDF8487C29405906007DCAE5 /* HTTPMethod.swift in Sources */,
|
||||
FDF8488429405A2B007DCAE5 /* RequestInfo.swift in Sources */,
|
||||
C3BBE0A92554D4DE0050F1E3 /* HTTP.swift in Sources */,
|
||||
|
@ -5892,6 +5912,7 @@
|
|||
FD245C632850664600B966DD /* Configuration.swift in Sources */,
|
||||
C32C5DBF256DD743003C73A2 /* ClosedGroupPoller.swift in Sources */,
|
||||
C352A35B2557824E00338F3E /* AttachmentUploadJob.swift in Sources */,
|
||||
FD2959922A4417A900888A17 /* PreparedSendData.swift in Sources */,
|
||||
FD5C7305284F0FF30029977D /* MessageReceiver+VisibleMessages.swift in Sources */,
|
||||
FD432437299DEA38008A0213 /* TypeConversion+Utilities.swift in Sources */,
|
||||
FD2B4B042949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift in Sources */,
|
||||
|
@ -6156,6 +6177,7 @@
|
|||
FD0B77B229B82B7A009169BA /* ArrayUtilitiesSpec.swift in Sources */,
|
||||
FD1A94FE2900D2EA000D73D3 /* PersistableRecordUtilitiesSpec.swift in Sources */,
|
||||
FDC290AA27D9B6FD005DAE71 /* Mock.swift in Sources */,
|
||||
FD2959902A43BE5F00888A17 /* VersionSpec.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
@ -6395,7 +6417,7 @@
|
|||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 408;
|
||||
CURRENT_PROJECT_VERSION = 409;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = SUQ8J2PCT7;
|
||||
FRAMEWORK_SEARCH_PATHS = "$(inherited)";
|
||||
|
@ -6467,7 +6489,7 @@
|
|||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 408;
|
||||
CURRENT_PROJECT_VERSION = 409;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = SUQ8J2PCT7;
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
|
@ -6532,7 +6554,7 @@
|
|||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 408;
|
||||
CURRENT_PROJECT_VERSION = 409;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = SUQ8J2PCT7;
|
||||
FRAMEWORK_SEARCH_PATHS = "$(inherited)";
|
||||
|
@ -6606,7 +6628,7 @@
|
|||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 408;
|
||||
CURRENT_PROJECT_VERSION = 409;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = SUQ8J2PCT7;
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
|
@ -6671,7 +6693,7 @@
|
|||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEFINES_MODULE = YES;
|
||||
DEVELOPMENT_TEAM = SUQ8J2PCT7;
|
||||
DYLIB_COMPATIBILITY_VERSION = 1;
|
||||
|
@ -6808,7 +6830,7 @@
|
|||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEFINES_MODULE = YES;
|
||||
DEVELOPMENT_TEAM = SUQ8J2PCT7;
|
||||
DYLIB_COMPATIBILITY_VERSION = 1;
|
||||
|
@ -6959,7 +6981,7 @@
|
|||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEFINES_MODULE = YES;
|
||||
DEVELOPMENT_TEAM = SUQ8J2PCT7;
|
||||
DYLIB_COMPATIBILITY_VERSION = 1;
|
||||
|
@ -7096,7 +7118,7 @@
|
|||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEFINES_MODULE = YES;
|
||||
DEVELOPMENT_TEAM = SUQ8J2PCT7;
|
||||
DYLIB_COMPATIBILITY_VERSION = 1;
|
||||
|
@ -7235,7 +7257,7 @@
|
|||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEFINES_MODULE = YES;
|
||||
DEVELOPMENT_TEAM = SUQ8J2PCT7;
|
||||
DYLIB_COMPATIBILITY_VERSION = 1;
|
||||
|
@ -7514,7 +7536,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CURRENT_PROJECT_VERSION = 408;
|
||||
CURRENT_PROJECT_VERSION = 409;
|
||||
DEVELOPMENT_TEAM = SUQ8J2PCT7;
|
||||
FRAMEWORK_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
|
@ -7585,7 +7607,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CURRENT_PROJECT_VERSION = 408;
|
||||
CURRENT_PROJECT_VERSION = 409;
|
||||
DEVELOPMENT_TEAM = SUQ8J2PCT7;
|
||||
FRAMEWORK_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
|
|
|
@ -214,7 +214,10 @@ extension ContextMenuVC {
|
|||
|
||||
let shouldShowEmojiActions: Bool = {
|
||||
if cellViewModel.threadVariant == .community {
|
||||
return OpenGroupManager.isOpenGroupSupport(.reactions, on: cellViewModel.threadOpenGroupServer)
|
||||
return OpenGroupManager.doesOpenGroupSupport(
|
||||
capability: .reactions,
|
||||
on: cellViewModel.threadOpenGroupServer
|
||||
)
|
||||
}
|
||||
return !currentThreadIsMessageRequest
|
||||
}()
|
||||
|
|
|
@ -150,10 +150,17 @@ extension ConversationVC:
|
|||
}
|
||||
|
||||
func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didApproveAttachments attachments: [SignalAttachment], forThreadId threadId: String, messageText: String?) {
|
||||
sendAttachments(attachments, with: messageText ?? "")
|
||||
self.snInputView.text = ""
|
||||
sendMessage(text: (messageText ?? ""), attachments: attachments)
|
||||
resetMentions()
|
||||
dismiss(animated: true) { }
|
||||
|
||||
dismiss(animated: true) { [weak self] in
|
||||
if self?.isFirstResponder == false {
|
||||
self?.becomeFirstResponder()
|
||||
}
|
||||
else {
|
||||
self?.reloadInputViews()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sendMediaNavInitialMessageText(_ sendMediaNavigationController: SendMediaNavigationController) -> String? {
|
||||
|
@ -167,13 +174,17 @@ extension ConversationVC:
|
|||
// MARK: - AttachmentApprovalViewControllerDelegate
|
||||
|
||||
func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didApproveAttachments attachments: [SignalAttachment], forThreadId threadId: String, messageText: String?) {
|
||||
sendAttachments(attachments, with: messageText ?? "") { [weak self] in
|
||||
self?.dismiss(animated: true, completion: nil)
|
||||
}
|
||||
|
||||
scrollToBottom(isAnimated: false)
|
||||
self.snInputView.text = ""
|
||||
sendMessage(text: (messageText ?? ""), attachments: attachments)
|
||||
resetMentions()
|
||||
|
||||
dismiss(animated: true) { [weak self] in
|
||||
if self?.isFirstResponder == false {
|
||||
self?.becomeFirstResponder()
|
||||
}
|
||||
else {
|
||||
self?.reloadInputViews()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func attachmentApprovalDidCancel(_ attachmentApproval: AttachmentApprovalViewController) {
|
||||
|
@ -181,7 +192,7 @@ extension ConversationVC:
|
|||
}
|
||||
|
||||
func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didChangeMessageText newMessageText: String?) {
|
||||
snInputView.text = newMessageText ?? ""
|
||||
snInputView.text = (newMessageText ?? "")
|
||||
}
|
||||
|
||||
func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didRemoveAttachment attachment: SignalAttachment) {
|
||||
|
@ -348,6 +359,7 @@ extension ConversationVC:
|
|||
attachments: attachments,
|
||||
approvalDelegate: self
|
||||
)
|
||||
navController.modalPresentationStyle = .fullScreen
|
||||
|
||||
present(navController, animated: true, completion: nil)
|
||||
}
|
||||
|
@ -369,7 +381,7 @@ extension ConversationVC:
|
|||
|
||||
modalActivityIndicator.dismiss {
|
||||
guard !attachment.hasError else {
|
||||
self?.showErrorAlert(for: attachment, onDismiss: nil)
|
||||
self?.showErrorAlert(for: attachment)
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -385,149 +397,33 @@ extension ConversationVC:
|
|||
// MARK: --Message Sending
|
||||
|
||||
func handleSendButtonTapped() {
|
||||
sendMessage()
|
||||
}
|
||||
|
||||
func sendMessage(hasPermissionToSendSeed: Bool = false) {
|
||||
guard !showBlockedModalIfNeeded() else { return }
|
||||
|
||||
let text = replaceMentions(in: snInputView.text.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
|
||||
guard !text.isEmpty else { return }
|
||||
|
||||
if text.contains(mnemonic) && !viewModel.threadData.threadIsNoteToSelf && !hasPermissionToSendSeed {
|
||||
// Warn the user if they're about to send their seed to someone
|
||||
let modal: ConfirmationModal = ConfirmationModal(
|
||||
info: ConfirmationModal.Info(
|
||||
title: "modal_send_seed_title".localized(),
|
||||
body: .text("modal_send_seed_explanation".localized()),
|
||||
confirmTitle: "modal_send_seed_send_button_title".localized(),
|
||||
confirmStyle: .danger,
|
||||
cancelStyle: .alert_text,
|
||||
onConfirm: { [weak self] _ in self?.sendMessage(hasPermissionToSendSeed: true) }
|
||||
)
|
||||
)
|
||||
|
||||
return present(modal, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
// Clearing this out immediately to make this appear more snappy
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.snInputView.text = ""
|
||||
self?.snInputView.quoteDraftInfo = nil
|
||||
|
||||
self?.resetMentions()
|
||||
}
|
||||
|
||||
// Note: 'shouldBeVisible' is set to true the first time a thread is saved so we can
|
||||
// use it to determine if the user is creating a new thread and update the 'isApproved'
|
||||
// flags appropriately
|
||||
let threadId: String = self.viewModel.threadData.threadId
|
||||
let threadVariant: SessionThread.Variant = self.viewModel.threadData.threadVariant
|
||||
let oldThreadShouldBeVisible: Bool = (self.viewModel.threadData.threadShouldBeVisible == true)
|
||||
let sentTimestampMs: Int64 = SnodeAPI.currentOffsetTimestampMs()
|
||||
let linkPreviewDraft: LinkPreviewDraft? = snInputView.linkPreviewInfo?.draft
|
||||
let quoteModel: QuotedReplyModel? = snInputView.quoteDraftInfo?.model
|
||||
|
||||
// If this was a message request then approve it
|
||||
approveMessageRequestIfNeeded(
|
||||
for: threadId,
|
||||
threadVariant: self.viewModel.threadData.threadVariant,
|
||||
isNewThread: !oldThreadShouldBeVisible,
|
||||
timestampMs: (sentTimestampMs - 1) // Set 1ms earlier as this is used for sorting
|
||||
sendMessage(
|
||||
text: snInputView.text.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
linkPreviewDraft: snInputView.linkPreviewInfo?.draft,
|
||||
quoteModel: snInputView.quoteDraftInfo?.model
|
||||
)
|
||||
|
||||
// Send the message
|
||||
Storage.shared
|
||||
.writePublisher { [weak self] db in
|
||||
// Let the viewModel know we are about to send a message
|
||||
self?.viewModel.sentMessageBeforeUpdate = true
|
||||
|
||||
// Update the thread to be visible (if it isn't already)
|
||||
if self?.viewModel.threadData.threadShouldBeVisible == false {
|
||||
_ = try SessionThread
|
||||
.filter(id: threadId)
|
||||
.updateAllAndConfig(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: authorId,
|
||||
variant: .standardOutgoing,
|
||||
body: text,
|
||||
timestampMs: sentTimestampMs,
|
||||
hasMention: Interaction.isUserMentioned(db, threadId: threadId, body: text),
|
||||
expiresInSeconds: try? DisappearingMessagesConfiguration
|
||||
.select(.durationSeconds)
|
||||
.filter(id: threadId)
|
||||
.filter(DisappearingMessagesConfiguration.Columns.isEnabled == true)
|
||||
.asRequest(of: TimeInterval.self)
|
||||
.fetchOne(db),
|
||||
linkPreviewUrl: linkPreviewDraft?.urlString
|
||||
).inserted(db)
|
||||
|
||||
// If there is a LinkPreview and it doesn't match an existing one then add it now
|
||||
if
|
||||
let linkPreviewDraft: LinkPreviewDraft = linkPreviewDraft,
|
||||
(try? interaction.linkPreview.isEmpty(db)) == true
|
||||
{
|
||||
try LinkPreview(
|
||||
url: linkPreviewDraft.urlString,
|
||||
title: linkPreviewDraft.title,
|
||||
attachmentId: LinkPreview.saveAttachmentIfPossible(
|
||||
db,
|
||||
imageData: linkPreviewDraft.jpegImageData,
|
||||
mimeType: OWSMimeTypeImageJpeg
|
||||
)
|
||||
).insert(db)
|
||||
}
|
||||
|
||||
// If there is a Quote the insert it now
|
||||
if let interactionId: Int64 = interaction.id, let quoteModel: QuotedReplyModel = quoteModel {
|
||||
try Quote(
|
||||
interactionId: interactionId,
|
||||
authorId: quoteModel.authorId,
|
||||
timestampMs: quoteModel.timestampMs,
|
||||
body: quoteModel.body,
|
||||
attachmentId: quoteModel.generateAttachmentThumbnailIfNeeded(db)
|
||||
).insert(db)
|
||||
}
|
||||
|
||||
try MessageSender.send(
|
||||
db,
|
||||
interaction: interaction,
|
||||
threadId: threadId,
|
||||
threadVariant: threadVariant
|
||||
)
|
||||
}
|
||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { [weak self] _ in
|
||||
self?.handleMessageSent()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func sendAttachments(_ attachments: [SignalAttachment], with text: String, hasPermissionToSendSeed: Bool = false, onComplete: (() -> ())? = nil) {
|
||||
func sendMessage(
|
||||
text: String,
|
||||
attachments: [SignalAttachment] = [],
|
||||
linkPreviewDraft: LinkPreviewDraft? = nil,
|
||||
quoteModel: QuotedReplyModel? = nil,
|
||||
hasPermissionToSendSeed: Bool = false
|
||||
) {
|
||||
guard !showBlockedModalIfNeeded() else { return }
|
||||
|
||||
for attachment in attachments {
|
||||
if attachment.hasError {
|
||||
return showErrorAlert(for: attachment, onDismiss: onComplete)
|
||||
}
|
||||
// Handle attachment errors if applicable
|
||||
if let failedAttachment: SignalAttachment = attachments.first(where: { $0.hasError }) {
|
||||
return showErrorAlert(for: failedAttachment)
|
||||
}
|
||||
|
||||
let text = replaceMentions(in: snInputView.text.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
let processedText: String = replaceMentions(in: text.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
|
||||
if text.contains(mnemonic) && !viewModel.threadData.threadIsNoteToSelf && !hasPermissionToSendSeed {
|
||||
// If we have no content then do nothing
|
||||
guard !processedText.isEmpty || !attachments.isEmpty else { return }
|
||||
|
||||
if processedText.contains(mnemonic) && !viewModel.threadData.threadIsNoteToSelf && !hasPermissionToSendSeed {
|
||||
// Warn the user if they're about to send their seed to someone
|
||||
let modal: ConfirmationModal = ConfirmationModal(
|
||||
info: ConfirmationModal.Info(
|
||||
|
@ -537,7 +433,13 @@ extension ConversationVC:
|
|||
confirmStyle: .danger,
|
||||
cancelStyle: .alert_text,
|
||||
onConfirm: { [weak self] _ in
|
||||
self?.sendAttachments(attachments, with: text, hasPermissionToSendSeed: true, onComplete: onComplete)
|
||||
self?.sendMessage(
|
||||
text: text,
|
||||
attachments: attachments,
|
||||
linkPreviewDraft: linkPreviewDraft,
|
||||
quoteModel: quoteModel,
|
||||
hasPermissionToSendSeed: true
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
@ -564,17 +466,24 @@ extension ConversationVC:
|
|||
// If this was a message request then approve it
|
||||
approveMessageRequestIfNeeded(
|
||||
for: threadId,
|
||||
threadVariant: self.viewModel.threadData.threadVariant,
|
||||
threadVariant: threadVariant,
|
||||
isNewThread: !oldThreadShouldBeVisible,
|
||||
timestampMs: (sentTimestampMs - 1) // Set 1ms earlier as this is used for sorting
|
||||
)
|
||||
|
||||
// Send the message
|
||||
// Optimistically insert the outgoing message (this will trigger a UI update)
|
||||
self.viewModel.sentMessageBeforeUpdate = true
|
||||
let optimisticData: ConversationViewModel.OptimisticMessageData = self.viewModel.optimisticallyAppendOutgoingMessage(
|
||||
text: processedText,
|
||||
sentTimestampMs: sentTimestampMs,
|
||||
attachments: attachments,
|
||||
linkPreviewDraft: linkPreviewDraft,
|
||||
quoteModel: quoteModel
|
||||
)
|
||||
|
||||
// Actually send the message
|
||||
Storage.shared
|
||||
.writePublisher { [weak self] db in
|
||||
// Let the viewModel know we are about to send a message
|
||||
self?.viewModel.sentMessageBeforeUpdate = true
|
||||
|
||||
// Update the thread to be visible (if it isn't already)
|
||||
if self?.viewModel.threadData.threadShouldBeVisible == false {
|
||||
_ = try SessionThread
|
||||
|
@ -582,35 +491,44 @@ extension ConversationVC:
|
|||
.updateAllAndConfig(db, SessionThread.Columns.shouldBeVisible.set(to: true))
|
||||
}
|
||||
|
||||
// Create the interaction
|
||||
let interaction: Interaction = try Interaction(
|
||||
threadId: threadId,
|
||||
authorId: getUserHexEncodedPublicKey(db),
|
||||
variant: .standardOutgoing,
|
||||
body: text,
|
||||
timestampMs: sentTimestampMs,
|
||||
hasMention: Interaction.isUserMentioned(db, threadId: threadId, body: text),
|
||||
expiresInSeconds: try? DisappearingMessagesConfiguration
|
||||
.select(.durationSeconds)
|
||||
.filter(id: threadId)
|
||||
.filter(DisappearingMessagesConfiguration.Columns.isEnabled == true)
|
||||
.asRequest(of: TimeInterval.self)
|
||||
.fetchOne(db)
|
||||
).inserted(db)
|
||||
// Insert the interaction and associated it with the optimistically inserted message so
|
||||
// we can remove it once the database triggers a UI update
|
||||
let insertedInteraction: Interaction = try optimisticData.interaction.inserted(db)
|
||||
self?.viewModel.associate(optimisticMessageId: optimisticData.id, to: insertedInteraction.id)
|
||||
|
||||
guard let interactionId: Int64 = interaction.id else { return }
|
||||
// If there is a LinkPreview and it doesn't match an existing one then add it now
|
||||
if
|
||||
let linkPreviewDraft: LinkPreviewDraft = linkPreviewDraft,
|
||||
(try? insertedInteraction.linkPreview.isEmpty(db)) == true
|
||||
{
|
||||
try LinkPreview(
|
||||
url: linkPreviewDraft.urlString,
|
||||
title: linkPreviewDraft.title,
|
||||
attachmentId: try optimisticData.linkPreviewAttachment?.inserted(db).id
|
||||
).insert(db)
|
||||
}
|
||||
|
||||
// Prepare any attachments
|
||||
try Attachment.prepare(
|
||||
// If there is a Quote the insert it now
|
||||
if let interactionId: Int64 = insertedInteraction.id, let quoteModel: QuotedReplyModel = quoteModel {
|
||||
try Quote(
|
||||
interactionId: interactionId,
|
||||
authorId: quoteModel.authorId,
|
||||
timestampMs: quoteModel.timestampMs,
|
||||
body: quoteModel.body,
|
||||
attachmentId: quoteModel.generateAttachmentThumbnailIfNeeded(db)
|
||||
).insert(db)
|
||||
}
|
||||
|
||||
// Process any attachments
|
||||
try Attachment.process(
|
||||
db,
|
||||
attachments: attachments,
|
||||
for: interactionId
|
||||
data: optimisticData.attachmentData,
|
||||
for: insertedInteraction.id
|
||||
)
|
||||
|
||||
// Send the message
|
||||
try MessageSender.send(
|
||||
db,
|
||||
interaction: interaction,
|
||||
interaction: insertedInteraction,
|
||||
threadId: threadId,
|
||||
threadVariant: threadVariant
|
||||
)
|
||||
|
@ -619,11 +537,6 @@ extension ConversationVC:
|
|||
.sinkUntilComplete(
|
||||
receiveCompletion: { [weak self] _ in
|
||||
self?.handleMessageSent()
|
||||
|
||||
// Attachment successfully sent - dismiss the screen
|
||||
DispatchQueue.main.async {
|
||||
onComplete?()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
@ -1212,7 +1125,7 @@ extension ConversationVC:
|
|||
guard cellViewModel.threadVariant == .community else { return }
|
||||
|
||||
Storage.shared
|
||||
.readPublisherFlatMap { db -> AnyPublisher<(OpenGroupAPI.ReactionRemoveAllResponse, OpenGroupAPI.PendingChange), Error> in
|
||||
.readPublisher { db -> (OpenGroupAPI.PreparedSendData<OpenGroupAPI.ReactionRemoveAllResponse>, OpenGroupAPI.PendingChange) in
|
||||
guard
|
||||
let openGroup: OpenGroup = try? OpenGroup
|
||||
.fetchOne(db, id: cellViewModel.threadId),
|
||||
|
@ -1223,6 +1136,14 @@ extension ConversationVC:
|
|||
.fetchOne(db)
|
||||
else { throw StorageError.objectNotFound }
|
||||
|
||||
let sendData: OpenGroupAPI.PreparedSendData<OpenGroupAPI.ReactionRemoveAllResponse> = try OpenGroupAPI
|
||||
.preparedReactionDeleteAll(
|
||||
db,
|
||||
emoji: emoji,
|
||||
id: openGroupServerMessageId,
|
||||
in: openGroup.roomToken,
|
||||
on: openGroup.server
|
||||
)
|
||||
let pendingChange: OpenGroupAPI.PendingChange = OpenGroupManager
|
||||
.addPendingReaction(
|
||||
emoji: emoji,
|
||||
|
@ -1232,27 +1153,22 @@ extension ConversationVC:
|
|||
type: .removeAll
|
||||
)
|
||||
|
||||
return OpenGroupAPI
|
||||
.reactionDeleteAll(
|
||||
db,
|
||||
emoji: emoji,
|
||||
id: openGroupServerMessageId,
|
||||
in: openGroup.roomToken,
|
||||
on: openGroup.server
|
||||
)
|
||||
.map { _, response in (response, pendingChange) }
|
||||
.eraseToAnyPublisher()
|
||||
return (sendData, pendingChange)
|
||||
}
|
||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||
.handleEvents(
|
||||
receiveOutput: { response, pendingChange in
|
||||
OpenGroupManager
|
||||
.updatePendingChange(
|
||||
pendingChange,
|
||||
seqNo: response.seqNo
|
||||
)
|
||||
}
|
||||
)
|
||||
.flatMap { sendData, pendingChange in
|
||||
OpenGroupAPI.send(data: sendData)
|
||||
.handleEvents(
|
||||
receiveOutput: { _, response in
|
||||
OpenGroupManager
|
||||
.updatePendingChange(
|
||||
pendingChange,
|
||||
seqNo: response.seqNo
|
||||
)
|
||||
}
|
||||
)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { _ in
|
||||
Storage.shared.writeAsync { db in
|
||||
|
@ -1266,14 +1182,16 @@ extension ConversationVC:
|
|||
}
|
||||
|
||||
func react(_ cellViewModel: MessageViewModel, with emoji: String, remove: Bool) {
|
||||
guard cellViewModel.variant == .standardIncoming || cellViewModel.variant == .standardOutgoing else {
|
||||
return
|
||||
}
|
||||
|
||||
let threadIsMessageRequest: Bool = (self.viewModel.threadData.threadIsMessageRequest == true)
|
||||
guard !threadIsMessageRequest else { return }
|
||||
guard
|
||||
self.viewModel.threadData.threadIsMessageRequest != true && (
|
||||
cellViewModel.variant == .standardIncoming ||
|
||||
cellViewModel.variant == .standardOutgoing
|
||||
)
|
||||
else { return }
|
||||
|
||||
// Perform local rate limiting (don't allow more than 20 reactions within 60 seconds)
|
||||
let threadVariant: SessionThread.Variant = self.viewModel.threadData.threadVariant
|
||||
let openGroupRoom: String? = self.viewModel.threadData.openGroupRoomToken
|
||||
let sentTimestamp: Int64 = SnodeAPI.currentOffsetTimestampMs()
|
||||
let recentReactionTimestamps: [Int64] = General.cache.wrappedValue.recentReactionTimestamps
|
||||
|
||||
|
@ -1299,9 +1217,38 @@ extension ConversationVC:
|
|||
.appending(sentTimestamp)
|
||||
}
|
||||
|
||||
// Perform the sending logic
|
||||
Storage.shared
|
||||
.writePublisherFlatMap { [weak self] db -> AnyPublisher<MessageSender.PreparedSendData?, Error> in
|
||||
typealias OpenGroupInfo = (
|
||||
pendingReaction: Reaction?,
|
||||
pendingChange: OpenGroupAPI.PendingChange,
|
||||
sendData: OpenGroupAPI.PreparedSendData<Int64?>
|
||||
)
|
||||
|
||||
/// Perform the sending logic, we generate the pending reaction first in a deferred future closure to prevent the OpenGroup
|
||||
/// cache from blocking either the main thread or the database write thread
|
||||
Deferred {
|
||||
Future<OpenGroupAPI.PendingChange?, Error> { resolver in
|
||||
guard
|
||||
threadVariant == .community,
|
||||
let serverMessageId: Int64 = cellViewModel.openGroupServerMessageId,
|
||||
let openGroupServer: String = cellViewModel.threadOpenGroupServer,
|
||||
let openGroupPublicKey: String = cellViewModel.threadOpenGroupPublicKey
|
||||
else { return resolver(Result.success(nil)) }
|
||||
|
||||
// Create the pending change if we have open group info
|
||||
return resolver(Result.success(
|
||||
OpenGroupManager.addPendingReaction(
|
||||
emoji: emoji,
|
||||
id: serverMessageId,
|
||||
in: openGroupServer,
|
||||
on: openGroupPublicKey,
|
||||
type: (remove ? .remove : .add)
|
||||
)
|
||||
))
|
||||
}
|
||||
}
|
||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||
.flatMap { pendingChange -> AnyPublisher<(MessageSender.PreparedSendData?, OpenGroupInfo?), Error> in
|
||||
Storage.shared.writePublisher { [weak self] db -> (MessageSender.PreparedSendData?, OpenGroupInfo?) in
|
||||
// Update the thread to be visible (if it isn't already)
|
||||
if self?.viewModel.threadData.threadShouldBeVisible == false {
|
||||
_ = try SessionThread
|
||||
|
@ -1310,29 +1257,29 @@ extension ConversationVC:
|
|||
}
|
||||
|
||||
let pendingReaction: Reaction? = {
|
||||
if remove {
|
||||
guard !remove else {
|
||||
return try? Reaction
|
||||
.filter(Reaction.Columns.interactionId == cellViewModel.id)
|
||||
.filter(Reaction.Columns.authorId == cellViewModel.currentUserPublicKey)
|
||||
.filter(Reaction.Columns.emoji == emoji)
|
||||
.fetchOne(db)
|
||||
} else {
|
||||
let sortId = Reaction.getSortId(
|
||||
db,
|
||||
interactionId: cellViewModel.id,
|
||||
emoji: emoji
|
||||
)
|
||||
|
||||
return Reaction(
|
||||
interactionId: cellViewModel.id,
|
||||
serverHash: nil,
|
||||
timestampMs: sentTimestamp,
|
||||
authorId: cellViewModel.currentUserPublicKey,
|
||||
emoji: emoji,
|
||||
count: 1,
|
||||
sortId: sortId
|
||||
)
|
||||
}
|
||||
|
||||
let sortId: Int64 = Reaction.getSortId(
|
||||
db,
|
||||
interactionId: cellViewModel.id,
|
||||
emoji: emoji
|
||||
)
|
||||
|
||||
return Reaction(
|
||||
interactionId: cellViewModel.id,
|
||||
serverHash: nil,
|
||||
timestampMs: sentTimestamp,
|
||||
authorId: cellViewModel.currentUserPublicKey,
|
||||
emoji: emoji,
|
||||
count: 1,
|
||||
sortId: sortId
|
||||
)
|
||||
}()
|
||||
|
||||
// Update the database
|
||||
|
@ -1350,125 +1297,108 @@ extension ConversationVC:
|
|||
Emoji.addRecent(db, emoji: emoji)
|
||||
}
|
||||
|
||||
// If it's not an OpenGroup then send the message directly to the thread
|
||||
guard
|
||||
let openGroup: OpenGroup = try? OpenGroup.fetchOne(db, id: cellViewModel.threadId),
|
||||
OpenGroupManager.isOpenGroupSupport(.reactions, on: openGroup.server)
|
||||
else {
|
||||
let sendData: MessageSender.PreparedSendData = try MessageSender.preparedSendData(
|
||||
db,
|
||||
message: VisibleMessage(
|
||||
sentTimestamp: UInt64(sentTimestamp),
|
||||
text: nil,
|
||||
reaction: VisibleMessage.VMReaction(
|
||||
timestamp: UInt64(cellViewModel.timestampMs),
|
||||
publicKey: {
|
||||
guard cellViewModel.variant == .standardIncoming else {
|
||||
return cellViewModel.currentUserPublicKey
|
||||
}
|
||||
|
||||
return cellViewModel.authorId
|
||||
}(),
|
||||
emoji: emoji,
|
||||
kind: (remove ? .remove : .react)
|
||||
)
|
||||
),
|
||||
to: try Message.Destination
|
||||
.from(db, threadId: cellViewModel.threadId, threadVariant: cellViewModel.threadVariant),
|
||||
namespace: try Message.Destination
|
||||
.from(db, threadId: cellViewModel.threadId, threadVariant: cellViewModel.threadVariant)
|
||||
.defaultNamespace,
|
||||
interactionId: cellViewModel.id
|
||||
)
|
||||
|
||||
return Just(sendData)
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
// Otherwise we need to make an API call to the OpenGroup
|
||||
// Send reaction to open groups
|
||||
guard
|
||||
let openGroupServerMessageId: Int64 = try? Interaction
|
||||
.select(.openGroupServerMessageId)
|
||||
.filter(id: cellViewModel.id)
|
||||
.asRequest(of: Int64.self)
|
||||
.fetchOne(db)
|
||||
else { throw MessageSenderError.invalidMessage }
|
||||
|
||||
let pendingChange: OpenGroupAPI.PendingChange = OpenGroupManager
|
||||
.addPendingReaction(
|
||||
emoji: emoji,
|
||||
id: openGroupServerMessageId,
|
||||
in: openGroup.roomToken,
|
||||
on: openGroup.server,
|
||||
type: (remove ? .remove : .add)
|
||||
)
|
||||
|
||||
let request: AnyPublisher<Int64?, Error> = {
|
||||
switch remove {
|
||||
case true:
|
||||
return OpenGroupAPI
|
||||
.reactionDelete(
|
||||
db,
|
||||
emoji: emoji,
|
||||
id: openGroupServerMessageId,
|
||||
in: openGroup.roomToken,
|
||||
on: openGroup.server
|
||||
)
|
||||
.map { _, response in response.seqNo }
|
||||
.eraseToAnyPublisher()
|
||||
|
||||
case false:
|
||||
return OpenGroupAPI
|
||||
.reactionAdd(
|
||||
db,
|
||||
emoji: emoji,
|
||||
id: openGroupServerMessageId,
|
||||
in: openGroup.roomToken,
|
||||
on: openGroup.server
|
||||
)
|
||||
.map { _, response in response.seqNo }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}()
|
||||
|
||||
return request
|
||||
.handleEvents(
|
||||
receiveOutput: { seqNo in
|
||||
OpenGroupManager
|
||||
.updatePendingChange(
|
||||
pendingChange,
|
||||
seqNo: seqNo
|
||||
)
|
||||
},
|
||||
receiveCompletion: { [weak self] result in
|
||||
switch result {
|
||||
case .finished: break
|
||||
case .failure:
|
||||
OpenGroupManager.removePendingChange(pendingChange)
|
||||
|
||||
self?.handleReactionSentFailure(
|
||||
pendingReaction,
|
||||
remove: remove
|
||||
switch threadVariant {
|
||||
case .community:
|
||||
guard
|
||||
let serverMessageId: Int64 = cellViewModel.openGroupServerMessageId,
|
||||
let openGroupServer: String = cellViewModel.threadOpenGroupServer,
|
||||
let openGroupRoom: String = openGroupRoom,
|
||||
let pendingChange: OpenGroupAPI.PendingChange = pendingChange,
|
||||
OpenGroupManager.doesOpenGroupSupport(db, capability: .reactions, on: openGroupServer)
|
||||
else { throw MessageSenderError.invalidMessage }
|
||||
|
||||
let sendData: OpenGroupAPI.PreparedSendData<Int64?> = try {
|
||||
guard !remove else {
|
||||
return try OpenGroupAPI
|
||||
.preparedReactionDelete(
|
||||
db,
|
||||
emoji: emoji,
|
||||
id: serverMessageId,
|
||||
in: openGroupRoom,
|
||||
on: openGroupServer
|
||||
)
|
||||
.map { _, response in response.seqNo }
|
||||
}
|
||||
}
|
||||
)
|
||||
.map { _ in nil }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||
.flatMap { maybeSendData -> AnyPublisher<Void, Error> in
|
||||
guard let sendData: MessageSender.PreparedSendData = maybeSendData else {
|
||||
return Just(())
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
|
||||
return try OpenGroupAPI
|
||||
.preparedReactionAdd(
|
||||
db,
|
||||
emoji: emoji,
|
||||
id: serverMessageId,
|
||||
in: openGroupRoom,
|
||||
on: openGroupServer
|
||||
)
|
||||
.map { _, response in response.seqNo }
|
||||
}()
|
||||
|
||||
return (nil, (pendingReaction, pendingChange, sendData))
|
||||
|
||||
default:
|
||||
let sendData: MessageSender.PreparedSendData = try MessageSender.preparedSendData(
|
||||
db,
|
||||
message: VisibleMessage(
|
||||
sentTimestamp: UInt64(sentTimestamp),
|
||||
text: nil,
|
||||
reaction: VisibleMessage.VMReaction(
|
||||
timestamp: UInt64(cellViewModel.timestampMs),
|
||||
publicKey: {
|
||||
guard cellViewModel.variant == .standardIncoming else {
|
||||
return cellViewModel.currentUserPublicKey
|
||||
}
|
||||
|
||||
return cellViewModel.authorId
|
||||
}(),
|
||||
emoji: emoji,
|
||||
kind: (remove ? .remove : .react)
|
||||
)
|
||||
),
|
||||
to: try Message.Destination
|
||||
.from(db, threadId: cellViewModel.threadId, threadVariant: cellViewModel.threadVariant),
|
||||
namespace: try Message.Destination
|
||||
.from(db, threadId: cellViewModel.threadId, threadVariant: cellViewModel.threadVariant)
|
||||
.defaultNamespace,
|
||||
interactionId: cellViewModel.id
|
||||
)
|
||||
|
||||
return (sendData, nil)
|
||||
}
|
||||
|
||||
return MessageSender.sendImmediate(preparedSendData: sendData)
|
||||
}
|
||||
.sinkUntilComplete()
|
||||
}
|
||||
.tryFlatMap { messageSendData, openGroupInfo -> AnyPublisher<Void, Error> in
|
||||
switch (messageSendData, openGroupInfo) {
|
||||
case (.some(let sendData), _):
|
||||
return MessageSender.sendImmediate(preparedSendData: sendData)
|
||||
|
||||
case (_, .some(let info)):
|
||||
return OpenGroupAPI.send(data: info.sendData)
|
||||
.handleEvents(
|
||||
receiveOutput: { _, seqNo in
|
||||
OpenGroupManager
|
||||
.updatePendingChange(
|
||||
info.pendingChange,
|
||||
seqNo: seqNo
|
||||
)
|
||||
},
|
||||
receiveCompletion: { [weak self] result in
|
||||
switch result {
|
||||
case .finished: break
|
||||
case .failure:
|
||||
OpenGroupManager.removePendingChange(info.pendingChange)
|
||||
|
||||
self?.handleReactionSentFailure(
|
||||
info.pendingReaction,
|
||||
remove: remove
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
.map { _ in () }
|
||||
.eraseToAnyPublisher()
|
||||
|
||||
default: throw MessageSenderError.invalidMessage
|
||||
}
|
||||
}
|
||||
.sinkUntilComplete()
|
||||
}
|
||||
|
||||
func handleReactionSentFailure(_ pendingReaction: Reaction?, remove: Bool) {
|
||||
|
@ -1891,16 +1821,18 @@ extension ConversationVC:
|
|||
// Delete the message from the open group
|
||||
deleteRemotely(
|
||||
from: self,
|
||||
request: Storage.shared.readPublisherFlatMap { db in
|
||||
OpenGroupAPI.messageDelete(
|
||||
db,
|
||||
id: openGroupServerMessageId,
|
||||
in: openGroup.roomToken,
|
||||
on: openGroup.server
|
||||
)
|
||||
request: Storage.shared
|
||||
.readPublisher { db in
|
||||
try OpenGroupAPI.preparedMessageDelete(
|
||||
db,
|
||||
id: openGroupServerMessageId,
|
||||
in: openGroup.roomToken,
|
||||
on: openGroup.server
|
||||
)
|
||||
}
|
||||
.flatMap { OpenGroupAPI.send(data: $0) }
|
||||
.map { _ in () }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
) { [weak self] in
|
||||
self?.showInputAccessoryView()
|
||||
}
|
||||
|
@ -2100,21 +2032,20 @@ extension ConversationVC:
|
|||
cancelStyle: .alert_text,
|
||||
onConfirm: { [weak self] _ in
|
||||
Storage.shared
|
||||
.readPublisherFlatMap { db -> AnyPublisher<Void, Error> in
|
||||
.readPublisher { db -> OpenGroupAPI.PreparedSendData<NoResponse> in
|
||||
guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: threadId) else {
|
||||
throw StorageError.objectNotFound
|
||||
}
|
||||
|
||||
return OpenGroupAPI
|
||||
.userBan(
|
||||
return try OpenGroupAPI
|
||||
.preparedUserBan(
|
||||
db,
|
||||
sessionId: cellViewModel.authorId,
|
||||
from: [openGroup.roomToken],
|
||||
on: openGroup.server
|
||||
)
|
||||
.map { _ in () }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.flatMap { OpenGroupAPI.send(data: $0) }
|
||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sinkUntilComplete(
|
||||
|
@ -2316,11 +2247,11 @@ extension ConversationVC:
|
|||
let attachment = SignalAttachment.voiceMessageAttachment(dataSource: dataSource, dataUTI: kUTTypeMPEG4Audio as String)
|
||||
|
||||
guard !attachment.hasError else {
|
||||
return showErrorAlert(for: attachment, onDismiss: nil)
|
||||
return showErrorAlert(for: attachment)
|
||||
}
|
||||
|
||||
// Send attachment
|
||||
sendAttachments([ attachment ], with: "")
|
||||
sendMessage(text: "", attachments: [attachment])
|
||||
}
|
||||
|
||||
func cancelVoiceMessageRecording() {
|
||||
|
@ -2360,15 +2291,14 @@ extension ConversationVC:
|
|||
|
||||
// MARK: - Convenience
|
||||
|
||||
func showErrorAlert(for attachment: SignalAttachment, onDismiss: (() -> ())?) {
|
||||
func showErrorAlert(for attachment: SignalAttachment) {
|
||||
let modal: ConfirmationModal = ConfirmationModal(
|
||||
targetView: self.view,
|
||||
info: ConfirmationModal.Info(
|
||||
title: "ATTACHMENT_ERROR_ALERT_TITLE".localized(),
|
||||
body: .text(attachment.localizedErrorDescription ?? SignalAttachment.missingDataErrorMessage),
|
||||
cancelTitle: "BUTTON_OK".localized(),
|
||||
cancelStyle: .alert_text,
|
||||
afterClosed: onDismiss
|
||||
cancelStyle: .alert_text
|
||||
)
|
||||
)
|
||||
self.present(modal, animated: true)
|
||||
|
|
|
@ -890,6 +890,7 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
|
|||
guard !didSendMessageBeforeUpdate && !wasOnlyUpdates else {
|
||||
self.viewModel.updateInteractionData(updatedData)
|
||||
self.tableView.reloadData()
|
||||
self.tableView.layoutIfNeeded()
|
||||
|
||||
// If we just sent a message then we want to jump to the bottom of the conversation instantly
|
||||
if didSendMessageBeforeUpdate {
|
||||
|
|
|
@ -176,9 +176,10 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
|
||||
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
|
||||
/// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this
|
||||
public lazy var observableThreadData: ValueObservation<ValueReducers.RemoveDuplicates<ValueReducers.Fetch<SessionThreadViewModel?>>> = setupObservableThreadData(for: self.threadId)
|
||||
public typealias ThreadObservation = ValueObservation<ValueReducers.Trace<ValueReducers.RemoveDuplicates<ValueReducers.Fetch<SessionThreadViewModel?>>>>
|
||||
public lazy var observableThreadData: ThreadObservation = setupObservableThreadData(for: self.threadId)
|
||||
|
||||
private func setupObservableThreadData(for threadId: String) -> ValueObservation<ValueReducers.RemoveDuplicates<ValueReducers.Fetch<SessionThreadViewModel?>>> {
|
||||
private func setupObservableThreadData(for threadId: String) -> ThreadObservation {
|
||||
return ValueObservation
|
||||
.trackingConstantRegion { [weak self] db -> SessionThreadViewModel? in
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||
|
@ -197,6 +198,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
}
|
||||
}
|
||||
.removeDuplicates()
|
||||
.handleEvents(didFail: { SNLog("[ConversationViewModel] Observation failed with error: \($0)") })
|
||||
}
|
||||
|
||||
public func updateThreadData(_ updatedData: SessionThreadViewModel) {
|
||||
|
@ -314,8 +316,16 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
)
|
||||
],
|
||||
onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in
|
||||
self?.resolveOptimisticUpdates(with: updatedData)
|
||||
|
||||
PagedData.processAndTriggerUpdates(
|
||||
updatedData: self?.process(data: updatedData, for: updatedPageInfo),
|
||||
updatedData: self?.process(
|
||||
data: updatedData,
|
||||
for: updatedPageInfo,
|
||||
optimisticMessages: (self?.optimisticallyInsertedMessages.wrappedValue.values)
|
||||
.map { Array($0) },
|
||||
initialUnreadInteractionId: self?.initialUnreadInteractionId
|
||||
),
|
||||
currentDataRetriever: { self?.interactionData },
|
||||
onDataChange: self?.onInteractionChange,
|
||||
onUnobservedDataChange: { updatedData, changeset in
|
||||
|
@ -329,11 +339,16 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
)
|
||||
}
|
||||
|
||||
private func process(data: [MessageViewModel], for pageInfo: PagedData.PageInfo) -> [SectionModel] {
|
||||
let initialUnreadInteractionId: Int64? = self.initialUnreadInteractionId
|
||||
private func process(
|
||||
data: [MessageViewModel],
|
||||
for pageInfo: PagedData.PageInfo,
|
||||
optimisticMessages: [MessageViewModel]?,
|
||||
initialUnreadInteractionId: Int64?
|
||||
) -> [SectionModel] {
|
||||
let typingIndicator: MessageViewModel? = data.first(where: { $0.isTypingIndicator == true })
|
||||
let sortedData: [MessageViewModel] = data
|
||||
.filter { $0.isTypingIndicator != true }
|
||||
.appending(contentsOf: (optimisticMessages ?? []))
|
||||
.filter { !$0.cellType.isPostProcessed }
|
||||
.sorted { lhs, rhs -> Bool in lhs.timestampMs < rhs.timestampMs }
|
||||
|
||||
// We load messages from newest to oldest so having a pageOffset larger than zero means
|
||||
|
@ -408,12 +423,151 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
self.interactionData = updatedData
|
||||
}
|
||||
|
||||
public func expandReactions(for interactionId: Int64) {
|
||||
reactionExpandedInteractionIds.insert(interactionId)
|
||||
// MARK: - Optimistic Message Handling
|
||||
|
||||
public typealias OptimisticMessageData = (
|
||||
id: UUID,
|
||||
interaction: Interaction,
|
||||
attachmentData: Attachment.PreparedData?,
|
||||
linkPreviewAttachment: Attachment?
|
||||
)
|
||||
|
||||
private var optimisticallyInsertedMessages: Atomic<[UUID: MessageViewModel]> = Atomic([:])
|
||||
private var optimisticMessageAssociatedInteractionIds: Atomic<[Int64: UUID]> = Atomic([:])
|
||||
|
||||
public func optimisticallyAppendOutgoingMessage(
|
||||
text: String?,
|
||||
sentTimestampMs: Int64,
|
||||
attachments: [SignalAttachment]?,
|
||||
linkPreviewDraft: LinkPreviewDraft?,
|
||||
quoteModel: QuotedReplyModel?
|
||||
) -> OptimisticMessageData {
|
||||
// Generate the optimistic data
|
||||
let optimisticMessageId: UUID = UUID()
|
||||
let currentUserProfile: Profile = Profile.fetchOrCreateCurrentUser()
|
||||
let interaction: Interaction = Interaction(
|
||||
threadId: threadData.threadId,
|
||||
authorId: (threadData.currentUserBlindedPublicKey ?? threadData.currentUserPublicKey),
|
||||
variant: .standardOutgoing,
|
||||
body: text,
|
||||
timestampMs: sentTimestampMs,
|
||||
hasMention: Interaction.isUserMentioned(
|
||||
publicKeysToCheck: [
|
||||
threadData.currentUserPublicKey,
|
||||
threadData.currentUserBlindedPublicKey
|
||||
].compactMap { $0 },
|
||||
body: text
|
||||
),
|
||||
expiresInSeconds: threadData.disappearingMessagesConfiguration
|
||||
.map { disappearingConfig in
|
||||
guard disappearingConfig.isEnabled else { return nil }
|
||||
|
||||
return disappearingConfig.durationSeconds
|
||||
},
|
||||
linkPreviewUrl: linkPreviewDraft?.urlString
|
||||
)
|
||||
let optimisticAttachments: Attachment.PreparedData? = attachments
|
||||
.map { Attachment.prepare(attachments: $0) }
|
||||
let linkPreviewAttachment: Attachment? = linkPreviewDraft.map { draft in
|
||||
try? LinkPreview.generateAttachmentIfPossible(
|
||||
imageData: draft.jpegImageData,
|
||||
mimeType: OWSMimeTypeImageJpeg
|
||||
)
|
||||
}
|
||||
let optimisticData: OptimisticMessageData = (
|
||||
optimisticMessageId,
|
||||
interaction,
|
||||
optimisticAttachments,
|
||||
linkPreviewAttachment
|
||||
)
|
||||
|
||||
// Generate the actual 'MessageViewModel'
|
||||
let messageViewModel: MessageViewModel = MessageViewModel(
|
||||
threadId: threadData.threadId,
|
||||
threadVariant: threadData.threadVariant,
|
||||
threadHasDisappearingMessagesEnabled: (threadData.disappearingMessagesConfiguration?.isEnabled ?? false),
|
||||
threadOpenGroupServer: threadData.openGroupServer,
|
||||
threadOpenGroupPublicKey: threadData.openGroupPublicKey,
|
||||
threadContactNameInternal: threadData.threadContactName(),
|
||||
timestampMs: interaction.timestampMs,
|
||||
receivedAtTimestampMs: interaction.receivedAtTimestampMs,
|
||||
authorId: interaction.authorId,
|
||||
authorNameInternal: currentUserProfile.displayName(),
|
||||
body: interaction.body,
|
||||
expiresStartedAtMs: interaction.expiresStartedAtMs,
|
||||
expiresInSeconds: interaction.expiresInSeconds,
|
||||
isSenderOpenGroupModerator: OpenGroupManager.isUserModeratorOrAdmin(
|
||||
threadData.currentUserPublicKey,
|
||||
for: threadData.openGroupRoomToken,
|
||||
on: threadData.openGroupServer
|
||||
),
|
||||
currentUserProfile: currentUserProfile,
|
||||
quote: quoteModel.map { model in
|
||||
// Don't care about this optimistic quote (the proper one will be generated in the database)
|
||||
Quote(
|
||||
interactionId: -1, // Can't save to db optimistically
|
||||
authorId: model.authorId,
|
||||
timestampMs: model.timestampMs,
|
||||
body: model.body,
|
||||
attachmentId: model.attachment?.id
|
||||
)
|
||||
},
|
||||
quoteAttachment: quoteModel?.attachment,
|
||||
linkPreview: linkPreviewDraft.map { draft in
|
||||
LinkPreview(
|
||||
url: draft.urlString,
|
||||
title: draft.title,
|
||||
attachmentId: nil // Can't save to db optimistically
|
||||
)
|
||||
},
|
||||
linkPreviewAttachment: linkPreviewAttachment,
|
||||
attachments: optimisticAttachments?.attachments
|
||||
)
|
||||
|
||||
optimisticallyInsertedMessages.mutate { $0[optimisticMessageId] = messageViewModel }
|
||||
|
||||
// If we can't get the current page data then don't bother trying to update (it's not going to work)
|
||||
guard let currentPageInfo: PagedData.PageInfo = self.pagedDataObserver?.pageInfo.wrappedValue else {
|
||||
return optimisticData
|
||||
}
|
||||
|
||||
/// **MUST** have the same logic as in the 'PagedDataObserver.onChangeUnsorted' above
|
||||
let currentData: [SectionModel] = (unobservedInteractionDataChanges?.0 ?? interactionData)
|
||||
|
||||
PagedData.processAndTriggerUpdates(
|
||||
updatedData: process(
|
||||
data: (currentData.first(where: { $0.model == .messages })?.elements ?? []),
|
||||
for: currentPageInfo,
|
||||
optimisticMessages: Array(optimisticallyInsertedMessages.wrappedValue.values),
|
||||
initialUnreadInteractionId: initialUnreadInteractionId
|
||||
),
|
||||
currentDataRetriever: { [weak self] in self?.interactionData },
|
||||
onDataChange: self.onInteractionChange,
|
||||
onUnobservedDataChange: { [weak self] updatedData, changeset in
|
||||
self?.unobservedInteractionDataChanges = (changeset.isEmpty ?
|
||||
nil :
|
||||
(updatedData, changeset)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
return optimisticData
|
||||
}
|
||||
|
||||
public func collapseReactions(for interactionId: Int64) {
|
||||
reactionExpandedInteractionIds.remove(interactionId)
|
||||
/// Record an association between an `optimisticMessageId` and a specific `interactionId`
|
||||
public func associate(optimisticMessageId: UUID, to interactionId: Int64?) {
|
||||
guard let interactionId: Int64 = interactionId else { return }
|
||||
|
||||
optimisticMessageAssociatedInteractionIds.mutate { $0[interactionId] = optimisticMessageId }
|
||||
}
|
||||
|
||||
/// Remove any optimisticUpdate entries which have an associated interactionId in the provided data
|
||||
private func resolveOptimisticUpdates(with data: [MessageViewModel]) {
|
||||
let interactionIds: [Int64] = data.map { $0.id }
|
||||
let idsToRemove: [UUID] = optimisticMessageAssociatedInteractionIds
|
||||
.mutate { associatedIds in interactionIds.compactMap { associatedIds.removeValue(forKey: $0) } }
|
||||
|
||||
optimisticallyInsertedMessages.mutate { messages in idsToRemove.forEach { messages.removeValue(forKey: $0) } }
|
||||
}
|
||||
|
||||
// MARK: - Mentions
|
||||
|
@ -575,6 +729,14 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
public func expandReactions(for interactionId: Int64) {
|
||||
reactionExpandedInteractionIds.insert(interactionId)
|
||||
}
|
||||
|
||||
public func collapseReactions(for interactionId: Int64) {
|
||||
reactionExpandedInteractionIds.remove(interactionId)
|
||||
}
|
||||
|
||||
// MARK: - Audio Playback
|
||||
|
||||
public struct PlaybackInfo {
|
||||
|
|
|
@ -332,6 +332,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
|
|||
|
||||
// Build the link preview
|
||||
LinkPreview.tryToBuildPreviewInfo(previewUrl: linkPreviewURL)
|
||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink(
|
||||
receiveCompletion: { [weak self] result in
|
||||
|
|
|
@ -119,17 +119,6 @@ final class QuoteView: UIView {
|
|||
contentView.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.top, UIView.VerticalEdge.bottom ], to: self)
|
||||
contentView.rightAnchor.constraint(lessThanOrEqualTo: self.rightAnchor).isActive = true
|
||||
|
||||
// Line view
|
||||
let lineColor: ThemeValue = {
|
||||
switch mode {
|
||||
case .regular: return (direction == .outgoing ? .messageBubble_outgoingText : .primary)
|
||||
case .draft: return .primary
|
||||
}
|
||||
}()
|
||||
let lineView = UIView()
|
||||
lineView.themeBackgroundColor = lineColor
|
||||
lineView.set(.width, to: Values.accentLineThickness)
|
||||
|
||||
if let attachment: Attachment = attachment {
|
||||
let isAudio: Bool = MIMETypeUtil.isAudio(attachment.contentType)
|
||||
let fallbackImageName: String = (isAudio ? "attachment_audio" : "actionsheet_document_black")
|
||||
|
@ -181,13 +170,26 @@ final class QuoteView: UIView {
|
|||
}
|
||||
}
|
||||
else {
|
||||
// Line view
|
||||
let lineColor: ThemeValue = {
|
||||
switch mode {
|
||||
case .regular: return (direction == .outgoing ? .messageBubble_outgoingText : .primary)
|
||||
case .draft: return .primary
|
||||
}
|
||||
}()
|
||||
let lineView = UIView()
|
||||
lineView.themeBackgroundColor = lineColor
|
||||
mainStackView.addArrangedSubview(lineView)
|
||||
|
||||
lineView.pin(.top, to: .top, of: mainStackView)
|
||||
lineView.pin(.bottom, to: .bottom, of: mainStackView)
|
||||
lineView.set(.width, to: Values.accentLineThickness)
|
||||
}
|
||||
|
||||
// Body label
|
||||
let bodyLabel = TappableLabel()
|
||||
bodyLabel.numberOfLines = 0
|
||||
bodyLabel.lineBreakMode = .byTruncatingTail
|
||||
bodyLabel.numberOfLines = 2
|
||||
|
||||
let targetThemeColor: ThemeValue = {
|
||||
switch mode {
|
||||
|
@ -229,7 +231,6 @@ final class QuoteView: UIView {
|
|||
|
||||
// Label stack view
|
||||
let bodyLabelSize = bodyLabel.systemLayoutSizeFitting(availableSpace)
|
||||
var authorLabelHeight: CGFloat?
|
||||
|
||||
let isCurrentUser: Bool = [
|
||||
currentUserPublicKey,
|
||||
|
@ -259,16 +260,12 @@ final class QuoteView: UIView {
|
|||
authorLabel.themeTextColor = targetThemeColor
|
||||
authorLabel.lineBreakMode = .byTruncatingTail
|
||||
authorLabel.isHidden = (authorLabel.text == nil)
|
||||
|
||||
let authorLabelSize = authorLabel.systemLayoutSizeFitting(availableSpace)
|
||||
authorLabel.set(.height, to: authorLabelSize.height)
|
||||
authorLabelHeight = authorLabelSize.height
|
||||
authorLabel.numberOfLines = 1
|
||||
|
||||
let labelStackView = UIStackView(arrangedSubviews: [ authorLabel, bodyLabel ])
|
||||
labelStackView.axis = .vertical
|
||||
labelStackView.spacing = labelStackViewSpacing
|
||||
labelStackView.distribution = .equalCentering
|
||||
labelStackView.set(.width, to: max(bodyLabelSize.width, authorLabelSize.width))
|
||||
labelStackView.isLayoutMarginsRelativeArrangement = true
|
||||
labelStackView.layoutMargins = UIEdgeInsets(top: labelStackViewVMargin, left: 0, bottom: labelStackViewVMargin, right: 0)
|
||||
mainStackView.addArrangedSubview(labelStackView)
|
||||
|
@ -277,29 +274,6 @@ final class QuoteView: UIView {
|
|||
contentView.addSubview(mainStackView)
|
||||
mainStackView.pin(to: contentView)
|
||||
|
||||
if threadVariant == .contact {
|
||||
bodyLabel.set(.width, to: bodyLabelSize.width)
|
||||
}
|
||||
|
||||
let bodyLabelHeight = bodyLabelSize.height.clamp(0, (mode == .regular ? 60 : 40))
|
||||
let contentViewHeight: CGFloat
|
||||
|
||||
if attachment != nil {
|
||||
contentViewHeight = thumbnailSize + 8 // Add a small amount of spacing above and below the thumbnail
|
||||
bodyLabel.set(.height, to: 18) // Experimentally determined
|
||||
}
|
||||
else {
|
||||
if let authorLabelHeight = authorLabelHeight { // Group thread
|
||||
contentViewHeight = bodyLabelHeight + (authorLabelHeight + labelStackViewSpacing) + 2 * labelStackViewVMargin
|
||||
}
|
||||
else {
|
||||
contentViewHeight = bodyLabelHeight + 2 * smallSpacing
|
||||
}
|
||||
}
|
||||
|
||||
contentView.set(.height, to: contentViewHeight)
|
||||
lineView.set(.height, to: contentViewHeight - 8) // Add a small amount of spacing above and below the line
|
||||
|
||||
if mode == .draft {
|
||||
// Cancel button
|
||||
let cancelButton = UIButton(type: .custom)
|
||||
|
|
|
@ -149,6 +149,7 @@ class ThreadDisappearingMessagesSettingsViewModel: SessionTableViewModel<ThreadD
|
|||
]
|
||||
}
|
||||
.removeDuplicates()
|
||||
.handleEvents(didFail: { SNLog("[ThreadDisappearingMessageSettingsViewModel] Observation failed with error: \($0)") })
|
||||
.publisher(in: dependencies.storage, scheduling: dependencies.scheduler)
|
||||
.mapToSessionTableViewData(for: self)
|
||||
|
||||
|
|
|
@ -709,6 +709,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
|||
]
|
||||
}
|
||||
.removeDuplicates()
|
||||
.handleEvents(didFail: { SNLog("[ThreadSettingsViewModel] Observation failed with error: \($0)") })
|
||||
.publisher(in: dependencies.storage, scheduling: dependencies.scheduler)
|
||||
.mapToSessionTableViewData(for: self)
|
||||
|
||||
|
|
|
@ -20,7 +20,7 @@ public class HomeViewModel {
|
|||
|
||||
// MARK: - Variables
|
||||
|
||||
public static let pageSize: Int = 15
|
||||
public static let pageSize: Int = (UIDevice.current.isIPad ? 20 : 15)
|
||||
|
||||
public struct State: Equatable {
|
||||
let showViewedSeedBanner: Bool
|
||||
|
@ -231,6 +231,7 @@ public class HomeViewModel {
|
|||
public lazy var observableState = ValueObservation
|
||||
.trackingConstantRegion { db -> State in try HomeViewModel.retrieveState(db) }
|
||||
.removeDuplicates()
|
||||
.handleEvents(didFail: { SNLog("[HomeViewModel] Observation failed with error: \($0)") })
|
||||
|
||||
private static func retrieveState(_ db: Database) throws -> State {
|
||||
let hasViewedSeed: Bool = db[.hasViewedSeed]
|
||||
|
|
|
@ -17,7 +17,7 @@ public class MessageRequestsViewModel {
|
|||
|
||||
// MARK: - Variables
|
||||
|
||||
public static let pageSize: Int = 15
|
||||
public static let pageSize: Int = (UIDevice.current.isIPad ? 20 : 15)
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
|
|
|
@ -210,6 +210,7 @@ final class NewDMVC: BaseVC, UIPageViewControllerDataSource, UIPageViewControlle
|
|||
.present(fromViewController: navigationController!, canCancel: false) { [weak self] modalActivityIndicator in
|
||||
SnodeAPI
|
||||
.getSessionID(for: onsNameOrPublicKey)
|
||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { result in
|
||||
|
|
|
@ -360,6 +360,7 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
|||
|
||||
cell
|
||||
.requestRenditionForSending()
|
||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink(
|
||||
receiveCompletion: { [weak self] result in
|
||||
|
@ -490,6 +491,7 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
|||
assert(searchBar.text == nil || searchBar.text?.count == 0)
|
||||
|
||||
GiphyAPI.trending()
|
||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink(
|
||||
receiveCompletion: { result in
|
||||
|
@ -527,6 +529,7 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
|||
|
||||
GiphyAPI
|
||||
.search(query: query)
|
||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink(
|
||||
receiveCompletion: { [weak self] result in
|
||||
|
|
|
@ -291,7 +291,6 @@ enum GiphyAPI {
|
|||
|
||||
return urlSession
|
||||
.dataTaskPublisher(for: url)
|
||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||
.mapError { urlError in
|
||||
Logger.error("search request failed: \(urlError)")
|
||||
|
||||
|
@ -340,7 +339,6 @@ enum GiphyAPI {
|
|||
|
||||
return urlSession
|
||||
.dataTaskPublisher(for: request)
|
||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||
.mapError { urlError in
|
||||
Logger.error("search request failed: \(urlError)")
|
||||
|
||||
|
|
|
@ -360,7 +360,7 @@ public class MediaGalleryViewModel {
|
|||
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
|
||||
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
|
||||
/// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this
|
||||
public typealias AlbumObservation = ValueObservation<ValueReducers.RemoveDuplicates<ValueReducers.Fetch<[Item]>>>
|
||||
public typealias AlbumObservation = ValueObservation<ValueReducers.Trace<ValueReducers.RemoveDuplicates<ValueReducers.Fetch<[Item]>>>>
|
||||
public lazy var observableAlbumData: AlbumObservation = buildAlbumObservation(for: nil)
|
||||
|
||||
private func buildAlbumObservation(for interactionId: Int64?) -> AlbumObservation {
|
||||
|
@ -383,6 +383,7 @@ public class MediaGalleryViewModel {
|
|||
.fetchAll(db)
|
||||
}
|
||||
.removeDuplicates()
|
||||
.handleEvents(didFail: { SNLog("[MediaGalleryViewModel] Observation failed with error: \($0)") })
|
||||
}
|
||||
|
||||
@discardableResult public func loadAndCacheAlbumData(for interactionId: Int64, in threadId: String) -> [Item] {
|
||||
|
|
|
@ -84,7 +84,7 @@ class PhotoCapture: NSObject {
|
|||
|
||||
func startCapture() -> AnyPublisher<Void, Error> {
|
||||
return Just(())
|
||||
.subscribe(on: sessionQueue)
|
||||
.subscribe(on: sessionQueue) // Must run this on a specific queue to prevent crashes
|
||||
.setFailureType(to: Error.self)
|
||||
.tryMap { [weak self] _ -> Void in
|
||||
self?.session.beginConfiguration()
|
||||
|
@ -136,7 +136,7 @@ class PhotoCapture: NSObject {
|
|||
|
||||
func stopCapture() -> AnyPublisher<Void, Never> {
|
||||
return Just(())
|
||||
.subscribe(on: sessionQueue)
|
||||
.subscribe(on: sessionQueue) // Must run this on a specific queue to prevent crashes
|
||||
.handleEvents(
|
||||
receiveOutput: { [weak self] in self?.session.stopRunning() }
|
||||
)
|
||||
|
@ -160,7 +160,7 @@ class PhotoCapture: NSObject {
|
|||
|
||||
return Just(())
|
||||
.setFailureType(to: Error.self)
|
||||
.subscribe(on: sessionQueue)
|
||||
.subscribe(on: sessionQueue) // Must run this on a specific queue to prevent crashes
|
||||
.tryMap { [weak self, newPosition = self.desiredPosition] _ -> Void in
|
||||
self?.session.beginConfiguration()
|
||||
defer { self?.session.commitConfiguration() }
|
||||
|
@ -196,7 +196,7 @@ class PhotoCapture: NSObject {
|
|||
|
||||
func switchFlashMode() -> AnyPublisher<Void, Never> {
|
||||
return Just(())
|
||||
.subscribe(on: sessionQueue)
|
||||
.subscribe(on: sessionQueue) // Must run this on a specific queue to prevent crashes
|
||||
.handleEvents(
|
||||
receiveOutput: { [weak self] _ in
|
||||
switch self?.captureOutput.flashMode {
|
||||
|
@ -351,7 +351,7 @@ extension PhotoCapture: CaptureButtonDelegate {
|
|||
Logger.verbose("")
|
||||
|
||||
Just(())
|
||||
.subscribe(on: sessionQueue)
|
||||
.subscribe(on: sessionQueue) // Must run this on a specific queue to prevent crashes
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { [weak self] _ in
|
||||
guard let strongSelf = self else { return }
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
|
||||
class MediaDismissAnimationController: NSObject {
|
||||
private let mediaItem: Media
|
||||
|
@ -46,6 +47,18 @@ extension MediaDismissAnimationController: UIViewControllerAnimatedTransitioning
|
|||
switch fromVC {
|
||||
case let contextProvider as MediaPresentationContextProvider:
|
||||
fromContextProvider = contextProvider
|
||||
|
||||
case let topBannerController as TopBannerController:
|
||||
guard
|
||||
let firstChild: UIViewController = topBannerController.children.first,
|
||||
let navController: UINavigationController = firstChild as? UINavigationController,
|
||||
let contextProvider = navController.topViewController as? MediaPresentationContextProvider
|
||||
else {
|
||||
transitionContext.completeTransition(false)
|
||||
return
|
||||
}
|
||||
|
||||
fromContextProvider = contextProvider
|
||||
|
||||
case let navController as UINavigationController:
|
||||
guard let contextProvider = navController.topViewController as? MediaPresentationContextProvider else {
|
||||
|
@ -64,6 +77,19 @@ extension MediaDismissAnimationController: UIViewControllerAnimatedTransitioning
|
|||
case let contextProvider as MediaPresentationContextProvider:
|
||||
toVC.view.layoutIfNeeded()
|
||||
toContextProvider = contextProvider
|
||||
|
||||
case let topBannerController as TopBannerController:
|
||||
guard
|
||||
let firstChild: UIViewController = topBannerController.children.first,
|
||||
let navController: UINavigationController = firstChild as? UINavigationController,
|
||||
let contextProvider = navController.topViewController as? MediaPresentationContextProvider
|
||||
else {
|
||||
transitionContext.completeTransition(false)
|
||||
return
|
||||
}
|
||||
|
||||
toVC.view.layoutIfNeeded()
|
||||
toContextProvider = contextProvider
|
||||
|
||||
case let navController as UINavigationController:
|
||||
guard let contextProvider = navController.topViewController as? MediaPresentationContextProvider else {
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
|
||||
class MediaZoomAnimationController: NSObject {
|
||||
private let mediaItem: Media
|
||||
|
@ -34,6 +35,18 @@ extension MediaZoomAnimationController: UIViewControllerAnimatedTransitioning {
|
|||
switch fromVC {
|
||||
case let contextProvider as MediaPresentationContextProvider:
|
||||
fromContextProvider = contextProvider
|
||||
|
||||
case let topBannerController as TopBannerController:
|
||||
guard
|
||||
let firstChild: UIViewController = topBannerController.children.first,
|
||||
let navController: UINavigationController = firstChild as? UINavigationController,
|
||||
let contextProvider = navController.topViewController as? MediaPresentationContextProvider
|
||||
else {
|
||||
transitionContext.completeTransition(false)
|
||||
return
|
||||
}
|
||||
|
||||
fromContextProvider = contextProvider
|
||||
|
||||
case let navController as UINavigationController:
|
||||
guard let contextProvider = navController.topViewController as? MediaPresentationContextProvider else {
|
||||
|
@ -51,6 +64,18 @@ extension MediaZoomAnimationController: UIViewControllerAnimatedTransitioning {
|
|||
switch toVC {
|
||||
case let contextProvider as MediaPresentationContextProvider:
|
||||
toContextProvider = contextProvider
|
||||
|
||||
case let topBannerController as TopBannerController:
|
||||
guard
|
||||
let firstChild: UIViewController = topBannerController.children.first,
|
||||
let navController: UINavigationController = firstChild as? UINavigationController,
|
||||
let contextProvider = navController.topViewController as? MediaPresentationContextProvider
|
||||
else {
|
||||
transitionContext.completeTransition(false)
|
||||
return
|
||||
}
|
||||
|
||||
toContextProvider = contextProvider
|
||||
|
||||
case let navController as UINavigationController:
|
||||
guard let contextProvider = navController.topViewController as? MediaPresentationContextProvider else {
|
||||
|
|
|
@ -332,12 +332,17 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
|||
}
|
||||
}
|
||||
|
||||
private func showFailedMigrationAlert(calledFrom lifecycleMethod: LifecycleMethod, error: Error?) {
|
||||
private func showFailedMigrationAlert(
|
||||
calledFrom lifecycleMethod: LifecycleMethod,
|
||||
error: Error?,
|
||||
isRestoreError: Bool = false
|
||||
) {
|
||||
let alert = UIAlertController(
|
||||
title: "Session",
|
||||
message: {
|
||||
switch (error ?? StorageError.generic) {
|
||||
case StorageError.startupFailed: return "DATABASE_STARTUP_FAILED".localized()
|
||||
switch (isRestoreError, (error ?? StorageError.generic)) {
|
||||
case (true, _): return "DATABASE_RESTORE_FAILED".localized()
|
||||
case (_, StorageError.startupFailed): return "DATABASE_STARTUP_FAILED".localized()
|
||||
default: return "DATABASE_MIGRATION_FAILED".localized()
|
||||
}
|
||||
}(),
|
||||
|
@ -348,32 +353,45 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
|||
self?.showFailedMigrationAlert(calledFrom: lifecycleMethod, error: error)
|
||||
}
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "vc_restore_title".localized(), style: .destructive) { _ in
|
||||
// Remove the legacy database and any message hashes that have been migrated to the new DB
|
||||
try? SUKLegacy.deleteLegacyDatabaseFilesAndKey()
|
||||
|
||||
Storage.shared.write { db in
|
||||
try SnodeReceivedMessageInfo.deleteAll(db)
|
||||
}
|
||||
|
||||
// The re-run the migration (should succeed since there is no data)
|
||||
AppSetup.runPostSetupMigrations(
|
||||
migrationProgressChanged: { [weak self] progress, minEstimatedTotalTime in
|
||||
self?.loadingViewController?.updateProgress(
|
||||
progress: progress,
|
||||
minEstimatedTotalTime: minEstimatedTotalTime
|
||||
)
|
||||
},
|
||||
migrationsCompletion: { [weak self] result, needsConfigSync in
|
||||
if case .failure(let error) = result {
|
||||
self?.showFailedMigrationAlert(calledFrom: lifecycleMethod, error: error)
|
||||
return
|
||||
}
|
||||
|
||||
// Only offer the 'Restore' option if the user hasn't already tried to restore
|
||||
if !isRestoreError {
|
||||
alert.addAction(UIAlertAction(title: "vc_restore_title".localized(), style: .destructive) { _ in
|
||||
if SUKLegacy.hasLegacyDatabaseFile {
|
||||
// Remove the legacy database and any message hashes that have been migrated to the new DB
|
||||
try? SUKLegacy.deleteLegacyDatabaseFilesAndKey()
|
||||
|
||||
self?.completePostMigrationSetup(calledFrom: lifecycleMethod, needsConfigSync: needsConfigSync)
|
||||
Storage.shared.write { db in
|
||||
try SnodeReceivedMessageInfo.deleteAll(db)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
else {
|
||||
// If we don't have a legacy database then reset the current database for a clean migration
|
||||
Storage.resetForCleanMigration()
|
||||
}
|
||||
|
||||
// Hide the top banner if there was one
|
||||
TopBannerController.hide()
|
||||
|
||||
// The re-run the migration (should succeed since there is no data)
|
||||
AppSetup.runPostSetupMigrations(
|
||||
migrationProgressChanged: { [weak self] progress, minEstimatedTotalTime in
|
||||
self?.loadingViewController?.updateProgress(
|
||||
progress: progress,
|
||||
minEstimatedTotalTime: minEstimatedTotalTime
|
||||
)
|
||||
},
|
||||
migrationsCompletion: { [weak self] result, needsConfigSync in
|
||||
if case .failure(let error) = result {
|
||||
self?.showFailedMigrationAlert(calledFrom: lifecycleMethod, error: error, isRestoreError: true)
|
||||
return
|
||||
}
|
||||
|
||||
self?.completePostMigrationSetup(calledFrom: lifecycleMethod, needsConfigSync: needsConfigSync)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
alert.addAction(UIAlertAction(title: "Close", style: .default) { _ in
|
||||
DDLog.flushLog()
|
||||
|
@ -612,12 +630,17 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
|||
public func startPollersIfNeeded(shouldStartGroupPollers: Bool = true) {
|
||||
guard Identity.userExists() else { return }
|
||||
|
||||
poller.start()
|
||||
|
||||
guard shouldStartGroupPollers else { return }
|
||||
|
||||
ClosedGroupPoller.shared.start()
|
||||
OpenGroupManager.shared.startPolling()
|
||||
/// There is a fun issue where if you launch without any valid paths then the pollers are guaranteed to fail their first poll due to
|
||||
/// trying and failing to build paths without having the `SnodeAPI.snodePool` populated, by waiting for the
|
||||
/// `JobRunner.blockingQueue` to complete we can have more confidence that paths won't fail to build incorrectly
|
||||
JobRunner.afterBlockingQueue { [weak self] in
|
||||
self?.poller.start()
|
||||
|
||||
guard shouldStartGroupPollers else { return }
|
||||
|
||||
ClosedGroupPoller.shared.start()
|
||||
OpenGroupManager.shared.startPolling()
|
||||
}
|
||||
}
|
||||
|
||||
public func stopPollers(shouldStopUserPoller: Bool = true) {
|
||||
|
|
|
@ -415,6 +415,7 @@
|
|||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
||||
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
|
||||
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
||||
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
||||
|
|
|
@ -415,6 +415,7 @@
|
|||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
||||
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
|
||||
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
||||
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
||||
|
|
|
@ -415,6 +415,7 @@
|
|||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
||||
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
|
||||
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
||||
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
||||
|
|
|
@ -415,6 +415,7 @@
|
|||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "لطفا بعدا دوباره تلاش کنید";
|
||||
"LOADING_CONVERSATIONS" = "درحال بارگزاری پیام ها...";
|
||||
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
|
||||
"DATABASE_MIGRATION_FAILED" = "هنگام بهینهسازی پایگاه داده خطایی روی داد\n\nشما میتوانید گزارشهای برنامه خود را صادر کنید تا بتوانید برای عیبیابی به اشتراک بگذارید یا میتوانید دستگاه خود را بازیابی کنید\n\nهشدار: بازیابی دستگاه شما منجر به از دست رفتن دادههای قدیمیتر از دو هفته میشود.";
|
||||
"RECOVERY_PHASE_ERROR_GENERIC" = "مشکلی پیش آمد. لطفاً عبارت بازیابی خود را بررسی کنید و دوباره امتحان کنید.";
|
||||
"RECOVERY_PHASE_ERROR_LENGTH" = "به نظر می رسد کلمات کافی وارد نکرده اید. لطفاً عبارت بازیابی خود را بررسی کنید و دوباره امتحان کنید.";
|
||||
|
|
|
@ -415,6 +415,7 @@
|
|||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
||||
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
|
||||
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
||||
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
||||
|
|
|
@ -415,6 +415,7 @@
|
|||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Veuillez réessayer plus tard";
|
||||
"LOADING_CONVERSATIONS" = "Chargement des conversations...";
|
||||
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
|
||||
"DATABASE_MIGRATION_FAILED" = "Une erreur est survenue pendant l'optimisation de la base de données\n\nVous pouvez exporter votre journal d'application pour le partager et aider à régler le problème ou vous pouvez restaurer votre appareil\n\nAttention : restaurer votre appareil résultera en une perte des données des deux dernières semaines";
|
||||
"RECOVERY_PHASE_ERROR_GENERIC" = "Quelque chose s'est mal passé. Vérifiez votre phrase de récupération et réessayez s'il vous plaît.";
|
||||
"RECOVERY_PHASE_ERROR_LENGTH" = "Il semble que vous n'avez pas saisi tous les mots. Vérifiez votre phrase de récupération et réessayez s'il vous plaît.";
|
||||
|
|
|
@ -415,6 +415,7 @@
|
|||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
||||
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
|
||||
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
||||
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
||||
|
|
|
@ -415,6 +415,7 @@
|
|||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
||||
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
|
||||
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
||||
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
||||
|
|
|
@ -415,6 +415,7 @@
|
|||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
||||
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
|
||||
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
||||
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
||||
|
|
|
@ -415,6 +415,7 @@
|
|||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
||||
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
|
||||
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
||||
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
||||
|
|
|
@ -415,6 +415,7 @@
|
|||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
||||
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
|
||||
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
||||
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
||||
|
|
|
@ -415,6 +415,7 @@
|
|||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
||||
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
|
||||
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
||||
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
||||
|
|
|
@ -415,6 +415,7 @@
|
|||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
||||
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
|
||||
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
||||
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
||||
|
|
|
@ -415,6 +415,7 @@
|
|||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
||||
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
|
||||
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
||||
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
||||
|
|
|
@ -415,6 +415,7 @@
|
|||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
||||
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
|
||||
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
||||
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
||||
|
|
|
@ -415,6 +415,7 @@
|
|||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
||||
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
|
||||
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
||||
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
||||
|
|
|
@ -415,6 +415,7 @@
|
|||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
||||
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
|
||||
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
||||
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
||||
|
|
|
@ -415,6 +415,7 @@
|
|||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
||||
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
|
||||
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
||||
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
||||
|
|
|
@ -415,6 +415,7 @@
|
|||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
||||
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
|
||||
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
||||
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
||||
|
|
|
@ -415,6 +415,7 @@
|
|||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
||||
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
|
||||
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
||||
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
||||
|
|
|
@ -415,6 +415,7 @@
|
|||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
||||
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
|
||||
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
||||
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
||||
|
|
|
@ -415,6 +415,7 @@
|
|||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
"LOADING_CONVERSATIONS" = "Loading Conversations...";
|
||||
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
|
||||
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
|
||||
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
|
||||
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";
|
||||
|
|
|
@ -575,9 +575,7 @@ class NotificationActionHandler {
|
|||
threadVariant: thread.variant
|
||||
)
|
||||
}
|
||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||
.flatMap { MessageSender.sendImmediate(preparedSendData: $0) }
|
||||
.receive(on: DispatchQueue.main)
|
||||
.handleEvents(
|
||||
receiveCompletion: { result in
|
||||
switch result {
|
||||
|
|
|
@ -52,8 +52,9 @@ public enum PushRegistrationError: Error {
|
|||
Logger.info("")
|
||||
|
||||
return registerUserNotificationSettings()
|
||||
.setFailureType(to: Error.self)
|
||||
.subscribe(on: DispatchQueue.global(qos: .default))
|
||||
.receive(on: DispatchQueue.main) // MUST be on main thread
|
||||
.setFailureType(to: Error.self)
|
||||
.tryFlatMap { _ -> AnyPublisher<(pushToken: String, voipToken: String), Error> in
|
||||
#if targetEnvironment(simulator)
|
||||
throw PushRegistrationError.pushNotSupported(description: "Push not supported on simulators")
|
||||
|
|
|
@ -251,6 +251,8 @@ public class UserNotificationActionHandler: NSObject {
|
|||
func handleNotificationResponse( _ response: UNNotificationResponse, completionHandler: @escaping () -> Void) {
|
||||
AssertIsOnMainThread()
|
||||
handleNotificationResponse(response)
|
||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { result in
|
||||
switch result {
|
||||
|
|
|
@ -189,7 +189,7 @@ final class DisplayNameVC: BaseVC {
|
|||
|
||||
// Try to save the user name but ignore the result
|
||||
ProfileManager.updateLocal(
|
||||
queue: DispatchQueue.global(qos: .default),
|
||||
queue: .global(qos: .default),
|
||||
profileName: displayName
|
||||
)
|
||||
|
||||
|
|
|
@ -36,14 +36,12 @@ enum Onboarding {
|
|||
let userPublicKey: String = getUserHexEncodedPublicKey()
|
||||
|
||||
return SnodeAPI.getSwarm(for: userPublicKey)
|
||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||
.tryFlatMapWithRandomSnode { snode -> AnyPublisher<Void, Error> in
|
||||
CurrentUserPoller
|
||||
.poll(
|
||||
namespaces: [.configUserProfile],
|
||||
from: snode,
|
||||
for: userPublicKey,
|
||||
on: DispatchQueue.global(qos: .userInitiated),
|
||||
// Note: These values mean the received messages will be
|
||||
// processed immediately rather than async as part of a Job
|
||||
calledFromBackgroundPoller: true,
|
||||
|
@ -67,7 +65,6 @@ enum Onboarding {
|
|||
namespaces: [.default],
|
||||
from: snode,
|
||||
for: userPublicKey,
|
||||
on: DispatchQueue.global(qos: .userInitiated),
|
||||
// Note: These values mean the received messages will be
|
||||
// processed immediately rather than async as part of a Job
|
||||
calledFromBackgroundPoller: true,
|
||||
|
@ -215,7 +212,9 @@ enum Onboarding {
|
|||
guard self != .register else { return }
|
||||
|
||||
// Fetch the
|
||||
Onboarding.profileNamePublisher.sinkUntilComplete()
|
||||
Onboarding.profileNamePublisher
|
||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||
.sinkUntilComplete()
|
||||
}
|
||||
|
||||
func completeRegistration() {
|
||||
|
|
|
@ -176,6 +176,7 @@ final class PNModeVC: BaseVC, OptionViewDelegate {
|
|||
// If we don't have one then show a loading indicator and try to retrieve the existing name
|
||||
ModalActivityIndicatorViewController.present(fromViewController: self) { [weak self, flow = self.flow] viewController in
|
||||
Onboarding.profileNamePublisher
|
||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||
.timeout(.seconds(15), scheduler: DispatchQueue.main, customError: { HTTPError.timeout })
|
||||
.catch { _ -> AnyPublisher<String?, Error> in
|
||||
SNLog("Onboarding failed to retrieve existing profile information")
|
||||
|
|
|
@ -143,7 +143,8 @@ final class OpenGroupSuggestionGrid: UIView, UICollectionViewDataSource, UIColle
|
|||
widthAnchor.constraint(greaterThanOrEqualToConstant: OpenGroupSuggestionGrid.cellHeight).isActive = true
|
||||
|
||||
OpenGroupManager.getDefaultRoomsIfNeeded()
|
||||
.receive(on: DispatchQueue.main)
|
||||
.subscribe(on: DispatchQueue.global(qos: .default))
|
||||
.receive(on: DispatchQueue.main, immediatelyIfMain: true)
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { [weak self] _ in self?.update() },
|
||||
receiveValue: { [weak self] rooms in self?.rooms = rooms }
|
||||
|
@ -336,7 +337,7 @@ extension OpenGroupSuggestionGrid {
|
|||
.eraseToAnyPublisher()
|
||||
)
|
||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||
.receiveOnMain(immediately: true)
|
||||
.receive(on: DispatchQueue.main, immediatelyIfMain: true)
|
||||
.sinkUntilComplete(
|
||||
receiveValue: { [weak self] imageData, hasData in
|
||||
guard hasData else {
|
||||
|
|
|
@ -104,6 +104,7 @@ class ConversationSettingsViewModel: SessionTableViewModel<NoNav, ConversationSe
|
|||
]
|
||||
}
|
||||
.removeDuplicates()
|
||||
.handleEvents(didFail: { SNLog("[ConversationSettingsViewModel] Observation failed with error: \($0)") })
|
||||
.publisher(in: Storage.shared)
|
||||
.mapToSessionTableViewData(for: self)
|
||||
}
|
||||
|
|
|
@ -168,6 +168,7 @@ class HelpViewModel: SessionTableViewModel<NoNav, HelpViewModel.Section, HelpVie
|
|||
#endif
|
||||
}
|
||||
.removeDuplicates()
|
||||
.handleEvents(didFail: { SNLog("[HelpViewModel] Observation failed with error: \($0)") })
|
||||
.publisher(in: Storage.shared)
|
||||
.mapToSessionTableViewData(for: self)
|
||||
|
||||
|
|
|
@ -69,6 +69,7 @@ class NotificationContentViewModel: SessionTableViewModel<NoNav, NotificationSet
|
|||
]
|
||||
}
|
||||
.removeDuplicates()
|
||||
.handleEvents(didFail: { SNLog("[NotificationContentViewModel] Observation failed with error: \($0)") })
|
||||
.publisher(in: storage, scheduling: scheduler)
|
||||
.mapToSessionTableViewData(for: self)
|
||||
}
|
||||
|
|
|
@ -147,6 +147,7 @@ class NotificationSettingsViewModel: SessionTableViewModel<NoNav, NotificationSe
|
|||
]
|
||||
}
|
||||
.removeDuplicates()
|
||||
.handleEvents(didFail: { SNLog("[NotificationSettingsViewModel] Observation failed with error: \($0)") })
|
||||
.publisher(in: Storage.shared)
|
||||
.mapToSessionTableViewData(for: self)
|
||||
}
|
||||
|
|
|
@ -146,6 +146,7 @@ class NotificationSoundViewModel: SessionTableViewModel<NotificationSoundViewMod
|
|||
]
|
||||
}
|
||||
.removeDuplicates()
|
||||
.handleEvents(didFail: { SNLog("[NotificationSoundViewModel] Observation failed with error: \($0)") })
|
||||
.publisher(in: Storage.shared)
|
||||
.mapToSessionTableViewData(for: self)
|
||||
|
||||
|
|
|
@ -150,6 +150,7 @@ final class NukeDataModal: Modal {
|
|||
private func clearDeviceOnly() {
|
||||
ModalActivityIndicatorViewController.present(fromViewController: self, canCancel: false) { [weak self] _ in
|
||||
ConfigurationSyncJob.run()
|
||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { _ in
|
||||
|
@ -164,7 +165,7 @@ final class NukeDataModal: Modal {
|
|||
ModalActivityIndicatorViewController
|
||||
.present(fromViewController: presentedViewController, canCancel: false) { [weak self] _ in
|
||||
SnodeAPI.deleteAllMessages(namespace: .all)
|
||||
.subscribe(on: DispatchQueue.global(qos: .default))
|
||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { result in
|
||||
|
|
|
@ -232,6 +232,7 @@ class PrivacySettingsViewModel: SessionTableViewModel<PrivacySettingsViewModel.N
|
|||
]
|
||||
}
|
||||
.removeDuplicates()
|
||||
.handleEvents(didFail: { SNLog("[PrivacySettingsViewModel] Observation failed with error: \($0)") })
|
||||
.publisher(in: Storage.shared)
|
||||
.mapToSessionTableViewData(for: self)
|
||||
}
|
||||
|
|
|
@ -466,6 +466,7 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
|
|||
]
|
||||
}
|
||||
.removeDuplicates()
|
||||
.handleEvents(didFail: { SNLog("[SettingsViewModel] Observation failed with error: \($0)") })
|
||||
.publisher(in: Storage.shared)
|
||||
.mapToSessionTableViewData(for: self)
|
||||
|
||||
|
@ -572,7 +573,7 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
|
|||
) {
|
||||
let viewController = ModalActivityIndicatorViewController(canCancel: false) { [weak self] modalActivityIndicator in
|
||||
ProfileManager.updateLocal(
|
||||
queue: DispatchQueue.global(qos: .default),
|
||||
queue: .global(qos: .default),
|
||||
profileName: name,
|
||||
avatarUpdate: avatarUpdate,
|
||||
success: { db in
|
||||
|
|
|
@ -187,11 +187,12 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
|||
private func startObservingChanges() {
|
||||
// Start observing for data changes
|
||||
dataChangeCancellable = viewModel.observableTableData
|
||||
.receiveOnMain(
|
||||
.receive(
|
||||
on: DispatchQueue.main,
|
||||
// If we haven't done the initial load the trigger it immediately (blocking the main
|
||||
// thread so we remain on the launch screen until it completes to be consistent with
|
||||
// the old behaviour)
|
||||
immediately: !hasLoadedInitialTableData
|
||||
immediatelyIfMain: !hasLoadedInitialTableData
|
||||
)
|
||||
.sink(
|
||||
receiveCompletion: { [weak self] result in
|
||||
|
@ -333,7 +334,7 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
|||
.store(in: &disposables)
|
||||
|
||||
viewModel.leftNavItems
|
||||
.receiveOnMain(immediately: true)
|
||||
.receive(on: DispatchQueue.main, immediatelyIfMain: true)
|
||||
.sink { [weak self] maybeItems in
|
||||
self?.navigationItem.setLeftBarButtonItems(
|
||||
maybeItems.map { items in
|
||||
|
@ -355,7 +356,7 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
|||
.store(in: &disposables)
|
||||
|
||||
viewModel.rightNavItems
|
||||
.receiveOnMain(immediately: true)
|
||||
.receive(on: DispatchQueue.main, immediatelyIfMain: true)
|
||||
.sink { [weak self] maybeItems in
|
||||
self?.navigationItem.setRightBarButtonItems(
|
||||
maybeItems.map { items in
|
||||
|
@ -377,21 +378,21 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
|||
.store(in: &disposables)
|
||||
|
||||
viewModel.emptyStateTextPublisher
|
||||
.receiveOnMain(immediately: true)
|
||||
.receive(on: DispatchQueue.main, immediatelyIfMain: true)
|
||||
.sink { [weak self] text in
|
||||
self?.emptyStateLabel.text = text
|
||||
}
|
||||
.store(in: &disposables)
|
||||
|
||||
viewModel.footerView
|
||||
.receiveOnMain(immediately: true)
|
||||
.receive(on: DispatchQueue.main, immediatelyIfMain: true)
|
||||
.sink { [weak self] footerView in
|
||||
self?.tableView.tableFooterView = footerView
|
||||
}
|
||||
.store(in: &disposables)
|
||||
|
||||
viewModel.footerButtonInfo
|
||||
.receiveOnMain(immediately: true)
|
||||
.receive(on: DispatchQueue.main, immediatelyIfMain: true)
|
||||
.sink { [weak self] buttonInfo in
|
||||
if let buttonInfo: SessionButton.Info = buttonInfo {
|
||||
self?.footerButton.setTitle(buttonInfo.title, for: .normal)
|
||||
|
|
|
@ -11,21 +11,32 @@ public final class BackgroundPoller {
|
|||
private static var publishers: [AnyPublisher<Void, Error>] = []
|
||||
public static var isValid: Bool = false
|
||||
|
||||
public static func poll(completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
|
||||
public static func poll(
|
||||
completionHandler: @escaping (UIBackgroundFetchResult) -> Void,
|
||||
dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies(
|
||||
subscribeQueue: .global(qos: .background),
|
||||
receiveQueue: .main
|
||||
)
|
||||
) {
|
||||
Publishers
|
||||
.MergeMany(
|
||||
[pollForMessages()]
|
||||
.appending(contentsOf: pollForClosedGroupMessages())
|
||||
[pollForMessages(using: dependencies)]
|
||||
.appending(contentsOf: pollForClosedGroupMessages(using: dependencies))
|
||||
.appending(
|
||||
contentsOf: Storage.shared
|
||||
.read { db in
|
||||
// The default room promise creates an OpenGroup with an empty
|
||||
// `roomToken` value, we don't want to start a poller for this
|
||||
// as the user hasn't actually joined a room
|
||||
/// The default room promise creates an OpenGroup with an empty `roomToken` value, we
|
||||
/// don't want to start a poller for this as the user hasn't actually joined a room
|
||||
///
|
||||
/// We also want to exclude any rooms which have failed to poll too many times in a row from
|
||||
/// the background poll as they are likely to fail again
|
||||
try OpenGroup
|
||||
.select(.server)
|
||||
.filter(OpenGroup.Columns.roomToken != "")
|
||||
.filter(OpenGroup.Columns.isActive)
|
||||
.filter(
|
||||
OpenGroup.Columns.roomToken != "" &&
|
||||
OpenGroup.Columns.isActive &&
|
||||
OpenGroup.Columns.pollFailureCount < OpenGroupAPI.Poller.maxRoomFailureCountForBackgroundPoll
|
||||
)
|
||||
.distinct()
|
||||
.asRequest(of: String.self)
|
||||
.fetchSet(db)
|
||||
|
@ -38,13 +49,14 @@ public final class BackgroundPoller {
|
|||
return poller.poll(
|
||||
calledFromBackgroundPoller: true,
|
||||
isBackgroundPollerValid: { BackgroundPoller.isValid },
|
||||
isPostCapabilitiesRetry: false
|
||||
isPostCapabilitiesRetry: false,
|
||||
using: dependencies
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
.subscribeOnMain(immediately: true)
|
||||
.receiveOnMain(immediately: true)
|
||||
.subscribe(on: dependencies.subscribeQueue, immediatelyIfMain: true)
|
||||
.receive(on: dependencies.receiveQueue, immediatelyIfMain: true)
|
||||
.collect()
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { result in
|
||||
|
@ -61,27 +73,29 @@ public final class BackgroundPoller {
|
|||
)
|
||||
}
|
||||
|
||||
private static func pollForMessages() -> AnyPublisher<Void, Error> {
|
||||
private static func pollForMessages(
|
||||
using dependencies: OpenGroupManager.OGMDependencies
|
||||
) -> AnyPublisher<Void, Error> {
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey()
|
||||
|
||||
return SnodeAPI.getSwarm(for: userPublicKey)
|
||||
.subscribeOnMain(immediately: true)
|
||||
.receiveOnMain(immediately: true)
|
||||
.tryFlatMapWithRandomSnode { snode -> AnyPublisher<[Message], Error> in
|
||||
CurrentUserPoller.poll(
|
||||
namespaces: CurrentUserPoller.namespaces,
|
||||
from: snode,
|
||||
for: userPublicKey,
|
||||
on: DispatchQueue.main,
|
||||
calledFromBackgroundPoller: true,
|
||||
isBackgroundPollValid: { BackgroundPoller.isValid }
|
||||
isBackgroundPollValid: { BackgroundPoller.isValid },
|
||||
using: dependencies
|
||||
)
|
||||
}
|
||||
.map { _ in () }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
private static func pollForClosedGroupMessages() -> [AnyPublisher<Void, Error>] {
|
||||
private static func pollForClosedGroupMessages(
|
||||
using dependencies: OpenGroupManager.OGMDependencies
|
||||
) -> [AnyPublisher<Void, Error>] {
|
||||
// Fetch all closed groups (excluding any don't contain the current user as a
|
||||
// GroupMemeber as the user is no longer a member of those)
|
||||
return Storage.shared
|
||||
|
@ -98,8 +112,6 @@ public final class BackgroundPoller {
|
|||
.defaulting(to: [])
|
||||
.map { groupPublicKey in
|
||||
SnodeAPI.getSwarm(for: groupPublicKey)
|
||||
.subscribeOnMain(immediately: true)
|
||||
.receiveOnMain(immediately: true)
|
||||
.tryFlatMap { swarm -> AnyPublisher<[Message], Error> in
|
||||
guard let snode: Snode = swarm.randomElement() else {
|
||||
throw OnionRequestAPIError.insufficientSnodes
|
||||
|
@ -109,9 +121,9 @@ public final class BackgroundPoller {
|
|||
namespaces: ClosedGroupPoller.namespaces,
|
||||
from: snode,
|
||||
for: groupPublicKey,
|
||||
on: DispatchQueue.main,
|
||||
calledFromBackgroundPoller: true,
|
||||
isBackgroundPollValid: { BackgroundPoller.isValid }
|
||||
isBackgroundPollValid: { BackgroundPoller.isValid },
|
||||
using: dependencies
|
||||
)
|
||||
}
|
||||
.map { _ in () }
|
||||
|
|
|
@ -198,6 +198,7 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate {
|
|||
)
|
||||
}
|
||||
.flatMap { MessageSender.sendImmediate(preparedSendData: $0) }
|
||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { result in
|
||||
switch result {
|
||||
|
@ -263,6 +264,7 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate {
|
|||
)
|
||||
}
|
||||
.flatMap { MessageSender.sendImmediate(preparedSendData: $0) }
|
||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { result in
|
||||
switch result {
|
||||
|
|
|
@ -994,11 +994,14 @@ extension Attachment {
|
|||
}
|
||||
}
|
||||
|
||||
public static func prepare(_ db: Database, attachments: [SignalAttachment], for interactionId: Int64) throws {
|
||||
// Prepare any attachments
|
||||
try attachments.enumerated()
|
||||
.forEach { index, signalAttachment in
|
||||
let maybeAttachment: Attachment? = Attachment(
|
||||
public struct PreparedData {
|
||||
public let attachments: [Attachment]
|
||||
}
|
||||
|
||||
public static func prepare(attachments: [SignalAttachment]) -> PreparedData {
|
||||
return PreparedData(
|
||||
attachments: attachments.compactMap { signalAttachment in
|
||||
Attachment(
|
||||
variant: (signalAttachment.isVoiceMessage ?
|
||||
.voiceMessage :
|
||||
.standard
|
||||
|
@ -1008,9 +1011,23 @@ extension Attachment {
|
|||
sourceFilename: signalAttachment.sourceFilename,
|
||||
caption: signalAttachment.captionText
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
public static func process(
|
||||
_ db: Database,
|
||||
data: PreparedData?,
|
||||
for interactionId: Int64?
|
||||
) throws {
|
||||
guard
|
||||
let data: PreparedData = data,
|
||||
let interactionId: Int64 = interactionId
|
||||
else { return }
|
||||
|
||||
guard let attachment: Attachment = maybeAttachment else { return }
|
||||
|
||||
try data.attachments
|
||||
.enumerated()
|
||||
.forEach { index, attachment in
|
||||
let interactionAttachment: InteractionAttachment = InteractionAttachment(
|
||||
albumIndex: index,
|
||||
interactionId: interactionId,
|
||||
|
@ -1042,7 +1059,7 @@ extension Attachment {
|
|||
let attachmentId: String = self.id
|
||||
|
||||
return Storage.shared
|
||||
.writePublisherFlatMap { db -> AnyPublisher<(String?, Data?, Data?), Error> in
|
||||
.writePublisher { db -> (OpenGroupAPI.PreparedSendData<FileUploadResponse>?, String?, Data?, Data?) in
|
||||
// If the attachment is a downloaded attachment, check if it came from
|
||||
// the server and if so just succeed immediately (no use re-uploading
|
||||
// an attachment that is already present on the server) - or if we want
|
||||
|
@ -1062,9 +1079,7 @@ extension Attachment {
|
|||
.filter(id: attachmentId)
|
||||
.updateAll(db, Attachment.Columns.state.set(to: Attachment.State.uploaded))
|
||||
|
||||
return Just((Attachment.fileId(for: self.downloadUrl), nil, nil))
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
return (nil, Attachment.fileId(for: self.downloadUrl), nil, nil)
|
||||
}
|
||||
|
||||
var encryptionKey: NSData = NSData()
|
||||
|
@ -1089,42 +1104,41 @@ extension Attachment {
|
|||
.filter(id: attachmentId)
|
||||
.updateAll(db, Attachment.Columns.state.set(to: Attachment.State.uploading))
|
||||
|
||||
switch destination {
|
||||
case .openGroup(let openGroup):
|
||||
return OpenGroupAPI
|
||||
.uploadFile(
|
||||
db,
|
||||
bytes: data.bytes,
|
||||
to: openGroup.roomToken,
|
||||
on: openGroup.server
|
||||
)
|
||||
.map { _, response -> (String, Data?, Data?) in
|
||||
(
|
||||
response.id,
|
||||
(destination.shouldEncrypt ? encryptionKey as Data : nil),
|
||||
(destination.shouldEncrypt ? digest as Data : nil)
|
||||
// We need database access for OpenGroup uploads so generate prepared data
|
||||
let preparedSendData: OpenGroupAPI.PreparedSendData<FileUploadResponse>? = try {
|
||||
switch destination {
|
||||
case .openGroup(let openGroup):
|
||||
return try OpenGroupAPI
|
||||
.preparedUploadFile(
|
||||
db,
|
||||
bytes: data.bytes,
|
||||
to: openGroup.roomToken,
|
||||
on: openGroup.server
|
||||
)
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
|
||||
case .fileServer:
|
||||
/// **Note:** FileServer uploads don't need database access so
|
||||
return Just((
|
||||
nil,
|
||||
(destination.shouldEncrypt ? encryptionKey as Data : nil),
|
||||
(destination.shouldEncrypt ? digest as Data : nil)
|
||||
))
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
default: return nil
|
||||
}
|
||||
}()
|
||||
|
||||
return (
|
||||
preparedSendData,
|
||||
nil,
|
||||
(destination.shouldEncrypt ? encryptionKey as Data : nil),
|
||||
(destination.shouldEncrypt ? digest as Data : nil)
|
||||
)
|
||||
}
|
||||
.flatMap { maybeFileId, encryptionKey, digest -> AnyPublisher<(String?, Data?, Data?), Error> in
|
||||
.flatMap { preparedSendData, existingFileId, encryptionKey, digest -> AnyPublisher<(String?, Data?, Data?), Error> in
|
||||
// No need to upload if the file was already uploaded
|
||||
if let fileId: String = existingFileId {
|
||||
return Just((fileId, encryptionKey, digest))
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
switch destination {
|
||||
case .openGroup:
|
||||
/// **Note:** OpenGroup uploads need database access so this should
|
||||
/// have already been uploaded
|
||||
return Just((maybeFileId, encryptionKey, digest))
|
||||
.setFailureType(to: Error.self)
|
||||
return OpenGroupAPI.send(data: preparedSendData)
|
||||
.map { _, response -> (String, Data?, Data?) in (response.id, encryptionKey, digest) }
|
||||
.eraseToAnyPublisher()
|
||||
|
||||
case .fileServer:
|
||||
|
|
|
@ -5,7 +5,7 @@ import GRDB
|
|||
import SessionUtilitiesKit
|
||||
import SessionSnodeKit
|
||||
|
||||
public struct DisappearingMessagesConfiguration: Codable, Identifiable, Equatable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible {
|
||||
public struct DisappearingMessagesConfiguration: Codable, Identifiable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible {
|
||||
public static var databaseTableName: String { "disappearingMessagesConfiguration" }
|
||||
internal static let threadForeignKey = ForeignKey([Columns.threadId], to: [SessionThread.Columns.id])
|
||||
private static let thread = belongsTo(SessionThread.self, using: threadForeignKey)
|
||||
|
|
|
@ -319,7 +319,7 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu
|
|||
openGroupServerMessageId: Int64? = nil,
|
||||
openGroupWhisperMods: Bool = false,
|
||||
openGroupWhisperTo: String? = nil
|
||||
) throws {
|
||||
) {
|
||||
self.serverHash = serverHash
|
||||
self.messageUuid = messageUuid
|
||||
self.threadId = threadId
|
||||
|
@ -821,6 +821,18 @@ public extension Interaction {
|
|||
}
|
||||
}
|
||||
|
||||
return isUserMentioned(
|
||||
publicKeysToCheck: publicKeysToCheck,
|
||||
body: body,
|
||||
quoteAuthorId: quoteAuthorId
|
||||
)
|
||||
}
|
||||
|
||||
static func isUserMentioned(
|
||||
publicKeysToCheck: [String],
|
||||
body: String?,
|
||||
quoteAuthorId: String? = nil
|
||||
) -> Bool {
|
||||
// A user is mentioned if their public key is in the body of a message or one of their messages
|
||||
// was quoted
|
||||
return publicKeysToCheck.contains { publicKey in
|
||||
|
|
|
@ -130,7 +130,7 @@ public extension LinkPreview {
|
|||
return (floor(sentTimestampMs / 1000 / LinkPreview.timstampResolution) * LinkPreview.timstampResolution)
|
||||
}
|
||||
|
||||
static func saveAttachmentIfPossible(_ db: Database, imageData: Data?, mimeType: String) throws -> String? {
|
||||
static func generateAttachmentIfPossible(imageData: Data?, mimeType: String) throws -> Attachment? {
|
||||
guard let imageData: Data = imageData, !imageData.isEmpty else { return nil }
|
||||
guard let fileExtension: String = MIMETypeUtil.fileExtension(forMIMEType: mimeType) else { return nil }
|
||||
|
||||
|
@ -141,9 +141,7 @@ public extension LinkPreview {
|
|||
return nil
|
||||
}
|
||||
|
||||
return try Attachment(contentType: mimeType, dataSource: dataSource)?
|
||||
.inserted(db)
|
||||
.id
|
||||
return Attachment(contentType: mimeType, dataSource: dataSource)
|
||||
}
|
||||
|
||||
static func isValidLinkUrl(_ urlString: String) -> Bool {
|
||||
|
@ -355,7 +353,6 @@ public extension LinkPreview {
|
|||
|
||||
return session
|
||||
.dataTaskPublisher(for: request)
|
||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||
.mapError { _ -> Error in HTTPError.generic } // URLError codes are negative values
|
||||
.tryMap { data, response -> (Data, URLResponse) in
|
||||
guard let urlResponse: HTTPURLResponse = response as? HTTPURLResponse else {
|
||||
|
|
|
@ -89,7 +89,6 @@ public enum FileServerAPI {
|
|||
with: serverPublicKey,
|
||||
timeout: timeout
|
||||
)
|
||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||
.tryMap { _, response -> Data in
|
||||
guard let response: Data = response else { throw HTTPError.parsingFailed }
|
||||
|
||||
|
|
|
@ -94,9 +94,20 @@ public enum AttachmentDownloadJob: JobExecutor {
|
|||
else { throw AttachmentDownloadError.invalidUrl }
|
||||
|
||||
return Storage.shared
|
||||
.readPublisher { db in try OpenGroup.fetchOne(db, id: threadId) }
|
||||
.flatMap { maybeOpenGroup -> AnyPublisher<Data, Error> in
|
||||
guard let openGroup: OpenGroup = maybeOpenGroup else {
|
||||
.readPublisher { db -> OpenGroupAPI.PreparedSendData<Data>? in
|
||||
try OpenGroup.fetchOne(db, id: threadId)
|
||||
.map { openGroup in
|
||||
try OpenGroupAPI
|
||||
.preparedDownloadFile(
|
||||
db,
|
||||
fileId: fileId,
|
||||
from: openGroup.roomToken,
|
||||
on: openGroup.server
|
||||
)
|
||||
}
|
||||
}
|
||||
.flatMap { maybePreparedSendData -> AnyPublisher<Data, Error> in
|
||||
guard let preparedSendData: OpenGroupAPI.PreparedSendData<Data> = maybePreparedSendData else {
|
||||
return FileServerAPI
|
||||
.download(
|
||||
fileId,
|
||||
|
@ -105,16 +116,8 @@ public enum AttachmentDownloadJob: JobExecutor {
|
|||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
return Storage.shared
|
||||
.readPublisherFlatMap { db in
|
||||
OpenGroupAPI
|
||||
.downloadFile(
|
||||
db,
|
||||
fileId: fileId,
|
||||
from: openGroup.roomToken,
|
||||
on: openGroup.server
|
||||
)
|
||||
}
|
||||
return OpenGroupAPI
|
||||
.send(data: preparedSendData)
|
||||
.map { _, data in data }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
|
|
@ -221,25 +221,29 @@ public extension ConfigurationSyncJob {
|
|||
return
|
||||
}
|
||||
|
||||
// Upsert a config sync job (if there is already an pending one then no need
|
||||
// to add another one)
|
||||
// Upsert a config sync job if needed
|
||||
JobRunner.upsert(
|
||||
db,
|
||||
job: ConfigurationSyncJob.createOrUpdateIfNeeded(db, publicKey: publicKey)
|
||||
job: ConfigurationSyncJob.createIfNeeded(db, publicKey: publicKey)
|
||||
)
|
||||
}
|
||||
|
||||
@discardableResult static func createOrUpdateIfNeeded(_ db: Database, publicKey: String) -> Job {
|
||||
// Try to get an existing job (if there is one that's not running)
|
||||
if
|
||||
let existingJobs: [Job] = try? Job
|
||||
@discardableResult static func createIfNeeded(_ db: Database, publicKey: String) -> Job? {
|
||||
/// The ConfigurationSyncJob will automatically reschedule itself to run again after 3 seconds so if there is an existing
|
||||
/// job then there is no need to create another instance
|
||||
///
|
||||
/// **Note:** Jobs with different `threadId` values can run concurrently
|
||||
guard
|
||||
JobRunner
|
||||
.infoForCurrentlyRunningJobs(of: .configurationSync)
|
||||
.filter({ _, info in info.threadId == publicKey })
|
||||
.isEmpty,
|
||||
(try? Job
|
||||
.filter(Job.Columns.variant == Job.Variant.configurationSync)
|
||||
.filter(Job.Columns.threadId == publicKey)
|
||||
.fetchAll(db),
|
||||
let existingJob: Job = existingJobs.first(where: { !JobRunner.isCurrentlyRunning($0) })
|
||||
{
|
||||
return existingJob
|
||||
}
|
||||
.isEmpty(db))
|
||||
.defaulting(to: false)
|
||||
else { return nil }
|
||||
|
||||
// Otherwise create a new job
|
||||
return Job(
|
||||
|
@ -278,7 +282,7 @@ public extension ConfigurationSyncJob {
|
|||
Future { resolver in
|
||||
ConfigurationSyncJob.run(
|
||||
Job(variant: .configurationSync),
|
||||
queue: DispatchQueue.global(qos: .userInitiated),
|
||||
queue: .global(qos: .userInitiated),
|
||||
success: { _, _ in resolver(Result.success(())) },
|
||||
failure: { _, error, _ in resolver(Result.failure(error ?? HTTPError.generic)) },
|
||||
deferred: { _ in }
|
||||
|
|
|
@ -55,6 +55,7 @@ public enum GroupLeavingJob: JobExecutor {
|
|||
)
|
||||
}
|
||||
.flatMap { MessageSender.sendImmediate(preparedSendData: $0) }
|
||||
.subscribe(on: queue)
|
||||
.receive(on: queue)
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { result in
|
||||
|
|
|
@ -189,6 +189,7 @@ public enum MessageSendJob: JobExecutor {
|
|||
}
|
||||
.map { sendData in sendData.with(fileIds: messageFileIds) }
|
||||
.flatMap { MessageSender.sendImmediate(preparedSendData: $0) }
|
||||
.subscribe(on: queue)
|
||||
.receive(on: queue)
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { result in
|
||||
|
|
|
@ -49,6 +49,7 @@ public enum SendReadReceiptsJob: JobExecutor {
|
|||
)
|
||||
}
|
||||
.flatMap { MessageSender.sendImmediate(preparedSendData: $0) }
|
||||
.subscribe(on: queue)
|
||||
.receive(on: queue)
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { result in
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -296,8 +296,6 @@ public final class OpenGroupManager {
|
|||
)
|
||||
}
|
||||
}
|
||||
.subscribe(on: OpenGroupAPI.workQueue)
|
||||
.receive(on: OpenGroupAPI.workQueue)
|
||||
.flatMap { response -> Future<Void, Error> in
|
||||
Future<Void, Error> { resolver in
|
||||
dependencies.storage.write { db in
|
||||
|
@ -347,7 +345,7 @@ public final class OpenGroupManager {
|
|||
_ db: Database,
|
||||
openGroupId: String,
|
||||
calledFromConfigHandling: Bool,
|
||||
dependencies: OGMDependencies = OGMDependencies()
|
||||
using dependencies: OGMDependencies = OGMDependencies()
|
||||
) {
|
||||
let server: String? = try? OpenGroup
|
||||
.select(.server)
|
||||
|
@ -907,26 +905,28 @@ public final class OpenGroupManager {
|
|||
}
|
||||
|
||||
/// This method specifies if the given capability is supported on a specified Open Group
|
||||
public static func isOpenGroupSupport(
|
||||
_ capability: Capability.Variant,
|
||||
public static func doesOpenGroupSupport(
|
||||
_ db: Database? = nil,
|
||||
capability: Capability.Variant,
|
||||
on server: String?,
|
||||
using dependencies: OGMDependencies = OGMDependencies()
|
||||
) -> Bool {
|
||||
guard let server: String = server else { return false }
|
||||
guard let db: Database = db else {
|
||||
return dependencies.storage
|
||||
.read { db in doesOpenGroupSupport(db, capability: capability, on: server, using: dependencies) }
|
||||
.defaulting(to: false)
|
||||
}
|
||||
|
||||
return dependencies.storage
|
||||
.read { db in
|
||||
let capabilities: [Capability.Variant] = (try? Capability
|
||||
.select(.variant)
|
||||
.filter(Capability.Columns.openGroupServer == server)
|
||||
.filter(Capability.Columns.isMissing == false)
|
||||
.asRequest(of: Capability.Variant.self)
|
||||
.fetchAll(db))
|
||||
.defaulting(to: [])
|
||||
let capabilities: [Capability.Variant] = (try? Capability
|
||||
.select(.variant)
|
||||
.filter(Capability.Columns.openGroupServer == server)
|
||||
.filter(Capability.Columns.isMissing == false)
|
||||
.asRequest(of: Capability.Variant.self)
|
||||
.fetchAll(db))
|
||||
.defaulting(to: [])
|
||||
|
||||
return capabilities.contains(capability)
|
||||
}
|
||||
.defaulting(to: false)
|
||||
return capabilities.contains(capability)
|
||||
}
|
||||
|
||||
/// This method specifies if the given publicKey is a moderator or an admin within a specified Open Group
|
||||
|
@ -1012,7 +1012,10 @@ public final class OpenGroupManager {
|
|||
}
|
||||
|
||||
@discardableResult public static func getDefaultRoomsIfNeeded(
|
||||
using dependencies: OGMDependencies = OGMDependencies()
|
||||
using dependencies: OGMDependencies = OGMDependencies(
|
||||
subscribeQueue: OpenGroupAPI.workQueue,
|
||||
receiveQueue: OpenGroupAPI.workQueue
|
||||
)
|
||||
) -> AnyPublisher<[OpenGroupAPI.Room], Error> {
|
||||
// Note: If we already have a 'defaultRoomsPromise' then there is no need to get it again
|
||||
if let existingPublisher: AnyPublisher<[OpenGroupAPI.Room], Error> = dependencies.cache.defaultRoomsPublisher {
|
||||
|
@ -1028,8 +1031,8 @@ public final class OpenGroupManager {
|
|||
using: dependencies
|
||||
)
|
||||
}
|
||||
.subscribe(on: OpenGroupAPI.workQueue)
|
||||
.receive(on: OpenGroupAPI.workQueue)
|
||||
.subscribe(on: dependencies.subscribeQueue, immediatelyIfMain: true)
|
||||
.receive(on: dependencies.receiveQueue, immediatelyIfMain: true)
|
||||
.retry(8)
|
||||
.map { response in
|
||||
dependencies.storage.writeAsync { db in
|
||||
|
@ -1077,7 +1080,6 @@ public final class OpenGroupManager {
|
|||
on: OpenGroupAPI.defaultServer,
|
||||
using: dependencies
|
||||
)
|
||||
.sinkUntilComplete()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1107,13 +1109,13 @@ public final class OpenGroupManager {
|
|||
return publisher
|
||||
}
|
||||
|
||||
public static func roomImage(
|
||||
@discardableResult public static func roomImage(
|
||||
_ db: Database,
|
||||
fileId: String,
|
||||
for roomToken: String,
|
||||
on server: String,
|
||||
using dependencies: OGMDependencies = OGMDependencies(
|
||||
queue: DispatchQueue.global(qos: .background)
|
||||
subscribeQueue: .global(qos: .background)
|
||||
)
|
||||
) -> AnyPublisher<Data, Error> {
|
||||
// Normally the image for a given group is stored with the group thread, so it's only
|
||||
|
@ -1149,37 +1151,63 @@ public final class OpenGroupManager {
|
|||
return publisher
|
||||
}
|
||||
|
||||
// Trigger the download on a background queue
|
||||
let publisher: AnyPublisher<Data, Error> = OpenGroupAPI
|
||||
.downloadFile(
|
||||
db,
|
||||
fileId: fileId,
|
||||
from: roomToken,
|
||||
on: server,
|
||||
using: dependencies
|
||||
)
|
||||
.map { _, imageData in
|
||||
if server.lowercased() == OpenGroupAPI.defaultServer {
|
||||
dependencies.storage.write { db in
|
||||
_ = try OpenGroup
|
||||
.filter(id: threadId)
|
||||
.updateAll(db, OpenGroup.Columns.imageData.set(to: imageData))
|
||||
}
|
||||
dependencies.standardUserDefaults[.lastOpenGroupImageUpdate] = now
|
||||
let sendData: OpenGroupAPI.PreparedSendData<Data>
|
||||
|
||||
do {
|
||||
sendData = try OpenGroupAPI
|
||||
.preparedDownloadFile(
|
||||
db,
|
||||
fileId: fileId,
|
||||
from: roomToken,
|
||||
on: server,
|
||||
using: dependencies
|
||||
)
|
||||
}
|
||||
catch {
|
||||
return Fail(error: error)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
// Defer the actual download and run it on a separate thread to avoid blocking the calling thread
|
||||
let publisher: AnyPublisher<Data, Error> = Deferred {
|
||||
Future { resolver in
|
||||
dependencies.subscribeQueue.async {
|
||||
// Hold on to the publisher until it has completed at least once
|
||||
OpenGroupAPI
|
||||
.send(
|
||||
data: sendData,
|
||||
using: dependencies
|
||||
)
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { result in
|
||||
switch result {
|
||||
case .finished: break
|
||||
case .failure(let error): resolver(Result.failure(error))
|
||||
}
|
||||
},
|
||||
receiveValue: { _, imageData in
|
||||
if server.lowercased() == OpenGroupAPI.defaultServer {
|
||||
dependencies.storage.write { db in
|
||||
_ = try OpenGroup
|
||||
.filter(id: threadId)
|
||||
.updateAll(db, OpenGroup.Columns.imageData.set(to: imageData))
|
||||
}
|
||||
dependencies.standardUserDefaults[.lastOpenGroupImageUpdate] = now
|
||||
}
|
||||
|
||||
resolver(Result.success(imageData))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return imageData
|
||||
}
|
||||
.shareReplay(1)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.shareReplay(1)
|
||||
.eraseToAnyPublisher()
|
||||
|
||||
dependencies.mutableCache.mutate { cache in
|
||||
cache.groupImagePublishers[threadId] = publisher
|
||||
}
|
||||
|
||||
// Hold on to the publisher until it has completed at least once
|
||||
publisher.sinkUntilComplete()
|
||||
|
||||
return publisher
|
||||
}
|
||||
}
|
||||
|
@ -1198,7 +1226,8 @@ extension OpenGroupManager {
|
|||
public var cache: OGMCacheType { return mutableCache.wrappedValue }
|
||||
|
||||
public init(
|
||||
queue: DispatchQueue? = nil,
|
||||
subscribeQueue: DispatchQueue? = nil,
|
||||
receiveQueue: DispatchQueue? = nil,
|
||||
cache: Atomic<OGMCacheType>? = nil,
|
||||
onionApi: OnionRequestAPIType.Type? = nil,
|
||||
generalCache: Atomic<GeneralCacheType>? = nil,
|
||||
|
@ -1218,7 +1247,8 @@ extension OpenGroupManager {
|
|||
_mutableCache = Atomic(cache)
|
||||
|
||||
super.init(
|
||||
queue: queue,
|
||||
subscribeQueue: subscribeQueue,
|
||||
receiveQueue: receiveQueue,
|
||||
onionApi: onionApi,
|
||||
generalCache: generalCache,
|
||||
storage: storage,
|
||||
|
|
|
@ -7,6 +7,7 @@ public enum OpenGroupAPIError: LocalizedError {
|
|||
case signingFailed
|
||||
case noPublicKey
|
||||
case invalidEmoji
|
||||
case invalidPreparedData
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
|
@ -14,6 +15,7 @@ public enum OpenGroupAPIError: LocalizedError {
|
|||
case .signingFailed: return "Couldn't sign message."
|
||||
case .noPublicKey: return "Couldn't find server public key."
|
||||
case .invalidEmoji: return "The emoji is invalid."
|
||||
case .invalidPreparedData: return "Invalid PreparedSendData provided."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,125 @@
|
|||
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import SessionUtilitiesKit
|
||||
|
||||
public extension OpenGroupAPI {
|
||||
struct PreparedSendData<R> {
|
||||
internal let request: URLRequest
|
||||
internal let endpoint: Endpoint
|
||||
internal let server: String
|
||||
internal let publicKey: String
|
||||
internal let originalType: Decodable.Type
|
||||
internal let responseType: R.Type
|
||||
internal let timeout: TimeInterval
|
||||
internal let responseConverter: ((ResponseInfoType, Any) throws -> R)
|
||||
|
||||
internal init(
|
||||
request: URLRequest,
|
||||
endpoint: Endpoint,
|
||||
server: String,
|
||||
publicKey: String,
|
||||
responseType: R.Type,
|
||||
timeout: TimeInterval
|
||||
) where R: Decodable {
|
||||
self.request = request
|
||||
self.endpoint = endpoint
|
||||
self.server = server
|
||||
self.publicKey = publicKey
|
||||
self.originalType = responseType
|
||||
self.responseType = responseType
|
||||
self.timeout = timeout
|
||||
self.responseConverter = { _, response in
|
||||
guard let validResponse: R = response as? R else { throw HTTPError.invalidResponse }
|
||||
|
||||
return validResponse
|
||||
}
|
||||
}
|
||||
|
||||
private init<U: Decodable>(
|
||||
request: URLRequest,
|
||||
endpoint: Endpoint,
|
||||
server: String,
|
||||
publicKey: String,
|
||||
originalType: U.Type,
|
||||
responseType: R.Type,
|
||||
timeout: TimeInterval,
|
||||
responseConverter: @escaping (ResponseInfoType, Any) throws -> R
|
||||
) {
|
||||
self.request = request
|
||||
self.endpoint = endpoint
|
||||
self.server = server
|
||||
self.publicKey = publicKey
|
||||
self.originalType = originalType
|
||||
self.responseType = responseType
|
||||
self.timeout = timeout
|
||||
self.responseConverter = responseConverter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension OpenGroupAPI.PreparedSendData {
|
||||
func map<O>(transform: @escaping (ResponseInfoType, R) throws -> O) -> OpenGroupAPI.PreparedSendData<O> {
|
||||
return OpenGroupAPI.PreparedSendData(
|
||||
request: request,
|
||||
endpoint: endpoint,
|
||||
server: server,
|
||||
publicKey: publicKey,
|
||||
originalType: originalType,
|
||||
responseType: O.self,
|
||||
timeout: timeout,
|
||||
responseConverter: { info, response in
|
||||
let validResponse: R = try responseConverter(info, response)
|
||||
|
||||
return try transform(info, validResponse)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Convenience
|
||||
|
||||
public extension Publisher where Output == (ResponseInfoType, Data?), Failure == Error {
|
||||
func decoded<R>(
|
||||
with preparedData: OpenGroupAPI.PreparedSendData<R>,
|
||||
using dependencies: Dependencies = Dependencies()
|
||||
) -> AnyPublisher<(ResponseInfoType, R), Error> {
|
||||
self
|
||||
.tryMap { responseInfo, maybeData -> (ResponseInfoType, R) in
|
||||
// Depending on the 'originalType' we need to process the response differently
|
||||
let targetData: Any = try {
|
||||
switch preparedData.originalType {
|
||||
case is NoResponse.Type: return NoResponse()
|
||||
case is Optional<Data>.Type: return maybeData as Any
|
||||
case is Data.Type: return try maybeData ?? { throw HTTPError.parsingFailed }()
|
||||
|
||||
case is _OptionalProtocol.Type:
|
||||
guard let data: Data = maybeData else { return maybeData as Any }
|
||||
|
||||
return try preparedData.originalType.decoded(from: data, using: dependencies)
|
||||
|
||||
default:
|
||||
guard let data: Data = maybeData else { throw HTTPError.parsingFailed }
|
||||
|
||||
return try preparedData.originalType.decoded(from: data, using: dependencies)
|
||||
}
|
||||
}()
|
||||
|
||||
// Generate and return the converted data
|
||||
let convertedData: R = try preparedData.responseConverter(responseInfo, targetData)
|
||||
|
||||
return (responseInfo, convertedData)
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - _OptionalProtocol
|
||||
|
||||
/// This protocol should only be used within this file and is used to distinguish between `Any.Type` and `Optional<Any>.Type` as
|
||||
/// it seems that `is Optional<Any>.Type` doesn't work nicely but this protocol works nicely as long as the case is under any explicit
|
||||
/// `Optional<T>` handling that we need
|
||||
private protocol _OptionalProtocol {}
|
||||
|
||||
extension Optional: _OptionalProtocol {}
|
|
@ -1,4 +1,5 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import GRDB
|
||||
import Sodium
|
||||
|
@ -57,7 +58,8 @@ public class SMKDependencies: SSKDependencies {
|
|||
// MARK: - Initialization
|
||||
|
||||
public init(
|
||||
queue: DispatchQueue? = nil,
|
||||
subscribeQueue: DispatchQueue? = nil,
|
||||
receiveQueue: DispatchQueue? = nil,
|
||||
onionApi: OnionRequestAPIType.Type? = nil,
|
||||
generalCache: Atomic<GeneralCacheType>? = nil,
|
||||
storage: Storage? = nil,
|
||||
|
@ -83,7 +85,8 @@ public class SMKDependencies: SSKDependencies {
|
|||
_nonceGenerator24 = Atomic(nonceGenerator24)
|
||||
|
||||
super.init(
|
||||
queue: queue,
|
||||
subscribeQueue: subscribeQueue,
|
||||
receiveQueue: receiveQueue,
|
||||
onionApi: onionApi,
|
||||
generalCache: generalCache,
|
||||
storage: storage,
|
||||
|
|
|
@ -64,12 +64,14 @@ extension MessageReceiver {
|
|||
let thread: SessionThread = try SessionThread
|
||||
.fetchOrCreate(db, id: sender, variant: .contact, shouldBeVisible: nil)
|
||||
|
||||
Environment.shared?.notificationsManager.wrappedValue?
|
||||
.notifyUser(
|
||||
db,
|
||||
forIncomingCall: interaction,
|
||||
in: thread
|
||||
)
|
||||
if !interaction.wasRead {
|
||||
Environment.shared?.notificationsManager.wrappedValue?
|
||||
.notifyUser(
|
||||
db,
|
||||
forIncomingCall: interaction,
|
||||
in: thread
|
||||
)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
@ -79,12 +81,14 @@ extension MessageReceiver {
|
|||
let thread: SessionThread = try SessionThread
|
||||
.fetchOrCreate(db, id: sender, variant: .contact, shouldBeVisible: nil)
|
||||
|
||||
Environment.shared?.notificationsManager.wrappedValue?
|
||||
.notifyUser(
|
||||
db,
|
||||
forIncomingCall: interaction,
|
||||
in: thread
|
||||
)
|
||||
if !interaction.wasRead {
|
||||
Environment.shared?.notificationsManager.wrappedValue?
|
||||
.notifyUser(
|
||||
db,
|
||||
forIncomingCall: interaction,
|
||||
in: thread
|
||||
)
|
||||
}
|
||||
|
||||
// Trigger the missed call UI if needed
|
||||
NotificationCenter.default.post(
|
||||
|
@ -196,6 +200,10 @@ extension MessageReceiver {
|
|||
|
||||
SNLog("[Calls] Sending end call message because there is an ongoing call.")
|
||||
|
||||
let messageSentTimestamp: Int64 = (
|
||||
message.sentTimestamp.map { Int64($0) } ??
|
||||
SnodeAPI.currentOffsetTimestampMs()
|
||||
)
|
||||
_ = try Interaction(
|
||||
serverHash: message.serverHash,
|
||||
messageUuid: message.uuid,
|
||||
|
@ -203,9 +211,13 @@ extension MessageReceiver {
|
|||
authorId: caller,
|
||||
variant: .infoCall,
|
||||
body: String(data: messageInfoData, encoding: .utf8),
|
||||
timestampMs: (
|
||||
message.sentTimestamp.map { Int64($0) } ??
|
||||
SnodeAPI.currentOffsetTimestampMs()
|
||||
timestampMs: messageSentTimestamp,
|
||||
wasRead: SessionUtil.timestampAlreadyRead(
|
||||
threadId: thread.id,
|
||||
threadVariant: thread.variant,
|
||||
timestampMs: (messageSentTimestamp * 1000),
|
||||
userPublicKey: getUserHexEncodedPublicKey(db),
|
||||
openGroup: nil
|
||||
)
|
||||
)
|
||||
.inserted(db)
|
||||
|
@ -227,6 +239,7 @@ extension MessageReceiver {
|
|||
interactionId: nil // Explicitly nil as it's a separate message from above
|
||||
)
|
||||
)
|
||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||
.sinkUntilComplete()
|
||||
}
|
||||
|
||||
|
@ -246,9 +259,10 @@ extension MessageReceiver {
|
|||
!thread.isMessageRequest(db)
|
||||
else { return nil }
|
||||
|
||||
let currentUserPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||
let messageInfo: CallMessage.MessageInfo = CallMessage.MessageInfo(
|
||||
state: state.defaulting(
|
||||
to: (sender == getUserHexEncodedPublicKey(db) ?
|
||||
to: (sender == currentUserPublicKey ?
|
||||
.outgoing :
|
||||
.incoming
|
||||
)
|
||||
|
@ -268,7 +282,14 @@ extension MessageReceiver {
|
|||
authorId: sender,
|
||||
variant: .infoCall,
|
||||
body: String(data: messageInfoData, encoding: .utf8),
|
||||
timestampMs: timestampMs
|
||||
timestampMs: timestampMs,
|
||||
wasRead: SessionUtil.timestampAlreadyRead(
|
||||
threadId: thread.id,
|
||||
threadVariant: thread.variant,
|
||||
timestampMs: (timestampMs * 1000),
|
||||
userPublicKey: currentUserPublicKey,
|
||||
openGroup: nil
|
||||
)
|
||||
).inserted(db)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
import Foundation
|
||||
import GRDB
|
||||
import SessionSnodeKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
extension MessageReceiver {
|
||||
internal static func handleDataExtractionNotification(
|
||||
|
@ -17,6 +18,10 @@ extension MessageReceiver {
|
|||
let messageKind: DataExtractionNotification.Kind = message.kind
|
||||
else { throw MessageReceiverError.invalidMessage }
|
||||
|
||||
let timestampMs: Int64 = (
|
||||
message.sentTimestamp.map { Int64($0) } ??
|
||||
SnodeAPI.currentOffsetTimestampMs()
|
||||
)
|
||||
_ = try Interaction(
|
||||
serverHash: message.serverHash,
|
||||
threadId: threadId,
|
||||
|
@ -27,9 +32,13 @@ extension MessageReceiver {
|
|||
case .mediaSaved: return .infoMediaSavedNotification
|
||||
}
|
||||
}(),
|
||||
timestampMs: (
|
||||
message.sentTimestamp.map { Int64($0) } ??
|
||||
SnodeAPI.currentOffsetTimestampMs()
|
||||
timestampMs: timestampMs,
|
||||
wasRead: SessionUtil.timestampAlreadyRead(
|
||||
threadId: threadId,
|
||||
threadVariant: threadVariant,
|
||||
timestampMs: (timestampMs * 1000),
|
||||
userPublicKey: getUserHexEncodedPublicKey(db),
|
||||
openGroup: nil
|
||||
)
|
||||
).inserted(db)
|
||||
}
|
||||
|
|
|
@ -71,18 +71,26 @@ extension MessageReceiver {
|
|||
}
|
||||
|
||||
// Add an info message for the user
|
||||
let currentUserPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||
_ = try Interaction(
|
||||
serverHash: nil, // Intentionally null so sync messages are seen as duplicates
|
||||
threadId: threadId,
|
||||
authorId: sender,
|
||||
variant: .infoDisappearingMessagesUpdate,
|
||||
body: config.messageInfoString(
|
||||
with: (sender != getUserHexEncodedPublicKey(db) ?
|
||||
with: (sender != currentUserPublicKey ?
|
||||
Profile.displayName(db, id: sender) :
|
||||
nil
|
||||
)
|
||||
),
|
||||
timestampMs: timestampMs
|
||||
timestampMs: timestampMs,
|
||||
wasRead: SessionUtil.timestampAlreadyRead(
|
||||
threadId: threadId,
|
||||
threadVariant: threadVariant,
|
||||
timestampMs: (timestampMs * 1000),
|
||||
userPublicKey: currentUserPublicKey,
|
||||
openGroup: nil
|
||||
)
|
||||
).inserted(db)
|
||||
|
||||
// Only save the updated config if we can perform the change
|
||||
|
|
|
@ -48,6 +48,7 @@ extension MessageReceiver {
|
|||
publicKey: author,
|
||||
serverHashes: [serverHash]
|
||||
)
|
||||
.subscribe(on: DispatchQueue.global(qos: .background))
|
||||
.sinkUntilComplete()
|
||||
}
|
||||
|
||||
|
|
|
@ -117,7 +117,8 @@ extension MessageReceiver {
|
|||
message: message,
|
||||
associatedWithProto: proto,
|
||||
sender: sender,
|
||||
messageSentTimestamp: messageSentTimestamp
|
||||
messageSentTimestamp: messageSentTimestamp,
|
||||
openGroup: maybeOpenGroup
|
||||
) {
|
||||
return interactionId
|
||||
}
|
||||
|
@ -323,7 +324,7 @@ extension MessageReceiver {
|
|||
}
|
||||
|
||||
// Notify the user if needed
|
||||
guard variant == .standardIncoming else { return interactionId }
|
||||
guard variant == .standardIncoming && !interaction.wasRead else { return interactionId }
|
||||
|
||||
// Use the same identifier for notifications when in backgroud polling to prevent spam
|
||||
Environment.shared?.notificationsManager.wrappedValue?
|
||||
|
@ -342,7 +343,8 @@ extension MessageReceiver {
|
|||
message: VisibleMessage,
|
||||
associatedWithProto proto: SNProtoContent,
|
||||
sender: String,
|
||||
messageSentTimestamp: TimeInterval
|
||||
messageSentTimestamp: TimeInterval,
|
||||
openGroup: OpenGroup?
|
||||
) throws -> Int64? {
|
||||
guard
|
||||
let reaction: VisibleMessage.VMReaction = message.reaction,
|
||||
|
@ -370,17 +372,28 @@ extension MessageReceiver {
|
|||
|
||||
switch reaction.kind {
|
||||
case .react:
|
||||
let timestampMs: Int64 = Int64(messageSentTimestamp * 1000)
|
||||
let currentUserPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||
let reaction: Reaction = try Reaction(
|
||||
interactionId: interactionId,
|
||||
serverHash: message.serverHash,
|
||||
timestampMs: Int64(messageSentTimestamp * 1000),
|
||||
timestampMs: timestampMs,
|
||||
authorId: sender,
|
||||
emoji: reaction.emoji,
|
||||
count: 1,
|
||||
sortId: sortId
|
||||
).inserted(db)
|
||||
let timestampAlreadyRead: Bool = SessionUtil.timestampAlreadyRead(
|
||||
threadId: thread.id,
|
||||
threadVariant: thread.variant,
|
||||
timestampMs: timestampMs,
|
||||
userPublicKey: currentUserPublicKey,
|
||||
openGroup: openGroup
|
||||
)
|
||||
|
||||
if sender != getUserHexEncodedPublicKey(db) {
|
||||
// Don't notify if the reaction was added before the lastest read timestamp for
|
||||
// the conversation
|
||||
if sender != currentUserPublicKey && !timestampAlreadyRead {
|
||||
Environment.shared?.notificationsManager.wrappedValue?
|
||||
.notifyUser(
|
||||
db,
|
||||
|
|
|
@ -108,10 +108,7 @@ extension MessageSender {
|
|||
)
|
||||
}
|
||||
|
||||
public static func performUploadsIfNeeded(
|
||||
queue: DispatchQueue,
|
||||
preparedSendData: PreparedSendData
|
||||
) -> AnyPublisher<PreparedSendData, Error> {
|
||||
public static func performUploadsIfNeeded(preparedSendData: PreparedSendData) -> AnyPublisher<PreparedSendData, Error> {
|
||||
// We need an interactionId in order for a message to have uploads
|
||||
guard let interactionId: Int64 = preparedSendData.interactionId else {
|
||||
return Just(preparedSendData)
|
||||
|
|
|
@ -643,7 +643,6 @@ public final class MessageSender {
|
|||
snodeMessage,
|
||||
in: namespace
|
||||
)
|
||||
.subscribe(on: DispatchQueue.global(qos: .default))
|
||||
.flatMap { response -> AnyPublisher<Void, Error> in
|
||||
let updatedMessage: Message = message
|
||||
updatedMessage.serverHash = response.1.hash
|
||||
|
@ -703,7 +702,7 @@ public final class MessageSender {
|
|||
Future<Void, Error> { resolver in
|
||||
NotifyPushServerJob.run(
|
||||
job,
|
||||
queue: DispatchQueue.global(qos: .default),
|
||||
queue: .global(qos: .default),
|
||||
success: { _, _ in resolver(Result.success(())) },
|
||||
failure: { _, _, _ in
|
||||
// Always fulfill because the notify PN server job isn't critical.
|
||||
|
@ -760,9 +759,9 @@ public final class MessageSender {
|
|||
|
||||
// Send the result
|
||||
return dependencies.storage
|
||||
.readPublisherFlatMap { db in
|
||||
OpenGroupAPI
|
||||
.send(
|
||||
.readPublisher { db in
|
||||
try OpenGroupAPI
|
||||
.preparedSend(
|
||||
db,
|
||||
plaintext: plaintext,
|
||||
to: roomToken,
|
||||
|
@ -773,7 +772,7 @@ public final class MessageSender {
|
|||
using: dependencies
|
||||
)
|
||||
}
|
||||
.subscribe(on: DispatchQueue.global(qos: .default))
|
||||
.flatMap { OpenGroupAPI.send(data: $0, using: dependencies) }
|
||||
.flatMap { (responseInfo, responseData) -> AnyPublisher<Void, Error> in
|
||||
let serverTimestampMs: UInt64? = responseData.posted.map { UInt64(floor($0 * 1000)) }
|
||||
let updatedMessage: Message = message
|
||||
|
@ -828,9 +827,9 @@ public final class MessageSender {
|
|||
|
||||
// Send the result
|
||||
return dependencies.storage
|
||||
.readPublisherFlatMap { db in
|
||||
return OpenGroupAPI
|
||||
.send(
|
||||
.readPublisher { db in
|
||||
try OpenGroupAPI
|
||||
.preparedSend(
|
||||
db,
|
||||
ciphertext: ciphertext,
|
||||
toInboxFor: recipientBlindedPublicKey,
|
||||
|
@ -838,7 +837,7 @@ public final class MessageSender {
|
|||
using: dependencies
|
||||
)
|
||||
}
|
||||
.subscribe(on: DispatchQueue.global(qos: .default))
|
||||
.flatMap { OpenGroupAPI.send(data: $0, using: dependencies) }
|
||||
.flatMap { (responseInfo, responseData) -> AnyPublisher<Void, Error> in
|
||||
let updatedMessage: Message = message
|
||||
updatedMessage.openGroupServerMessageId = UInt64(responseData.id)
|
||||
|
|
|
@ -92,12 +92,16 @@ public final class ClosedGroupPoller: Poller {
|
|||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
override func handlePollError(_ error: Error, for publicKey: String) {
|
||||
override func handlePollError(
|
||||
_ error: Error,
|
||||
for publicKey: String,
|
||||
using dependencies: SMKDependencies = SMKDependencies()
|
||||
) {
|
||||
SNLog("Polling failed for closed group with public key: \(publicKey) due to error: \(error).")
|
||||
|
||||
// Try to restart the poller from scratch
|
||||
Threading.pollerQueue.async { [weak self] in
|
||||
self?.setUpPolling(for: publicKey)
|
||||
self?.setUpPolling(for: publicKey, using: dependencies)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -100,7 +100,11 @@ public final class CurrentUserPoller: Poller {
|
|||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
override func handlePollError(_ error: Error, for publicKey: String) {
|
||||
override func handlePollError(
|
||||
_ error: Error,
|
||||
for publicKey: String,
|
||||
using dependencies: SMKDependencies = SMKDependencies()
|
||||
) {
|
||||
if UserDefaults.sharedLokiProject?[.isMainAppActive] != true {
|
||||
// Do nothing when an error gets throws right after returning from the background (happens frequently)
|
||||
}
|
||||
|
@ -115,7 +119,7 @@ public final class CurrentUserPoller: Poller {
|
|||
|
||||
// Try to restart the poller from scratch
|
||||
Threading.pollerQueue.async { [weak self] in
|
||||
self?.setUpPolling(for: publicKey)
|
||||
self?.setUpPolling(for: publicKey, using: dependencies)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,8 +18,16 @@ extension OpenGroupAPI {
|
|||
// MARK: - Settings
|
||||
|
||||
private static let minPollInterval: TimeInterval = 3
|
||||
private static let maxPollInterval: Double = (60 * 60)
|
||||
internal static let maxInactivityPeriod: Double = (14 * 24 * 60 * 60)
|
||||
private static let maxPollInterval: TimeInterval = (60 * 60)
|
||||
internal static let maxInactivityPeriod: TimeInterval = (14 * 24 * 60 * 60)
|
||||
|
||||
/// If there are hidden rooms that we poll and they fail too many times we want to prune them (as it likely means they no longer
|
||||
/// exist, and since they are already hidden it's unlikely that the user will notice that we stopped polling for them)
|
||||
internal static let maxHiddenRoomFailureCount: Int64 = 10
|
||||
|
||||
/// When doing a background poll we want to only fetch from rooms which are unlikely to timeout, in order to do this we exclude
|
||||
/// any rooms which have failed more than this threashold
|
||||
public static let maxRoomFailureCountForBackgroundPoll: Int64 = 15
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
|
@ -41,7 +49,12 @@ extension OpenGroupAPI {
|
|||
|
||||
// MARK: - Polling
|
||||
|
||||
private func pollRecursively(using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies()) {
|
||||
private func pollRecursively(
|
||||
using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies(
|
||||
subscribeQueue: Threading.pollerQueue,
|
||||
receiveQueue: OpenGroupAPI.workQueue
|
||||
)
|
||||
) {
|
||||
guard hasStarted else { return }
|
||||
|
||||
let minPollFailureCount: TimeInterval = Storage.shared
|
||||
|
@ -59,6 +72,8 @@ extension OpenGroupAPI {
|
|||
// Wait until the last poll completes before polling again ensuring we don't poll any faster than
|
||||
// the 'nextPollInterval' value
|
||||
poll(using: dependencies)
|
||||
.subscribe(on: dependencies.subscribeQueue, immediatelyIfMain: true)
|
||||
.receive(on: dependencies.receiveQueue, immediatelyIfMain: true)
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { [weak self] _ in
|
||||
let currentTime: TimeInterval = Date().timeIntervalSince1970
|
||||
|
@ -129,8 +144,6 @@ extension OpenGroupAPI {
|
|||
.map { response in (failureCount, response) }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.subscribe(on: Threading.pollerQueue)
|
||||
.receive(on: Threading.pollerQueue)
|
||||
.handleEvents(
|
||||
receiveOutput: { [weak self] failureCount, response in
|
||||
guard !calledFromBackgroundPoller || isBackgroundPollerValid() else {
|
||||
|
@ -179,7 +192,8 @@ extension OpenGroupAPI {
|
|||
calledFromBackgroundPoller: calledFromBackgroundPoller,
|
||||
isBackgroundPollerValid: isBackgroundPollerValid,
|
||||
isPostCapabilitiesRetry: isPostCapabilitiesRetry,
|
||||
error: error
|
||||
error: error,
|
||||
using: dependencies
|
||||
)
|
||||
.handleEvents(
|
||||
receiveOutput: { [weak self] didHandleError in
|
||||
|
@ -232,7 +246,7 @@ extension OpenGroupAPI {
|
|||
/// If the polling has failed 10+ times then try to prune any invalid rooms that
|
||||
/// aren't visible (they would have been added via config messages and will
|
||||
/// likely always fail but the user has no way to delete them)
|
||||
guard pollFailureCount > 10 else { return }
|
||||
guard pollFailureCount > Poller.maxHiddenRoomFailureCount else { return }
|
||||
|
||||
prunedIds = roomsAreVisible
|
||||
.filter { !$0.shouldBeVisible }
|
||||
|
@ -247,19 +261,20 @@ extension OpenGroupAPI {
|
|||
/// not be in an invalid state on other devices - one of the other devices
|
||||
/// will eventually trigger a new config update which will re-add this room
|
||||
/// and hopefully at that time it'll work again
|
||||
calledFromConfigHandling: true
|
||||
calledFromConfigHandling: true,
|
||||
using: dependencies
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SNLog("Open group polling failed due to error: \(error). Setting failure count to \(pollFailureCount).")
|
||||
SNLog("Open group polling to \(server) failed due to error: \(error). Setting failure count to \(pollFailureCount).")
|
||||
|
||||
// Add a note to the logs that this happened
|
||||
if !prunedIds.isEmpty {
|
||||
let rooms: String = prunedIds
|
||||
.compactMap { $0.components(separatedBy: server).last }
|
||||
.joined(separator: ", ")
|
||||
SNLog("Hidden open group failure count surpassed 10, removed hidden rooms \(rooms).")
|
||||
SNLog("Hidden open group failure count surpassed \(Poller.maxHiddenRoomFailureCount), removed hidden rooms \(rooms).")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -299,16 +314,15 @@ extension OpenGroupAPI {
|
|||
}
|
||||
|
||||
return dependencies.storage
|
||||
.readPublisherFlatMap { db in
|
||||
OpenGroupAPI.capabilities(
|
||||
.readPublisher { db in
|
||||
try OpenGroupAPI.preparedCapabilities(
|
||||
db,
|
||||
server: server,
|
||||
forceBlinded: true,
|
||||
using: dependencies
|
||||
)
|
||||
}
|
||||
.subscribe(on: OpenGroupAPI.workQueue)
|
||||
.receive(on: OpenGroupAPI.workQueue)
|
||||
.flatMap { OpenGroupAPI.send(data: $0, using: dependencies) }
|
||||
.flatMap { [weak self] _, responseBody -> AnyPublisher<Void, Error> in
|
||||
guard let strongSelf = self, isBackgroundPollerValid() else {
|
||||
return Just(())
|
||||
|
|
|
@ -59,7 +59,7 @@ public class Poller {
|
|||
preconditionFailure("abstract class - override in subclass")
|
||||
}
|
||||
|
||||
internal func handlePollError(_ error: Error, for publicKey: String) {
|
||||
internal func handlePollError(_ error: Error, for publicKey: String, using dependencies: SMKDependencies) {
|
||||
preconditionFailure("abstract class - override in subclass")
|
||||
}
|
||||
|
||||
|
@ -81,37 +81,46 @@ public class Poller {
|
|||
|
||||
/// We want to initially trigger a poll against the target service node and then run the recursive polling,
|
||||
/// if an error is thrown during the poll then this should automatically restart the polling
|
||||
internal func setUpPolling(for publicKey: String) {
|
||||
internal func setUpPolling(
|
||||
for publicKey: String,
|
||||
using dependencies: SMKDependencies = SMKDependencies(
|
||||
subscribeQueue: Threading.pollerQueue,
|
||||
receiveQueue: Threading.pollerQueue
|
||||
)
|
||||
) {
|
||||
guard isPolling.wrappedValue[publicKey] == true else { return }
|
||||
|
||||
let namespaces: [SnodeAPI.Namespace] = self.namespaces
|
||||
|
||||
getSnodeForPolling(for: publicKey)
|
||||
.subscribe(on: Threading.pollerQueue)
|
||||
.subscribe(on: dependencies.subscribeQueue, immediatelyIfMain: true)
|
||||
.flatMap { snode -> AnyPublisher<[Message], Error> in
|
||||
Poller.poll(
|
||||
namespaces: namespaces,
|
||||
from: snode,
|
||||
for: publicKey,
|
||||
on: Threading.pollerQueue,
|
||||
poller: self
|
||||
poller: self,
|
||||
using: dependencies
|
||||
)
|
||||
}
|
||||
.receive(on: Threading.pollerQueue)
|
||||
.receive(on: dependencies.receiveQueue, immediatelyIfMain: true)
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { [weak self] result in
|
||||
switch result {
|
||||
case .finished: self?.pollRecursively(for: publicKey)
|
||||
case .finished: self?.pollRecursively(for: publicKey, using: dependencies)
|
||||
case .failure(let error):
|
||||
guard self?.isPolling.wrappedValue[publicKey] == true else { return }
|
||||
|
||||
self?.handlePollError(error, for: publicKey)
|
||||
self?.handlePollError(error, for: publicKey, using: dependencies)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func pollRecursively(for publicKey: String) {
|
||||
private func pollRecursively(
|
||||
for publicKey: String,
|
||||
using dependencies: SMKDependencies = SMKDependencies()
|
||||
) {
|
||||
guard isPolling.wrappedValue[publicKey] == true else { return }
|
||||
|
||||
let namespaces: [SnodeAPI.Namespace] = self.namespaces
|
||||
|
@ -125,21 +134,21 @@ public class Poller {
|
|||
timer.invalidate()
|
||||
|
||||
self?.getSnodeForPolling(for: publicKey)
|
||||
.subscribe(on: Threading.pollerQueue)
|
||||
.subscribe(on: dependencies.subscribeQueue, immediatelyIfMain: true)
|
||||
.flatMap { snode -> AnyPublisher<[Message], Error> in
|
||||
Poller.poll(
|
||||
namespaces: namespaces,
|
||||
from: snode,
|
||||
for: publicKey,
|
||||
on: Threading.pollerQueue,
|
||||
poller: self
|
||||
poller: self,
|
||||
using: dependencies
|
||||
)
|
||||
}
|
||||
.receive(on: Threading.pollerQueue)
|
||||
.receive(on: dependencies.receiveQueue, immediatelyIfMain: true)
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { result in
|
||||
switch result {
|
||||
case .failure(let error): self?.handlePollError(error, for: publicKey)
|
||||
case .failure(let error): self?.handlePollError(error, for: publicKey, using: dependencies)
|
||||
case .finished:
|
||||
let maxNodePollCount: UInt = (self?.maxNodePollCount ?? 0)
|
||||
|
||||
|
@ -161,7 +170,7 @@ public class Poller {
|
|||
timer.invalidate()
|
||||
|
||||
self?.pollCount.mutate { $0[publicKey] = 0 }
|
||||
self?.setUpPolling(for: publicKey)
|
||||
self?.setUpPolling(for: publicKey, using: dependencies)
|
||||
}
|
||||
}
|
||||
return
|
||||
|
@ -169,7 +178,7 @@ public class Poller {
|
|||
}
|
||||
|
||||
// Otherwise just loop
|
||||
self?.pollRecursively(for: publicKey)
|
||||
self?.pollRecursively(for: publicKey, using: dependencies)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
@ -186,10 +195,12 @@ public class Poller {
|
|||
namespaces: [SnodeAPI.Namespace],
|
||||
from snode: Snode,
|
||||
for publicKey: String,
|
||||
on queue: DispatchQueue,
|
||||
calledFromBackgroundPoller: Bool = false,
|
||||
isBackgroundPollValid: @escaping (() -> Bool) = { true },
|
||||
poller: Poller? = nil
|
||||
poller: Poller? = nil,
|
||||
using dependencies: SMKDependencies = SMKDependencies(
|
||||
receiveQueue: Threading.pollerQueue
|
||||
)
|
||||
) -> AnyPublisher<[Message], Error> {
|
||||
// If the polling has been cancelled then don't continue
|
||||
guard
|
||||
|
@ -213,7 +224,8 @@ public class Poller {
|
|||
namespaces: namespaces,
|
||||
refreshingConfigHashes: configHashes,
|
||||
from: snode,
|
||||
associatedWith: publicKey
|
||||
associatedWith: publicKey,
|
||||
using: dependencies
|
||||
)
|
||||
.flatMap { namespacedResults -> AnyPublisher<[Message], Error> in
|
||||
guard
|
||||
|
@ -391,7 +403,7 @@ public class Poller {
|
|||
// Note: In the background we just want jobs to fail silently
|
||||
ConfigMessageReceiveJob.run(
|
||||
job,
|
||||
queue: queue,
|
||||
queue: dependencies.receiveQueue,
|
||||
success: { _, _ in resolver(Result.success(())) },
|
||||
failure: { _, _, _ in resolver(Result.success(())) },
|
||||
deferred: { _ in resolver(Result.success(())) }
|
||||
|
@ -411,7 +423,7 @@ public class Poller {
|
|||
// Note: In the background we just want jobs to fail silently
|
||||
MessageReceiveJob.run(
|
||||
job,
|
||||
queue: queue,
|
||||
queue: dependencies.receiveQueue,
|
||||
success: { _, _ in resolver(Result.success(())) },
|
||||
failure: { _, _, _ in resolver(Result.success(())) },
|
||||
deferred: { _ in resolver(Result.success(())) }
|
||||
|
|
|
@ -328,7 +328,7 @@ public extension SessionUtil {
|
|||
return false
|
||||
}
|
||||
|
||||
return (oneToOne.last_read > timestampMs)
|
||||
return (oneToOne.last_read >= timestampMs)
|
||||
|
||||
case .legacyGroup:
|
||||
var cThreadId: [CChar] = threadId.cArray.nullTerminated()
|
||||
|
@ -338,7 +338,7 @@ public extension SessionUtil {
|
|||
return false
|
||||
}
|
||||
|
||||
return (legacyGroup.last_read > timestampMs)
|
||||
return (legacyGroup.last_read >= timestampMs)
|
||||
|
||||
case .community:
|
||||
guard let openGroup: OpenGroup = openGroup else { return false }
|
||||
|
@ -351,7 +351,7 @@ public extension SessionUtil {
|
|||
return false
|
||||
}
|
||||
|
||||
return (convoCommunity.last_read > timestampMs)
|
||||
return (convoCommunity.last_read >= timestampMs)
|
||||
|
||||
case .group: return false
|
||||
}
|
||||
|
|
|
@ -355,7 +355,6 @@ public enum SessionUtil {
|
|||
)
|
||||
let seqNo: Int64 = cPushData.pointee.seqno
|
||||
cPushData.deallocate()
|
||||
SNLog("[libSession - DEBUG] Push data for \(variant) config data, size: \(configCountInfo), bytes: \(pushData.count)")
|
||||
|
||||
return OutgoingConfResult(
|
||||
message: SharedConfigMessage(
|
||||
|
|
|
@ -56,6 +56,15 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable,
|
|||
case typingIndicator
|
||||
case dateHeader
|
||||
case unreadMarker
|
||||
|
||||
/// A number of the `CellType` entries are dynamically added to the dataset after processing, this flag indicates
|
||||
/// whether the given type is one of them
|
||||
public var isPostProcessed: Bool {
|
||||
switch self {
|
||||
case .typingIndicator, .dateHeader, .unreadMarker: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public var differenceIdentifier: Int64 { id }
|
||||
|
@ -74,6 +83,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable,
|
|||
|
||||
public let rowId: Int64
|
||||
public let id: Int64
|
||||
public let openGroupServerMessageId: Int64?
|
||||
public let variant: Interaction.Variant
|
||||
public let timestampMs: Int64
|
||||
public let receivedAtTimestampMs: Int64
|
||||
|
@ -171,6 +181,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable,
|
|||
threadContactNameInternal: self.threadContactNameInternal,
|
||||
rowId: self.rowId,
|
||||
id: self.id,
|
||||
openGroupServerMessageId: self.openGroupServerMessageId,
|
||||
variant: self.variant,
|
||||
timestampMs: self.timestampMs,
|
||||
receivedAtTimestampMs: self.receivedAtTimestampMs,
|
||||
|
@ -335,6 +346,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable,
|
|||
threadContactNameInternal: self.threadContactNameInternal,
|
||||
rowId: self.rowId,
|
||||
id: self.id,
|
||||
openGroupServerMessageId: self.openGroupServerMessageId,
|
||||
variant: self.variant,
|
||||
timestampMs: self.timestampMs,
|
||||
receivedAtTimestampMs: self.receivedAtTimestampMs,
|
||||
|
@ -516,7 +528,7 @@ public extension MessageViewModel {
|
|||
static let genericId: Int64 = -1
|
||||
static let typingIndicatorId: Int64 = -2
|
||||
|
||||
// Note: This init method is only used system-created cells or empty states
|
||||
/// This init method is only used for system-created cells or empty states
|
||||
init(
|
||||
variant: Interaction.Variant = .standardOutgoing,
|
||||
timestampMs: Int64 = Int64.max,
|
||||
|
@ -546,6 +558,7 @@ public extension MessageViewModel {
|
|||
}()
|
||||
self.rowId = targetId
|
||||
self.id = targetId
|
||||
self.openGroupServerMessageId = nil
|
||||
self.variant = variant
|
||||
self.timestampMs = timestampMs
|
||||
self.receivedAtTimestampMs = receivedAtTimestampMs
|
||||
|
@ -567,11 +580,11 @@ public extension MessageViewModel {
|
|||
self.linkPreview = nil
|
||||
self.linkPreviewAttachment = nil
|
||||
self.currentUserPublicKey = ""
|
||||
self.attachments = nil
|
||||
self.reactionInfo = nil
|
||||
|
||||
// Post-Query Processing Data
|
||||
|
||||
self.attachments = nil
|
||||
self.reactionInfo = nil
|
||||
self.cellType = cellType
|
||||
self.authorName = ""
|
||||
self.senderName = nil
|
||||
|
@ -587,6 +600,84 @@ public extension MessageViewModel {
|
|||
self.isLastOutgoing = isLastOutgoing
|
||||
self.currentUserBlindedPublicKey = nil
|
||||
}
|
||||
|
||||
/// This init method is only used for optimistic outgoing messages
|
||||
init(
|
||||
threadId: String,
|
||||
threadVariant: SessionThread.Variant,
|
||||
threadHasDisappearingMessagesEnabled: Bool,
|
||||
threadOpenGroupServer: String?,
|
||||
threadOpenGroupPublicKey: String?,
|
||||
threadContactNameInternal: String,
|
||||
timestampMs: Int64,
|
||||
receivedAtTimestampMs: Int64,
|
||||
authorId: String,
|
||||
authorNameInternal: String,
|
||||
body: String?,
|
||||
expiresStartedAtMs: Double?,
|
||||
expiresInSeconds: TimeInterval?,
|
||||
isSenderOpenGroupModerator: Bool,
|
||||
currentUserProfile: Profile,
|
||||
quote: Quote?,
|
||||
quoteAttachment: Attachment?,
|
||||
linkPreview: LinkPreview?,
|
||||
linkPreviewAttachment: Attachment?,
|
||||
attachments: [Attachment]?
|
||||
) {
|
||||
self.threadId = threadId
|
||||
self.threadVariant = threadVariant
|
||||
self.threadIsTrusted = false
|
||||
self.threadHasDisappearingMessagesEnabled = threadHasDisappearingMessagesEnabled
|
||||
self.threadOpenGroupServer = threadOpenGroupServer
|
||||
self.threadOpenGroupPublicKey = threadOpenGroupPublicKey
|
||||
self.threadContactNameInternal = threadContactNameInternal
|
||||
|
||||
// Interaction Info
|
||||
|
||||
self.rowId = -1
|
||||
self.id = -1
|
||||
self.openGroupServerMessageId = nil
|
||||
self.variant = .standardOutgoing
|
||||
self.timestampMs = timestampMs
|
||||
self.receivedAtTimestampMs = receivedAtTimestampMs
|
||||
self.authorId = authorId
|
||||
self.authorNameInternal = authorNameInternal
|
||||
self.body = body
|
||||
self.rawBody = body
|
||||
self.expiresStartedAtMs = expiresStartedAtMs
|
||||
self.expiresInSeconds = expiresInSeconds
|
||||
|
||||
self.state = .sending
|
||||
self.hasAtLeastOneReadReceipt = false
|
||||
self.mostRecentFailureText = nil
|
||||
self.isSenderOpenGroupModerator = isSenderOpenGroupModerator
|
||||
self.isTypingIndicator = false
|
||||
self.profile = currentUserProfile
|
||||
self.quote = quote
|
||||
self.quoteAttachment = quoteAttachment
|
||||
self.linkPreview = linkPreview
|
||||
self.linkPreviewAttachment = linkPreviewAttachment
|
||||
self.currentUserPublicKey = currentUserProfile.id
|
||||
self.attachments = attachments
|
||||
self.reactionInfo = nil
|
||||
|
||||
// Post-Query Processing Data
|
||||
|
||||
self.cellType = .textOnlyMessage
|
||||
self.authorName = ""
|
||||
self.senderName = nil
|
||||
self.canHaveProfile = false
|
||||
self.shouldShowProfile = false
|
||||
self.shouldShowDateHeader = false
|
||||
self.containsOnlyEmoji = nil
|
||||
self.glyphCount = nil
|
||||
self.previousVariant = nil
|
||||
self.positionInCluster = .middle
|
||||
self.isOnlyMessageInCluster = true
|
||||
self.isLast = false
|
||||
self.isLastOutgoing = false
|
||||
self.currentUserBlindedPublicKey = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Convenience
|
||||
|
@ -688,7 +779,7 @@ public extension MessageViewModel {
|
|||
let interactionAttachmentAttachmentIdColumn: SQL = SQL(stringLiteral: InteractionAttachment.Columns.attachmentId.name)
|
||||
let interactionAttachmentAlbumIndexColumn: SQL = SQL(stringLiteral: InteractionAttachment.Columns.albumIndex.name)
|
||||
|
||||
let numColumnsBeforeLinkedRecords: Int = 21
|
||||
let numColumnsBeforeLinkedRecords: Int = 22
|
||||
let finalGroupSQL: SQL = (groupSQL ?? "")
|
||||
let request: SQLRequest<ViewModel> = """
|
||||
SELECT
|
||||
|
@ -704,6 +795,7 @@ public extension MessageViewModel {
|
|||
|
||||
\(interaction.alias[Column.rowID]) AS \(ViewModel.rowIdKey),
|
||||
\(interaction[.id]),
|
||||
\(interaction[.openGroupServerMessageId]),
|
||||
\(interaction[.variant]),
|
||||
\(interaction[.timestampMs]),
|
||||
\(interaction[.receivedAtTimestampMs]),
|
||||
|
@ -977,7 +1069,7 @@ public extension MessageViewModel.ReactionInfo {
|
|||
items: pagedRowIdsWithNoReactions
|
||||
.compactMap { rowId -> ViewModel? in updatedPagedDataCache.data[rowId] }
|
||||
.filter { viewModel -> Bool in (viewModel.reactionInfo?.isEmpty == false) }
|
||||
.map { viewModel -> ViewModel in viewModel.with(reactionInfo: nil) }
|
||||
.map { viewModel -> ViewModel in viewModel.with(reactionInfo: []) }
|
||||
)
|
||||
|
||||
return updatedPagedDataCache
|
||||
|
|
|
@ -33,6 +33,7 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat
|
|||
public static let threadWasMarkedUnreadKey: SQL = SQL(stringLiteral: CodingKeys.threadWasMarkedUnread.stringValue)
|
||||
public static let threadUnreadCountKey: SQL = SQL(stringLiteral: CodingKeys.threadUnreadCount.stringValue)
|
||||
public static let threadUnreadMentionCountKey: SQL = SQL(stringLiteral: CodingKeys.threadUnreadMentionCount.stringValue)
|
||||
public static let disappearingMessagesConfigurationKey: SQL = SQL(stringLiteral: CodingKeys.disappearingMessagesConfiguration.stringValue)
|
||||
public static let contactProfileKey: SQL = SQL(stringLiteral: CodingKeys.contactProfile.stringValue)
|
||||
public static let closedGroupNameKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupName.stringValue)
|
||||
public static let closedGroupUserCountKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupUserCount.stringValue)
|
||||
|
@ -66,6 +67,7 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat
|
|||
public static let threadUnreadMentionCountString: String = CodingKeys.threadUnreadMentionCount.stringValue
|
||||
public static let closedGroupUserCountString: String = CodingKeys.closedGroupUserCount.stringValue
|
||||
public static let openGroupUserCountString: String = CodingKeys.openGroupUserCount.stringValue
|
||||
public static let disappearingMessagesConfigurationString: String = CodingKeys.disappearingMessagesConfiguration.stringValue
|
||||
public static let contactProfileString: String = CodingKeys.contactProfile.stringValue
|
||||
public static let closedGroupProfileFrontString: String = CodingKeys.closedGroupProfileFront.stringValue
|
||||
public static let closedGroupProfileBackString: String = CodingKeys.closedGroupProfileBack.stringValue
|
||||
|
@ -116,6 +118,8 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat
|
|||
|
||||
// Thread display info
|
||||
|
||||
public let disappearingMessagesConfiguration: DisappearingMessagesConfiguration?
|
||||
|
||||
private let contactProfile: Profile?
|
||||
private let closedGroupProfileFront: Profile?
|
||||
private let closedGroupProfileBack: Profile?
|
||||
|
@ -343,7 +347,8 @@ public extension SessionThreadViewModel {
|
|||
contactProfile: Profile? = nil,
|
||||
currentUserIsClosedGroupMember: Bool? = nil,
|
||||
openGroupPermissions: OpenGroup.Permissions? = nil,
|
||||
unreadCount: UInt = 0
|
||||
unreadCount: UInt = 0,
|
||||
disappearingMessagesConfiguration: DisappearingMessagesConfiguration? = nil
|
||||
) {
|
||||
self.rowId = -1
|
||||
self.threadId = threadId
|
||||
|
@ -368,6 +373,8 @@ public extension SessionThreadViewModel {
|
|||
|
||||
// Thread display info
|
||||
|
||||
self.disappearingMessagesConfiguration = disappearingMessagesConfiguration
|
||||
|
||||
self.contactProfile = contactProfile
|
||||
self.closedGroupProfileFront = nil
|
||||
self.closedGroupProfileBack = nil
|
||||
|
@ -430,6 +437,7 @@ public extension SessionThreadViewModel {
|
|||
threadWasMarkedUnread: self.threadWasMarkedUnread,
|
||||
threadUnreadCount: self.threadUnreadCount,
|
||||
threadUnreadMentionCount: self.threadUnreadMentionCount,
|
||||
disappearingMessagesConfiguration: self.disappearingMessagesConfiguration,
|
||||
contactProfile: self.contactProfile,
|
||||
closedGroupProfileFront: self.closedGroupProfileFront,
|
||||
closedGroupProfileBack: self.closedGroupProfileBack,
|
||||
|
@ -486,6 +494,7 @@ public extension SessionThreadViewModel {
|
|||
threadWasMarkedUnread: self.threadWasMarkedUnread,
|
||||
threadUnreadCount: self.threadUnreadCount,
|
||||
threadUnreadMentionCount: self.threadUnreadMentionCount,
|
||||
disappearingMessagesConfiguration: self.disappearingMessagesConfiguration,
|
||||
contactProfile: self.contactProfile,
|
||||
closedGroupProfileFront: self.closedGroupProfileFront,
|
||||
closedGroupProfileBack: self.closedGroupProfileBack,
|
||||
|
@ -839,6 +848,7 @@ public extension SessionThreadViewModel {
|
|||
/// but including this warning just in case there is a discrepancy)
|
||||
static func conversationQuery(threadId: String, userPublicKey: String) -> AdaptedFetchRequest<SQLRequest<SessionThreadViewModel>> {
|
||||
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
|
||||
let disappearingMessagesConfiguration: TypedTableAlias<DisappearingMessagesConfiguration> = TypedTableAlias()
|
||||
let contact: TypedTableAlias<Contact> = TypedTableAlias()
|
||||
let closedGroup: TypedTableAlias<ClosedGroup> = TypedTableAlias()
|
||||
let groupMember: TypedTableAlias<GroupMember> = TypedTableAlias()
|
||||
|
@ -883,6 +893,8 @@ public extension SessionThreadViewModel {
|
|||
|
||||
\(thread[.markedAsUnread]) AS \(ViewModel.threadWasMarkedUnreadKey),
|
||||
\(aggregateInteractionLiteral).\(ViewModel.threadUnreadCountKey),
|
||||
|
||||
\(ViewModel.disappearingMessagesConfigurationKey).*,
|
||||
|
||||
\(ViewModel.contactProfileKey).*,
|
||||
\(closedGroup[.name]) AS \(ViewModel.closedGroupNameKey),
|
||||
|
@ -911,6 +923,7 @@ public extension SessionThreadViewModel {
|
|||
\(SQL("\(userPublicKey)")) AS \(ViewModel.currentUserPublicKeyKey)
|
||||
|
||||
FROM \(SessionThread.self)
|
||||
LEFT JOIN \(DisappearingMessagesConfiguration.self) ON \(disappearingMessagesConfiguration[.threadId]) = \(thread[.id])
|
||||
LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id])
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
|
@ -945,11 +958,13 @@ public extension SessionThreadViewModel {
|
|||
return request.adapted { db in
|
||||
let adapters = try splittingRowAdapters(columnCounts: [
|
||||
numColumnsBeforeProfiles,
|
||||
DisappearingMessagesConfiguration.numberOfSelectedColumns(db),
|
||||
Profile.numberOfSelectedColumns(db)
|
||||
])
|
||||
|
||||
return ScopeAdapter([
|
||||
ViewModel.contactProfileString: adapters[1]
|
||||
ViewModel.disappearingMessagesConfigurationString: adapters[1],
|
||||
ViewModel.contactProfileString: adapters[2]
|
||||
])
|
||||
}
|
||||
}
|
||||
|
|
|
@ -51,7 +51,10 @@ public struct ProfileManager {
|
|||
}
|
||||
|
||||
if let profilePictureUrl: String = profile.profilePictureUrl, !profilePictureUrl.isEmpty {
|
||||
downloadAvatar(for: profile)
|
||||
// FIXME: Refactor avatar downloading to be a proper Job so we can avoid this
|
||||
JobRunner.afterBlockingQueue {
|
||||
ProfileManager.downloadAvatar(for: profile)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
@ -78,7 +81,10 @@ public struct ProfileManager {
|
|||
completion: { _, _ in
|
||||
// Try to re-download the avatar if it has a URL
|
||||
if let profilePictureUrl: String = profile.profilePictureUrl, !profilePictureUrl.isEmpty {
|
||||
downloadAvatar(for: profile)
|
||||
// FIXME: Refactor avatar downloading to be a proper Job so we can avoid this
|
||||
JobRunner.afterBlockingQueue {
|
||||
ProfileManager.downloadAvatar(for: profile)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
@ -214,7 +220,8 @@ public struct ProfileManager {
|
|||
|
||||
FileServerAPI
|
||||
.download(fileId, useOldServer: useOldServer)
|
||||
.receive(on: DispatchQueue.global(qos: .default))
|
||||
.subscribe(on: DispatchQueue.global(qos: .background))
|
||||
.receive(on: DispatchQueue.global(qos: .background))
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { _ in
|
||||
currentAvatarDownloads.mutate { $0.remove(profile.id) }
|
||||
|
@ -451,6 +458,7 @@ public struct ProfileManager {
|
|||
// Upload the avatar to the FileServer
|
||||
FileServerAPI
|
||||
.upload(encryptedAvatarData)
|
||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||
.receive(on: queue)
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { result in
|
||||
|
@ -590,7 +598,12 @@ public struct ProfileManager {
|
|||
|
||||
db.afterNextTransactionNestedOnce(dedupeId: dedupeIdentifier) { db in
|
||||
// Need to refetch to ensure the db changes have occurred
|
||||
ProfileManager.downloadAvatar(for: Profile.fetchOrCreate(db, id: publicKey))
|
||||
let targetProfile: Profile = Profile.fetchOrCreate(db, id: publicKey)
|
||||
|
||||
// FIXME: Refactor avatar downloading to be a proper Job so we can avoid this
|
||||
JobRunner.afterBlockingQueue {
|
||||
ProfileManager.downloadAvatar(for: targetProfile)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import Foundation
|
||||
|
||||
internal enum Threading {
|
||||
|
||||
internal static let pollerQueue = DispatchQueue(label: "SessionMessagingKit.pollerQueue")
|
||||
public enum Threading {
|
||||
public static let pollerQueue = DispatchQueue(label: "SessionMessagingKit.pollerQueue")
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -116,7 +116,8 @@ class OpenGroupManagerSpec: QuickSpec {
|
|||
mockNonce24Generator = MockNonce24Generator()
|
||||
mockUserDefaults = MockUserDefaults()
|
||||
dependencies = OpenGroupManager.OGMDependencies(
|
||||
queue: DispatchQueue.main,
|
||||
subscribeQueue: DispatchQueue.main,
|
||||
receiveQueue: DispatchQueue.main,
|
||||
cache: Atomic(mockOGMCache),
|
||||
onionApi: TestCapabilitiesAndRoomApi.self,
|
||||
generalCache: Atomic(mockGeneralCache),
|
||||
|
@ -991,7 +992,7 @@ class OpenGroupManagerSpec: QuickSpec {
|
|||
db,
|
||||
openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: "testServer"),
|
||||
calledFromConfigHandling: true, // Don't trigger SessionUtil logic
|
||||
dependencies: dependencies
|
||||
using: dependencies
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -1006,7 +1007,7 @@ class OpenGroupManagerSpec: QuickSpec {
|
|||
db,
|
||||
openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: "testServer"),
|
||||
calledFromConfigHandling: true, // Don't trigger SessionUtil logic
|
||||
dependencies: dependencies
|
||||
using: dependencies
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -1024,7 +1025,7 @@ class OpenGroupManagerSpec: QuickSpec {
|
|||
db,
|
||||
openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: "testServer"),
|
||||
calledFromConfigHandling: true, // Don't trigger SessionUtil logic
|
||||
dependencies: dependencies
|
||||
using: dependencies
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -1038,7 +1039,7 @@ class OpenGroupManagerSpec: QuickSpec {
|
|||
db,
|
||||
openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: "testServer"),
|
||||
calledFromConfigHandling: true, // Don't trigger SessionUtil logic
|
||||
dependencies: dependencies
|
||||
using: dependencies
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -1077,7 +1078,7 @@ class OpenGroupManagerSpec: QuickSpec {
|
|||
db,
|
||||
openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: "testServer"),
|
||||
calledFromConfigHandling: true, // Don't trigger SessionUtil logic
|
||||
dependencies: dependencies
|
||||
using: dependencies
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -1130,7 +1131,7 @@ class OpenGroupManagerSpec: QuickSpec {
|
|||
db,
|
||||
openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: OpenGroupAPI.defaultServer),
|
||||
calledFromConfigHandling: true, // Don't trigger SessionUtil logic
|
||||
dependencies: dependencies
|
||||
using: dependencies
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -1145,7 +1146,7 @@ class OpenGroupManagerSpec: QuickSpec {
|
|||
db,
|
||||
openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: OpenGroupAPI.defaultServer),
|
||||
calledFromConfigHandling: true, // Don't trigger SessionUtil logic
|
||||
dependencies: dependencies
|
||||
using: dependencies
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -136,7 +136,14 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension
|
|||
guard case .preOffer = callMessage.kind else { return self.completeSilenty() }
|
||||
|
||||
if !db[.areCallsEnabled] {
|
||||
if let sender: String = callMessage.sender, let interaction: Interaction = try MessageReceiver.insertCallInfoMessage(db, for: callMessage, state: .permissionDenied) {
|
||||
if
|
||||
let sender: String = callMessage.sender,
|
||||
let interaction: Interaction = try MessageReceiver.insertCallInfoMessage(
|
||||
db,
|
||||
for: callMessage,
|
||||
state: .permissionDenied
|
||||
)
|
||||
{
|
||||
let thread: SessionThread = try SessionThread
|
||||
.fetchOrCreate(
|
||||
db,
|
||||
|
@ -145,12 +152,15 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension
|
|||
shouldBeVisible: nil
|
||||
)
|
||||
|
||||
Environment.shared?.notificationsManager.wrappedValue?
|
||||
.notifyUser(
|
||||
db,
|
||||
forIncomingCall: interaction,
|
||||
in: thread
|
||||
)
|
||||
// Notify the user if the call message wasn't already read
|
||||
if !interaction.wasRead {
|
||||
Environment.shared?.notificationsManager.wrappedValue?
|
||||
.notifyUser(
|
||||
db,
|
||||
forIncomingCall: interaction,
|
||||
in: thread
|
||||
)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
|
|
@ -153,7 +153,8 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView
|
|||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
|
||||
ShareNavController.attachmentPrepPublisher?
|
||||
.receiveOnMain(immediately: true)
|
||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||
.receive(on: DispatchQueue.main, immediatelyIfMain: true)
|
||||
.sinkUntilComplete(
|
||||
receiveValue: { [weak self] attachments in
|
||||
guard let strongSelf = self else { return }
|
||||
|
@ -232,18 +233,20 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView
|
|||
try LinkPreview(
|
||||
url: linkPreviewDraft.urlString,
|
||||
title: linkPreviewDraft.title,
|
||||
attachmentId: LinkPreview.saveAttachmentIfPossible(
|
||||
db,
|
||||
imageData: linkPreviewDraft.jpegImageData,
|
||||
mimeType: OWSMimeTypeImageJpeg
|
||||
)
|
||||
attachmentId: LinkPreview
|
||||
.generateAttachmentIfPossible(
|
||||
imageData: linkPreviewDraft.jpegImageData,
|
||||
mimeType: OWSMimeTypeImageJpeg
|
||||
)?
|
||||
.inserted(db)
|
||||
.id
|
||||
).insert(db)
|
||||
}
|
||||
|
||||
// Prepare any attachments
|
||||
try Attachment.prepare(
|
||||
try Attachment.process(
|
||||
db,
|
||||
attachments: finalAttachments,
|
||||
data: Attachment.prepare(attachments: finalAttachments),
|
||||
for: interactionId
|
||||
)
|
||||
|
||||
|
@ -257,13 +260,9 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView
|
|||
)
|
||||
}
|
||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||
.flatMap {
|
||||
MessageSender.performUploadsIfNeeded(
|
||||
queue: DispatchQueue.global(qos: .userInitiated),
|
||||
preparedSendData: $0
|
||||
)
|
||||
}
|
||||
.flatMap { MessageSender.performUploadsIfNeeded(preparedSendData: $0) }
|
||||
.flatMap { MessageSender.sendImmediate(preparedSendData: $0) }
|
||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { [weak self] result in
|
||||
|
|
|
@ -29,6 +29,7 @@ public class ThreadPickerViewModel {
|
|||
.fetchAll(db)
|
||||
}
|
||||
.removeDuplicates()
|
||||
.handleEvents(didFail: { SNLog("[ThreadPickerViewModel] Observation failed with error: \($0)") })
|
||||
|
||||
// MARK: - Functions
|
||||
|
||||
|
|
|
@ -56,7 +56,7 @@ public enum GetSnodePoolJob: JobExecutor {
|
|||
public static func run() {
|
||||
GetSnodePoolJob.run(
|
||||
Job(variant: .getSnodePool),
|
||||
queue: DispatchQueue.global(qos: .background),
|
||||
queue: .global(qos: .background),
|
||||
success: { _, _ in },
|
||||
failure: { _, _, _ in },
|
||||
deferred: { _ in }
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import SessionUtilitiesKit
|
||||
|
||||
extension SnodeAPI {
|
||||
public struct GetStatsResponse: Codable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case versionString = "version"
|
||||
}
|
||||
|
||||
let versionString: String?
|
||||
|
||||
var version: Version? { versionString.map { Version.from($0) } }
|
||||
}
|
||||
}
|
|
@ -71,18 +71,12 @@ public enum OnionRequestAPI: OnionRequestAPIType {
|
|||
let timeout: TimeInterval = 3 // Use a shorter timeout for testing
|
||||
|
||||
return HTTP.execute(.get, url, timeout: timeout)
|
||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||
.tryMap { responseData -> Void in
|
||||
// TODO: Remove JSON usage
|
||||
guard let responseJson: JSON = try? JSONSerialization.jsonObject(with: responseData, options: [ .fragmentsAllowed ]) as? JSON else {
|
||||
throw HTTPError.invalidJSON
|
||||
}
|
||||
guard let version = responseJson["version"] as? String else {
|
||||
throw OnionRequestAPIError.missingSnodeVersion
|
||||
}
|
||||
guard version >= "2.0.7" else {
|
||||
SNLog("Unsupported snode version: \(version).")
|
||||
throw OnionRequestAPIError.unsupportedSnodeVersion(version)
|
||||
.decoded(as: SnodeAPI.GetStatsResponse.self)
|
||||
.tryMap { response -> Void in
|
||||
guard let version: Version = response.version else { throw OnionRequestAPIError.missingSnodeVersion }
|
||||
guard version >= Version(major: 2, minor: 0, patch: 7) else {
|
||||
SNLog("Unsupported snode version: \(version.stringValue).")
|
||||
throw OnionRequestAPIError.unsupportedSnodeVersion(version.stringValue)
|
||||
}
|
||||
|
||||
return ()
|
||||
|
@ -154,63 +148,82 @@ public enum OnionRequestAPI: OnionRequestAPIType {
|
|||
return existingBuildPathsPublisher
|
||||
}
|
||||
|
||||
SNLog("Building onion request paths.")
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: .buildingPaths, object: nil)
|
||||
}
|
||||
let reusableGuardSnodes = reusablePaths.map { $0[0] }
|
||||
let publisher: AnyPublisher<[[Snode]], Error> = getGuardSnodes(reusing: reusableGuardSnodes)
|
||||
.flatMap { guardSnodes -> AnyPublisher<[[Snode]], Error> in
|
||||
var unusedSnodes = SnodeAPI.snodePool.wrappedValue
|
||||
.subtracting(guardSnodes)
|
||||
.subtracting(reusablePaths.flatMap { $0 })
|
||||
let reusableGuardSnodeCount = UInt(reusableGuardSnodes.count)
|
||||
let pathSnodeCount = (targetGuardSnodeCount - reusableGuardSnodeCount) * pathSize - (targetGuardSnodeCount - reusableGuardSnodeCount)
|
||||
|
||||
guard unusedSnodes.count >= pathSnodeCount else {
|
||||
return Fail<[[Snode]], Error>(error: OnionRequestAPIError.insufficientSnodes)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
// Don't test path snodes as this would reveal the user's IP to them
|
||||
return Just(
|
||||
guardSnodes
|
||||
return buildPathsPublisher.mutate { result in
|
||||
/// It was possible for multiple threads to call this at the same time resulting in duplicate promises getting created, while
|
||||
/// this should no longer be possible (as the `wrappedValue` should now properly be blocked) this is a sanity check
|
||||
/// to make sure we don't create an additional promise when one already exists
|
||||
if let previouslyBlockedPublisher: AnyPublisher<[[Snode]], Error> = result {
|
||||
return previouslyBlockedPublisher
|
||||
}
|
||||
|
||||
SNLog("Building onion request paths.")
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: .buildingPaths, object: nil)
|
||||
}
|
||||
|
||||
/// Need to include the post-request code and a `shareReplay` within the publisher otherwise it can still be executed
|
||||
/// multiple times as a result of multiple subscribers
|
||||
let reusableGuardSnodes = reusablePaths.map { $0[0] }
|
||||
let publisher: AnyPublisher<[[Snode]], Error> = getGuardSnodes(reusing: reusableGuardSnodes)
|
||||
.flatMap { (guardSnodes: Set<Snode>) -> AnyPublisher<[[Snode]], Error> in
|
||||
var unusedSnodes: Set<Snode> = SnodeAPI.snodePool.wrappedValue
|
||||
.subtracting(guardSnodes)
|
||||
.subtracting(reusablePaths.flatMap { $0 })
|
||||
let reusableGuardSnodeCount: UInt = UInt(reusableGuardSnodes.count)
|
||||
let pathSnodeCount: UInt = (targetGuardSnodeCount - reusableGuardSnodeCount) * pathSize - (targetGuardSnodeCount - reusableGuardSnodeCount)
|
||||
|
||||
guard unusedSnodes.count >= pathSnodeCount else {
|
||||
return Fail<[[Snode]], Error>(error: OnionRequestAPIError.insufficientSnodes)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
// Don't test path snodes as this would reveal the user's IP to them
|
||||
let paths: [[Snode]] = guardSnodes
|
||||
.subtracting(reusableGuardSnodes)
|
||||
.map { guardSnode in
|
||||
let result = [ guardSnode ] + (0..<(pathSize - 1)).map { _ in
|
||||
// randomElement() uses the system's default random generator, which is cryptographically secure
|
||||
let pathSnode = unusedSnodes.randomElement()! // Safe because of the pathSnodeCount check above
|
||||
unusedSnodes.remove(pathSnode) // All used snodes should be unique
|
||||
return pathSnode
|
||||
}
|
||||
.map { (guardSnode: Snode) in
|
||||
let result: [Snode] = [guardSnode]
|
||||
.appending(
|
||||
contentsOf: (0..<(pathSize - 1))
|
||||
.map { _ in
|
||||
// randomElement() uses the system's default random generator,
|
||||
// which is cryptographically secure
|
||||
let pathSnode: Snode = unusedSnodes.randomElement()! // Safe because of the pathSnodeCount check above
|
||||
unusedSnodes.remove(pathSnode) // All used snodes should be unique
|
||||
return pathSnode
|
||||
}
|
||||
)
|
||||
|
||||
SNLog("Built new onion request path: \(result.prettifiedDescription).")
|
||||
return result
|
||||
}
|
||||
|
||||
return Just(paths)
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.handleEvents(
|
||||
receiveOutput: { output in
|
||||
OnionRequestAPI.paths = (output + reusablePaths)
|
||||
|
||||
Storage.shared.write { db in
|
||||
SNLog("Persisting onion request paths to database.")
|
||||
try? output.save(db)
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: .pathsBuilt, object: nil)
|
||||
}
|
||||
},
|
||||
receiveCompletion: { _ in buildPathsPublisher.mutate { $0 = nil } }
|
||||
)
|
||||
.setFailureType(to: Error.self)
|
||||
.shareReplay(1)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.handleEvents(
|
||||
receiveOutput: { output in
|
||||
OnionRequestAPI.paths = (output + reusablePaths)
|
||||
|
||||
Storage.shared.write { db in
|
||||
SNLog("Persisting onion request paths to database.")
|
||||
try? output.save(db)
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: .pathsBuilt, object: nil)
|
||||
}
|
||||
},
|
||||
receiveCompletion: { _ in buildPathsPublisher.mutate { $0 = nil } }
|
||||
)
|
||||
.eraseToAnyPublisher()
|
||||
|
||||
buildPathsPublisher.mutate { $0 = publisher }
|
||||
|
||||
return publisher
|
||||
|
||||
/// Actually assign the atomic value
|
||||
result = publisher
|
||||
|
||||
return publisher
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a `Path` to be used for building an onion request. Builds new paths as needed.
|
||||
|
@ -245,6 +258,7 @@ public enum OnionRequestAPI: OnionRequestAPIType {
|
|||
if let snode = snode {
|
||||
if let path = paths.first(where: { !$0.contains(snode) }) {
|
||||
buildPaths(reusing: paths) // Re-build paths in the background
|
||||
.subscribe(on: DispatchQueue.global(qos: .background))
|
||||
.sink(receiveCompletion: { _ in cancellable = [] }, receiveValue: { _ in })
|
||||
.store(in: &cancellable)
|
||||
|
||||
|
@ -269,6 +283,7 @@ public enum OnionRequestAPI: OnionRequestAPIType {
|
|||
}
|
||||
else {
|
||||
buildPaths(reusing: paths) // Re-build paths in the background
|
||||
.subscribe(on: DispatchQueue.global(qos: .background))
|
||||
.sink(receiveCompletion: { _ in cancellable = [] }, receiveValue: { _ in })
|
||||
.store(in: &cancellable)
|
||||
|
||||
|
@ -480,7 +495,6 @@ public enum OnionRequestAPI: OnionRequestAPIType {
|
|||
var guardSnode: Snode?
|
||||
|
||||
return buildOnion(around: payload, targetedAt: destination)
|
||||
.subscribe(on: Threading.workQueue)
|
||||
.flatMap { intermediate -> AnyPublisher<(ResponseInfoType, Data?), Error> in
|
||||
guardSnode = intermediate.guardSnode
|
||||
let url = "\(guardSnode!.address):\(guardSnode!.port)/onion_req/v2"
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue