mirror of https://github.com/oxen-io/session-ios
				
				
				
			
			You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			450 lines
		
	
	
		
			20 KiB
		
	
	
	
		
			Swift
		
	
			
		
		
	
	
			450 lines
		
	
	
		
			20 KiB
		
	
	
	
		
			Swift
		
	
| // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
 | |
| 
 | |
| import UIKit
 | |
| import Combine
 | |
| import UniformTypeIdentifiers
 | |
| import GRDB
 | |
| import DifferenceKit
 | |
| import SessionUIKit
 | |
| import SignalUtilitiesKit
 | |
| import SessionMessagingKit
 | |
| import SessionSnodeKit
 | |
| import SessionUtilitiesKit
 | |
| 
 | |
| final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableViewDelegate, AttachmentApprovalViewControllerDelegate, ThemedNavigation {
 | |
|     private let viewModel: ThreadPickerViewModel
 | |
|     private var dataChangeObservable: DatabaseCancellable? {
 | |
|         didSet { oldValue?.cancel() }   // Cancel the old observable if there was one
 | |
|     }
 | |
|     private var hasLoadedInitialData: Bool = false
 | |
|     public var navigationBackground: ThemeValue? { .backgroundPrimary }
 | |
|     
 | |
|     var shareNavController: ShareNavController?
 | |
|     
 | |
|     // MARK: - Intialization
 | |
|     
 | |
|     init(using dependencies: Dependencies) {
 | |
|         viewModel = ThreadPickerViewModel(using: dependencies)
 | |
|         
 | |
|         super.init(nibName: nil, bundle: nil)
 | |
|     }
 | |
|     
 | |
|     required init?(coder: NSCoder) {
 | |
|         fatalError("init(coder:) has not been implemented")
 | |
|     }
 | |
|     
 | |
|     deinit {
 | |
|         NotificationCenter.default.removeObserver(self)
 | |
|     }
 | |
|     
 | |
|     // MARK: - UI
 | |
|     
 | |
|     private lazy var titleLabel: UILabel = {
 | |
|         let titleLabel: UILabel = UILabel()
 | |
|         titleLabel.font = .boldSystemFont(ofSize: Values.veryLargeFontSize)
 | |
|         titleLabel.text = "shareToSession"
 | |
|             .put(key: "app_name", value: Constants.app_name)
 | |
|             .localized()
 | |
|         titleLabel.themeTextColor = .textPrimary
 | |
|         
 | |
|         return titleLabel
 | |
|     }()
 | |
|     
 | |
|     private lazy var databaseErrorLabel: UILabel = {
 | |
|         let result: UILabel = UILabel()
 | |
|         result.font = .systemFont(ofSize: Values.mediumFontSize)
 | |
|         result.text = "shareExtensionDatabaseError".localized()
 | |
|         result.textAlignment = .center
 | |
|         result.themeTextColor = .textPrimary
 | |
|         result.numberOfLines = 0
 | |
|         result.isHidden = true
 | |
|         
 | |
|         return result
 | |
|     }()
 | |
|     
 | |
|     private lazy var noAccountErrorLabel: UILabel = {
 | |
|         let result: UILabel = UILabel()
 | |
|         result.font = .systemFont(ofSize: Values.mediumFontSize)
 | |
|         result.text = "shareExtensionNoAccountError"
 | |
|             .put(key: "app_name", value: Constants.app_name)
 | |
|             .localized()
 | |
|         result.textAlignment = .center
 | |
|         result.themeTextColor = .textPrimary
 | |
|         result.numberOfLines = 0
 | |
|         result.isHidden = true
 | |
|         
 | |
|         return result
 | |
|     }()
 | |
| 
 | |
|     private lazy var tableView: UITableView = {
 | |
|         let tableView: UITableView = UITableView()
 | |
|         tableView.themeBackgroundColor = .backgroundPrimary
 | |
|         tableView.separatorStyle = .none
 | |
|         tableView.register(view: SimplifiedConversationCell.self)
 | |
|         tableView.showsVerticalScrollIndicator = false
 | |
|         tableView.dataSource = self
 | |
|         tableView.delegate = self
 | |
|         
 | |
|         return tableView
 | |
|     }()
 | |
|     
 | |
|     // MARK: - Lifecycle
 | |
|     
 | |
|     override func viewDidLoad() {
 | |
|         super.viewDidLoad()
 | |
|         
 | |
|         navigationItem.titleView = titleLabel
 | |
|         ThemeManager.applyNavigationStylingIfNeeded(to: self)
 | |
|         
 | |
|         view.themeBackgroundColor = .backgroundPrimary
 | |
|         view.addSubview(tableView)
 | |
|         view.addSubview(databaseErrorLabel)
 | |
|         view.addSubview(noAccountErrorLabel)
 | |
|         
 | |
|         setupLayout()
 | |
|         
 | |
|         // Notifications
 | |
|         NotificationCenter.default.addObserver(
 | |
|             self,
 | |
|             selector: #selector(applicationDidBecomeActive(_:)),
 | |
|             name: UIApplication.didBecomeActiveNotification,
 | |
|             object: nil
 | |
|         )
 | |
|         NotificationCenter.default.addObserver(
 | |
|             self,
 | |
|             selector: #selector(applicationDidResignActive(_:)),
 | |
|             name: UIApplication.didEnterBackgroundNotification, object: nil
 | |
|         )
 | |
|     }
 | |
|     
 | |
|     override func viewWillAppear(_ animated: Bool) {
 | |
|         super.viewWillAppear(animated)
 | |
|         
 | |
|         startObservingChanges()
 | |
|     }
 | |
|     
 | |
|     override func viewWillDisappear(_ animated: Bool) {
 | |
|         super.viewWillDisappear(animated)
 | |
|         
 | |
|         stopObservingChanges()
 | |
|         
 | |
|         // When the thread picker disappears it means the user has left the screen (this will be called
 | |
|         // whether the user has sent the message or cancelled sending)
 | |
|         viewModel.dependencies.mutate(cache: .libSessionNetwork) { $0.suspendNetworkAccess() }
 | |
|         viewModel.dependencies[singleton: .storage].suspendDatabaseAccess()
 | |
|         Log.flush()
 | |
|     }
 | |
|     
 | |
|     @objc func applicationDidBecomeActive(_ notification: Notification) {
 | |
|         /// Need to dispatch to the next run loop to prevent a possible crash caused by the database resuming mid-query
 | |
|         DispatchQueue.main.async { [weak self] in
 | |
|             self?.startObservingChanges()
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     @objc func applicationDidResignActive(_ notification: Notification) {
 | |
|         stopObservingChanges()
 | |
|     }
 | |
|     
 | |
|     // MARK: Layout
 | |
|     
 | |
|     private func setupLayout() {
 | |
|         tableView.pin(to: view)
 | |
|         
 | |
|         databaseErrorLabel.pin(.top, to: .top, of: view, withInset: Values.massiveSpacing)
 | |
|         databaseErrorLabel.pin(.leading, to: .leading, of: view, withInset: Values.veryLargeSpacing)
 | |
|         databaseErrorLabel.pin(.trailing, to: .trailing, of: view, withInset: -Values.veryLargeSpacing)
 | |
|         
 | |
|         noAccountErrorLabel.pin(.top, to: .top, of: view, withInset: Values.massiveSpacing)
 | |
|         noAccountErrorLabel.pin(.leading, to: .leading, of: view, withInset: Values.veryLargeSpacing)
 | |
|         noAccountErrorLabel.pin(.trailing, to: .trailing, of: view, withInset: -Values.veryLargeSpacing)
 | |
|     }
 | |
|     
 | |
|     // MARK: - Updating
 | |
|     
 | |
|     private func startObservingChanges() {
 | |
|         guard dataChangeObservable == nil else { return }
 | |
|         
 | |
|         noAccountErrorLabel.isHidden = viewModel.dependencies[singleton: .storage, key: .isReadyForAppExtensions]
 | |
|         tableView.isHidden = !viewModel.dependencies[singleton: .storage, key: .isReadyForAppExtensions]
 | |
|         
 | |
|         guard viewModel.dependencies[singleton: .storage, key: .isReadyForAppExtensions] else { return }
 | |
|         
 | |
|         // Start observing for data changes
 | |
|         dataChangeObservable = self.viewModel.dependencies[singleton: .storage].start(
 | |
|             viewModel.observableViewData,
 | |
|             onError:  { [weak self, dependencies = self.viewModel.dependencies] _ in
 | |
|                 self?.databaseErrorLabel.isHidden = dependencies[singleton: .storage].isValid
 | |
|             },
 | |
|             onChange: { [weak self] viewData in
 | |
|                 // The defaul scheduler emits changes on the main thread
 | |
|                 self?.handleUpdates(viewData)
 | |
|             }
 | |
|         )
 | |
|     }
 | |
|     
 | |
|     private func stopObservingChanges() {
 | |
|         dataChangeObservable = nil
 | |
|     }
 | |
|     
 | |
|     private func handleUpdates(_ updatedViewData: [SessionThreadViewModel]) {
 | |
|         // Ensure the first load runs without animations (if we don't do this the cells will animate
 | |
|         // in from a frame of CGRect.zero)
 | |
|         guard hasLoadedInitialData else {
 | |
|             hasLoadedInitialData = true
 | |
|             UIView.performWithoutAnimation { handleUpdates(updatedViewData) }
 | |
|             return
 | |
|         }
 | |
|         
 | |
|         // Reload the table content (animate changes after the first load)
 | |
|         tableView.reload(
 | |
|             using: StagedChangeset(source: viewModel.viewData, target: updatedViewData),
 | |
|             with: .automatic,
 | |
|             interrupt: { $0.changeCount > 100 }    // Prevent too many changes from causing performance issues
 | |
|         ) { [weak self] updatedData in
 | |
|             self?.viewModel.updateData(updatedData)
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     // MARK: - UITableViewDataSource
 | |
|     
 | |
|     func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
 | |
|         return self.viewModel.viewData.count
 | |
|     }
 | |
|     
 | |
|     func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
 | |
|         let cell: SimplifiedConversationCell = tableView.dequeue(type: SimplifiedConversationCell.self, for: indexPath)
 | |
|         cell.update(with: self.viewModel.viewData[indexPath.row], using: viewModel.dependencies)
 | |
|         
 | |
|         return cell
 | |
|     }
 | |
|     
 | |
|     // MARK: - Interaction
 | |
|     
 | |
|     func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
 | |
|         tableView.deselectRow(at: indexPath, animated: true)
 | |
|         
 | |
|         ShareNavController.attachmentPrepPublisher?
 | |
|             .subscribe(on: DispatchQueue.global(qos: .userInitiated))
 | |
|             .receive(on: DispatchQueue.main)
 | |
|             .sinkUntilComplete(
 | |
|                 receiveValue: { [weak self, dependencies = self.viewModel.dependencies] attachments in
 | |
|                     guard
 | |
|                         let strongSelf = self,
 | |
|                         let approvalVC: UINavigationController = AttachmentApprovalViewController.wrappedInNavController(
 | |
|                             threadId: strongSelf.viewModel.viewData[indexPath.row].threadId,
 | |
|                             threadVariant: strongSelf.viewModel.viewData[indexPath.row].threadVariant,
 | |
|                             attachments: attachments,
 | |
|                             approvalDelegate: strongSelf,
 | |
|                             using: dependencies
 | |
|                         )
 | |
|                     else { return }
 | |
|                     
 | |
|                     self?.navigationController?.present(approvalVC, animated: true, completion: nil)
 | |
|                 }
 | |
|             )
 | |
|     }
 | |
|     
 | |
|     func attachmentApproval(
 | |
|         _ attachmentApproval: AttachmentApprovalViewController,
 | |
|         didApproveAttachments attachments: [SignalAttachment],
 | |
|         forThreadId threadId: String,
 | |
|         threadVariant: SessionThread.Variant,
 | |
|         messageText: String?
 | |
|     ) {
 | |
|         // Sharing a URL or plain text will populate the 'messageText' field so in those
 | |
|         // cases we should ignore the attachments
 | |
|         let isSharingUrl: Bool = (attachments.count == 1 && attachments[0].isUrl)
 | |
|         let isSharingText: Bool = (attachments.count == 1 && attachments[0].isText)
 | |
|         let finalAttachments: [SignalAttachment] = (isSharingUrl || isSharingText ? [] : attachments)
 | |
|         let body: String? = (
 | |
|             isSharingUrl && (messageText?.isEmpty == true || attachments[0].linkPreviewDraft == nil) ?
 | |
|             (
 | |
|                 (messageText?.isEmpty == true || (attachments[0].text() == messageText) ?
 | |
|                     attachments[0].text() :
 | |
|                     "\(attachments[0].text() ?? "")\n\n\(messageText ?? "")" // stringlint:ignore
 | |
|                 )
 | |
|             ) :
 | |
|             messageText
 | |
|         )
 | |
|         let userSessionId: SessionId = viewModel.dependencies[cache: .general].sessionId
 | |
|         let swarmPublicKey: String = {
 | |
|             switch threadVariant {
 | |
|                 case .contact, .legacyGroup, .group: return threadId
 | |
|                 case .community: return userSessionId.hexString
 | |
|             }
 | |
|         }()
 | |
|         
 | |
|         shareNavController?.dismiss(animated: true, completion: nil)
 | |
|         
 | |
|         ModalActivityIndicatorViewController.present(fromViewController: shareNavController!, canCancel: false, message: "sending".localized()) { [dependencies = viewModel.dependencies] activityIndicator in
 | |
|             dependencies[singleton: .storage].resumeDatabaseAccess()
 | |
|             dependencies.mutate(cache: .libSessionNetwork) { $0.resumeNetworkAccess() }
 | |
|             
 | |
|             /// When we prepare the message we set the timestamp to be the `dependencies[cache: .snodeAPI].currentOffsetTimestampMs()`
 | |
|             /// but won't actually have a value because the share extension won't have talked to a service node yet which can cause
 | |
|             /// issues with Disappearing Messages, as a result we need to explicitly `getNetworkTime` in order to ensure it's accurate
 | |
|             /// before we create the interaction
 | |
|             dependencies[singleton: .network]
 | |
|                 .getSwarm(for: swarmPublicKey)
 | |
|                 .tryFlatMapWithRandomSnode(using: dependencies) { snode in
 | |
|                     try SnodeAPI
 | |
|                         .preparedGetNetworkTime(from: snode, using: dependencies)
 | |
|                         .send(using: dependencies)
 | |
|                 }
 | |
|                 .subscribe(on: DispatchQueue.global(qos: .userInitiated))
 | |
|                 .flatMapStorageWritePublisher(using: dependencies) { db, _ -> (Interaction, [Network.PreparedRequest<String>]) in
 | |
|                     guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else {
 | |
|                         throw MessageSenderError.noThread
 | |
|                     }
 | |
|                     
 | |
|                     // Update the thread to be visible (if it isn't already)
 | |
|                     if !thread.shouldBeVisible || thread.pinnedPriority == LibSession.hiddenPriority {
 | |
|                         _ = try SessionThread
 | |
|                             .filter(id: threadId)
 | |
|                             .updateAllAndConfig(
 | |
|                                 db,
 | |
|                                 SessionThread.Columns.shouldBeVisible.set(to: true),
 | |
|                                 SessionThread.Columns.pinnedPriority.set(to: LibSession.visiblePriority),
 | |
|                                 SessionThread.Columns.isDraft.set(to: false),
 | |
|                                 using: dependencies
 | |
|                             )
 | |
|                     }
 | |
|                     
 | |
|                     // Create the interaction
 | |
|                     let sentTimestampMs: Int64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs()
 | |
|                     let destinationDisappearingMessagesConfiguration: DisappearingMessagesConfiguration? = try? DisappearingMessagesConfiguration
 | |
|                         .filter(id: threadId)
 | |
|                         .filter(DisappearingMessagesConfiguration.Columns.isEnabled == true)
 | |
|                         .fetchOne(db)
 | |
|                     let interaction: Interaction = try Interaction(
 | |
|                         threadId: threadId,
 | |
|                         threadVariant: threadVariant,
 | |
|                         authorId: userSessionId.hexString,
 | |
|                         variant: .standardOutgoing,
 | |
|                         body: body,
 | |
|                         timestampMs: sentTimestampMs,
 | |
|                         hasMention: Interaction.isUserMentioned(db, threadId: threadId, body: body, using: dependencies),
 | |
|                         expiresInSeconds: destinationDisappearingMessagesConfiguration?.expiresInSeconds(),
 | |
|                         expiresStartedAtMs: destinationDisappearingMessagesConfiguration?.initialExpiresStartedAtMs(
 | |
|                             sentTimestampMs: Double(sentTimestampMs)
 | |
|                         ),
 | |
|                         linkPreviewUrl: (isSharingUrl ? attachments.first?.linkPreviewDraft?.urlString : nil),
 | |
|                         using: dependencies
 | |
|                     ).inserted(db)
 | |
|                     
 | |
|                     guard let interactionId: Int64 = interaction.id else {
 | |
|                         throw StorageError.failedToSave
 | |
|                     }
 | |
|                     
 | |
|                     // If the user is sharing a Url, there is a LinkPreview and it doesn't match an existing
 | |
|                     // one then add it now
 | |
|                     if
 | |
|                         isSharingUrl,
 | |
|                         let linkPreviewDraft: LinkPreviewDraft = attachments.first?.linkPreviewDraft,
 | |
|                         (try? interaction.linkPreview.isEmpty(db)) == true
 | |
|                     {
 | |
|                         try LinkPreview(
 | |
|                             url: linkPreviewDraft.urlString,
 | |
|                             title: linkPreviewDraft.title,
 | |
|                             attachmentId: LinkPreview
 | |
|                                 .generateAttachmentIfPossible(
 | |
|                                     imageData: linkPreviewDraft.jpegImageData,
 | |
|                                     type: .jpeg,
 | |
|                                     using: dependencies
 | |
|                                 )?
 | |
|                                 .inserted(db)
 | |
|                                 .id,
 | |
|                             using: dependencies
 | |
|                         ).insert(db)
 | |
|                     }
 | |
|                     
 | |
|                     // Process any attachments
 | |
|                     try Attachment.process(
 | |
|                         db,
 | |
|                         attachments: Attachment.prepare(attachments: finalAttachments, using: dependencies),
 | |
|                         for: interactionId
 | |
|                     )
 | |
|                     
 | |
|                     // Using the same logic as the `MessageSendJob` retrieve 
 | |
|                     let attachmentState: MessageSendJob.AttachmentState = try MessageSendJob
 | |
|                         .fetchAttachmentState(db, interactionId: interactionId)
 | |
|                     let preparedUploads: [Network.PreparedRequest<String>] = try Attachment
 | |
|                         .filter(ids: attachmentState.allAttachmentIds)
 | |
|                         .fetchAll(db)
 | |
|                         .map { attachment in
 | |
|                             try attachment.preparedUpload(
 | |
|                                 db,
 | |
|                                 threadId: threadId,
 | |
|                                 logCategory: nil,
 | |
|                                 using: dependencies
 | |
|                             )
 | |
|                         }
 | |
|                     
 | |
|                     return (interaction, preparedUploads)
 | |
|                 }
 | |
|                 .flatMap { (interaction: Interaction, preparedUploads: [Network.PreparedRequest<String>]) -> AnyPublisher<(interaction: Interaction, fileIds: [String]), Error> in
 | |
|                     guard !preparedUploads.isEmpty else {
 | |
|                         return Just((interaction, []))
 | |
|                             .setFailureType(to: Error.self)
 | |
|                             .eraseToAnyPublisher()
 | |
|                     }
 | |
|                         
 | |
|                     return Publishers
 | |
|                         .MergeMany(preparedUploads.map { $0.send(using: dependencies) })
 | |
|                         .collect()
 | |
|                         .map { results in (interaction, results.map { _, id in id }) }
 | |
|                         .eraseToAnyPublisher()
 | |
|                 }
 | |
|                 .flatMapStorageWritePublisher(using: dependencies) { db, info -> Network.PreparedRequest<Void> in
 | |
|                     // Prepare the message send data
 | |
|                     guard
 | |
|                         let threadVariant: SessionThread.Variant = try SessionThread
 | |
|                             .filter(id: info.interaction.threadId)
 | |
|                             .select(.variant)
 | |
|                             .asRequest(of: SessionThread.Variant.self)
 | |
|                             .fetchOne(db)
 | |
|                     else { throw MessageSenderError.noThread }
 | |
|                     
 | |
|                     return try MessageSender
 | |
|                         .preparedSend(
 | |
|                             db,
 | |
|                             interaction: info.interaction,
 | |
|                             fileIds: info.fileIds,
 | |
|                             threadId: threadId,
 | |
|                             threadVariant: threadVariant,
 | |
|                             using: dependencies
 | |
|                         )
 | |
|                 }
 | |
|                 .flatMap { $0.send(using: dependencies) }
 | |
|                 .receive(on: DispatchQueue.main)
 | |
|                 .sinkUntilComplete(
 | |
|                     receiveCompletion: { [weak self] result in
 | |
|                         dependencies.mutate(cache: .libSessionNetwork) { $0.suspendNetworkAccess() }
 | |
|                         dependencies[singleton: .storage].suspendDatabaseAccess()
 | |
|                         Log.flush()
 | |
|                         activityIndicator.dismiss { }
 | |
|                         
 | |
|                         switch result {
 | |
|                             case .finished: self?.shareNavController?.shareViewWasCompleted()
 | |
|                             case .failure(let error): self?.shareNavController?.shareViewFailed(error: error)
 | |
|                         }
 | |
|                     }
 | |
|                 )
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     func attachmentApprovalDidCancel(_ attachmentApproval: AttachmentApprovalViewController) {
 | |
|         dismiss(animated: true, completion: nil)
 | |
|     }
 | |
| 
 | |
|     func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didChangeMessageText newMessageText: String?) {
 | |
|     }
 | |
|     
 | |
|     func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didRemoveAttachment attachment: SignalAttachment) {
 | |
|     }
 | |
|     
 | |
|     func attachmentApprovalDidTapAddMore(_ attachmentApproval: AttachmentApprovalViewController) {
 | |
|     }
 | |
| }
 |