From 352f6d73374a85c60a85b2641642230ac862e28d Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 16 Apr 2024 17:06:46 +1000 Subject: [PATCH] Fixed a number of bugs found while testing the internal build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Reworked the keyboard avoidance in ConversationVC to fix some bugs and simplify the behaviour • Moved the message request footer UI into it's own view • Fixed an issue where paths wouldn't get built for a new isntall • Fixed an issue where a couple of LibSession+Networking errors weren't getting logged correctly • Fixed a log that could be thrown incorrect for a unique constraint failure • Fixed an annoying startup warning due to thread priorities --- LibSession-Util | 2 +- Scripts/build_libSession_util.sh | 2 +- Session.xcodeproj/project.pbxproj | 4 + .../ConversationVC+Interaction.swift | 6 +- Session/Conversations/ConversationVC.swift | 346 ++++++------------ .../MessageRequestFooterView.swift | 170 +++++++++ Session/Meta/AppDelegate.swift | 32 +- Session/Onboarding/Onboarding.swift | 4 - .../Jobs/Types/MessageReceiveJob.swift | 1 + .../Open Groups/OpenGroupManager.swift | 2 + .../Sending & Receiving/Pollers/Poller.swift | 1 + SessionSnodeKit/Jobs/BuildPathsJob.swift | 3 +- .../LibSession/LibSession+Networking.swift | 4 +- SessionUtilitiesKit/JobRunner/JobRunner.swift | 2 +- .../JobRunner/JobRunnerSpec.swift | 104 ++++++ _SharedTestUtilities/MockJobRunner.swift | 5 +- 16 files changed, 435 insertions(+), 253 deletions(-) create mode 100644 Session/Conversations/Views & Modals/MessageRequestFooterView.swift diff --git a/LibSession-Util b/LibSession-Util index 765196710..b0656090e 160000 --- a/LibSession-Util +++ b/LibSession-Util @@ -1 +1 @@ -Subproject commit 7651967104845db16e6a58f70635c01f7f4c2033 +Subproject commit b0656090eac45723a55dc764d24c9ddb078cd61d diff --git a/Scripts/build_libSession_util.sh b/Scripts/build_libSession_util.sh index 7e46f7e2d..886aa2bf5 100755 --- a/Scripts/build_libSession_util.sh +++ b/Scripts/build_libSession_util.sh @@ -26,7 +26,7 @@ # request ever gets implemented: https://github.com/CocoaPods/CocoaPods/issues/8464 # Need to set the path or we won't find cmake -PATH=${PATH}:/usr/local/bin:/opt/local/bin:/opt/homebrew/bin:/sbin/md5 +PATH=${PATH}:/usr/local/bin:/opt/local/bin:/opt/homebrew/bin:/opt/homebrew/opt/m4/bin:/sbin/md5 exec 3>&1 # Save original stdout diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 2447ff886..4320bf30a 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -461,6 +461,7 @@ FD0606BD2BC8BF6F00C3816E /* BuildPathsJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0606BC2BC8BF6F00C3816E /* BuildPathsJob.swift */; }; FD0606BF2BC8C10200C3816E /* _005_AddJobUniqueHash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0606BE2BC8C10200C3816E /* _005_AddJobUniqueHash.swift */; }; FD0606C12BCC9A1500C3816E /* GetSwarmJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0606C02BCC9A1500C3816E /* GetSwarmJob.swift */; }; + FD0606C32BCE13ED00C3816E /* MessageRequestFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0606C22BCE13ED00C3816E /* MessageRequestFooterView.swift */; }; FD078E4827E02561000769AF /* CommonMockedExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E4727E02561000769AF /* CommonMockedExtensions.swift */; }; FD078E4927E02576000769AF /* CommonMockedExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E4727E02561000769AF /* CommonMockedExtensions.swift */; }; FD078E4D27E17156000769AF /* MockOGMCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E4C27E17156000769AF /* MockOGMCache.swift */; }; @@ -1690,6 +1691,7 @@ FD0606BC2BC8BF6F00C3816E /* BuildPathsJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildPathsJob.swift; sourceTree = ""; }; FD0606BE2BC8C10200C3816E /* _005_AddJobUniqueHash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _005_AddJobUniqueHash.swift; sourceTree = ""; }; FD0606C02BCC9A1500C3816E /* GetSwarmJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetSwarmJob.swift; sourceTree = ""; }; + FD0606C22BCE13ED00C3816E /* MessageRequestFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestFooterView.swift; sourceTree = ""; }; FD078E4727E02561000769AF /* CommonMockedExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonMockedExtensions.swift; sourceTree = ""; }; FD078E4C27E17156000769AF /* MockOGMCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockOGMCache.swift; sourceTree = ""; }; FD0969F82A69FFE700C5C365 /* Mocked.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mocked.swift; sourceTree = ""; }; @@ -2629,6 +2631,7 @@ FD4B200D283492210034334B /* InsetLockableTableView.swift */, 7B9F71C828470667006DFE7B /* ReactionListSheet.swift */, 7B5233C32900E90F00F8F375 /* SessionLabelCarouselView.swift */, + FD0606C22BCE13ED00C3816E /* MessageRequestFooterView.swift */, ); path = "Views & Modals"; sourceTree = ""; @@ -6375,6 +6378,7 @@ buildActionMask = 2147483647; files = ( FD97B2422A3FEBF30027DD57 /* UnreadMarkerCell.swift in Sources */, + FD0606C32BCE13ED00C3816E /* MessageRequestFooterView.swift in Sources */, FD52090928B59411006098F6 /* ScreenLockUI.swift in Sources */, 7B2561C429874851005C086C /* SessionCarouselView+Info.swift in Sources */, FDF2220B2818F38D000A4995 /* SessionApp.swift in Sources */, diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 40e55b263..8d7731455 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -2714,7 +2714,7 @@ extension ConversationVC { ) } - @objc func acceptMessageRequest() { + func acceptMessageRequest() { self.approveMessageRequestIfNeeded( for: self.viewModel.threadData.threadId, threadVariant: self.viewModel.threadData.threadVariant, @@ -2723,7 +2723,7 @@ extension ConversationVC { ) } - @objc func deleteMessageRequest() { + func declineMessageRequest() { let actions: [UIContextualAction]? = UIContextualAction.generateSwipeActions( [.delete], for: .trailing, @@ -2746,7 +2746,7 @@ extension ConversationVC { }) } - @objc func blockMessageRequest() { + func blockMessageRequest() { let actions: [UIContextualAction]? = UIContextualAction.generateSwipeActions( [.block], for: .trailing, diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 92eed2a1a..cbbad8e52 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -118,10 +118,11 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa // MARK: - UI + var lastKnownKeyboardFrame: CGRect? + var scrollButtonBottomConstraint: NSLayoutConstraint? var scrollButtonMessageRequestsBottomConstraint: NSLayoutConstraint? var messageRequestsViewBotomConstraint: NSLayoutConstraint? - var messageRequestDescriptionLabelBottomConstraint: NSLayoutConstraint? var emptyStateLabelTopConstraint: NSLayoutConstraint? lazy var titleView: ConversationTitleView = { @@ -162,6 +163,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa result.sectionFooterHeight = 0 result.dataSource = self result.delegate = self + result.contentInsetAdjustmentBehavior = .never // We custom handle it to prevent bugs return result }() @@ -296,98 +298,15 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa return result }() - lazy var messageRequestBackgroundView: UIView = { - let result: UIView = UIView() - result.translatesAutoresizingMaskIntoConstraints = false - result.themeBackgroundColor = .backgroundPrimary - result.isHidden = messageRequestStackView.isHidden - - return result - }() - - lazy var messageRequestStackView: UIStackView = { - let result: UIStackView = UIStackView() - result.translatesAutoresizingMaskIntoConstraints = false - result.axis = .vertical - result.alignment = .fill - result.distribution = .fill - result.isHidden = ( - self.viewModel.threadData.threadIsMessageRequest == false || - self.viewModel.threadData.threadRequiresApproval == true - ) - - return result - }() - - private lazy var messageRequestDescriptionContainerView: UIView = { - let result: UIView = UIView() - result.translatesAutoresizingMaskIntoConstraints = false - - return result - }() - - private lazy var messageRequestDescriptionLabel: UILabel = { - let result: UILabel = UILabel() - result.translatesAutoresizingMaskIntoConstraints = false - result.setContentCompressionResistancePriority(.required, for: .vertical) - result.font = UIFont.systemFont(ofSize: 12) - result.text = (self.viewModel.threadData.threadRequiresApproval == false ? - "MESSAGE_REQUESTS_INFO".localized() : - "MESSAGE_REQUEST_PENDING_APPROVAL_INFO".localized() - ) - result.themeTextColor = .textSecondary - result.textAlignment = .center - result.numberOfLines = 0 - - return result - }() - - private lazy var messageRequestActionStackView: UIStackView = { - let result: UIStackView = UIStackView() - result.translatesAutoresizingMaskIntoConstraints = false - result.axis = .horizontal - result.alignment = .fill - result.distribution = .fill - result.spacing = (UIDevice.current.isIPad ? Values.iPadButtonSpacing : 20) - - return result - }() - - private lazy var messageRequestAcceptButton: UIButton = { - let result: SessionButton = SessionButton(style: .bordered, size: .medium) - result.accessibilityLabel = "Accept message request" - result.isAccessibilityElement = true - result.translatesAutoresizingMaskIntoConstraints = false - result.setTitle("TXT_DELETE_ACCEPT".localized(), for: .normal) - result.addTarget(self, action: #selector(acceptMessageRequest), for: .touchUpInside) - - return result - }() - - private lazy var messageRequestDeleteButton: UIButton = { - let result: SessionButton = SessionButton(style: .destructive, size: .medium) - result.accessibilityLabel = "Delete message request" - result.isAccessibilityElement = true - result.translatesAutoresizingMaskIntoConstraints = false - result.setTitle("TXT_DELETE_TITLE".localized(), for: .normal) - result.addTarget(self, action: #selector(deleteMessageRequest), for: .touchUpInside) - - return result - }() - - private lazy var messageRequestBlockButton: UIButton = { - let result: UIButton = UIButton() - result.accessibilityLabel = "Block message request" - result.translatesAutoresizingMaskIntoConstraints = false - result.clipsToBounds = true - result.titleLabel?.font = UIFont.boldSystemFont(ofSize: 16) - result.setTitle("TXT_BLOCK_USER_TITLE".localized(), for: .normal) - result.setThemeTitleColor(.danger, for: .normal) - result.addTarget(self, action: #selector(blockMessageRequest), for: .touchUpInside) - result.isHidden = (self.viewModel.threadData.threadVariant != .contact) - - return result - }() + lazy var messageRequestFooterView: MessageRequestFooterView = MessageRequestFooterView( + threadVariant: self.viewModel.threadData.threadVariant, + canWrite: self.viewModel.threadData.canWrite, + threadIsMessageRequest: (self.viewModel.threadData.threadIsMessageRequest == true), + threadRequiresApproval: (self.viewModel.threadData.threadRequiresApproval == true), + onBlock: { [weak self] in self?.blockMessageRequest() }, + onAccept: { [weak self] in self?.acceptMessageRequest() }, + onDecline: { [weak self] in self?.declineMessageRequest() } + ) // MARK: - Settings @@ -450,40 +369,20 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa // Message requests view & scroll to bottom view.addSubview(scrollButton) view.addSubview(stateStackView) - view.addSubview(messageRequestBackgroundView) - view.addSubview(messageRequestStackView) + view.addSubview(messageRequestFooterView) stateStackView.pin(.top, to: .top, of: view, withInset: 0) stateStackView.pin(.leading, to: .leading, of: view, withInset: 0) stateStackView.pin(.trailing, to: .trailing, of: view, withInset: 0) self.emptyStateLabelTopConstraint = emptyStateLabel.pin(.top, to: .top, of: emptyStateLabelContainer, withInset: Values.largeSpacing) - messageRequestStackView.addArrangedSubview(messageRequestBlockButton) - messageRequestStackView.addArrangedSubview(messageRequestDescriptionContainerView) - messageRequestStackView.addArrangedSubview(messageRequestActionStackView) - messageRequestDescriptionContainerView.addSubview(messageRequestDescriptionLabel) - messageRequestActionStackView.addArrangedSubview(messageRequestAcceptButton) - messageRequestActionStackView.addArrangedSubview(messageRequestDeleteButton) - scrollButton.pin(.trailing, to: .trailing, of: view, withInset: -20) - messageRequestStackView.pin(.leading, to: .leading, of: view, withInset: 16) - messageRequestStackView.pin(.trailing, to: .trailing, of: view, withInset: -16) - self.messageRequestsViewBotomConstraint = messageRequestStackView.pin(.bottom, to: .bottom, of: view, withInset: -16) + messageRequestFooterView.pin(.leading, to: .leading, of: view, withInset: 16) + messageRequestFooterView.pin(.trailing, to: .trailing, of: view, withInset: -16) + self.messageRequestsViewBotomConstraint = messageRequestFooterView.pin(.bottom, to: .bottom, of: view, withInset: -16) self.scrollButtonBottomConstraint = scrollButton.pin(.bottom, to: .bottom, of: view, withInset: -16) self.scrollButtonBottomConstraint?.isActive = false // Note: Need to disable this to avoid a conflict with the other bottom constraint - self.scrollButtonMessageRequestsBottomConstraint = scrollButton.pin(.bottom, to: .top, of: messageRequestStackView, withInset: -4) - - messageRequestDescriptionLabel.pin(.top, to: .top, of: messageRequestDescriptionContainerView, withInset: 4) - messageRequestDescriptionLabel.pin(.leading, to: .leading, of: messageRequestDescriptionContainerView, withInset: 20) - messageRequestDescriptionLabel.pin(.trailing, to: .trailing, of: messageRequestDescriptionContainerView, withInset: -20) - self.messageRequestDescriptionLabelBottomConstraint = messageRequestDescriptionLabel.pin(.bottom, to: .bottom, of: messageRequestDescriptionContainerView, withInset: -20) - messageRequestActionStackView.pin(.top, to: .bottom, of: messageRequestDescriptionContainerView) - - messageRequestDeleteButton.set(.width, to: .width, of: messageRequestAcceptButton) - messageRequestBackgroundView.pin(.top, to: .top, of: messageRequestStackView) - messageRequestBackgroundView.pin(.leading, to: .leading, of: view) - messageRequestBackgroundView.pin(.trailing, to: .trailing, of: view) - messageRequestBackgroundView.pin(.bottom, to: .bottom, of: view) + self.scrollButtonMessageRequestsBottomConstraint = scrollButton.pin(.bottom, to: .top, of: messageRequestFooterView, withInset: -4) // Unread count view view.addSubview(unreadCountView) @@ -507,18 +406,6 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa selector: #selector(applicationDidResignActive(_:)), name: UIApplication.didEnterBackgroundNotification, object: nil ) - NotificationCenter.default.addObserver( - self, - selector: #selector(handleKeyboardWillChangeFrameNotification(_:)), - name: UIResponder.keyboardWillChangeFrameNotification, - object: nil - ) - NotificationCenter.default.addObserver( - self, - selector: #selector(handleKeyboardWillHideNotification(_:)), - name: UIResponder.keyboardWillHideNotification, - object: nil - ) NotificationCenter.default.addObserver( self, selector: #selector(sendScreenshotNotification), @@ -526,6 +413,24 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa object: nil ) + // Observe keyboard notifications + let keyboardNotifications: [Notification.Name] = [ + UIResponder.keyboardWillShowNotification, + UIResponder.keyboardDidShowNotification, + UIResponder.keyboardWillChangeFrameNotification, + UIResponder.keyboardDidChangeFrameNotification, + UIResponder.keyboardWillHideNotification, + UIResponder.keyboardDidHideNotification + ] + keyboardNotifications.forEach { notification in + NotificationCenter.default.addObserver( + self, + selector: #selector(handleKeyboardNotification(_:)), + name: notification, + object: nil + ) + } + // The first time the view loads we should mark the thread as read (in case it was manually // marked as unread) - doing this here means if we add a "mark as unread" action within the // conversation settings then we don't need to worry about the conversation getting marked as @@ -810,9 +715,8 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa if initialLoad || viewModel.threadData.threadVariant != updatedThreadData.threadVariant || + viewModel.threadData.threadIsNoteToSelf != updatedThreadData.threadIsNoteToSelf || viewModel.threadData.threadIsBlocked != updatedThreadData.threadIsBlocked || - viewModel.threadData.threadRequiresApproval != updatedThreadData.threadRequiresApproval || - viewModel.threadData.threadIsMessageRequest != updatedThreadData.threadIsMessageRequest || viewModel.threadData.profile != updatedThreadData.profile { updateNavBarButtons( @@ -821,35 +725,26 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa initialIsNoteToSelf: viewModel.threadData.threadIsNoteToSelf, initialIsBlocked: (viewModel.threadData.threadIsBlocked == true) ) - - messageRequestDescriptionLabel.text = (updatedThreadData.threadRequiresApproval == false ? - "MESSAGE_REQUESTS_INFO".localized() : - "MESSAGE_REQUEST_PENDING_APPROVAL_INFO".localized() - ) - - let messageRequestsViewWasVisible: Bool = ( - messageRequestStackView.isHidden == false - ) + } + + if + initialLoad || + viewModel.threadData.canWrite != updatedThreadData.canWrite || + viewModel.threadData.threadVariant != updatedThreadData.threadVariant || + viewModel.threadData.threadIsMessageRequest != updatedThreadData.threadIsMessageRequest || + viewModel.threadData.threadRequiresApproval != updatedThreadData.threadRequiresApproval + { + let messageRequestsViewWasVisible: Bool = (self.messageRequestFooterView.isHidden == false) UIView.animate(withDuration: 0.3) { [weak self] in - self?.messageRequestBlockButton.isHidden = ( - self?.viewModel.threadData.threadVariant != .contact || - updatedThreadData.threadRequiresApproval == true - ) - self?.messageRequestActionStackView.isHidden = ( - updatedThreadData.threadRequiresApproval == true - ) - self?.messageRequestStackView.isHidden = ( - !updatedThreadData.canWrite || ( - updatedThreadData.threadIsMessageRequest == false && - updatedThreadData.threadRequiresApproval == false - ) + self?.messageRequestFooterView.update( + threadVariant: updatedThreadData.threadVariant, + canWrite: updatedThreadData.canWrite, + threadIsMessageRequest: (updatedThreadData.threadIsMessageRequest == true), + threadRequiresApproval: (updatedThreadData.threadRequiresApproval == true) ) - self?.messageRequestBackgroundView.isHidden = (self?.messageRequestStackView.isHidden == true) - self?.messageRequestDescriptionLabelBottomConstraint?.constant = (updatedThreadData.threadRequiresApproval == true ? -4 : -20) - self?.scrollButtonMessageRequestsBottomConstraint?.isActive = ( - self?.messageRequestStackView.isHidden == false + self?.messageRequestFooterView.isHidden == false ) self?.scrollButtonBottomConstraint?.isActive = ( self?.scrollButtonMessageRequestsBottomConstraint?.isActive == false @@ -857,8 +752,8 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa // Update the table content inset and offset to account for // the dissapearance of the messageRequestsView - if messageRequestsViewWasVisible != (self?.messageRequestStackView.isHidden == false) { - let messageRequestsOffset: CGFloat = ((self?.messageRequestStackView.bounds.height ?? 0) + 12) + if messageRequestsViewWasVisible != (self?.messageRequestFooterView.isHidden == false) { + let messageRequestsOffset: CGFloat = (self?.messageRequestFooterView.bounds.height ?? 0) let oldContentInset: UIEdgeInsets = (self?.tableView.contentInset ?? UIEdgeInsets.zero) self?.tableView.contentInset = UIEdgeInsets( top: 0, @@ -1433,97 +1328,94 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa } } - // MARK: - Notifications + // MARK: - Keyboard Avoidance - @objc func handleKeyboardWillChangeFrameNotification(_ notification: Notification) { + @objc func handleKeyboardNotification(_ notification: Notification) { guard !viewIsDisappearing else { return } + guard + !viewIsDisappearing, + let userInfo: [AnyHashable: Any] = notification.userInfo, + var keyboardEndFrame: CGRect = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect + else { return } + + // If reduce motion+crossfade transitions is on, in iOS 14 UIKit vends out a keyboard end frame + // of CGRect zero. This breaks the math below. + // + // If our keyboard end frame is CGRectZero, build a fake rect that's translated off the bottom edge. + if keyboardEndFrame == .zero { + keyboardEndFrame = CGRect( + x: UIScreen.main.bounds.minX, + y: UIScreen.main.bounds.maxY, + width: UIScreen.main.bounds.width, + height: 0 + ) + } + + // No nothing if there was no change + let keyboardEndFrameConverted: CGRect = self.view.convert(keyboardEndFrame, from: nil) + guard keyboardEndFrameConverted != lastKnownKeyboardFrame else { return } + + self.lastKnownKeyboardFrame = keyboardEndFrameConverted // Please refer to https://github.com/mapbox/mapbox-navigation-ios/issues/1600 // and https://stackoverflow.com/a/25260930 to better understand what we are // doing with the UIViewAnimationOptions - let userInfo: [AnyHashable: Any] = (notification.userInfo ?? [:]) - let duration = ((userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval) ?? 0) let curveValue: Int = ((userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? Int) ?? Int(UIView.AnimationOptions.curveEaseInOut.rawValue)) let options: UIView.AnimationOptions = UIView.AnimationOptions(rawValue: UInt(curveValue << 16)) - let keyboardRect: CGRect = ((userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect) ?? CGRect.zero) - - // Calculate new positions (Need the ensure the 'messageRequestView' has been layed out as it's - // needed for proper calculations, so force an initial layout if it doesn't have a size) - var hasDoneLayout: Bool = true - - if messageRequestStackView.bounds.height <= CGFloat.leastNonzeroMagnitude { - hasDoneLayout = false - - UIView.performWithoutAnimation { - self.view.layoutIfNeeded() - } - } + let duration = ((userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval) ?? 0) - let keyboardTop = (UIScreen.main.bounds.height - keyboardRect.minY) - let messageRequestsOffset: CGFloat = (messageRequestStackView.isHidden ? 0 : messageRequestStackView.bounds.height + 12) - let oldContentInset: UIEdgeInsets = tableView.contentInset - let newContentInset: UIEdgeInsets = UIEdgeInsets( - top: 0, - leading: 0, - bottom: (Values.mediumSpacing + keyboardTop + messageRequestsOffset), - trailing: 0 - ) - let newContentOffsetY: CGFloat = (tableView.contentOffset.y + (newContentInset.bottom - oldContentInset.bottom)) - let changes = { [weak self] in - self?.scrollButtonBottomConstraint?.constant = -(keyboardTop + 12) - self?.messageRequestsViewBotomConstraint?.constant = -(keyboardTop + 12) - self?.tableView.contentInset = newContentInset - self?.tableView.contentOffset.y = newContentOffsetY - self?.updateScrollToBottom() - - self?.view.setNeedsLayout() - self?.view.layoutIfNeeded() - } - - // Perform the changes (don't animate if the initial layout hasn't been completed) - guard hasDoneLayout && didFinishInitialLayout && !viewIsAppearing else { + guard didFinishInitialLayout && !viewIsAppearing, duration > 0, !UIAccessibility.isReduceMotionEnabled else { + // UIKit by default (sometimes? never?) animates all changes in response to keyboard events. + // We want to suppress those animations if the view isn't visible, + // otherwise presentation animations don't work properly. UIView.performWithoutAnimation { - changes() + self.updateKeyboardAvoidance() } return } - - UIView.animate( - withDuration: duration, - delay: 0, - options: options, - animations: changes, - completion: nil - ) - } - - @objc func handleKeyboardWillHideNotification(_ notification: Notification) { - // Please refer to https://github.com/mapbox/mapbox-navigation-ios/issues/1600 - // and https://stackoverflow.com/a/25260930 to better understand what we are - // doing with the UIViewAnimationOptions - let userInfo: [AnyHashable: Any] = (notification.userInfo ?? [:]) - let duration = ((userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval) ?? 0) - let curveValue: Int = ((userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? Int) ?? Int(UIView.AnimationOptions.curveEaseInOut.rawValue)) - let options: UIView.AnimationOptions = UIView.AnimationOptions(rawValue: UInt(curveValue << 16)) - - let keyboardRect: CGRect = ((userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect) ?? CGRect.zero) - let keyboardTop = (UIScreen.main.bounds.height - keyboardRect.minY) - + UIView.animate( withDuration: duration, delay: 0, options: options, animations: { [weak self] in - self?.scrollButtonBottomConstraint?.constant = -(keyboardTop + 12) - self?.messageRequestsViewBotomConstraint?.constant = -(keyboardTop + 12) - self?.updateScrollToBottom() - - self?.view.setNeedsLayout() + self?.updateKeyboardAvoidance() self?.view.layoutIfNeeded() }, completion: nil ) } + + private func updateKeyboardAvoidance() { + guard let lastKnownKeyboardFrame: CGRect = self.lastKnownKeyboardFrame else { return } + + let messageRequestsOffset: CGFloat = (messageRequestFooterView.isHidden ? 0 : + messageRequestFooterView.bounds.height) + let viewIntersection = view.bounds.intersection(lastKnownKeyboardFrame) + let bottomOffset: CGFloat = (viewIntersection.isEmpty ? 0 : view.bounds.maxY - viewIntersection.minY) + let contentInsets = UIEdgeInsets( + top: 0, + left: 0, + bottom: bottomOffset + Values.mediumSpacing + messageRequestsOffset, + right: 0 + ) + let insetDifference: CGFloat = (contentInsets.bottom - tableView.contentInset.bottom) + scrollButtonBottomConstraint?.constant = -(bottomOffset + 12) + messageRequestsViewBotomConstraint?.constant = -bottomOffset + tableView.contentInset = contentInsets + tableView.scrollIndicatorInsets = contentInsets + + // Only modify the contentOffset if we aren't at the bottom of the tableView, with a little + // buffer (if we are at the bottom then it'll automatically scroll for us and modifying the + // value will break things) + let tableViewBottom: CGFloat = (tableView.contentSize.height - tableView.bounds.height + tableView.contentInset.bottom) + + if tableView.contentOffset.y < (tableViewBottom - 5) { + tableView.contentOffset.y += insetDifference + } + + updateScrollToBottom() + } // MARK: - General diff --git a/Session/Conversations/Views & Modals/MessageRequestFooterView.swift b/Session/Conversations/Views & Modals/MessageRequestFooterView.swift new file mode 100644 index 000000000..334c66b36 --- /dev/null +++ b/Session/Conversations/Views & Modals/MessageRequestFooterView.swift @@ -0,0 +1,170 @@ +// Copyright © 2024 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import SessionUIKit + +class MessageRequestFooterView: UIView { + private var onBlock: (() -> ())? + private var onAccept: (() -> ())? + private var onDecline: (() -> ())? + + // MARK: - UI + + var messageRequestDescriptionLabelBottomConstraint: NSLayoutConstraint? + + lazy var stackView: UIStackView = { + let result: UIStackView = UIStackView() + result.translatesAutoresizingMaskIntoConstraints = false + result.axis = .vertical + result.alignment = .fill + result.distribution = .fill + + return result + }() + + private lazy var descriptionContainerView: UIView = { + let result: UIView = UIView() + result.translatesAutoresizingMaskIntoConstraints = false + + return result + }() + + private lazy var descriptionLabel: UILabel = { + let result: UILabel = UILabel() + result.translatesAutoresizingMaskIntoConstraints = false + result.setContentCompressionResistancePriority(.required, for: .vertical) + result.font = UIFont.systemFont(ofSize: 12) + result.themeTextColor = .textSecondary + result.textAlignment = .center + result.numberOfLines = 0 + + return result + }() + + private lazy var actionStackView: UIStackView = { + let result: UIStackView = UIStackView() + result.translatesAutoresizingMaskIntoConstraints = false + result.axis = .horizontal + result.alignment = .fill + result.distribution = .fill + result.spacing = (UIDevice.current.isIPad ? Values.iPadButtonSpacing : 20) + + return result + }() + + private lazy var blockButton: UIButton = { + let result: UIButton = UIButton() + result.setCompressionResistanceHigh() + result.accessibilityLabel = "Block message request" + result.translatesAutoresizingMaskIntoConstraints = false + result.clipsToBounds = true + result.titleLabel?.font = UIFont.boldSystemFont(ofSize: 16) + result.setTitle("TXT_BLOCK_USER_TITLE".localized(), for: .normal) + result.setThemeTitleColor(.danger, for: .normal) + result.addTarget(self, action: #selector(block), for: .touchUpInside) + + return result + }() + + private lazy var acceptButton: UIButton = { + let result: SessionButton = SessionButton(style: .bordered, size: .medium) + result.accessibilityLabel = "Accept message request" + result.isAccessibilityElement = true + result.translatesAutoresizingMaskIntoConstraints = false + result.setTitle("TXT_DELETE_ACCEPT".localized(), for: .normal) + result.addTarget(self, action: #selector(accept), for: .touchUpInside) + + return result + }() + + private lazy var declineButton: UIButton = { + let result: SessionButton = SessionButton(style: .destructive, size: .medium) + result.accessibilityLabel = "Delete message request" + result.isAccessibilityElement = true + result.translatesAutoresizingMaskIntoConstraints = false + result.setTitle("TXT_DELETE_TITLE".localized(), for: .normal) + result.addTarget(self, action: #selector(decline), for: .touchUpInside) + + return result + }() + + // MARK: - Initialization + + init( + threadVariant: SessionThread.Variant, + canWrite: Bool, + threadIsMessageRequest: Bool, + threadRequiresApproval: Bool, + onBlock: @escaping () -> (), + onAccept: @escaping () -> (), + onDecline: @escaping () -> () + ) { + super.init(frame: .zero) + + self.onBlock = onBlock + self.onAccept = onAccept + self.onDecline = onDecline + self.themeBackgroundColor = .backgroundPrimary + + update( + threadVariant: threadVariant, + canWrite: canWrite, + threadIsMessageRequest: threadIsMessageRequest, + threadRequiresApproval: threadRequiresApproval + ) + setupLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Layout + + private func setupLayout() { + addSubview(stackView) + stackView.addArrangedSubview(blockButton) + stackView.addArrangedSubview(descriptionContainerView) + stackView.addArrangedSubview(actionStackView) + + descriptionContainerView.addSubview(descriptionLabel) + actionStackView.addArrangedSubview(acceptButton) + actionStackView.addArrangedSubview(declineButton) + + stackView.pin(.top, to: .top, of: self, withInset: 16) + stackView.pin(.leading, to: .leading, of: self, withInset: 16) + stackView.pin(.trailing, to: .trailing, of: self, withInset: -16) + stackView.pin(.bottom, to: .bottom, of: self, withInset: -16) + + descriptionLabel.pin(.top, to: .top, of: descriptionContainerView, withInset: 4) + descriptionLabel.pin(.leading, to: .leading, of: descriptionContainerView, withInset: 20) + descriptionLabel.pin(.trailing, to: .trailing, of: descriptionContainerView, withInset: -20) + messageRequestDescriptionLabelBottomConstraint = descriptionLabel.pin(.bottom, to: .bottom, of: descriptionContainerView, withInset: -20) + actionStackView.pin(.top, to: .bottom, of: descriptionContainerView) + + declineButton.set(.width, to: .width, of: acceptButton) + } + + // MARK: - Content + + func update( + threadVariant: SessionThread.Variant, + canWrite: Bool, + threadIsMessageRequest: Bool, + threadRequiresApproval: Bool + ) { + self.isHidden = (!canWrite || (!threadIsMessageRequest && !threadRequiresApproval)) + self.blockButton.isHidden = (threadVariant != .contact) + self.descriptionLabel.text = (threadRequiresApproval ? + "MESSAGE_REQUEST_PENDING_APPROVAL_INFO".localized() : + "MESSAGE_REQUESTS_INFO".localized() + ) + self.messageRequestDescriptionLabelBottomConstraint?.constant = (threadRequiresApproval ? -4 : -20) + } + + // MARK: - Actions + + @objc private func block() { onBlock?() } + @objc private func accept() { onAccept?() } + @objc private func decline() { onDecline?() } +} diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index b1ca239b8..02e9669db 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -513,18 +513,24 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD /// There is a _fun_ behaviour here where if the user launches the app, sends it to the background at the right time and then /// opens it again the `AppReadiness` closures can be triggered before `applicationDidBecomeActive` has been /// called again - this can result in odd behaviours so hold off on running this logic until it's properly called again - guard - Identity.userExists() && - UserDefaults.sharedLokiProject?[.isMainAppActive] == true - else { return } - - enableBackgroundRefreshIfNecessary() - JobRunner.appDidBecomeActive() + guard UserDefaults.sharedLokiProject?[.isMainAppActive] == true else { return } - startPollersIfNeeded() - - if Singleton.hasAppContext && Singleton.appContext.isMainApp { - handleAppActivatedWithOngoingCallIfNeeded() + /// There is a warning which can happen on launch because the Database read can be blocked by another database operation + /// which could result in this blocking the main thread, as a result we want to check the identity exists on a background thread + /// and then return to the main thread only when required + DispatchQueue.global(qos: .default).async { [weak self] in + guard Identity.userExists() else { return } + + self?.enableBackgroundRefreshIfNecessary() + JobRunner.appDidBecomeActive() + + self?.startPollersIfNeeded() + + if Singleton.hasAppContext && Singleton.appContext.isMainApp { + DispatchQueue.main.async { + self?.handleAppActivatedWithOngoingCallIfNeeded() + } + } } } @@ -603,6 +609,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD // Navigate to the approriate screen depending on the onboarding state switch Onboarding.State.current { case .newUser: + /// Enable single-execution jobs (this allows fetching the snode pool, building paths and fetching the swarm for + /// retrieving the profile name when restoring an account before the account is properly created) + JobRunner.enableNewSingleExecutionJobsOnly() + DispatchQueue.main.async { let viewController: LandingVC = LandingVC() populateHomeScreenTimer.invalidate() diff --git a/Session/Onboarding/Onboarding.swift b/Session/Onboarding/Onboarding.swift index 7e8a48e8d..ad6366a15 100644 --- a/Session/Onboarding/Onboarding.swift +++ b/Session/Onboarding/Onboarding.swift @@ -176,10 +176,6 @@ enum Onboarding { // Only continue if this isn't a new account guard self != .register else { return } - // Enable single-execution jobs (this allows fetching the swarm for retrieving the - // profile name below without triggering other jobs) - JobRunner.enableNewSingleExecutionJobsOnly() - // Fetch any existing profile name Onboarding.profileNamePublisher .subscribe(on: DispatchQueue.global(qos: .userInitiated)) diff --git a/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift b/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift index fabae4e97..2f6012099 100644 --- a/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift @@ -72,6 +72,7 @@ public enum MessageReceiveJob: JobExecutor { // for open group messages) we also don't bother logging as it results in // excessive logging which isn't useful) case DatabaseError.SQLITE_CONSTRAINT_UNIQUE, + DatabaseError.SQLITE_CONSTRAINT, // Sometimes thrown for UNIQUE MessageReceiverError.duplicateMessage, MessageReceiverError.duplicateControlMessage, MessageReceiverError.selfSend: diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index b71f64b08..aef40a4a9 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -618,6 +618,7 @@ public final class OpenGroupManager { // Ignore duplicate & selfSend message errors (and don't bother logging // them as there will be a lot since we each service node duplicates messages) case DatabaseError.SQLITE_CONSTRAINT_UNIQUE, + DatabaseError.SQLITE_CONSTRAINT, // Sometimes thrown for UNIQUE MessageReceiverError.duplicateMessage, MessageReceiverError.duplicateControlMessage, MessageReceiverError.selfSend: @@ -800,6 +801,7 @@ public final class OpenGroupManager { // Ignore duplicate and self-send errors (we will always receive a duplicate message back // whenever we send a message so this ends up being spam otherwise) case DatabaseError.SQLITE_CONSTRAINT_UNIQUE, + DatabaseError.SQLITE_CONSTRAINT, // Sometimes thrown for UNIQUE MessageReceiverError.duplicateMessage, MessageReceiverError.duplicateControlMessage, MessageReceiverError.selfSend: diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift index 007ef7671..91eb783f4 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift @@ -235,6 +235,7 @@ public class Poller { // Ignore duplicate & selfSend message errors (and don't bother logging // them as there will be a lot since we each service node duplicates messages) case DatabaseError.SQLITE_CONSTRAINT_UNIQUE, + DatabaseError.SQLITE_CONSTRAINT, // Sometimes thrown for UNIQUE MessageReceiverError.duplicateMessage, MessageReceiverError.duplicateControlMessage, MessageReceiverError.selfSend: diff --git a/SessionSnodeKit/Jobs/BuildPathsJob.swift b/SessionSnodeKit/Jobs/BuildPathsJob.swift index 3c6e43f94..91437190c 100644 --- a/SessionSnodeKit/Jobs/BuildPathsJob.swift +++ b/SessionSnodeKit/Jobs/BuildPathsJob.swift @@ -197,7 +197,7 @@ public enum BuildPathsJob: JobExecutor { Future { resolver in let hasValidPath: Bool = snodeToExclude .map { snode in paths.contains { !$0.contains(snode) } } - .defaulting(to: true) + .defaulting(to: !paths.isEmpty) let targetJob: Job? = dependencies.storage.write(using: dependencies) { db in return dependencies.jobRunner.upsert( @@ -233,6 +233,7 @@ public enum BuildPathsJob: JobExecutor { // Otherwise we can let the `BuildPathsJob` run in the background and should just return // immediately + SNLog("[BuildPathsJob] Scheduled in background due to existing valid path.") resolver(Result.success(())) } }.eraseToAnyPublisher() diff --git a/SessionSnodeKit/LibSession/LibSession+Networking.swift b/SessionSnodeKit/LibSession/LibSession+Networking.swift index 8148298f0..b17582295 100644 --- a/SessionSnodeKit/LibSession/LibSession+Networking.swift +++ b/SessionSnodeKit/LibSession/LibSession+Networking.swift @@ -270,7 +270,7 @@ public extension LibSession { ) var error: [CChar] = [CChar](repeating: 0, count: 256) if !network_add_path(network, cOnionPath, &error) { - SNLog("[LibSession] Failed to add path due to error: \(error).") + SNLog("[LibSession] Failed to add path due to error: \(String(cString: error)).") } cNodes?.deallocate() } @@ -300,7 +300,7 @@ public extension LibSession { ) var error: [CChar] = [CChar](repeating: 0, count: 256) if !network_remove_path(network, cNode, &error) { - SNLog("[LibSession] Failed to remove path due to error: \(error).") + SNLog("[LibSession] Failed to remove path due to error: \(String(cString: error)).") } } diff --git a/SessionUtilitiesKit/JobRunner/JobRunner.swift b/SessionUtilitiesKit/JobRunner/JobRunner.swift index aba80078a..36f7b159b 100644 --- a/SessionUtilitiesKit/JobRunner/JobRunner.swift +++ b/SessionUtilitiesKit/JobRunner/JobRunner.swift @@ -480,7 +480,6 @@ public final class JobRunner: JobRunnerType { // Flag that the JobRunner can start it's queues appReadyToStartQueues.mutate { $0 = true } - forceAllowSingleExecutionJobs.mutate { $0 = false } // Note: 'appDidBecomeActive' will run on first launch anyway so we can // leave those jobs out and can wait until then to start the JobRunner @@ -542,6 +541,7 @@ public final class JobRunner: JobRunnerType { // Flag that the JobRunner can start it's queues and start queueing non-launch jobs appReadyToStartQueues.mutate { $0 = true } appHasBecomeActive.mutate { $0 = true } + forceAllowSingleExecutionJobs.mutate { $0 = false } // If we have a running "sutdownBackgroundTask" then we want to cancel it as otherwise it // can result in the database being suspended and us being unable to interact with it at all diff --git a/SessionUtilitiesKitTests/JobRunner/JobRunnerSpec.swift b/SessionUtilitiesKitTests/JobRunner/JobRunnerSpec.swift index 4943ffc3c..b78ee6253 100644 --- a/SessionUtilitiesKitTests/JobRunner/JobRunnerSpec.swift +++ b/SessionUtilitiesKitTests/JobRunner/JobRunnerSpec.swift @@ -1736,6 +1736,110 @@ class JobRunnerSpec: QuickSpec { } } } + + // MARK: ---- when running in single execution mode + context("when running in single execution mode") { + beforeEach { + jobRunner.enableNewSingleExecutionJobsOnly(using: dependencies) + } + + // MARK: ------ starts the job if it has the run once transient behaviour + it("starts the job if it has the run once transient behaviour") { + job1 = Job( + id: 101, + failureCount: 0, + variant: .messageSend, + behaviour: .runOnceTransient, + shouldBlock: false, + shouldBeUnique: false, + shouldSkipLaunchBecomeActive: false, + nextRunTimestamp: 0, + threadId: nil, + interactionId: nil, + details: try? JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(result: .success, completeTime: 1)) + ) + + mockStorage.write { db in + try job1.insert(db) + + jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies) + } + + // Make sure the job is run + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([101])) + + // Make sure there are no running jobs + dependencies.stepForwardInTime() + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty()) + } + + // MARK: ------ does not start the job if it does not have the run once transient behaviour + it("does not start the job if it does not have the run once transient behaviour") { + job1 = Job( + id: 101, + failureCount: 0, + variant: .messageSend, + behaviour: .runOnce, + shouldBlock: false, + shouldBeUnique: false, + shouldSkipLaunchBecomeActive: false, + nextRunTimestamp: 0, + threadId: nil, + interactionId: nil, + details: try? JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(result: .success, completeTime: 1)) + ) + + mockStorage.write { db in + try job1.insert(db) + + jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies) + } + + // Make sure the job does not run + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty()) + } + + // MARK: ------ after the app properly launches + context("after the app properly launches") { + beforeEach { + jobRunner.appDidFinishLaunching(using: dependencies) + jobRunner.appDidBecomeActive(using: dependencies) + } + + // MARK: -------- is able to start jobs without the run once transient behaviour again + it("is able to start jobs without the run once transient behaviour again") { + job1 = Job( + id: 101, + failureCount: 0, + variant: .messageSend, + behaviour: .runOnce, + shouldBlock: false, + shouldBeUnique: false, + shouldSkipLaunchBecomeActive: false, + nextRunTimestamp: 0, + threadId: nil, + interactionId: nil, + details: try? JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(result: .success, completeTime: 1)) + ) + + mockStorage.write { db in + try job1.insert(db) + + jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies) + + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty()) + } + + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([101])) + } + } + } } } } diff --git a/_SharedTestUtilities/MockJobRunner.swift b/_SharedTestUtilities/MockJobRunner.swift index d16743de4..2209ec570 100644 --- a/_SharedTestUtilities/MockJobRunner.swift +++ b/_SharedTestUtilities/MockJobRunner.swift @@ -30,6 +30,7 @@ class MockJobRunner: Mock, JobRunnerType { func appDidFinishLaunching(using dependencies: Dependencies) {} func appDidBecomeActive(using dependencies: Dependencies) {} + func enableNewSingleExecutionJobsOnly(using dependencies: Dependencies) {} func startNonBlockingQueues(using dependencies: Dependencies) {} func stopAndClearPendingJobs(exceptForVariant: Job.Variant?, onComplete: (() -> ())?) { @@ -43,8 +44,8 @@ class MockJobRunner: Mock, JobRunnerType { return accept(args: [db, job, dependantJob, canStartJob]) as? Job } - func upsert(_ db: Database, job: Job?, canStartJob: Bool, using dependencies: Dependencies) { - accept(args: [db, job, canStartJob]) + func upsert(_ db: Database, job: Job?, canStartJob: Bool, using dependencies: Dependencies) -> Job? { + return accept(args: [db, job, canStartJob]) as? Job } func insert(_ db: Database, job: Job?, before otherJob: Job) -> (Int64, Job)? {