From 0db74ce1e3d1d874a4ee469f7e42e32eb65867c9 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Sun, 8 May 2022 22:01:39 +1000 Subject: [PATCH] Working on the MediaGallery and ClosedGroup handling Fixed a couple of issues around the duplicate messages handling Fixed a few issues with ClosedGroup polling and ClosedGroup control message handling Started working through updating the MediaGallery --- Session.xcodeproj/project.pbxproj | 8 + Session/Closed Groups/EditClosedGroupVC.swift | 333 +++++--- Session/Closed Groups/NewClosedGroupVC.swift | 129 +-- .../ConversationVC+Interaction.swift | 332 +++++--- Session/Conversations/ConversationVC.swift | 774 ++++++++++-------- .../OWSConversationSettingsViewController.h | 2 +- .../OWSConversationSettingsViewController.m | 316 +++---- .../Settings/ProfilePictureVC.swift | 6 +- .../ConversationTitleView.swift | 64 +- Session/Home/HomeViewModel.swift | 33 +- .../MediaDetailViewController.swift | 3 + .../MediaGalleryViewModel.swift | 3 + .../PhotoCollectionPickerController.swift | 2 +- .../PhotoGridViewCell.swift | 28 +- .../PhotoLibrary.swift | 8 +- Session/Meta/SessionApp.swift | 24 +- Session/Notifications/AppNotifications.swift | 37 +- Session/Shared/ConversationCell.swift | 4 +- Session/Shared/UserCell.swift | 81 +- Session/Shared/UserSelectionVC.swift | 56 +- Session/Utilities/BackgroundPoller.swift | 3 +- .../LegacyDatabase/SMKLegacyModels.swift | 4 +- .../_001_InitialSetupMigration.swift | 18 +- .../Migrations/_003_YDBToGRDBMigration.swift | 58 +- .../Database/Models/Attachment.swift | 42 +- .../Database/Models/ClosedGroup.swift | 2 +- .../Database/Models/Contact.swift | 34 +- .../Models/ControlMessageProcessRecord.swift | 190 ++++- .../DisappearingMessageConfiguration.swift | 97 ++- .../Database/Models/GroupMember.swift | 36 + .../Database/Models/Interaction.swift | 2 +- .../Database/Models/OpenGroup.swift | 40 + .../Database/Models/Profile.swift | 45 +- .../Database/Models/SessionThread.swift | 87 +- .../Jobs/Types/MessageReceiveJob.swift | 6 +- .../DataExtractionNotification.swift | 2 +- .../MessageReceiver+Handling.swift | 37 +- .../Sending & Receiving/MessageReceiver.swift | 54 +- .../Sending & Receiving/MessageSender.swift | 42 +- .../Pollers/ClosedGroupPoller.swift | 112 ++- .../Sending & Receiving/Pollers/Poller.swift | 3 +- .../NSENotificationPresenter.swift | 6 +- .../NotificationServiceExtension.swift | 6 +- .../General/Set+Utilities.swift | 9 + .../AttachmentPrepViewController.swift | 2 +- .../OWSVideoPlayer.swift | 10 +- .../Messaging/BlockListUIUtils.swift | 14 +- ...ModalActivityIndicatorViewController.swift | 10 +- .../Shared Views/GalleryRailView.swift | 6 +- SignalUtilitiesKit/Utilities/UIView+OWS.swift | 39 +- 50 files changed, 2089 insertions(+), 1170 deletions(-) create mode 100644 Session/Media Viewing & Editing/MediaDetailViewController.swift create mode 100644 Session/Media Viewing & Editing/MediaGalleryViewModel.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 32db73521..eb189d562 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -727,6 +727,8 @@ FD09799B27FFC82D00936362 /* Quote.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09799A27FFC82D00936362 /* Quote.swift */; }; FD09C5E2282212B3000CE219 /* JobDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5E1282212B3000CE219 /* JobDependencies.swift */; }; FD09C5E428237209000CE219 /* MessageRequestsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5E328237209000CE219 /* MessageRequestsViewModel.swift */; }; + FD09C5E628260FF9000CE219 /* MediaGalleryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5E528260FF9000CE219 /* MediaGalleryViewModel.swift */; }; + FD09C5E828264937000CE219 /* MediaDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5E728264937000CE219 /* MediaDetailViewController.swift */; }; FD17D79927F40AB800122BE0 /* _003_YDBToGRDBMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79827F40AB800122BE0 /* _003_YDBToGRDBMigration.swift */; }; FD17D79C27F40B2E00122BE0 /* SMKLegacyModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79B27F40B2E00122BE0 /* SMKLegacyModels.swift */; }; FD17D7A027F40CC800122BE0 /* _001_InitialSetupMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79F27F40CC800122BE0 /* _001_InitialSetupMigration.swift */; }; @@ -1782,6 +1784,8 @@ FD09799A27FFC82D00936362 /* Quote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Quote.swift; sourceTree = ""; }; FD09C5E1282212B3000CE219 /* JobDependencies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JobDependencies.swift; sourceTree = ""; }; FD09C5E328237209000CE219 /* MessageRequestsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsViewModel.swift; sourceTree = ""; }; + FD09C5E528260FF9000CE219 /* MediaGalleryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaGalleryViewModel.swift; sourceTree = ""; }; + FD09C5E728264937000CE219 /* MediaDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaDetailViewController.swift; sourceTree = ""; }; FD17D79527F3E04600122BE0 /* _001_InitialSetupMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _001_InitialSetupMigration.swift; sourceTree = ""; }; FD17D79827F40AB800122BE0 /* _003_YDBToGRDBMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _003_YDBToGRDBMigration.swift; sourceTree = ""; }; FD17D79B27F40B2E00122BE0 /* SMKLegacyModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMKLegacyModels.swift; sourceTree = ""; }; @@ -2927,6 +2931,8 @@ 34969559219B605E00DCFE74 /* ImagePickerController.swift */, 45B9EE9A200E91FB005D2F2D /* MediaDetailViewController.h */, 45B9EE9B200E91FB005D2F2D /* MediaDetailViewController.m */, + FD09C5E728264937000CE219 /* MediaDetailViewController.swift */, + FD09C5E528260FF9000CE219 /* MediaGalleryViewModel.swift */, 452EC6DE205E9E30000E787C /* MediaGalleryViewController.swift */, 45F32C1D205718B000A300D5 /* MediaPageViewController.swift */, 454A84032059C787008B8C75 /* MediaTileViewController.swift */, @@ -5067,6 +5073,7 @@ 3496955D219B605E00DCFE74 /* PhotoCollectionPickerController.swift in Sources */, 34B0796D1FCF46B100E248C2 /* MainAppContext.m in Sources */, 34A8B3512190A40E00218A25 /* MediaAlbumView.swift in Sources */, + FD09C5E828264937000CE219 /* MediaDetailViewController.swift in Sources */, 7B7CB18B270591630079FF93 /* ShareLogsModal.swift in Sources */, 4C4AEC4520EC343B0020E72B /* DismissableTextField.swift in Sources */, 3496955E219B605E00DCFE74 /* PhotoLibrary.swift in Sources */, @@ -5140,6 +5147,7 @@ 34BECE2E1F7ABCE000D7438D /* GifPickerViewController.swift in Sources */, B84664F5235022F30083A1CD /* MentionUtilities.swift in Sources */, 34D1F0C01F8EC1760066283D /* MessageRecipientStatusUtils.swift in Sources */, + FD09C5E628260FF9000CE219 /* MediaGalleryViewModel.swift in Sources */, FD859F0027C4691300510D0C /* MockDataGenerator.swift in Sources */, C328250F25CA06020062D0A7 /* VoiceMessageView.swift in Sources */, B82B4090239DD75000A248E7 /* RestoreVC.swift in Sources */, diff --git a/Session/Closed Groups/EditClosedGroupVC.swift b/Session/Closed Groups/EditClosedGroupVC.swift index 3faf66b3a..56b7da6b4 100644 --- a/Session/Closed Groups/EditClosedGroupVC.swift +++ b/Session/Closed Groups/EditClosedGroupVC.swift @@ -1,66 +1,77 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import GRDB import PromiseKit +import SessionUIKit import SessionMessagingKit +import SignalUtilitiesKit @objc(SNEditClosedGroupVC) -final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelegate { - private let thread: TSGroupThread - private var name = "" - private var zombies: Set = [] - private var membersAndZombies: [String] = [] { didSet { handleMembersChanged() } } +final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate { + private struct GroupMemberDisplayInfo: FetchableRecord, Decodable { + let profileId: String + let role: GroupMember.Role + let profile: Profile? + } + + private let threadId: String + private var originalName: String = "" + private var originalMembersAndZombieIds: Set = [] + private var name: String = "" + private var hasContactsToAdd: Bool = false + private var userPublicKey: String = "" + private var membersAndZombies: [GroupMemberDisplayInfo] = [] + private var adminIds: Set = [] private var isEditingGroupName = false { didSet { handleIsEditingGroupNameChanged() } } private var tableViewHeightConstraint: NSLayoutConstraint! - private lazy var groupPublicKey: String = { - let groupID = thread.groupModel.groupId - return LKGroupUtilities.getDecodedGroupID(groupID) - }() + // MARK: - Components - // MARK: Components private lazy var groupNameLabel: UILabel = { - let result = UILabel() + let result: UILabel = UILabel() result.textColor = Colors.text result.font = .boldSystemFont(ofSize: Values.veryLargeFontSize) result.lineBreakMode = .byTruncatingTail result.textAlignment = .center + return result }() private lazy var groupNameTextField: TextField = { - let result = TextField(placeholder: "Enter a group name", usesDefaultHeight: false) + let result: TextField = TextField(placeholder: "Enter a group name", usesDefaultHeight: false) result.textAlignment = .center + return result }() private lazy var addMembersButton: Button = { - let result = Button(style: .prominentOutline, size: .large) + let result: Button = Button(style: .prominentOutline, size: .large) result.setTitle("Add Members", for: UIControl.State.normal) result.addTarget(self, action: #selector(addMembers), for: UIControl.Event.touchUpInside) result.contentEdgeInsets = UIEdgeInsets(top: 0, leading: Values.mediumSpacing, bottom: 0, trailing: Values.mediumSpacing) + return result }() @objc private lazy var tableView: UITableView = { - let result = UITableView() + let result: UITableView = UITableView() result.dataSource = self result.delegate = self - result.register(UserCell.self, forCellReuseIdentifier: "UserCell") result.separatorStyle = .none result.backgroundColor = .clear result.isScrollEnabled = false + result.register(view: UserCell.self) + return result }() - // MARK: Lifecycle - @objc(initWithThreadID:) - init(with threadID: String) { - var thread: TSGroupThread! - Storage.read { transaction in - thread = TSGroupThread.fetch(uniqueId: threadID, transaction: transaction)! - } - self.thread = thread + // MARK: - Lifecycle + + @objc(initWithThreadId:) + init(with threadId: String) { + self.threadId = threadId + super.init(nibName: nil, bundle: nil) } @@ -70,27 +81,61 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega override func viewDidLoad() { super.viewDidLoad() + setUpGradientBackground() setUpNavBarStyle() setNavBarTitle("Edit Group") + let backButton = UIBarButtonItem(title: "Back", style: .plain, target: nil, action: nil) backButton.tintColor = Colors.text navigationItem.backBarButtonItem = backButton - func getDisplayName(for publicKey: String) -> String { - return Profile.displayName(for: publicKey) + + let threadId: String = self.threadId + + GRDBStorage.shared.read { [weak self] db in + self?.userPublicKey = getUserHexEncodedPublicKey(db) + self?.name = try ClosedGroup + .select(.name) + .filter(id: threadId) + .asRequest(of: String.self) + .fetchOne(db) + .defaulting(to: "Group") + self?.originalName = (self?.name ?? "") + + let profileAlias: TypedTableAlias = TypedTableAlias() + let allGroupMembers: [GroupMemberDisplayInfo] = try GroupMember + .filter(GroupMember.Columns.groupId == threadId) + .including(optional: GroupMember.profile.aliased(profileAlias)) + .order( + (GroupMember.Columns.role == GroupMember.Role.zombie), // Non-zombies at the top + profileAlias[.nickname], + profileAlias[.name], + GroupMember.Columns.profileId + ) + .asRequest(of: GroupMemberDisplayInfo.self) + .fetchAll(db) + self?.membersAndZombies = allGroupMembers + .filter { $0.role == .standard || $0.role == .zombie } + self?.adminIds = allGroupMembers + .filter { $0.role == .admin } + .map { $0.profileId } + .asSet() + + let uniqueGroupMemberIds: Set = allGroupMembers + .map { $0.profileId } + .asSet() + self?.originalMembersAndZombieIds = uniqueGroupMemberIds + self?.hasContactsToAdd = ((try Profile.fetchCount(db) - uniqueGroupMemberIds.count) > 0) } + setUpViewHierarchy() - // Always show zombies at the bottom - zombies = Storage.shared.getZombieMembers(for: groupPublicKey) - membersAndZombies = GroupUtilities.getClosedGroupMembers(thread).sorted { getDisplayName(for: $0) < getDisplayName(for: $1) } - + zombies.sorted { getDisplayName(for: $0) < getDisplayName(for: $1) } updateNavigationBarButtons() - name = thread.groupModel.groupName! } private func setUpViewHierarchy() { // Group name container - groupNameLabel.text = thread.groupModel.groupName + groupNameLabel.text = name + let groupNameContainer = UIView() groupNameContainer.addSubview(groupNameLabel) groupNameLabel.pin(to: groupNameContainer) @@ -98,6 +143,7 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega groupNameTextField.pin(to: groupNameContainer) groupNameContainer.set(.height, to: 40) groupNameTextField.alpha = 0 + // Top container let topContainer = UIView() topContainer.addSubview(groupNameContainer) @@ -105,19 +151,21 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega topContainer.set(.height, to: 40) let topContainerTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(showEditGroupNameUI)) topContainer.addGestureRecognizer(topContainerTapGestureRecognizer) + // Members label let membersLabel = UILabel() membersLabel.textColor = Colors.text membersLabel.font = .systemFont(ofSize: Values.mediumFontSize) membersLabel.text = "Members" + // Add members button - let hasContactsToAdd = !Set(Contact.fetchAllIds()).subtracting(self.membersAndZombies).isEmpty - if (!hasContactsToAdd) { + if !self.hasContactsToAdd { addMembersButton.isUserInteractionEnabled = false let disabledColor = Colors.text.withAlphaComponent(Values.mediumOpacity) addMembersButton.layer.borderColor = disabledColor.cgColor addMembersButton.setTitleColor(disabledColor, for: UIControl.State.normal) } + // Middle stack view let middleStackView = UIStackView(arrangedSubviews: [ membersLabel, addMembersButton ]) middleStackView.axis = .horizontal @@ -125,8 +173,10 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega middleStackView.layoutMargins = UIEdgeInsets(top: Values.smallSpacing, leading: Values.mediumSpacing, bottom: Values.smallSpacing, trailing: Values.mediumSpacing) middleStackView.isLayoutMarginsRelativeArrangement = true middleStackView.set(.height, to: Values.largeButtonHeight + Values.smallSpacing * 2) + // Table view tableViewHeightConstraint = tableView.set(.height, to: 0) + // Main stack view let mainStackView = UIStackView(arrangedSubviews: [ UIView.vSpacer(Values.veryLargeSpacing), @@ -140,6 +190,7 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega mainStackView.axis = .vertical mainStackView.alignment = .fill mainStackView.set(.width, to: UIScreen.main.bounds.width) + // Scroll view let scrollView = UIScrollView() scrollView.showsVerticalScrollIndicator = false @@ -155,41 +206,48 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "UserCell") as! UserCell - let publicKey = membersAndZombies[indexPath.row] - cell.publicKey = publicKey - cell.isZombie = zombies.contains(publicKey) - let userPublicKey = getUserHexEncodedPublicKey() - let isCurrentUserAdmin = thread.groupModel.groupAdminIds.contains(userPublicKey) - cell.accessory = !isCurrentUserAdmin ? .lock : .none - cell.update() + let cell: UserCell = tableView.dequeue(type: UserCell.self, for: indexPath) + cell.update( + with: membersAndZombies[indexPath.row].profileId, + profile: membersAndZombies[indexPath.row].profile, + isZombie: (membersAndZombies[indexPath.row].role == .zombie), + accessory: (adminIds.contains(userPublicKey) ? + .none : + .lock + ) + ) + return cell } func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { - let userPublicKey = getUserHexEncodedPublicKey() - return thread.groupModel.groupAdminIds.contains(userPublicKey) + return adminIds.contains(userPublicKey) } func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? { - let publicKey = membersAndZombies[indexPath.row] + let profileId: String = self.membersAndZombies[indexPath.row].profileId + let removeAction = UITableViewRowAction(style: .destructive, title: "Remove") { [weak self] _, _ in - guard let self = self, let index = self.membersAndZombies.firstIndex(of: publicKey) else { return } - self.membersAndZombies.remove(at: index) + self?.adminIds.remove(profileId) + self?.membersAndZombies.remove(at: indexPath.row) } removeAction.backgroundColor = Colors.destructive + return [ removeAction ] } - // MARK: Updating + // MARK: - Updating + private func updateNavigationBarButtons() { if isEditingGroupName { let cancelButton = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(handleCancelGroupNameEditingButtonTapped)) cancelButton.tintColor = Colors.text navigationItem.leftBarButtonItem = cancelButton - } else { + } + else { navigationItem.leftBarButtonItem = nil } + let doneButton = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(handleDoneButtonTapped)) doneButton.tintColor = Colors.text navigationItem.rightBarButtonItem = doneButton @@ -199,21 +257,25 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega tableViewHeightConstraint.constant = CGFloat(membersAndZombies.count) * 67 tableView.reloadData() } - + private func handleIsEditingGroupNameChanged() { updateNavigationBarButtons() + UIView.animate(withDuration: 0.25) { self.groupNameLabel.alpha = self.isEditingGroupName ? 0 : 1 self.groupNameTextField.alpha = self.isEditingGroupName ? 1 : 0 } + if isEditingGroupName { groupNameTextField.becomeFirstResponder() - } else { + } + else { groupNameTextField.resignFirstResponder() } } - // MARK: Interaction + // MARK: - Interaction + @objc private func showEditGroupNameUI() { isEditingGroupName = true } @@ -225,92 +287,159 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega @objc private func handleDoneButtonTapped() { if isEditingGroupName { updateGroupName() - } else { + } + else { commitChanges() } } private func updateGroupName() { - let name = groupNameTextField.text!.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) - guard !name.isEmpty else { - return showError(title: NSLocalizedString("vc_create_closed_group_group_name_missing_error", comment: "")) + let updatedName: String = groupNameTextField.text + .defaulting(to: "") + .trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + + guard !updatedName.isEmpty else { + return showError(title: "vc_create_closed_group_group_name_missing_error".lowercased()) } - guard name.count < 64 else { - return showError(title: NSLocalizedString("vc_create_closed_group_group_name_too_long_error", comment: "")) + guard updatedName.count < 64 else { + return showError(title: "vc_create_closed_group_group_name_too_long_error".localized()) } + isEditingGroupName = false - self.name = name - groupNameLabel.text = name + groupNameLabel.text = updatedName + self.name = updatedName } @objc private func addMembers() { let title = "Add Members" - let userSelectionVC = UserSelectionVC(with: title, excluding: Set(membersAndZombies)) { [weak self] selectedUsers in - guard let self = self else { return } - var members = self.membersAndZombies - members.append(contentsOf: selectedUsers) - func getDisplayName(for publicKey: String) -> String { - return Profile.displayName(for: publicKey) + + let userSelectionVC: UserSelectionVC = UserSelectionVC( + with: title, + excluding: membersAndZombies + .map { $0.profileId } + .asSet() + ) { [weak self] selectedUserIds in + GRDBStorage.shared.read { [weak self] db in + let profileAlias: TypedTableAlias = TypedTableAlias() + let selectedGroupMembers: [GroupMemberDisplayInfo] = try GroupMember + .filter(selectedUserIds.contains(GroupMember.Columns.profileId)) + .including(optional: GroupMember.profile.aliased(profileAlias)) + .asRequest(of: GroupMemberDisplayInfo.self) + .fetchAll(db) + + self?.membersAndZombies = (self?.membersAndZombies ?? []) + .appending(contentsOf: selectedGroupMembers) + .sorted(by: { lhs, rhs in + if lhs.role == .zombie && rhs.role != .zombie { + return false + } + else if lhs.role != .zombie && rhs.role == .zombie { + return true + } + + let lhsDisplayName: String = Profile.displayName( + for: .contact, + id: lhs.profileId, + name: lhs.profile?.name, + nickname: lhs.profile?.nickname + ) + let rhsDisplayName: String = Profile.displayName( + for: .contact, + id: rhs.profileId, + name: rhs.profile?.name, + nickname: rhs.profile?.nickname + ) + + return (lhsDisplayName < rhsDisplayName) + }) + .filter { $0.role == .standard || $0.role == .zombie } + + let uniqueGroupMemberIds: Set = (self?.membersAndZombies ?? []) + .map { $0.profileId } + .asSet() + .inserting(contentsOf: self?.adminIds) + self?.hasContactsToAdd = ((try Profile.fetchCount(db) - uniqueGroupMemberIds.count) > 0) } - self.membersAndZombies = members.sorted { getDisplayName(for: $0) < getDisplayName(for: $1) } - let hasContactsToAdd = !Set(Contact.fetchAllIds()).subtracting(self.membersAndZombies).isEmpty - self.addMembersButton.isUserInteractionEnabled = hasContactsToAdd - let color = hasContactsToAdd ? Colors.accent : Colors.text.withAlphaComponent(Values.mediumOpacity) - self.addMembersButton.layer.borderColor = color.cgColor - self.addMembersButton.setTitleColor(color, for: UIControl.State.normal) + + let color = (self?.hasContactsToAdd == true ? + Colors.accent : + Colors.text.withAlphaComponent(Values.mediumOpacity) + ) + self?.addMembersButton.isUserInteractionEnabled = (self?.hasContactsToAdd == true) + self?.addMembersButton.layer.borderColor = color.cgColor + self?.addMembersButton.setTitleColor(color, for: UIControl.State.normal) } - navigationController!.pushViewController(userSelectionVC, animated: true, completion: nil) + + navigationController?.pushViewController(userSelectionVC, animated: true, completion: nil) } private func commitChanges() { - let popToConversationVC: (EditClosedGroupVC) -> Void = { editVC in - if let conversationVC = editVC.navigationController!.viewControllers.first(where: { $0 is ConversationVC }) { - editVC.navigationController!.popToViewController(conversationVC, animated: true) - } else { - editVC.navigationController!.popViewController(animated: true) + let popToConversationVC: ((EditClosedGroupVC?) -> ()) = { editVC in + guard + let viewControllers: [UIViewController] = editVC?.navigationController?.viewControllers, + let conversationVC: ConversationVC = viewControllers.first(where: { $0 is ConversationVC }) as? ConversationVC + else { + editVC?.navigationController?.popViewController(animated: true) + return } + + editVC?.navigationController?.popToViewController(conversationVC, animated: true) } - let storage = SNMessagingKitConfiguration.shared.storage - let members = Set(self.membersAndZombies) - let name = self.name - let zombies = storage.getZombieMembers(for: groupPublicKey) - guard members != Set(thread.groupModel.groupMemberIds + zombies) || name != thread.groupModel.groupName else { + + let threadId: String = self.threadId + let updatedName: String = self.name + let userPublicKey: String = self.userPublicKey + let updatedMemberIds: Set = self.membersAndZombies + .map { $0.profileId } + .asSet() + + guard updatedMemberIds != self.originalMembersAndZombieIds || updatedName != self.originalName else { return popToConversationVC(self) } - if !members.contains(getUserHexEncodedPublicKey()) { - guard Set(thread.groupModel.groupMemberIds).subtracting([ getUserHexEncodedPublicKey() ]) == members else { - return showError(title: "Couldn't Update Group", message: "Can't leave while adding or removing other members.") + + if !updatedMemberIds.contains(userPublicKey) { + guard self.originalMembersAndZombieIds.removing(userPublicKey) == updatedMemberIds else { + return showError( + title: "Couldn't Update Group", + message: "Can't leave while adding or removing other members." + ) } } - guard members.count <= 100 else { - return showError(title: NSLocalizedString("vc_create_closed_group_too_many_group_members_error", comment: "")) + guard updatedMemberIds.count <= 100 else { + return showError(title: "vc_create_closed_group_too_many_group_members_error".localized()) } - var promise: Promise! - ModalActivityIndicatorViewController.present(fromViewController: navigationController!) { [groupPublicKey, weak self] _ in - Storage.write(with: { transaction in - if !members.contains(getUserHexEncodedPublicKey()) { - promise = MessageSender.leave(groupPublicKey, using: transaction) - } else { - promise = MessageSender.update(groupPublicKey, with: members, name: name, transaction: transaction) + + ModalActivityIndicatorViewController.present(fromViewController: navigationController) { _ in + GRDBStorage.shared + .write { db in + if !updatedMemberIds.contains(userPublicKey) { + return try MessageSender.leave(db, groupPublicKey: threadId) + } + + return try MessageSender.update( + db, + groupPublicKey: threadId, + with: updatedMemberIds, + name: updatedName + ) } - }, completion: { - let _ = promise.done(on: DispatchQueue.main) { - guard let self = self else { return } - self.dismiss(animated: true, completion: nil) // Dismiss the loader + .done(on: DispatchQueue.main) { [weak self] in + self?.dismiss(animated: true, completion: nil) // Dismiss the loader popToConversationVC(self) } - promise.catch(on: DispatchQueue.main) { error in + .catch(on: DispatchQueue.main) { [weak self] error in self?.dismiss(animated: true, completion: nil) // Dismiss the loader self?.showError(title: "Couldn't Update Group", message: error.localizedDescription) } - }) + .retainUntilComplete() } } - // MARK: Convenience + // MARK: - Convenience + private func showError(title: String, message: String = "") { let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) - alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: nil)) + alert.addAction(UIAlertAction(title: "BUTTON_OK".localized(), style: .default, handler: nil)) presentAlert(alert) } } diff --git a/Session/Closed Groups/NewClosedGroupVC.swift b/Session/Closed Groups/NewClosedGroupVC.swift index 9f075b825..3e9b60e43 100644 --- a/Session/Closed Groups/NewClosedGroupVC.swift +++ b/Session/Closed Groups/NewClosedGroupVC.swift @@ -1,15 +1,16 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import GRDB import PromiseKit +import SessionUIKit import SessionMessagingKit private protocol TableViewTouchDelegate { - func tableViewWasTouched(_ tableView: TableView) } -private final class TableView : UITableView { +private final class TableView: UITableView { var touchDelegate: TableViewTouchDelegate? override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { @@ -18,107 +19,127 @@ private final class TableView : UITableView { } } -final class NewClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelegate, TableViewTouchDelegate, UITextFieldDelegate, UIScrollViewDelegate { - private let contacts = Contact.fetchAllIds() +final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate, TableViewTouchDelegate, UITextFieldDelegate, UIScrollViewDelegate { + private let contactProfiles: [Profile] = Profile.fetchAllContactProfiles(excludeCurrentUser: true) private var selectedContacts: Set = [] - // MARK: Components - private lazy var nameTextField = TextField(placeholder: NSLocalizedString("vc_create_closed_group_text_field_hint", comment: "")) + // MARK: - Components + + private lazy var nameTextField = TextField(placeholder: "vc_create_closed_group_text_field_hint".localized()) private lazy var tableView: TableView = { - let result = TableView() + let result: TableView = TableView() result.dataSource = self result.delegate = self result.touchDelegate = self - result.register(UserCell.self, forCellReuseIdentifier: "UserCell") result.separatorStyle = .none result.backgroundColor = .clear result.isScrollEnabled = false + result.register(view: UserCell.self) + return result }() - // MARK: Lifecycle + // MARK: - Lifecycle + override func viewDidLoad() { super.viewDidLoad() + setUpGradientBackground() setUpNavBarStyle() + let customTitleFontSize = Values.largeFontSize - setNavBarTitle(NSLocalizedString("vc_create_closed_group_title", comment: ""), customFontSize: customTitleFontSize) + setNavBarTitle("vc_create_closed_group_title".localized(), customFontSize: customTitleFontSize) + // Set up navigation bar buttons let closeButton = UIBarButtonItem(image: #imageLiteral(resourceName: "X"), style: .plain, target: self, action: #selector(close)) closeButton.tintColor = Colors.text navigationItem.leftBarButtonItem = closeButton + let doneButton = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(createClosedGroup)) doneButton.tintColor = Colors.text navigationItem.rightBarButtonItem = doneButton + // Set up content setUpViewHierarchy() } private func setUpViewHierarchy() { - if !contacts.isEmpty { - let mainStackView = UIStackView() - mainStackView.axis = .vertical - nameTextField.delegate = self - let nameTextFieldContainer = UIView() - nameTextFieldContainer.addSubview(nameTextField) - nameTextField.pin(.leading, to: .leading, of: nameTextFieldContainer, withInset: Values.largeSpacing) - nameTextField.pin(.top, to: .top, of: nameTextFieldContainer, withInset: Values.mediumSpacing) - nameTextFieldContainer.pin(.trailing, to: .trailing, of: nameTextField, withInset: Values.largeSpacing) - nameTextFieldContainer.pin(.bottom, to: .bottom, of: nameTextField, withInset: Values.largeSpacing) - mainStackView.addArrangedSubview(nameTextFieldContainer) - let separator = UIView() - separator.backgroundColor = Colors.separator - separator.set(.height, to: Values.separatorThickness) - mainStackView.addArrangedSubview(separator) - tableView.set(.height, to: CGFloat(contacts.count * 65)) // A cell is exactly 65 points high - tableView.set(.width, to: UIScreen.main.bounds.width) - mainStackView.addArrangedSubview(tableView) - let scrollView = UIScrollView(wrapping: mainStackView, withInsets: UIEdgeInsets.zero) - scrollView.showsVerticalScrollIndicator = false - scrollView.delegate = self - view.addSubview(scrollView) - scrollView.set(.width, to: UIScreen.main.bounds.width) - scrollView.pin(to: view) - } else { - let explanationLabel = UILabel() + guard !contactProfiles.isEmpty else { + let explanationLabel: UILabel = UILabel() explanationLabel.textColor = Colors.text explanationLabel.font = .systemFont(ofSize: Values.smallFontSize) explanationLabel.numberOfLines = 0 explanationLabel.lineBreakMode = .byWordWrapping explanationLabel.textAlignment = .center explanationLabel.text = NSLocalizedString("vc_create_closed_group_empty_state_message", comment: "") - let createNewPrivateChatButton = Button(style: .prominentOutline, size: .large) + + let createNewPrivateChatButton: Button = Button(style: .prominentOutline, size: .large) createNewPrivateChatButton.setTitle(NSLocalizedString("vc_create_closed_group_empty_state_button_title", comment: ""), for: UIControl.State.normal) createNewPrivateChatButton.addTarget(self, action: #selector(createNewDM), for: UIControl.Event.touchUpInside) createNewPrivateChatButton.set(.width, to: 196) - let stackView = UIStackView(arrangedSubviews: [ explanationLabel, createNewPrivateChatButton ]) + + let stackView: UIStackView = UIStackView(arrangedSubviews: [ explanationLabel, createNewPrivateChatButton ]) stackView.axis = .vertical stackView.spacing = Values.mediumSpacing stackView.alignment = .center view.addSubview(stackView) stackView.center(.horizontal, in: view) + let verticalCenteringConstraint = stackView.center(.vertical, in: view) verticalCenteringConstraint.constant = -16 // Makes things appear centered visually + return } + + let mainStackView: UIStackView = UIStackView() + mainStackView.axis = .vertical + nameTextField.delegate = self + + let nameTextFieldContainer: UIView = UIView() + nameTextFieldContainer.addSubview(nameTextField) + nameTextField.pin(.leading, to: .leading, of: nameTextFieldContainer, withInset: Values.largeSpacing) + nameTextField.pin(.top, to: .top, of: nameTextFieldContainer, withInset: Values.mediumSpacing) + nameTextFieldContainer.pin(.trailing, to: .trailing, of: nameTextField, withInset: Values.largeSpacing) + nameTextFieldContainer.pin(.bottom, to: .bottom, of: nameTextField, withInset: Values.largeSpacing) + mainStackView.addArrangedSubview(nameTextFieldContainer) + + let separator: UIView = UIView() + separator.backgroundColor = Colors.separator + separator.set(.height, to: Values.separatorThickness) + mainStackView.addArrangedSubview(separator) + tableView.set(.height, to: CGFloat(contactProfiles.count * 65)) // A cell is exactly 65 points high + tableView.set(.width, to: UIScreen.main.bounds.width) + mainStackView.addArrangedSubview(tableView) + + let scrollView: UIScrollView = UIScrollView(wrapping: mainStackView, withInsets: UIEdgeInsets.zero) + scrollView.showsVerticalScrollIndicator = false + scrollView.delegate = self + view.addSubview(scrollView) + + scrollView.set(.width, to: UIScreen.main.bounds.width) + scrollView.pin(to: view) } - // MARK: Table View Data Source + // MARK: - Table View Data Source + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return contacts.count + return contactProfiles.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "UserCell") as! UserCell - let publicKey = contacts[indexPath.row] - cell.publicKey = publicKey - let isSelected = selectedContacts.contains(publicKey) - cell.accessory = .tick(isSelected: isSelected) - cell.update() + let cell: UserCell = tableView.dequeue(type: UserCell.self, for: indexPath) + cell.update( + with: contactProfiles[indexPath.row].id, + profile: contactProfiles[indexPath.row], + isZombie: false, + accessory: .tick(isSelected: selectedContacts.contains(contactProfiles[indexPath.row].id)) + ) + return cell } - // MARK: Interaction + // MARK: - Interaction + func textFieldDidEndEditing(_ textField: UITextField) { crossfadeLabel.text = textField.text!.isEmpty ? NSLocalizedString("vc_create_closed_group_title", comment: "") : textField.text! } @@ -139,13 +160,15 @@ final class NewClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelegat } func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - let publicKey = contacts[indexPath.row] - if !selectedContacts.contains(publicKey) { selectedContacts.insert(publicKey) } else { selectedContacts.remove(publicKey) } - guard let cell = tableView.cellForRow(at: indexPath) as? UserCell else { return } - let isSelected = selectedContacts.contains(publicKey) - cell.accessory = .tick(isSelected: isSelected) - cell.update() + if !selectedContacts.contains(contactProfiles[indexPath.row].id) { + selectedContacts.insert(contactProfiles[indexPath.row].id) + } + else { + selectedContacts.remove(contactProfiles[indexPath.row].id) + } + tableView.deselectRow(at: indexPath, animated: true) + tableView.reloadRows(at: [indexPath], with: .none) } @objc private func close() { diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index c97206312..14b5b5c9c 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -9,41 +9,46 @@ import GRDB import SessionUtilitiesKit import SignalUtilitiesKit -extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuActionDelegate, ScrollToBottomButtonDelegate, - SendMediaNavDelegate, UIDocumentPickerDelegate, AttachmentApprovalViewControllerDelegate, GifPickerViewControllerDelegate, - ConversationTitleViewDelegate { +extension ConversationVC: + InputViewDelegate, + MessageCellDelegate, + ScrollToBottomButtonDelegate, + SendMediaNavDelegate, + UIDocumentPickerDelegate, + AttachmentApprovalViewControllerDelegate, + GifPickerViewControllerDelegate +{ + @objc func handleTitleViewTapped() { + // Don't take the user to settings for unapproved threads + guard !viewModel.viewData.requiresApproval else { return } - func handleTitleViewTapped() { - // Don't take the user to settings for message requests - guard - let contactThread: TSContactThread = thread as? TSContactThread, - let contact: Contact = GRDBStorage.shared.read({ db in try Contact.fetchOne(db, id: contactThread.contactSessionID()) }), - contact.isApproved, - contact.didApproveMe - else { - return - } - openSettings() } - + @objc func openSettings() { - let settingsVC = OWSConversationSettingsViewController() - settingsVC.configure(with: thread, uiDatabaseConnection: OWSPrimaryStorage.shared().uiDatabaseConnection) + let settingsVC: OWSConversationSettingsViewController = OWSConversationSettingsViewController() + settingsVC.configure( + withThreadId: viewModel.viewData.thread.id, + threadName: viewModel.viewData.threadName, + isClosedGroup: (viewModel.viewData.thread.variant == .closedGroup), + isOpenGroup: (viewModel.viewData.thread.variant == .openGroup), + isNoteToSelf: viewModel.viewData.threadIsNoteToSelf + ) settingsVC.conversationSettingsViewDelegate = self - navigationController!.pushViewController(settingsVC, animated: true, completion: nil) + navigationController?.pushViewController(settingsVC, animated: true, completion: nil) } + + // MARK: - ScrollToBottomButtonDelegate func handleScrollToBottomButtonTapped() { // The table view's content size is calculated by the estimated height of cells, // so the result may be inaccurate before all the cells are loaded. Use this // to scroll to the last row instead. - let indexPath = IndexPath(row: viewItems.count - 1, section: 0) - unreadViewItems.removeAll() - messagesTableView.scrollToRow(at: indexPath, at: .top, animated: true) + scrollToBottom(isAnimated: true) } - // MARK: Blocking + // MARK: - Blocking + @objc func unblock() { guard let thread = thread as? TSContactThread else { return } let publicKey = thread.contactSessionID() @@ -75,26 +80,18 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc } func showBlockedModalIfNeeded() -> Bool { - guard let thread = thread as? TSContactThread, thread.isBlocked() else { return false } + guard viewModel.viewData.threadIsBlocked else { return false } - let blockedModal = BlockedModal(publicKey: thread.contactSessionID()) + let blockedModal = BlockedModal(publicKey: viewModel.viewData.thread.id) blockedModal.modalPresentationStyle = .overFullScreen blockedModal.modalTransitionStyle = .crossDissolve present(blockedModal, animated: true, completion: nil) + return true } - // MARK: Attachments - func didPasteImageFromPasteboard(_ image: UIImage) { - guard let imageData = image.jpegData(compressionQuality: 1.0) else { return } - let dataSource = DataSourceValue.dataSource(with: imageData, utiType: kUTTypeJPEG as String) - let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: kUTTypeJPEG as String, imageQuality: .medium) - - let approvalVC = AttachmentApprovalViewController.wrappedInNavController(attachments: [ attachment ], approvalDelegate: self) - approvalVC.modalPresentationStyle = .fullScreen - self.present(approvalVC, animated: true, completion: nil) - } - + // MARK: - SendMediaNavDelegate + func sendMediaNavDidCancel(_ sendMediaNavigationController: SendMediaNavigationController) { dismiss(animated: true, completion: nil) } @@ -111,14 +108,16 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc } func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didChangeMessageText newMessageText: String?) { - snInputView.text = newMessageText ?? "" + snInputView.text = (newMessageText ?? "") } + // MARK: - AttachmentApprovalViewControllerDelegate + func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didApproveAttachments attachments: [SignalAttachment], messageText: String?) { sendAttachments(attachments, with: messageText ?? "") { [weak self] in self?.dismiss(animated: true, completion: nil) } - + scrollToBottom(isAnimated: false) resetMentions() self.snInputView.text = "" @@ -142,6 +141,25 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc sendMediaNavController.sendMediaNavDelegate = self sendMediaNavController.modalPresentationStyle = .fullScreen present(sendMediaNavController, animated: true, completion: nil) + // MARK: - ExpandingAttachmentsButtonDelegate + + func handleGIFButtonTapped() { + let gifVC = GifPickerViewController() + gifVC.delegate = self + + let navController = OWSNavigationController(rootViewController: gifVC) + navController.modalPresentationStyle = .fullScreen + present(navController, animated: true) { } + } + + func handleDocumentButtonTapped() { + // UIDocumentPickerModeImport copies to a temp file within our container. + // It uses more memory than "open" but lets us avoid working with security scoped URLs. + let documentPickerVC = UIDocumentPickerViewController(documentTypes: [ kUTTypeItem as String ], in: UIDocumentPickerMode.import) + documentPickerVC.delegate = self + documentPickerVC.modalPresentationStyle = .fullScreen + SNAppearance.switchToDocumentPickerAppearance() + present(documentPickerVC, animated: true, completion: nil) } func handleLibraryButtonTapped() { @@ -155,14 +173,20 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc } } - func handleGIFButtonTapped() { - let gifVC = GifPickerViewController(thread: thread) - gifVC.delegate = self - let navController = OWSNavigationController(rootViewController: gifVC) - navController.modalPresentationStyle = .fullScreen - present(navController, animated: true) { } + func handleCameraButtonTapped() { + guard requestCameraPermissionIfNeeded() else { return } + requestMicrophonePermissionIfNeeded { } + if AVAudioSession.sharedInstance().recordPermission != .granted { + SNLog("Proceeding without microphone access. Any recorded video will be silent.") + } + let sendMediaNavController = SendMediaNavigationController.showingCameraFirst() + sendMediaNavController.sendMediaNavDelegate = self + sendMediaNavController.modalPresentationStyle = .fullScreen + present(sendMediaNavController, animated: true, completion: nil) } - + + // MARK: - GifPickerViewControllerDelegate + func gifPickerDidSelect(attachment: SignalAttachment) { showAttachmentApprovalDialog(for: [ attachment ]) } @@ -176,6 +200,7 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc SNAppearance.switchToDocumentPickerAppearance() present(documentPickerVC, animated: true, completion: nil) } + // MARK: - UIDocumentPickerDelegate func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { SNAppearance.switchToSessionAppearance() // Switch back to the correct appearance @@ -242,21 +267,24 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc }.retainUntilComplete() } } + + // MARK: - InputViewDelegate - // MARK: Message Sending + // MARK: --Message Sending func handleSendButtonTapped() { sendMessage() } func sendMessage(hasPermissionToSendSeed: Bool = false) { guard !showBlockedModalIfNeeded() else { return } - + let text = replaceMentions(in: snInputView.text.trimmingCharacters(in: .whitespacesAndNewlines)) - let thread = self.thread guard !text.isEmpty else { return } - - if text.contains(mnemonic) && !thread.isNoteToSelf() && !hasPermissionToSendSeed { + + let isNoteToSelf: Bool = GRDBStorage.shared.read { db in viewModel.viewData.thread.isNoteToSelf(db) } + .defaulting(to: false) + if text.contains(mnemonic) && !isNoteToSelf && !hasPermissionToSendSeed { // Warn the user if they're about to send their seed to someone let modal = SendSeedModal() modal.modalPresentationStyle = .overFullScreen @@ -264,118 +292,166 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc modal.proceed = { self.sendMessage(hasPermissionToSendSeed: true) } return present(modal, animated: true, completion: nil) } - - let sentTimestamp: UInt64 = NSDate.millisecondTimestamp() - let message: VisibleMessage = VisibleMessage() - message.sentTimestamp = sentTimestamp - message.text = text - message.quote = VisibleMessage.Quote.from(snInputView.quoteDraftInfo?.model) - + // 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 thread: SessionThread = viewModel.viewData.thread let oldThreadShouldBeVisible: Bool = thread.shouldBeVisible - let linkPreviewDraft = snInputView.linkPreviewInfo?.draft - let tsMessage = TSOutgoingMessage.from(message, associatedWith: thread) + let sentTimestampMs: Int64 = Int64(floor((Date().timeIntervalSince1970 * 1000))) + let linkPreviewDraft: OWSLinkPreviewDraft? = snInputView.linkPreviewInfo?.draft + let quoteModel: QuotedReplyModel? = snInputView.quoteDraftInfo?.model - let promise: Promise = self.approveMessageRequestIfNeeded( for: self.thread, + approveMessageRequestIfNeeded( + for: thread, isNewThread: !oldThreadShouldBeVisible, - timestamp: (sentTimestamp - 1) // Set 1ms earlier as this is used for sorting + timestampMs: (sentTimestampMs - 1) // Set 1ms earlier as this is used for sorting ) - .map { [weak self] _ in - self?.viewModel.appendUnsavedOutgoingTextMessage(tsMessage) - - Storage.write(with: { transaction in - message.linkPreview = VisibleMessage.LinkPreview.from(linkPreviewDraft, using: transaction) - }, completion: { [weak self] in - tsMessage.linkPreview = OWSLinkPreview.from(message.linkPreview) - - Storage.shared.write( - with: { transaction in - tsMessage.save(with: transaction as! YapDatabaseReadWriteTransaction) - }, - completion: { [weak self] in - // At this point the TSOutgoingMessage should have its link preview set, so we can scroll to the bottom knowing - // the height of the new message cell - self?.scrollToBottom(isAnimated: false) + .done { [weak self] _ in + GRDBStorage.shared.writeAsync( + updates: { db in + // Update the thread to be visible + _ = try SessionThread + .filter(id: thread.id) + .updateAll(db, SessionThread.Columns.shouldBeVisible.set(to: true)) + + // Create the interaction + let interaction: Interaction = try Interaction( + threadId: thread.id, + authorId: getUserHexEncodedPublicKey(db), + variant: .standardOutgoing, + body: text, + timestampMs: sentTimestampMs, + linkPreviewUrl: linkPreviewDraft?.urlString + ).inserted(db) + + // If there is a LinkPreview add it now + if let linkPreviewDraft: OWSLinkPreviewDraft = linkPreviewDraft { + var attachmentId: String? + + // If the LinkPreview has image data then create an attachment first + if let imageData: Data = linkPreviewDraft.jpegImageData { + attachmentId = try LinkPreview.saveAttachmentIfPossible( + db, + imageData: imageData, + mimeType: OWSMimeTypeImageJpeg + ) + } + + try LinkPreview( + url: linkPreviewDraft.urlString, + title: linkPreviewDraft.title, + attachmentId: attachmentId + ).insert(db) + } + + guard let interactionId: Int64 = interaction.id else { return } + + // If there is a Quote the insert it now + if let quoteModel: QuotedReplyModel = quoteModel { + try Quote( + interactionId: interactionId, + authorId: quoteModel.authorId, + timestampMs: quoteModel.timestampMs, + body: quoteModel.body, + attachmentId: quoteModel.attachment?.id + ).insert(db) + } + + try MessageSender.send( + db, + interaction: interaction, + with: [], + in: thread + ) + }, + completion: { [weak self] _, _ in + // At this point the Interaction should have its link preview set, so we can + // scroll to the bottom knowing the height of the new message cell + DispatchQueue.main.async { + self?.scrollToBottom(isAnimated: false) + self?.handleMessageSent() } - ) - - Storage.shared.write { transaction in - MessageSender.send(message, with: [], in: thread, using: transaction as! YapDatabaseReadWriteTransaction) } - - self?.handleMessageSent() - }) + ) } - - // Show an error indicating that approving the thread failed - promise.catch(on: DispatchQueue.main) { [weak self] _ in + .catch(on: DispatchQueue.main) { [weak self] _ in + // Show an error indicating that approving the thread failed let alert = UIAlertController(title: "Session", message: "An error occurred when trying to accept this message request", preferredStyle: .alert) alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)) self?.present(alert, animated: true, completion: nil) } - - promise.retainUntilComplete() + .retainUntilComplete() } func sendAttachments(_ attachments: [SignalAttachment], with text: String, onComplete: (() -> ())? = nil) { guard !showBlockedModalIfNeeded() else { return } - + for attachment in attachments { if attachment.hasError { return showErrorAlert(for: attachment, onDismiss: onComplete) } } - let thread = self.thread - let sentTimestamp: UInt64 = NSDate.millisecondTimestamp() - let message = VisibleMessage() - message.sentTimestamp = sentTimestamp - message.text = replaceMentions(in: text) - + let text = replaceMentions(in: snInputView.text.trimmingCharacters(in: .whitespacesAndNewlines)) + // 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 thread: SessionThread = viewModel.viewData.thread let oldThreadShouldBeVisible: Bool = thread.shouldBeVisible - let tsMessage = TSOutgoingMessage.from(message, associatedWith: thread) - - let promise: Promise = self.approveMessageRequestIfNeeded( - for: self.thread, + let sentTimestampMs: Int64 = Int64(floor((Date().timeIntervalSince1970 * 1000))) + + approveMessageRequestIfNeeded( + for: thread, isNewThread: !oldThreadShouldBeVisible, - timestamp: (sentTimestamp - 1) // Set 1ms earlier as this is used for sorting + timestampMs: (sentTimestampMs - 1) // Set 1ms earlier as this is used for sorting ) - .map { [weak self] _ in - Storage.write( - with: { transaction in - tsMessage.save(with: transaction) - // The new message cell is inserted at this point, but the TSOutgoingMessage doesn't have its attachment yet + .done { [weak self] _ in + GRDBStorage.shared.writeAsync( + updates: { db in + // Update the thread to be visible + _ = try SessionThread + .filter(id: thread.id) + .updateAll(db, SessionThread.Columns.shouldBeVisible.set(to: true)) + + // Create the interaction + let interaction: Interaction = try Interaction( + threadId: thread.id, + authorId: getUserHexEncodedPublicKey(db), + variant: .standardOutgoing, + body: text, + timestampMs: sentTimestampMs + ).inserted(db) + + try MessageSender.send( + db, + interaction: interaction, + with: attachments, + in: thread + ) }, - completion: { [weak self] in - Storage.write(with: { transaction in - MessageSender.send(message, with: attachments, in: thread, using: transaction) - }, completion: { [weak self] in - // At this point the TSOutgoingMessage should have its attachments set, so we can scroll to the bottom knowing - // the height of the new message cell + completion: { [weak self] _, _ in + // At this point the Interaction should have its link preview set, so we can + // scroll to the bottom knowing the height of the new message cell + DispatchQueue.main.async { self?.scrollToBottom(isAnimated: false) - }) - self?.handleMessageSent() - - // Attachment successfully sent - dismiss the screen - onComplete?() + self?.handleMessageSent() + + // Attachment successfully sent - dismiss the screen + onComplete?() + } } ) } - - // Show an error indicating that approving the thread failed - promise.catch(on: DispatchQueue.main) { [weak self] _ in + .catch(on: DispatchQueue.main) { [weak self] _ in + // Show an error indicating that approving the thread failed let alert = UIAlertController(title: "Session", message: "An error occurred when trying to accept this message request", preferredStyle: .alert) alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)) self?.present(alert, animated: true, completion: nil) } - - promise.retainUntilComplete() + .retainUntilComplete() } func handleMessageSent() { @@ -399,7 +475,7 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc self.markAllAsRead() if Environment.shared.preferences.soundInForeground() { - let soundID = OWSSounds.systemSoundID(for: .messageSent, quiet: true) + let soundID = Preferences.Sound.systemSoundId(for: .messageSent, quiet: true) AudioServicesPlaySystemSound(soundID) } SSKEnvironment.shared.typingIndicators.didSendOutgoingMessage(inThread: thread) @@ -467,6 +543,13 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc currentMentionStartIndex = nil mentions = [] } + + // MARK: --Attachments + + func didPasteImageFromPasteboard(_ image: UIImage) { + guard let imageData = image.jpegData(compressionQuality: 1.0) else { return } + let dataSource = DataSourceValue.dataSource(with: imageData, utiType: kUTTypeJPEG as String) + let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: kUTTypeJPEG as String, imageQuality: .medium) func replaceMentions(in text: String) -> String { var result = text @@ -475,8 +558,13 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc result = result.replacingCharacters(in: range, with: "@\(mention.publicKey)") } return result + let approvalVC = AttachmentApprovalViewController.wrappedInNavController(attachments: [ attachment ], approvalDelegate: self) + approvalVC.modalPresentationStyle = .fullScreen + self.present(approvalVC, animated: true, completion: nil) } + // MARK: --Mentions + func handleMentionSelected(_ mention: Mention, from view: MentionSelectionView) { guard let currentMentionStartIndex = currentMentionStartIndex else { return } mentions.append(mention) diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index b627e4a40..1725b6cb7 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -1,39 +1,48 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import GRDB +import DifferenceKit import SessionUIKit import SessionMessagingKit import SessionUtilitiesKit +import SignalUtilitiesKit // TODO: // • Slight paging glitch when scrolling up and loading more content // • Photo rounding (the small corners don't have the correct rounding) // • Remaining search glitchiness -final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversationSettingsViewDelegate, ConversationSearchControllerDelegate, UITableViewDataSource, UITableViewDelegate { - let thread: TSThread - let threadStartedAsMessageRequest: Bool - let focusedMessageID: String? // This is used for global search +final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, ConversationSearchControllerDelegate, UITableViewDataSource, UITableViewDelegate { + internal let viewModel: ConversationViewModel + private var dataChangeObservable: DatabaseCancellable? + private var hasLoadedInitialData: Bool = false + var focusedMessageIndexPath: IndexPath? var initialUnreadCount: UInt = 0 var unreadViewItems: [ConversationViewItem] = [] var scrollButtonBottomConstraint: NSLayoutConstraint? var scrollButtonMessageRequestsBottomConstraint: NSLayoutConstraint? var messageRequestsViewBotomConstraint: NSLayoutConstraint? + // Search var isShowingSearchUI = false var lastSearchedText: String? + // Audio playback & recording var audioPlayer: OWSAudioPlayer? var audioRecorder: AVAudioRecorder? var audioTimer: Timer? + // Context menu var contextMenuWindow: ContextMenuWindow? var contextMenuVC: ContextMenuVC? + // Mentions var oldText = "" var currentMentionStartIndex: String.Index? var mentions: [Mention] = [] + // Scrolling & paging var isUserScrolling = false var didFinishInitialLayout = false @@ -42,46 +51,44 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat var baselineKeyboardHeight: CGFloat = 0 var audioSession: OWSAudioSession { Environment.shared.audioSession } - var dbConnection: YapDatabaseConnection { OWSPrimaryStorage.shared().uiDatabaseConnection } - var viewItems: [ConversationViewItem] { viewModel.viewState.viewItems } override var canBecomeFirstResponder: Bool { true } - + override var inputAccessoryView: UIView? { - if let thread = thread as? TSGroupThread, thread.groupModel.groupType == .closedGroup && !thread.isCurrentUserMemberInGroup() { - return nil - } else { - return isShowingSearchUI ? searchController.resultsBar : snInputView - } + guard + viewModel.viewData.thread.variant != .closedGroup || + viewModel.viewData.isClosedGroupMember + else { return nil } + + return (isShowingSearchUI ? searchController.resultsBar : snInputView) } /// The height of the visible part of the table view, i.e. the distance from the navigation bar (where the table view's origin is) - /// to the top of the input view (`messagesTableView.adjustedContentInset.bottom`). + /// to the top of the input view (`tableView.adjustedContentInset.bottom`). var tableViewUnobscuredHeight: CGFloat { - let bottomInset = messagesTableView.adjustedContentInset.bottom - return messagesTableView.bounds.height - bottomInset + let bottomInset = tableView.adjustedContentInset.bottom + return tableView.bounds.height - bottomInset } /// The offset at which the table view is exactly scrolled to the bottom. var lastPageTop: CGFloat { - return messagesTableView.contentSize.height - tableViewUnobscuredHeight + return tableView.contentSize.height - tableViewUnobscuredHeight } - + var isCloseToBottom: Bool { - let margin = (self.lastPageTop - self.messagesTableView.contentOffset.y) + let margin = (self.lastPageTop - self.tableView.contentOffset.y) return margin <= ConversationVC.scrollToBottomMargin } - + lazy var mnemonic: String = { if let hexEncodedSeed: String = Identity.fetchHexEncodedSeed() { return Mnemonic.encode(hexEncodedString: hexEncodedSeed) } - + // Legacy account return Mnemonic.encode(hexEncodedString: Identity.fetchUserPrivateKey()!.toHexString()) }() - - lazy var viewModel = ConversationViewModel(thread: thread, focusMessageIdOnOpen: nil, delegate: self) - + + // FIXME: Would be good to create a Swift-based cache and replace this lazy var mediaCache: NSCache = { let result = NSCache() result.countLimit = 40 @@ -89,79 +96,88 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat }() lazy var recordVoiceMessageActivity = AudioActivity(audioDescription: "Voice message", behavior: .playAndRecord) - + lazy var searchController: ConversationSearchController = { - let result = ConversationSearchController(thread: thread) - result.delegate = self - if #available(iOS 13, *) { - result.uiSearchController.obscuresBackgroundDuringPresentation = false - } else { - result.uiSearchController.dimsBackgroundDuringPresentation = false - } - return result - }() - - // MARK: - UI - - private static let messageRequestButtonHeight: CGFloat = 34 - - lazy var titleView: ConversationTitleView = { - let result = ConversationTitleView(thread: thread) + let result: ConversationSearchController = ConversationSearchController() + result.uiSearchController.obscuresBackgroundDuringPresentation = false result.delegate = self + return result }() - lazy var messagesTableView: MessagesTableView = { - let result: MessagesTableView = MessagesTableView() - result.dataSource = self - result.delegate = self + // MARK: - UI + + private static let messageRequestButtonHeight: CGFloat = 34 + + lazy var titleView: ConversationTitleView = { + let result: ConversationTitleView = ConversationTitleView() + let tapGestureRecognizer = UITapGestureRecognizer( + target: self, + action: #selector(handleTitleViewTapped) + ) + result.addGestureRecognizer(tapGestureRecognizer) + + return result + }() + + lazy var tableView: UITableView = { + let result: UITableView = UITableView() + result.separatorStyle = .none + result.backgroundColor = .clear + result.showsVerticalScrollIndicator = false result.contentInsetAdjustmentBehavior = .never + result.keyboardDismissMode = .interactive result.contentInset = UIEdgeInsets( top: 0, leading: 0, bottom: Values.mediumSpacing, trailing: 0 ) + result.register(view: VisibleMessageCell.self) + result.register(view: InfoMessageCell.self) + result.register(view: TypingIndicatorCell.self) + result.dataSource = self + result.delegate = self + + return result + }() + + lazy var snInputView: InputView = InputView( + threadVariant: viewModel.viewData.thread.variant, + delegate: self + ) + + lazy var unreadCountView: UIView = { + let result: UIView = UIView() + result.backgroundColor = Colors.text.withAlphaComponent(Values.veryLowOpacity) + result.set(.width, greaterThanOrEqualTo: ConversationVC.unreadCountViewSize) + result.set(.height, to: ConversationVC.unreadCountViewSize) + result.layer.masksToBounds = true + result.layer.cornerRadius = (ConversationVC.unreadCountViewSize / 2) return result }() - - lazy var snInputView: InputView = InputView(delegate: self) - - lazy var unreadCountView: UIView = { - let result = UIView() - result.backgroundColor = Colors.text.withAlphaComponent(Values.veryLowOpacity) - let size = ConversationVC.unreadCountViewSize - result.set(.width, greaterThanOrEqualTo: size) - result.set(.height, to: size) - result.layer.masksToBounds = true - result.layer.cornerRadius = size / 2 - return result - }() - + lazy var unreadCountLabel: UILabel = { - let result = UILabel() + let result: UILabel = UILabel() result.font = .boldSystemFont(ofSize: Values.verySmallFontSize) result.textColor = Colors.text result.textAlignment = .center + return result }() - + lazy var blockedBanner: InfoBanner = { - let name: String - if let thread = thread as? TSContactThread { - name = Profile.displayName(for: thread.contactSessionID(), thread: thread) - } - else { - name = "Thread" - } - let message = "\(name) is blocked. Unblock them?" - let result = InfoBanner(message: message, backgroundColor: Colors.destructive) + let result: InfoBanner = InfoBanner( + message: viewModel.blockedBannerMessage, + backgroundColor: Colors.destructive + ) let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(unblock)) result.addGestureRecognizer(tapGestureRecognizer) + return result }() - + lazy var footerControlsStackView: UIStackView = { let result: UIStackView = UIStackView() result.translatesAutoresizingMaskIntoConstraints = false @@ -171,21 +187,21 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat result.spacing = 10 result.layoutMargins = UIEdgeInsets(top: 0, left: 20, bottom: 0, right: 20) result.isLayoutMarginsRelativeArrangement = true - + return result }() - - lazy var scrollButton = ScrollToBottomButton(delegate: self) - + + lazy var scrollButton: ScrollToBottomButton = ScrollToBottomButton(delegate: self) + lazy var messageRequestView: UIView = { let result: UIView = UIView() result.translatesAutoresizingMaskIntoConstraints = false - result.isHidden = !thread.isMessageRequest() + result.isHidden = !viewModel.viewData.threadIsMessageRequest result.setGradient(Gradients.defaultBackground) - + return result }() - + private let messageRequestDescriptionLabel: UILabel = { let result: UILabel = UILabel() result.translatesAutoresizingMaskIntoConstraints = false @@ -194,10 +210,10 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat result.textColor = Colors.sessionMessageRequestsInfoText result.textAlignment = .center result.numberOfLines = 2 - + return result }() - + private let messageRequestAcceptButton: UIButton = { let result: UIButton = UIButton() result.translatesAutoresizingMaskIntoConstraints = false @@ -220,15 +236,15 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat with: UITraitCollection(userInterfaceStyle: isDarkMode ? .dark : .light) ).cgColor } - + return Colors.sessionHeading.cgColor }() result.layer.borderWidth = 1 result.addTarget(self, action: #selector(acceptMessageRequest), for: .touchUpInside) - + return result }() - + private let messageRequestDeleteButton: UIButton = { let result: UIButton = UIButton() result.translatesAutoresizingMaskIntoConstraints = false @@ -251,16 +267,17 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat with: UITraitCollection(userInterfaceStyle: isDarkMode ? .dark : .light) ).cgColor } - + return Colors.destructive.cgColor }() result.layer.borderWidth = 1 result.addTarget(self, action: #selector(deleteMessageRequest), for: .touchUpInside) - + return result }() + + // MARK: - Settings - // MARK: Settings static let unreadCountViewSize: CGFloat = 20 /// The table view's bottom inset (content will have this distance to the bottom if the table view is fully scrolled down). static let bottomInset = Values.mediumSpacing @@ -272,47 +289,55 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat static let scrollButtonNoVisibilityThreshold: CGFloat = 20 /// Automatically scroll to the bottom of the conversation when sending a message if the scroll distance from the bottom is less than this number. static let scrollToBottomMargin: CGFloat = 60 + + // MARK: - Initialization - // MARK: Lifecycle - init(thread: TSThread, focusedMessageID: String? = nil) { - self.thread = thread - self.threadStartedAsMessageRequest = thread.isMessageRequest() - self.focusedMessageID = focusedMessageID - super.init(nibName: nil, bundle: nil) - Storage.read { transaction in - self.initialUnreadCount = self.thread.unreadMessageCount(transaction: transaction) + init?(threadId: String, focusedInteractionId: Int64? = nil) { + guard let viewModel: ConversationViewModel = ConversationViewModel(threadId: threadId, focusedInteractionId: focusedInteractionId) else { + return nil } - let clampedUnreadCount = min(self.initialUnreadCount, UInt(kConversationInitialMaxRangeSize), UInt(viewItems.endIndex)) - unreadViewItems = clampedUnreadCount != 0 ? [ConversationViewItem](viewItems[viewItems.endIndex - Int(clampedUnreadCount) ..< viewItems.endIndex]) : [] + + self.viewModel = viewModel + + super.init(nibName: nil, bundle: nil) } - + required init?(coder: NSCoder) { preconditionFailure("Use init(thread:) instead.") } + deinit { + NotificationCenter.default.removeObserver(self) + } + + // MARK: - Lifecycle + override func viewDidLoad() { super.viewDidLoad() + // Gradient setUpGradientBackground() + // Nav bar setUpNavBarStyle() navigationItem.titleView = titleView - updateNavBarButtons() + updateNavBarButtons(viewData: viewModel.viewData) + // Constraints - view.addSubview(messagesTableView) - messagesTableView.pin(to: view) - + view.addSubview(tableView) + tableView.pin(to: view) + // Blocked banner - addOrRemoveBlockedBanner() - + addOrRemoveBlockedBanner(threadIsBlocked: viewModel.viewData.threadIsBlocked) + // Message requests view & scroll to bottom view.addSubview(scrollButton) view.addSubview(messageRequestView) - + messageRequestView.addSubview(messageRequestDescriptionLabel) messageRequestView.addSubview(messageRequestAcceptButton) messageRequestView.addSubview(messageRequestDeleteButton) - + scrollButton.pin(.right, to: .right, of: view, withInset: -20) messageRequestView.pin(.left, to: .left, of: view) messageRequestView.pin(.right, to: .right, of: view) @@ -320,30 +345,30 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat 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: messageRequestView, withInset: -16) - self.scrollButtonMessageRequestsBottomConstraint?.isActive = thread.isMessageRequest() - self.scrollButtonBottomConstraint?.isActive = !thread.isMessageRequest() + self.scrollButtonMessageRequestsBottomConstraint?.isActive = viewModel.viewData.threadIsMessageRequest + self.scrollButtonBottomConstraint?.isActive = !viewModel.viewData.threadIsMessageRequest messageRequestDescriptionLabel.pin(.top, to: .top, of: messageRequestView, withInset: 10) messageRequestDescriptionLabel.pin(.left, to: .left, of: messageRequestView, withInset: 40) messageRequestDescriptionLabel.pin(.right, to: .right, of: messageRequestView, withInset: -40) - + messageRequestAcceptButton.pin(.top, to: .bottom, of: messageRequestDescriptionLabel, withInset: 20) messageRequestAcceptButton.pin(.left, to: .left, of: messageRequestView, withInset: 20) messageRequestAcceptButton.pin(.bottom, to: .bottom, of: messageRequestView) messageRequestAcceptButton.set(.height, to: ConversationVC.messageRequestButtonHeight) - + messageRequestAcceptButton.pin(.top, to: .bottom, of: messageRequestDescriptionLabel, withInset: 20) messageRequestAcceptButton.pin(.left, to: .left, of: messageRequestView, withInset: 20) messageRequestAcceptButton.pin(.bottom, to: .bottom, of: messageRequestView) messageRequestAcceptButton.set(.height, to: ConversationVC.messageRequestButtonHeight) - + messageRequestDeleteButton.pin(.top, to: .bottom, of: messageRequestDescriptionLabel, withInset: 20) messageRequestDeleteButton.pin(.left, to: .right, of: messageRequestAcceptButton, withInset: 20) messageRequestDeleteButton.pin(.right, to: .right, of: messageRequestView, withInset: -20) messageRequestDeleteButton.pin(.bottom, to: .bottom, of: messageRequestView) messageRequestDeleteButton.set(.width, to: .width, of: messageRequestAcceptButton) messageRequestDeleteButton.set(.height, to: ConversationVC.messageRequestButtonHeight) - + // Unread count view view.addSubview(unreadCountView) unreadCountView.addSubview(unreadCountLabel) @@ -353,169 +378,263 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat unreadCountView.pin(.trailing, to: .trailing, of: unreadCountLabel, withInset: 4) unreadCountView.centerYAnchor.constraint(equalTo: scrollButton.topAnchor).isActive = true unreadCountView.center(.horizontal, in: scrollButton) - updateUnreadCountView() - + updateUnreadCountView(unreadCount: viewModel.viewData.unreadCount) + // Notifications - let notificationCenter = NotificationCenter.default - notificationCenter.addObserver(self, selector: #selector(handleKeyboardWillChangeFrameNotification(_:)), name: UIResponder.keyboardWillChangeFrameNotification, object: nil) - notificationCenter.addObserver(self, selector: #selector(handleKeyboardWillHideNotification(_:)), name: UIResponder.keyboardWillHideNotification, object: nil) - notificationCenter.addObserver(self, selector: #selector(handleAudioDidFinishPlayingNotification(_:)), name: .SNAudioDidFinishPlaying, object: nil) - notificationCenter.addObserver(self, selector: #selector(addOrRemoveBlockedBanner), name: .contactBlockedStateChanged, object: nil) - notificationCenter.addObserver(self, selector: #selector(handleGroupUpdatedNotification), name: .groupThreadUpdated, object: nil) - notificationCenter.addObserver(self, selector: #selector(sendScreenshotNotificationIfNeeded), name: UIApplication.userDidTakeScreenshotNotification, object: nil) - notificationCenter.addObserver(self, selector: #selector(handleMessageSentStatusChanged), name: .messageSentStatusDidChange, object: nil) + NotificationCenter.default.addObserver( + self, + selector: #selector(applicationDidBecomeActive(_:)), + name: UIApplication.didBecomeActiveNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + 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 + ) // Mentions - MentionsManager.populateUserPublicKeyCacheIfNeeded(for: thread.uniqueId!) + MentionsManager.populateUserPublicKeyCacheIfNeeded(for: viewModel.viewData.thread.id) + // Draft - var draft = "" - Storage.read { transaction in - draft = self.thread.currentDraft(with: transaction) - } - if !draft.isEmpty { + if let draft: String = viewModel.viewData.thread.messageDraft, !draft.isEmpty { snInputView.text = draft } + + // Update the input state + snInputView.setEnabledMessageTypes(viewModel.viewData.enabledMessageTypes, message: nil) + + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() - // Update the input state if this is a contact thread - if let contactThread: TSContactThread = thread as? TSContactThread { - let contact: Contact? = GRDBStorage.shared.read { db in try Contact.fetchOne(db, id: contactThread.contactSessionID()) } + guard !didFinishInitialLayout else { return } + + // Scroll to the last unread message if possible; otherwise scroll to the bottom. + // When the unread message count is more than the number of view items of a page, + // the screen will scroll to the bottom instead of the first unread message. + // unreadIndicatorIndex is calculated during loading of the viewItems, so it's + // supposed to be accurate. + DispatchQueue.main.async { + if let focusedInteractionId: Int64 = self.viewModel.focusedInteractionId { + self.scrollToInteraction(with: focusedInteractionId, isAnimated: false, highlighted: true) + } + else if let firstUnreadInteractionId: Int64 = self.viewModel.firstUnreadInteractionId { + self.scrollToInteraction(with: firstUnreadInteractionId, position: .top, isAnimated: false) + self.unreadCountView.alpha = self.scrollButton.alpha + } + else { + self.scrollToBottom(isAnimated: false) + } - // If the contact doesn't exist yet then it's a message request without the first message sent - // so only allow text-based messages - self.snInputView.setEnabledMessageTypes( - (thread.isNoteToSelf() || contact?.didApproveMe == true || thread.isMessageRequest() ? - .all : .textOnly - ), + self.scrollButton.alpha = self.getScrollButtonOpacity() + } + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + startObservingChanges() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + highlightFocusedMessageIfNeeded() + didFinishInitialLayout = true + viewModel.markAllAsRead() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + // Stop observing database changes + dataChangeObservable?.cancel() + viewModel.updateDraft(to: snInputView.text) + inputAccessoryView?.resignFirstResponder() + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + mediaCache.removeAllObjects() + } + + @objc func applicationDidBecomeActive(_ notification: Notification) { + startObservingChanges() + } + + @objc func applicationDidResignActive(_ notification: Notification) { + // Stop observing database changes + dataChangeObservable?.cancel() + } + + // MARK: - Updating + + private func startObservingChanges() { + // Start observing for data changes + dataChangeObservable = GRDBStorage.shared.start( + viewModel.observableViewData, + onError: { error in + }, + onChange: { [weak self] viewData in + // The default scheduler emits changes on the main thread + self?.handleUpdates(viewData) + } + ) + } + + private func handleUpdates(_ updatedViewData: ConversationViewModel.ViewData, initialLoad: Bool = false) { + // 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, initialLoad: true) } + return + } + // Update general conversation UI + + if + initialLoad || + viewModel.viewData.threadName != updatedViewData.threadName || + viewModel.viewData.thread.mutedUntilTimestamp != updatedViewData.thread.mutedUntilTimestamp || + viewModel.viewData.thread.onlyNotifyForMentions != updatedViewData.thread.onlyNotifyForMentions || + viewModel.viewData.userCount != updatedViewData.userCount + { + titleView.update( + with: updatedViewData.threadName, + mutedUntilTimestamp: updatedViewData.thread.mutedUntilTimestamp, + onlyNotifyForMentions: updatedViewData.thread.onlyNotifyForMentions, + userCount: updatedViewData.userCount + ) + } + + if + initialLoad || + viewModel.viewData.requiresApproval != updatedViewData.requiresApproval || + viewModel.viewData.threadAvatarProfiles != updatedViewData.threadAvatarProfiles + { + updateNavBarButtons(viewData: updatedViewData) + } + + if initialLoad || viewModel.viewData.enabledMessageTypes != updatedViewData.enabledMessageTypes { + snInputView.setEnabledMessageTypes( + updatedViewData.enabledMessageTypes, message: nil ) } - // Update member count if this is a V2 open group - if let v2OpenGroup = Storage.shared.getV2OpenGroup(for: thread.uniqueId!) { - OpenGroupAPIV2.getMemberCount(for: v2OpenGroup.room, on: v2OpenGroup.server).retainUntilComplete() + if initialLoad || viewModel.viewData.threadIsBlocked != updatedViewData.threadIsBlocked { + addOrRemoveBlockedBanner(threadIsBlocked: updatedViewData.threadIsBlocked) } - } - - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - if !didFinishInitialLayout { - // Scroll to the last unread message if possible; otherwise scroll to the bottom. - // When the unread message count is more than the number of view items of a page, - // the screen will scroll to the bottom instead of the first unread message. - // unreadIndicatorIndex is calculated during loading of the viewItems, so it's - // supposed to be accurate. - DispatchQueue.main.async { - if let focusedMessageID = self.focusedMessageID { - self.scrollToInteraction(with: focusedMessageID, isAnimated: false, highlighted: true) - } else { - let firstUnreadMessageIndex = self.viewModel.viewState.unreadIndicatorIndex?.intValue - ?? (self.viewItems.count - self.unreadViewItems.count) - if self.initialUnreadCount > 0, let viewItem = self.viewItems[ifValid: firstUnreadMessageIndex], let interactionID = viewItem.interaction.uniqueId { - self.scrollToInteraction(with: interactionID, position: .top, isAnimated: false) - self.unreadCountView.alpha = self.scrollButton.alpha - } else { - self.scrollToBottom(isAnimated: false) - } - } - self.scrollButton.alpha = self.getScrollButtonOpacity() - } - } - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - highlightFocusedMessageIfNeeded() - didFinishInitialLayout = true - markAllAsRead() - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - let text = snInputView.text - Storage.write { transaction in - self.thread.setDraft(text, transaction: transaction) - } - inputAccessoryView?.resignFirstResponder() - } - - override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - mediaCache.removeAllObjects() - } - - deinit { - NotificationCenter.default.removeObserver(self) - } - - // MARK: Table View Data Source - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return viewItems.count - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let viewItem = viewItems[indexPath.row] - let cell = tableView.dequeueReusableCell(withIdentifier: MessageCell.getCellType(for: viewItem).identifier) as! MessageCell - cell.delegate = self - cell.thread = thread - cell.viewItem = viewItem - return cell - } - - // MARK: Updating - - func updateNavBarButtons() { - navigationItem.hidesBackButton = isShowingSearchUI + if initialLoad || viewModel.viewData.unreadCount != updatedViewData.unreadCount { + updateUnreadCountView(unreadCount: updatedViewData.unreadCount) + } + + // Reload the table content (animate changes after the first load) + let changeset = StagedChangeset(source: viewModel.viewData.items, target: updatedViewData.items) + tableView.reload( + using: StagedChangeset(source: viewModel.viewData.items, target: updatedViewData.items), + interrupt: { + return $0.changeCount > 100 + } // Prevent too many changes from causing performance issues + ) { [weak self] items in + self?.viewModel.updateData(updatedViewData.with(items: items)) + } + + // Scroll to the bottom if we just sent a message or are close enough + // to the bottom + + // Only if it was an insert + if + changeset.contains(where: { !$0.elementInserted.isEmpty }) && ( + updatedViewData.items.last?.interactionVariant == .standardOutgoing || + isCloseToBottom + ) + { + scrollToBottom(isAnimated: true) + } + + // Mark received messages as read + viewModel.markAllAsRead() + } + + func updateNavBarButtons(viewData: ConversationViewModel.ViewData) { + navigationItem.hidesBackButton = isShowingSearchUI + if isShowingSearchUI { navigationItem.leftBarButtonItem = nil navigationItem.rightBarButtonItems = [] } else { - if let contactThread: TSContactThread = thread as? TSContactThread { - // Don't show the settings button for message requests - if - let contact: Contact = GRDBStorage.shared.read({ db in try Contact.fetchOne(db, id: contactThread.contactSessionID()) }), - contact.isApproved, - contact.didApproveMe - { - let size = Values.verySmallProfilePictureSize + guard !viewData.requiresApproval else { + // Note: Adding an empty button because without it the title alignment is + // busted (Note: The size was taken from the layout inspector for the back + // button in Xcode + navigationItem.rightBarButtonItem = UIBarButtonItem( + customView: UIView( + frame: CGRect( + x: 0, + y: 0, + width: (44 - 16), // Width of the standard back button + height: 44 + ) + ) + ) + return + } + + switch viewData.thread.variant { + case .contact: let profilePictureView = ProfilePictureView() - profilePictureView.size = size - profilePictureView.update(for: thread) - profilePictureView.set(.width, to: size) - profilePictureView.set(.height, to: size) - + profilePictureView.size = Values.verySmallProfilePictureSize + profilePictureView.update( + publicKey: viewData.thread.id, // Contact thread uses the contactId + profile: viewData.threadAvatarProfiles.first, + threadVariant: viewData.thread.variant + ) + profilePictureView.set(.width, to: (44 - 16)) // Width of the standard back button + profilePictureView.set(.height, to: Values.verySmallProfilePictureSize) + let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(openSettings)) profilePictureView.addGestureRecognizer(tapGestureRecognizer) - + let rightBarButtonItem: UIBarButtonItem = UIBarButtonItem(customView: profilePictureView) rightBarButtonItem.accessibilityLabel = "Settings button" rightBarButtonItem.isAccessibilityElement = true - + + navigationItem.rightBarButtonItem = rightBarButtonItem + + default: + let rightBarButtonItem: UIBarButtonItem = UIBarButtonItem(image: UIImage(named: "Gear"), style: .plain, target: self, action: #selector(openSettings)) + rightBarButtonItem.accessibilityLabel = "Settings button" + rightBarButtonItem.isAccessibilityElement = true + navigationItem.rightBarButtonItem = rightBarButtonItem - } - else { - // Note: Adding an empty button because without it the title alignment is busted (Note: The size was - // taken from the layout inspector for the back button in Xcode - navigationItem.rightBarButtonItem = UIBarButtonItem(customView: UIView(frame: CGRect(x: 0, y: 0, width: 37, height: 44))) - } - } - else { - let rightBarButtonItem: UIBarButtonItem = UIBarButtonItem(image: UIImage(named: "Gear"), style: .plain, target: self, action: #selector(openSettings)) - rightBarButtonItem.accessibilityLabel = "Settings button" - rightBarButtonItem.isAccessibilityElement = true - - navigationItem.rightBarButtonItem = rightBarButtonItem } } } private func highlightFocusedMessageIfNeeded() { - if let indexPath = focusedMessageIndexPath, let cell = messagesTableView.cellForRow(at: indexPath) as? VisibleMessageCell { + if let indexPath = focusedMessageIndexPath, let cell = tableView.cellForRow(at: indexPath) as? VisibleMessageCell { cell.highlight() focusedMessageIndexPath = nil } } - + @objc func handleKeyboardWillChangeFrameNotification(_ 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 @@ -525,42 +644,42 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat 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 messageRequestView.bounds.height <= CGFloat.leastNonzeroMagnitude { hasDoneLayout = false - + UIView.performWithoutAnimation { self.view.layoutIfNeeded() } } - + let keyboardTop = (UIScreen.main.bounds.height - keyboardRect.minY) let messageRequestsOffset: CGFloat = (messageRequestView.isHidden ? 0 : messageRequestView.bounds.height + 16) - let oldContentInset: UIEdgeInsets = messagesTableView.contentInset + let oldContentInset: UIEdgeInsets = tableView.contentInset let newContentInset: UIEdgeInsets = UIEdgeInsets( top: 0, leading: 0, bottom: (Values.mediumSpacing + keyboardTop + messageRequestsOffset), trailing: 0 ) - let newContentOffsetY: CGFloat = (messagesTableView.contentOffset.y + (newContentInset.bottom - oldContentInset.bottom)) + let newContentOffsetY: CGFloat = (tableView.contentOffset.y + (newContentInset.bottom - oldContentInset.bottom)) let changes = { [weak self] in self?.scrollButtonBottomConstraint?.constant = -(keyboardTop + 16) self?.messageRequestsViewBotomConstraint?.constant = -(keyboardTop + 16) - self?.messagesTableView.contentInset = newContentInset - self?.messagesTableView.contentOffset.y = newContentOffsetY - + self?.tableView.contentInset = newContentInset + self?.tableView.contentOffset.y = newContentOffsetY + let scrollButtonOpacity: CGFloat = (self?.getScrollButtonOpacity() ?? 0) self?.scrollButton.alpha = scrollButtonOpacity - + self?.view.setNeedsLayout() self?.view.layoutIfNeeded() } - + // Perform the changes (don't animate if the initial layout hasn't been completed) guard hasDoneLayout else { UIView.performWithoutAnimation { @@ -568,7 +687,7 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat } return } - + UIView.animate( withDuration: duration, delay: 0, @@ -577,7 +696,7 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat 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 @@ -586,10 +705,10 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat 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, @@ -597,11 +716,11 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat animations: { [weak self] in self?.scrollButtonBottomConstraint?.constant = -(keyboardTop + 16) self?.messageRequestsViewBotomConstraint?.constant = -(keyboardTop + 16) - + let scrollButtonOpacity: CGFloat = (self?.getScrollButtonOpacity() ?? 0) self?.scrollButton.alpha = scrollButtonOpacity self?.unreadCountView.alpha = scrollButtonOpacity - + self?.view.setNeedsLayout() self?.view.layoutIfNeeded() }, @@ -702,49 +821,54 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat thread.reload() // Needed so that thread.isCurrentUserMemberInGroup() is up to date reloadInputViews() } - + @objc private func handleMessageSentStatusChanged() { DispatchQueue.main.async { - guard let indexPaths = self.messagesTableView.indexPathsForVisibleRows else { return } + guard let indexPaths = self.tableView.indexPathsForVisibleRows else { return } var indexPathsToReload: [IndexPath] = [] for indexPath in indexPaths { - guard let cell = self.messagesTableView.cellForRow(at: indexPath) as? VisibleMessageCell else { continue } - let isLast = (indexPath.item == (self.messagesTableView.numberOfRows(inSection: 0) - 1)) + guard let cell = self.tableView.cellForRow(at: indexPath) as? VisibleMessageCell else { continue } + let isLast = (indexPath.item == (self.tableView.numberOfRows(inSection: 0) - 1)) guard !isLast else { continue } if !cell.messageStatusImageView.isHidden { indexPathsToReload.append(indexPath) } } UIView.performWithoutAnimation { - self.messagesTableView.reloadRows(at: indexPathsToReload, with: .none) + self.tableView.reloadRows(at: indexPathsToReload, with: .none) } } } - + // MARK: - General - - @objc func addOrRemoveBlockedBanner() { - DispatchQueue.main.async { - guard let thread = self.thread as? TSContactThread, thread.isBlocked() else { - self.blockedBanner.removeFromSuperview() - return - } - - self.view.addSubview(self.blockedBanner) - self.blockedBanner.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.top, UIView.HorizontalEdge.right ], to: self.view) + + func addOrRemoveBlockedBanner(threadIsBlocked: Bool) { + guard threadIsBlocked else { + self.blockedBanner.removeFromSuperview() + return } + + self.view.addSubview(self.blockedBanner) + self.blockedBanner.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.top, UIView.HorizontalEdge.right ], to: self.view) + } + + // MARK: - UITableViewDataSource + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return viewModel.viewData.items.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let item: ConversationViewModel.Item = viewModel.viewData.items[indexPath.row] + let cell: MessageCell = tableView.dequeue(type: MessageCell.cellType(for: item), for: indexPath) + cell.update(with: item, mediaCache: mediaCache, lastSearchText: viewModel.viewData.lastSearchedText) + cell.delegate = self + + return cell } - func markAllAsRead() { - guard let lastSortID = viewItems.last?.interaction.sortId else { return } - OWSReadReceiptManager.shared().markAsReadLocally( - beforeSortId: lastSortID, - thread: thread, - trySendReadReceipt: !thread.isMessageRequest() - ) - SSKEnvironment.shared.disappearingMessagesJob.cleanupMessagesWhichFailedToStartExpiringFromNow() - } - + // MARK: - UITableViewDelegate + func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { return UITableView.automaticDimension } @@ -753,43 +877,39 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat return UITableView.automaticDimension } - func getMediaCache() -> NSCache { - return mediaCache - } - func scrollToBottom(isAnimated: Bool) { - guard !isUserScrolling && !viewItems.isEmpty else { return } - messagesTableView.scrollToRow(at: IndexPath(row: viewItems.count - 1, section: 0), at: .bottom, animated: isAnimated) + guard !isUserScrolling && !viewModel.viewData.items.isEmpty else { return } + + tableView.scrollToRow( + at: IndexPath( + row: viewModel.viewData.items.count - 1, + section: 0), + at: .bottom, + animated: isAnimated + ) } - + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { isUserScrolling = true } - + func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { isUserScrolling = false } - + func scrollViewDidScroll(_ scrollView: UIScrollView) { scrollButton.alpha = getScrollButtonOpacity() unreadCountView.alpha = scrollButton.alpha autoLoadMoreIfNeeded() - updateUnreadCountView() } - - func updateUnreadCountView() { - let visibleViewItems = (messagesTableView.indexPathsForVisibleRows ?? []).map { viewItems[ifValid: $0.row] } - for visibleItem in visibleViewItems { - guard let index = unreadViewItems.firstIndex(where: { $0 === visibleItem }) else { continue } - unreadViewItems.remove(at: index) - } - let unreadCount = unreadViewItems.count - unreadCountLabel.text = unreadCount < 10000 ? "\(unreadCount)" : "9999+" - let fontSize = (unreadCount < 10000) ? Values.verySmallFontSize : 8 + + func updateUnreadCountView(unreadCount: Int) { + let fontSize: CGFloat = (unreadCount < 10000 ? Values.verySmallFontSize : 8) + unreadCountLabel.text = (unreadCount < 10000 ? "\(unreadCount)" : "9999+") unreadCountLabel.font = .boldSystemFont(ofSize: fontSize) unreadCountView.isHidden = (unreadCount == 0) } - + func autoLoadMoreIfNeeded() { let isMainAppAndActive = CurrentAppContext().isMainAppAndActive guard isMainAppAndActive && didFinishInitialLayout && viewModel.canLoadMoreItems() && !isLoadingMore @@ -797,18 +917,18 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat isLoadingMore = true viewModel.loadAnotherPageOfMessages() } - + func getScrollButtonOpacity() -> CGFloat { - let contentOffsetY = messagesTableView.contentOffset.y + let contentOffsetY = tableView.contentOffset.y let x = (lastPageTop - ConversationVC.bottomInset - contentOffsetY).clamp(0, .greatestFiniteMagnitude) let a = 1 / (ConversationVC.scrollButtonFullVisibilityThreshold - ConversationVC.scrollButtonNoVisibilityThreshold) return a * x } - + func groupWasUpdated(_ groupModel: TSGroupModel) { // Not currently in use } - + // MARK: Search func conversationSettingsDidRequestConversationSearch(_ conversationSettingsViewController: OWSConversationSettingsViewController) { showSearchUI() @@ -818,7 +938,7 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat } } } - + func popAllConversationSettingsViews(completion completionBlock: (() -> Void)? = nil) { if presentedViewController != nil { dismiss(animated: true) { @@ -828,15 +948,18 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat navigationController!.popToViewController(self, animated: true, completion: completionBlock) } } - + func showSearchUI() { isShowingSearchUI = true + // Search bar let searchBar = searchController.uiSearchController.searchBar searchBar.setUpSessionStyle() navigationItem.titleView = searchBar + // Nav bar buttons - updateNavBarButtons() + updateNavBarButtons(viewData: viewModel.viewData) + // Hack so that the ResultsBar stays on the screen when dismissing the search field // keyboard. // @@ -867,35 +990,36 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat let navBar = navigationController!.navigationBar as! OWSNavigationBar navBar.stubbedNextResponder = self } - + func hideSearchUI() { isShowingSearchUI = false navigationItem.titleView = titleView - updateNavBarButtons() + updateNavBarButtons(viewData: viewModel.viewData) + let navBar = navigationController!.navigationBar as! OWSNavigationBar navBar.stubbedNextResponder = nil becomeFirstResponder() reloadInputViews() } - + func didDismissSearchController(_ searchController: UISearchController) { hideSearchUI() } - + func conversationSearchController(_ conversationSearchController: ConversationSearchController, didUpdateSearchResults resultSet: ConversationScreenSearchResultSet?) { lastSearchedText = resultSet?.searchText - messagesTableView.reloadRows(at: messagesTableView.indexPathsForVisibleRows ?? [], with: UITableView.RowAnimation.none) + tableView.reloadRows(at: tableView.indexPathsForVisibleRows ?? [], with: UITableView.RowAnimation.none) } - - func conversationSearchController(_ conversationSearchController: ConversationSearchController, didSelectMessageId interactionID: String) { - scrollToInteraction(with: interactionID) + + func conversationSearchController(_ conversationSearchController: ConversationSearchController, didSelectInteractionId interactionId: Int64) { + scrollToInteraction(with: interactionId) } - - func scrollToInteraction(with interactionID: String, position: UITableView.ScrollPosition = .middle, isAnimated: Bool = true, highlighted: Bool = false) { - guard let indexPath = viewModel.ensureLoadWindowContainsInteractionId(interactionID) else { return } - messagesTableView.scrollToRow(at: indexPath, at: position, animated: isAnimated) - if highlighted { - focusedMessageIndexPath = indexPath - } + + func scrollToInteraction( + with interactionId: Int64, + position: UITableView.ScrollPosition = .middle, + isAnimated: Bool = true, + highlighted: Bool = false + ) { } } diff --git a/Session/Conversations/Settings/OWSConversationSettingsViewController.h b/Session/Conversations/Settings/OWSConversationSettingsViewController.h index 7d7542008..a072027a1 100644 --- a/Session/Conversations/Settings/OWSConversationSettingsViewController.h +++ b/Session/Conversations/Settings/OWSConversationSettingsViewController.h @@ -17,7 +17,7 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic) BOOL showVerificationOnAppear; -- (void)configureWithThread:(TSThread *)thread uiDatabaseConnection:(YapDatabaseConnection *)uiDatabaseConnection; +- (void)configureWithThreadId:(NSString *)threadId threadName:(nullable NSString *)threadName isClosedGroup:(BOOL)isClosedGroup isOpenGroup:(BOOL)isOpenGroup isNoteToSelf:(BOOL)isNoteToSelf; @end diff --git a/Session/Conversations/Settings/OWSConversationSettingsViewController.m b/Session/Conversations/Settings/OWSConversationSettingsViewController.m index 2da6b6b56..0d7749665 100644 --- a/Session/Conversations/Settings/OWSConversationSettingsViewController.m +++ b/Session/Conversations/Settings/OWSConversationSettingsViewController.m @@ -11,11 +11,8 @@ #import #import #import -#import #import #import -#import -#import #import #import #import @@ -30,11 +27,20 @@ CGFloat kIconViewLength = 24; @interface OWSConversationSettingsViewController () -@property (nonatomic) TSThread *thread; +@property (nonatomic) NSString *threadId; +@property (nonatomic) NSString *threadName; +@property (nonatomic) BOOL isNoteToSelf; +@property (nonatomic) BOOL isClosedGroup; +@property (nonatomic) BOOL isOpenGroup; @property (nonatomic) YapDatabaseConnection *uiDatabaseConnection; @property (nonatomic, readonly) YapDatabaseConnection *editingDatabaseConnection; @property (nonatomic) NSArray *disappearingMessagesDurations; -@property (nonatomic) OWSDisappearingMessagesConfiguration *disappearingMessagesConfiguration; + +@property (nonatomic) BOOL originalIsDisappearingMessagesEnabled; +@property (nonatomic) NSInteger originalDisappearingMessagesDurationIndex; +@property (nonatomic) BOOL isDisappearingMessagesEnabled; +@property (nonatomic) NSInteger disappearingMessagesDurationIndex; + @property (nullable, nonatomic) MediaGallery *mediaGallery; @property (nonatomic, readonly) UIImageView *avatarView; @property (nonatomic, readonly) UILabel *disappearingMessagesDurationLabel; @@ -96,15 +102,6 @@ CGFloat kIconViewLength = 24; [[NSNotificationCenter defaultCenter] removeObserver:self]; } -#pragma mark - Dependencies - -- (TSAccountManager *)tsAccountManager -{ - OWSAssertDebug(SSKEnvironment.shared.tsAccountManager); - - return SSKEnvironment.shared.tsAccountManager; -} - #pragma mark - (void)observeNotifications @@ -120,46 +117,19 @@ CGFloat kIconViewLength = 24; return [OWSPrimaryStorage sharedManager].dbReadWriteConnection; } -- (nullable NSString *)threadName -{ - NSString *threadName = self.thread.name; - if ([self.thread isKindOfClass:TSContactThread.class]) { - TSContactThread *thread = (TSContactThread *)self.thread; - return [SMKProfile displayNameWithId:thread.contactSessionID customFallback: @"Anonymous"]; - } else if (threadName.length == 0 && [self isGroupThread]) { - threadName = [MessageStrings newGroupDefaultTitle]; +- (void)configureWithThreadId:(NSString *)threadId threadName:(nullable NSString *)threadName isClosedGroup:(BOOL)isClosedGroup isOpenGroup:(BOOL)isOpenGroup isNoteToSelf:(BOOL)isNoteToSelf { + self.threadId = threadId; + self.threadName = threadName; + self.isClosedGroup = isClosedGroup; + self.isOpenGroup = isOpenGroup; + self.isNoteToSelf = isNoteToSelf; + + if (!isClosedGroup && !isOpenGroup) { + self.threadName = [SMKProfile displayNameWithId:threadId customFallback:@"Anonymous"]; } - return threadName; -} - -- (BOOL)isGroupThread -{ - return [self.thread isKindOfClass:[TSGroupThread class]]; -} - -- (BOOL)isOpenGroup -{ - if ([self isGroupThread]) { - TSGroupThread *thread = (TSGroupThread *)self.thread; - return thread.isOpenGroup; + else { + self.threadName = (threadName ?: [MessageStrings newGroupDefaultTitle]); } - return false; -} - --(BOOL)isClosedGroup -{ - if (self.isGroupThread) { - TSGroupThread *thread = (TSGroupThread *)self.thread; - return thread.groupModel.groupType == closedGroup; - } - return false; -} - -- (void)configureWithThread:(TSThread *)thread uiDatabaseConnection:(YapDatabaseConnection *)uiDatabaseConnection -{ - OWSAssertDebug(thread); - self.thread = thread; - self.uiDatabaseConnection = uiDatabaseConnection; } #pragma mark - ContactEditingDelegate @@ -202,7 +172,7 @@ CGFloat kIconViewLength = 24; self.displayNameLabel.font = [UIFont boldSystemFontOfSize:LKValues.largeFontSize]; self.displayNameLabel.lineBreakMode = NSLineBreakByTruncatingTail; self.displayNameLabel.textAlignment = NSTextAlignmentCenter; - + self.displayNameTextField = [[SNTextField alloc] initWithPlaceholder:@"Enter a name" usesDefaultHeight:NO]; self.displayNameTextField.textAlignment = NSTextAlignmentCenter; self.displayNameTextField.accessibilityLabel = @"Edit name text field"; @@ -211,45 +181,42 @@ CGFloat kIconViewLength = 24; self.displayNameContainer = [UIView new]; self.displayNameContainer.accessibilityLabel = @"Edit name text field"; self.displayNameContainer.isAccessibilityElement = YES; - + [self.displayNameContainer autoSetDimension:ALDimensionHeight toSize:40]; [self.displayNameContainer addSubview:self.displayNameLabel]; [self.displayNameLabel autoPinToEdgesOfView:self.displayNameContainer]; [self.displayNameContainer addSubview:self.displayNameTextField]; [self.displayNameTextField autoPinToEdgesOfView:self.displayNameContainer]; - - if ([self.thread isKindOfClass:TSContactThread.class]) { + + if (!self.isClosedGroup && !self.isOpenGroup) { UITapGestureRecognizer *tapGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(showEditNameUI)]; [self.displayNameContainer addGestureRecognizer:tapGestureRecognizer]; } - + self.tableView.estimatedRowHeight = 45; self.tableView.rowHeight = UITableViewAutomaticDimension; _disappearingMessagesDurationLabel = [UILabel new]; SET_SUBVIEW_ACCESSIBILITY_IDENTIFIER(self, _disappearingMessagesDurationLabel); - self.disappearingMessagesDurations = [OWSDisappearingMessagesConfiguration validDurationsSeconds]; + self.disappearingMessagesDurations = [SMKDisappearingMessagesConfiguration validDurationsSeconds]; + self.isDisappearingMessagesEnabled = [SMKDisappearingMessagesConfiguration isEnabledFor: self.threadId]; + self.disappearingMessagesDurationIndex = [SMKDisappearingMessagesConfiguration durationIndexFor: self.threadId]; + self.originalIsDisappearingMessagesEnabled = self.isDisappearingMessagesEnabled; + self.originalDisappearingMessagesDurationIndex = self.disappearingMessagesDurationIndex; - self.disappearingMessagesConfiguration = - [OWSDisappearingMessagesConfiguration fetchObjectWithUniqueId:self.thread.uniqueId]; - - if (!self.disappearingMessagesConfiguration) { - self.disappearingMessagesConfiguration = [OWSDisappearingMessagesConfiguration defaultWith: self.thread.uniqueId]; - } - [self updateTableContents]; - + NSString *title; - if ([self.thread isKindOfClass:[TSContactThread class]]) { + if (!self.isClosedGroup && !self.isOpenGroup) { title = NSLocalizedString(@"Settings", @""); } else { title = NSLocalizedString(@"Group Settings", @""); } [LKViewControllerUtilities setUpDefaultSessionStyleForVC:self withTitle:title customBackButton:YES]; self.tableView.backgroundColor = UIColor.clearColor; - - if ([self.thread isKindOfClass:TSContactThread.class]) { + + if (!self.isClosedGroup && !self.isOpenGroup) { [self updateNavBarButtons]; } } @@ -259,8 +226,6 @@ CGFloat kIconViewLength = 24; OWSTableContents *contents = [OWSTableContents new]; contents.title = NSLocalizedString(@"CONVERSATION_SETTINGS", @"title for conversation settings screen"); - BOOL isNoteToSelf = self.thread.isNoteToSelf; - __weak OWSConversationSettingsViewController *weakSelf = self; OWSTableSection *section = [OWSTableSection new]; @@ -269,7 +234,7 @@ CGFloat kIconViewLength = 24; section.customHeaderHeight = @(UITableViewAutomaticDimension); // Copy Session ID - if ([self.thread isKindOfClass:TSContactThread.class]) { + if (!self.isClosedGroup && !self.isOpenGroup) { [section addItem:[OWSTableItem itemWithCustomCellBlock:^{ return [weakSelf disclosureCellWithName:NSLocalizedString(@"vc_conversation_settings_copy_session_id_button_title", "") @@ -290,7 +255,7 @@ CGFloat kIconViewLength = 24; } actionBlock:^{ [weakSelf showMediaGallery]; }]]; - + // Invite button if (self.isOpenGroup) { [section addItem:[OWSTableItem itemWithCustomCellBlock:^{ @@ -315,7 +280,7 @@ CGFloat kIconViewLength = 24; } actionBlock:^{ [weakSelf tappedConversationSearch]; }]]; - + // Disappearing messages if (![self isOpenGroup]) { [section addItem:[OWSTableItem itemWithCustomCellBlock:^{ @@ -327,7 +292,7 @@ CGFloat kIconViewLength = 24; cell.selectionStyle = UITableViewCellSelectionStyleNone; NSString *iconName - = (strongSelf.disappearingMessagesConfiguration.isEnabled ? @"ic_timer" : @"ic_timer_disabled"); + = (strongSelf.isDisappearingMessagesEnabled ? @"ic_timer" : @"ic_timer_disabled"); UIImageView *iconView = [strongSelf viewForIconWithName:iconName]; UILabel *rowLabel = [UILabel new]; @@ -338,7 +303,7 @@ CGFloat kIconViewLength = 24; rowLabel.lineBreakMode = NSLineBreakByTruncatingTail; UISwitch *switchView = [UISwitch new]; - switchView.on = strongSelf.disappearingMessagesConfiguration.isEnabled; + switchView.on = strongSelf.isDisappearingMessagesEnabled; [switchView addTarget:strongSelf action:@selector(disappearingMessagesSwitchValueDidChange:) forControlEvents:UIControlEventValueChanged]; @@ -351,11 +316,10 @@ CGFloat kIconViewLength = 24; UILabel *subtitleLabel = [UILabel new]; NSString *displayName; - if (self.thread.isGroupThread) { + if (self.isClosedGroup || self.isOpenGroup) { displayName = @"the group"; } else { - TSContactThread *thread = (TSContactThread *)self.thread; - displayName = [SMKProfile displayNameWithId:thread.contactSessionID customFallback:@"anonymous"]; + displayName = [SMKProfile displayNameWithId:self.threadId customFallback:@"anonymous"]; } subtitleLabel.text = [NSString stringWithFormat:NSLocalizedString(@"When enabled, messages between you and %@ will disappear after they have been seen.", ""), displayName]; subtitleLabel.textColor = LKColors.text; @@ -375,7 +339,7 @@ CGFloat kIconViewLength = 24; return cell; } customRowHeight:UITableViewAutomaticDimension actionBlock:nil]]; - if (self.disappearingMessagesConfiguration.isEnabled) { + if (self.isDisappearingMessagesEnabled) { [section addItem:[OWSTableItem itemWithCustomCellBlock:^{ UITableViewCell *cell = [OWSTableItem newCell]; OWSConversationSettingsViewController *strongSelf = weakSelf; @@ -405,7 +369,7 @@ CGFloat kIconViewLength = 24; slider.minimumValue = 0; slider.tintColor = LKColors.accent; slider.continuous = NO; - slider.value = strongSelf.disappearingMessagesConfiguration.durationIndex; + slider.value = strongSelf.disappearingMessagesDurationIndex; [slider addTarget:strongSelf action:@selector(durationSliderDidChange:) forControlEvents:UIControlEventValueChanged]; [cell.contentView addSubview:slider]; @@ -413,7 +377,7 @@ CGFloat kIconViewLength = 24; [slider autoPinEdge:ALEdgeLeading toEdge:ALEdgeLeading ofView:rowLabel]; [slider autoPinTrailingToSuperviewMargin]; [slider autoPinBottomToSuperviewMargin]; - + cell.userInteractionEnabled = !strongSelf.hasLeftGroup; cell.accessibilityIdentifier = ACCESSIBILITY_IDENTIFIER_WITH_NAME( @@ -428,11 +392,10 @@ CGFloat kIconViewLength = 24; // Closed group settings __block BOOL isUserMember = NO; - if (self.isGroupThread) { - NSString *userPublicKey = [SNGeneralUtilities getUserPublicKey]; - isUserMember = [(TSGroupThread *)self.thread isUserMemberInGroup:userPublicKey]; + if (self.isClosedGroup || self.isOpenGroup) { + isUserMember = [SMKGroupMember isCurrentUserMemberOf:self.threadId]; } - if (self.isGroupThread && self.isClosedGroup && isUserMember) { + if (self.isClosedGroup && isUserMember) { [section addItem:[OWSTableItem itemWithCustomCellBlock:^{ UITableViewCell *cell = [weakSelf disclosureCellWithName:NSLocalizedString(@"EDIT_GROUP_ACTION", @"table cell label in conversation settings") @@ -455,8 +418,8 @@ CGFloat kIconViewLength = 24; [weakSelf didTapLeaveGroup]; }]]; } - - if (!isNoteToSelf) { + + if (!self.isNoteToSelf) { // Notification sound [section addItem:[OWSTableItem itemWithCustomCellBlock:^{ UITableViewCell *cell = @@ -483,8 +446,8 @@ CGFloat kIconViewLength = 24; [cell.contentView addSubview:contentRow]; [contentRow autoPinEdgesToSuperviewMargins]; - OWSSound sound = [OWSSounds notificationSoundForThread:strongSelf.thread]; - cell.detailTextLabel.text = [OWSSounds displayNameForSound:sound]; + NSInteger sound = [SMKSound notificationSoundFor:strongSelf.threadId]; + cell.detailTextLabel.text = [SMKSound displayNameFor:sound]; cell.accessibilityIdentifier = ACCESSIBILITY_IDENTIFIER_WITH_NAME( OWSConversationSettingsViewController, @"notifications"); @@ -494,11 +457,11 @@ CGFloat kIconViewLength = 24; customRowHeight:UITableViewAutomaticDimension actionBlock:^{ OWSSoundSettingsViewController *vc = [OWSSoundSettingsViewController new]; - vc.thread = weakSelf.thread; + vc.threadId = weakSelf.threadId; [weakSelf.navigationController pushViewController:vc animated:YES]; }]]; - - if (self.isGroupThread) { + + if (self.isClosedGroup || self.isOpenGroup) { // Notification Settings [section addItem:[OWSTableItem itemWithCustomCellBlock:^{ UITableViewCell *cell = [OWSTableItem newCell]; @@ -517,7 +480,7 @@ CGFloat kIconViewLength = 24; rowLabel.lineBreakMode = NSLineBreakByTruncatingTail; UISwitch *switchView = [UISwitch new]; - switchView.on = ((TSGroupThread *)strongSelf.thread).isOnlyNotifyingForMentions; + switchView.on = [SMKThread isOnlyNotifyingForMentions:strongSelf.threadId]; [switchView addTarget:strongSelf action:@selector(notifyForMentionsOnlySwitchValueDidChange:) forControlEvents:UIControlEventValueChanged]; @@ -547,7 +510,7 @@ CGFloat kIconViewLength = 24; return cell; } customRowHeight:UITableViewAutomaticDimension actionBlock:nil]]; } - + // Mute thread [section addItem:[OWSTableItem itemWithCustomCellBlock:^{ OWSConversationSettingsViewController *strongSelf = weakSelf; @@ -560,7 +523,7 @@ CGFloat kIconViewLength = 24; cell.selectionStyle = UITableViewCellSelectionStyleNone; UISwitch *muteConversationSwitch = [UISwitch new]; - NSDate *mutedUntilDate = strongSelf.thread.mutedUntilDate; + NSDate *mutedUntilDate = [SMKThread mutedUntilDateFor:strongSelf.threadId]; NSDate *now = [NSDate date]; muteConversationSwitch.on = (mutedUntilDate != nil && [mutedUntilDate timeIntervalSinceDate:now] > 0); [muteConversationSwitch addTarget:strongSelf action:@selector(handleMuteSwitchToggled:) @@ -570,9 +533,9 @@ CGFloat kIconViewLength = 24; return cell; } actionBlock:nil]]; } - + // Block contact - if (!isNoteToSelf && [self.thread isKindOfClass:TSContactThread.class]) { + if (!self.isNoteToSelf && !self.isClosedGroup && !self.isOpenGroup) { [section addItem:[OWSTableItem itemWithCustomCellBlock:^{ OWSConversationSettingsViewController *strongSelf = weakSelf; if (!strongSelf) { return [UITableViewCell new]; } @@ -584,7 +547,7 @@ CGFloat kIconViewLength = 24; cell.selectionStyle = UITableViewCellSelectionStyleNone; UISwitch *blockConversationSwitch = [UISwitch new]; - blockConversationSwitch.on = strongSelf.thread.isBlocked; + blockConversationSwitch.on = [SMKContact isBlockedFor:strongSelf.threadId]; [blockConversationSwitch addTarget:strongSelf action:@selector(blockConversationSwitchDidChange:) forControlEvents:UIControlEventValueChanged]; cell.accessoryView = blockConversationSwitch; @@ -671,36 +634,36 @@ CGFloat kIconViewLength = 24; [profilePictureView autoSetDimension:ALDimensionWidth toSize:size]; [profilePictureView autoSetDimension:ALDimensionHeight toSize:size]; [profilePictureView addGestureRecognizer:profilePictureTapGestureRecognizer]; - + self.displayNameLabel.text = (self.threadName != nil && self.threadName.length > 0) ? self.threadName : @"Anonymous"; - if ([self.thread isKindOfClass:TSContactThread.class]) { + if (!self.isClosedGroup && !self.isOpenGroup) { UITapGestureRecognizer *tapGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(showEditNameUI)]; [self.displayNameContainer addGestureRecognizer:tapGestureRecognizer]; } - + UIStackView *stackView = [[UIStackView alloc] initWithArrangedSubviews:@[ profilePictureView, self.displayNameContainer ]]; stackView.axis = UILayoutConstraintAxisVertical; stackView.spacing = LKValues.mediumSpacing; - stackView.distribution = UIStackViewDistributionEqualCentering; + stackView.distribution = UIStackViewDistributionEqualCentering; stackView.alignment = UIStackViewAlignmentCenter; BOOL isSmallScreen = (UIScreen.mainScreen.bounds.size.height - 568) < 1; CGFloat horizontalSpacing = isSmallScreen ? LKValues.largeSpacing : LKValues.veryLargeSpacing; stackView.layoutMargins = UIEdgeInsetsMake(LKValues.mediumSpacing, horizontalSpacing, LKValues.mediumSpacing, horizontalSpacing); [stackView setLayoutMarginsRelativeArrangement:YES]; - if (!self.isGroupThread) { + if (!self.isClosedGroup && !self.isOpenGroup) { SRCopyableLabel *subtitleView = [SRCopyableLabel new]; subtitleView.textColor = LKColors.text; subtitleView.font = [LKFonts spaceMonoOfSize:LKValues.smallFontSize]; subtitleView.lineBreakMode = NSLineBreakByCharWrapping; subtitleView.numberOfLines = 2; - subtitleView.text = ((TSContactThread *)self.thread).contactSessionID; + subtitleView.text = self.threadId; subtitleView.textAlignment = NSTextAlignmentCenter; [stackView addArrangedSubview:subtitleView]; } - - [profilePictureView updateForThread:self.thread]; - + + [profilePictureView updateForThreadId:self.threadId]; + return stackView; } @@ -739,48 +702,41 @@ CGFloat kIconViewLength = 24; { [super viewWillDisappear:animated]; - if (self.disappearingMessagesConfiguration.isNewRecord && !self.disappearingMessagesConfiguration.isEnabled) { - // don't save defaults, else we'll unintentionally save the configuration and notify the contact. + // Do nothing if the values haven't changed (or if it's disabled and only the 'durationIndex' + // has changed as the 'durationIndex' value defaults to 1 hour when disabled) + if ( + self.isDisappearingMessagesEnabled == self.originalIsDisappearingMessagesEnabled && ( + !self.originalIsDisappearingMessagesEnabled || + self.disappearingMessagesDurationIndex == self.originalDisappearingMessagesDurationIndex + ) + ) { return; } - - if (self.disappearingMessagesConfiguration.dictionaryValueDidChange) { - [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *_Nonnull transaction) { - [self.disappearingMessagesConfiguration saveWithTransaction:transaction]; - OWSDisappearingConfigurationUpdateInfoMessage *infoMessage = [[OWSDisappearingConfigurationUpdateInfoMessage alloc] - initWithTimestamp:[NSDate ows_millisecondTimeStamp] - thread:self.thread - configuration:self.disappearingMessagesConfiguration - createdByRemoteName:nil - createdInExistingGroup:NO]; - [infoMessage saveWithTransaction:transaction]; - - SNExpirationTimerUpdate *expirationTimerUpdate = [SNExpirationTimerUpdate new]; - BOOL isEnabled = self.disappearingMessagesConfiguration.isEnabled; - expirationTimerUpdate.duration = isEnabled ? self.disappearingMessagesConfiguration.durationSeconds : 0; - [SNMessageSender send:expirationTimerUpdate inThread:self.thread usingTransaction:transaction]; - }]; - } + + [SMKDisappearingMessagesConfiguration + update:self.threadId + isEnabled: self.isDisappearingMessagesEnabled + durationIndex: self.disappearingMessagesDurationIndex + ]; } #pragma mark - Actions - (void)editGroup { - SNEditClosedGroupVC *editClosedGroupVC = [[SNEditClosedGroupVC alloc] initWithThreadID:self.thread.uniqueId]; + SNEditClosedGroupVC *editClosedGroupVC = [[SNEditClosedGroupVC alloc] initWithThreadId:self.threadId]; [self.navigationController pushViewController:editClosedGroupVC animated:YES completion:nil]; } - (void)didTapLeaveGroup { - NSString *userPublicKey = [SNGeneralUtilities getUserPublicKey]; NSString *message; - if ([((TSGroupThread *)self.thread).groupModel.groupAdminIds containsObject:userPublicKey]) { + if ([SMKGroupMember isCurrentUserAdminOf:self.threadId]) { message = @"Because you are the creator of this group it will be deleted for everyone. This cannot be undone."; } else { message = NSLocalizedString(@"CONFIRM_LEAVE_GROUP_DESCRIPTION", @"Alert body"); } - + UIAlertController *alert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"CONFIRM_LEAVE_GROUP_TITLE", @"Alert title") message:message @@ -801,9 +757,8 @@ CGFloat kIconViewLength = 24; - (BOOL)hasLeftGroup { - if (self.isGroupThread) { - TSGroupThread *groupThread = (TSGroupThread *)self.thread; - return !groupThread.isCurrentUserMemberInGroup; + if (self.isClosedGroup || self.isOpenGroup) { + return ![SMKGroupMember isCurrentUserMemberOf:self.threadId]; } return NO; @@ -834,13 +789,9 @@ CGFloat kIconViewLength = 24; { UISwitch *uiSwitch = (UISwitch *)sender; if (uiSwitch.isOn) { - [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - [self.thread updateWithMutedUntilDate:[NSDate distantFuture] transaction:transaction]; - }]; + [SMKThread updateWithMutedUntilDateTo:[NSDate distantFuture] forThreadId:self.threadId]; } else { - [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - [self.thread updateWithMutedUntilDate:nil transaction:transaction]; - }]; + [SMKThread updateWithMutedUntilDateTo:nil forThreadId:self.threadId]; } } @@ -849,13 +800,12 @@ CGFloat kIconViewLength = 24; if (![sender isKindOfClass:[UISwitch class]]) { OWSFailDebug(@"Unexpected sender for block user switch: %@", sender); } - if (![self.thread isKindOfClass:[TSContactThread class]]) { - OWSFailDebug(@"unexpected thread type: %@", self.thread.class); + if (self.isClosedGroup || self.isOpenGroup) { + OWSFailDebug(@"unexpected group thread"); } UISwitch *blockConversationSwitch = (UISwitch *)sender; - TSContactThread *contactThread = (TSContactThread *)self.thread; - BOOL isCurrentlyBlocked = contactThread.isBlocked; + BOOL isCurrentlyBlocked = [SMKContact isBlockedFor:self.threadId]; __weak OWSConversationSettingsViewController *weakSelf = self; if (blockConversationSwitch.isOn) { @@ -863,12 +813,12 @@ CGFloat kIconViewLength = 24; if (isCurrentlyBlocked) { return; } - [BlockListUIUtils showBlockThreadActionSheet:contactThread + [BlockListUIUtils showBlockThreadActionSheet:self.threadId from:self completionBlock:^(BOOL isBlocked) { // Update switch state if user cancels action. blockConversationSwitch.on = isBlocked; - + // If we successfully blocked then force a config sync if (isBlocked) { [SNMessageSender forceSyncConfigurationNow]; @@ -882,12 +832,12 @@ CGFloat kIconViewLength = 24; if (!isCurrentlyBlocked) { return; } - [BlockListUIUtils showUnblockThreadActionSheet:contactThread + [BlockListUIUtils showUnblockThreadActionSheet:self.threadId from:self completionBlock:^(BOOL isBlocked) { // Update switch state if user cancels action. blockConversationSwitch.on = isBlocked; - + // If we successfully unblocked then force a config sync if (!isBlocked) { [SNMessageSender forceSyncConfigurationNow]; @@ -900,7 +850,7 @@ CGFloat kIconViewLength = 24; - (void)toggleDisappearingMessages:(BOOL)flag { - self.disappearingMessagesConfiguration.isEnabled = flag; + self.isDisappearingMessagesEnabled = flag; [self updateTableContents]; } @@ -908,21 +858,23 @@ CGFloat kIconViewLength = 24; - (void)durationSliderDidChange:(UISlider *)slider { // snap the slider to a valid value - NSUInteger index = (NSUInteger)(slider.value + 0.5); + NSInteger index = (NSInteger)(slider.value + 0.5); [slider setValue:index animated:YES]; - NSNumber *numberOfSeconds = self.disappearingMessagesDurations[index]; - self.disappearingMessagesConfiguration.durationSeconds = [numberOfSeconds unsignedIntValue]; + self.disappearingMessagesDurationIndex = index; [self updateDisappearingMessagesDurationLabel]; } - (void)updateDisappearingMessagesDurationLabel { - if (self.disappearingMessagesConfiguration.isEnabled) { + if (self.isDisappearingMessagesEnabled) { NSString *keepForFormat = @"Disappear after %@"; - self.disappearingMessagesDurationLabel.text = - [NSString stringWithFormat:keepForFormat, self.disappearingMessagesConfiguration.durationString]; - } else { + self.disappearingMessagesDurationLabel.text = [NSString + stringWithFormat:keepForFormat, + [SMKDisappearingMessagesConfiguration durationStringFor: self.disappearingMessagesDurationIndex] + ]; + } + else { self.disappearingMessagesDurationLabel.text = NSLocalizedString(@"KEEP_MESSAGES_FOREVER", @"Slider label when disappearing messages is off"); } @@ -933,30 +885,16 @@ CGFloat kIconViewLength = 24; - (void)copySessionID { - UIPasteboard.generalPasteboard.string = ((TSContactThread *)self.thread).contactSessionID; + UIPasteboard.generalPasteboard.string = self.threadId; } - (void)inviteUsersToOpenGroup { - NSString *threadID = self.thread.uniqueId; - SNOpenGroupV2 *openGroup = [LKStorage.shared getV2OpenGroupForThreadID:threadID]; - NSString *url = [NSString stringWithFormat:@"%@/%@?public_key=%@", openGroup.server, openGroup.room, openGroup.publicKey]; + NSString *threadId = self.threadId; SNUserSelectionVC *userSelectionVC = [[SNUserSelectionVC alloc] initWithTitle:NSLocalizedString(@"vc_conversation_settings_invite_button_title", @"") excluding:[NSSet new] completion:^(NSSet *selectedUsers) { - for (NSString *user in selectedUsers) { - SNVisibleMessage *message = [SNVisibleMessage new]; - message.sentTimestamp = [NSDate millisecondTimestamp]; - message.openGroupInvitation = [[SNOpenGroupInvitation alloc] initWithName:openGroup.name url:url]; - TSContactThread *thread = [TSContactThread getOrCreateThreadWithContactSessionID:user]; - TSOutgoingMessage *tsMessage = [TSOutgoingMessage from:message associatedWith:thread]; - [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - [tsMessage saveWithTransaction:transaction]; - }]; - [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - [SNMessageSender send:message inThread:thread usingTransaction:transaction]; - }]; - } + [SMKOpenGroup inviteUsers:selectedUsers toOpenGroupFor:threadId]; }]; [self.navigationController pushViewController:userSelectionVC animated:YES]; } @@ -965,8 +903,7 @@ CGFloat kIconViewLength = 24; { OWSLogDebug(@""); - MediaGallery *mediaGallery = [[MediaGallery alloc] initWithThread:self.thread - options:MediaGalleryOptionSliderEnabled]; + MediaGallery *mediaGallery = [[MediaGallery alloc] initWithSliderEnabledForThreadId:self.threadId isClosedGroup: self.isClosedGroup isOpenGroup: self.isOpenGroup]; self.mediaGallery = mediaGallery; @@ -983,9 +920,8 @@ CGFloat kIconViewLength = 24; { UISwitch *uiSwitch = (UISwitch *)sender; BOOL isEnabled = uiSwitch.isOn; - [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - [(TSGroupThread *)self.thread setIsOnlyNotifyingForMentions:isEnabled withTransaction:transaction]; - }]; + + [SMKThread setIsOnlyNotifyingForMentions:self.threadId to:isEnabled]; } - (void)hideEditNameUI @@ -1001,9 +937,9 @@ CGFloat kIconViewLength = 24; - (void)setIsEditingDisplayName:(BOOL)isEditingDisplayName { _isEditingDisplayName = isEditingDisplayName; - + [self updateNavBarButtons]; - + [UIView animateWithDuration:0.25 animations:^{ self.displayNameLabel.alpha = self.isEditingDisplayName ? 0 : 1; self.displayNameTextField.alpha = self.isEditingDisplayName ? 1 : 0; @@ -1017,13 +953,10 @@ CGFloat kIconViewLength = 24; - (void)saveName { - if (![self.thread isKindOfClass:TSContactThread.class]) { return; } - NSString *sessionID = ((TSContactThread *)self.thread).contactSessionID; - SMKProfile *profile = [SMKProfile fetchOrCreateWithId:sessionID]; + if (self.isClosedGroup || self.isOpenGroup) { return; } + NSString *text = [self.displayNameTextField.text stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet]; - profile.nickname = text.length > 0 ? text : nil; - [SMKProfile saveProfile: profile]; - self.displayNameLabel.text = text.length > 0 ? text : profile.name; + self.displayNameLabel.text = [SMKProfile displayNameAfterSavingNickname:text forProfileId:self.threadId]; [self hideEditNameUI]; } @@ -1054,14 +987,13 @@ CGFloat kIconViewLength = 24; - (void)otherUsersProfileDidChange:(NSNotification *)notification { - OWSAssertIsOnMainThread(); - NSString *recipientId = notification.userInfo[NSNotification.profileRecipientIdKey]; OWSAssertDebug(recipientId.length > 0); - if (recipientId.length > 0 && [self.thread isKindOfClass:[TSContactThread class]] && - [((TSContactThread *)self.thread).contactSessionID isEqualToString:recipientId]) { - [self updateTableContents]; + if (recipientId.length > 0 && !self.isClosedGroup && !self.isOpenGroup && self.threadId == recipientId) { + DispatchMainThreadSafe(^{ + [self updateTableContents]; + }); } } diff --git a/Session/Conversations/Settings/ProfilePictureVC.swift b/Session/Conversations/Settings/ProfilePictureVC.swift index 98df010f9..44d693394 100644 --- a/Session/Conversations/Settings/ProfilePictureVC.swift +++ b/Session/Conversations/Settings/ProfilePictureVC.swift @@ -1,13 +1,17 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit /// Shown when the user taps a profile picture in the conversation settings. @objc(SNProfilePictureVC) -final class ProfilePictureVC : BaseVC { +final class ProfilePictureVC: BaseVC { private let image: UIImage private let snTitle: String @objc init(image: UIImage, title: String) { self.image = image self.snTitle = title + super.init(nibName: nil, bundle: nil) } diff --git a/Session/Conversations/Views & Modals/ConversationTitleView.swift b/Session/Conversations/Views & Modals/ConversationTitleView.swift index 4e319f6a7..56d182613 100644 --- a/Session/Conversations/Views & Modals/ConversationTitleView.swift +++ b/Session/Conversations/Views & Modals/ConversationTitleView.swift @@ -52,50 +52,48 @@ final class ConversationTitleView: UIView { public func update( with name: String, - notificationMode: SessionThread.NotificationMode, + mutedUntilTimestamp: TimeInterval?, + onlyNotifyForMentions: Bool, userCount: Int? ) { guard Thread.isMainThread else { DispatchQueue.main.async { [weak self] in - self?.update(with: name, notificationMode: notificationMode, userCount: userCount) + self?.update(with: name, mutedUntilTimestamp: mutedUntilTimestamp, onlyNotifyForMentions: onlyNotifyForMentions, userCount: userCount) } return } // Generate the subtitle let subtitle: NSAttributedString? = { - switch notificationMode { - case .none: - return NSAttributedString( - string: "\u{e067} ", - attributes: [ - .font: UIFont.ows_elegantIconsFont(10), - .foregroundColor: Colors.text - ] - ) - .appending(string: "Muted") - - case .mentionsOnly: - // FIXME: This is going to have issues when swapping between light/dark mode - let imageAttachment = NSTextAttachment() - let color: UIColor = (isDarkMode ? .white : .black) - imageAttachment.image = UIImage(named: "NotifyMentions.png")?.asTintedImage(color: color) - imageAttachment.bounds = CGRect( - x: 0, - y: -2, - width: Values.smallFontSize, - height: Values.smallFontSize - ) - - return NSAttributedString(attachment: imageAttachment) - .appending(string: " ") - .appending(string: "view_conversation_title_notify_for_mentions_only".localized()) - - case .all: - guard let userCount: Int = userCount else { return nil } - - return NSAttributedString(string: "\(userCount) member\(userCount == 1 ? "" : "s")") + guard Date().timeIntervalSince1970 > (mutedUntilTimestamp ?? 0) else { + return NSAttributedString( + string: "\u{e067} ", + attributes: [ + .font: UIFont.ows_elegantIconsFont(10), + .foregroundColor: Colors.text + ] + ) + .appending(string: "Muted") } + guard !onlyNotifyForMentions else { + // FIXME: This is going to have issues when swapping between light/dark mode + let imageAttachment = NSTextAttachment() + let color: UIColor = (isDarkMode ? .white : .black) + imageAttachment.image = UIImage(named: "NotifyMentions.png")?.asTintedImage(color: color) + imageAttachment.bounds = CGRect( + x: 0, + y: -2, + width: Values.smallFontSize, + height: Values.smallFontSize + ) + + return NSAttributedString(attachment: imageAttachment) + .appending(string: " ") + .appending(string: "view_conversation_title_notify_for_mentions_only".localized()) + } + guard let userCount: Int = userCount else { return nil } + + return NSAttributedString(string: "\(userCount) member\(userCount == 1 ? "" : "s")") }() self.titleLabel.text = name diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift index 3e3833fcb..5ddf938ca 100644 --- a/Session/Home/HomeViewModel.swift +++ b/Session/Home/HomeViewModel.swift @@ -98,7 +98,8 @@ public class HomeViewModel { private let contactProfile: Profile? private let closedGroupAvatarProfiles: [GroupMemberInfo]? - public let notificationMode: SessionThread.NotificationMode + public let mutedUntilTimestamp: TimeInterval? + public let onlyNotifyForMentions: Bool public let isPinned: Bool /// A flag indicating whether the contact is blocked (will be null for non-contact threads) @@ -113,17 +114,14 @@ public class HomeViewModel { public let lastInteractionInfo: InteractionInfo? public var displayName: String { - switch variant { - case .closedGroup: return (closedGroupName ?? "Unknown Group") - case .openGroup: return (openGroupName ?? "Unknown Group") - case .contact: - guard !isNoteToSelf else { return "NOTE_TO_SELF".localized() } - guard let profile: Profile = profile else { - return Profile.truncated(id: id, truncating: .middle) - } - - return (profile.nickname ?? profile.name) - } + return SessionThread.displayName( + threadId: id, + variant: variant, + closedGroupName: closedGroupName, + openGroupName: openGroupName, + isNoteToSelf: isNoteToSelf, + profile: contactProfile + ) } public var profile: Profile? { @@ -179,7 +177,8 @@ public class HomeViewModel { self.currentUserProfile = Profile(id: "", name: "") self.contactProfile = nil self.closedGroupAvatarProfiles = nil - self.notificationMode = .none + self.mutedUntilTimestamp = nil + self.onlyNotifyForMentions = false self.isPinned = false self.contactIsBlocked = nil self.isNoteToSelf = false @@ -255,7 +254,8 @@ public class HomeViewModel { openGroup[.name].forKey(ThreadInfo.openGroupNameKey), openGroup[.imageData].forKey(ThreadInfo.openGroupProfilePictureDataKey), - thread[.notificationMode], + thread[.mutedUntilTimestamp], + thread[.onlyNotifyForMentions], thread[.isPinned], contact[.isBlocked].forKey(ThreadInfo.contactIsBlockedKey), SessionThread.isNoteToSelf(userPublicKey: userPublicKey).forKey(ThreadInfo.isNoteToSelfKey), @@ -293,7 +293,10 @@ public class HomeViewModel { ) .joining(optional: SessionThread.openGroup.aliased(openGroup)) .with(currentUserProfileExpression) - .including(required: SessionThread.association(to: currentUserProfileExpression).forKey(ThreadInfo.currentUserProfileKey)) + .including( + required: SessionThread.association(to: currentUserProfileExpression) + .forKey(ThreadInfo.currentUserProfileKey) + ) .with(unreadInteractionExpression) .joining( optional: SessionThread diff --git a/Session/Media Viewing & Editing/MediaDetailViewController.swift b/Session/Media Viewing & Editing/MediaDetailViewController.swift new file mode 100644 index 000000000..0a8ab0dac --- /dev/null +++ b/Session/Media Viewing & Editing/MediaDetailViewController.swift @@ -0,0 +1,3 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation diff --git a/Session/Media Viewing & Editing/MediaGalleryViewModel.swift b/Session/Media Viewing & Editing/MediaGalleryViewModel.swift new file mode 100644 index 000000000..0a8ab0dac --- /dev/null +++ b/Session/Media Viewing & Editing/MediaGalleryViewModel.swift @@ -0,0 +1,3 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation diff --git a/Session/Media Viewing & Editing/PhotoCollectionPickerController.swift b/Session/Media Viewing & Editing/PhotoCollectionPickerController.swift index a386e51ff..2c6682307 100644 --- a/Session/Media Viewing & Editing/PhotoCollectionPickerController.swift +++ b/Session/Media Viewing & Editing/PhotoCollectionPickerController.swift @@ -102,7 +102,7 @@ class PhotoCollectionPickerController: OWSTableViewController, PhotoLibraryDeleg let photoMediaSize = PhotoMediaSize(thumbnailSize: CGSize(width: kImageSize, height: kImageSize)) if let assetItem = contents.lastAssetItem(photoMediaSize: photoMediaSize) { - imageView.image = assetItem.asyncThumbnail { [weak imageView] image in + assetItem.asyncThumbnail { [weak imageView] image in AssertIsOnMainThread() guard let imageView = imageView else { diff --git a/Session/Media Viewing & Editing/PhotoGridViewCell.swift b/Session/Media Viewing & Editing/PhotoGridViewCell.swift index 4b2470c1c..8d8ca2ad9 100644 --- a/Session/Media Viewing & Editing/PhotoGridViewCell.swift +++ b/Session/Media Viewing & Editing/PhotoGridViewCell.swift @@ -9,9 +9,10 @@ public enum PhotoGridItemType { case photo, animated, video } -public protocol PhotoGridItem: class { +public protocol PhotoGridItem: AnyObject { var type: PhotoGridItemType { get } - func asyncThumbnail(completion: @escaping (UIImage?) -> Void) -> UIImage? + + func asyncThumbnail(completion: @escaping (UIImage?) -> Void) } public class PhotoGridViewCell: UICollectionViewCell { @@ -119,28 +120,21 @@ public class PhotoGridViewCell: UICollectionViewCell { public func configure(item: PhotoGridItem) { self.item = item - self.image = item.asyncThumbnail { image in - guard let currentItem = self.item else { - return - } - - guard currentItem === item else { - return - } + item.asyncThumbnail { [weak self] image in + guard let currentItem = self?.item else { return } + guard currentItem === item else { return } if image == nil { Logger.debug("image == nil") } - self.image = image + + self?.image = image } switch item.type { - case .video: - self.contentTypeBadgeImage = PhotoGridViewCell.videoBadgeImage - case .animated: - self.contentTypeBadgeImage = PhotoGridViewCell.animatedBadgeImage - case .photo: - self.contentTypeBadgeImage = nil + case .video: self.contentTypeBadgeImage = PhotoGridViewCell.videoBadgeImage + case .animated: self.contentTypeBadgeImage = PhotoGridViewCell.animatedBadgeImage + case .photo: self.contentTypeBadgeImage = nil } } diff --git a/Session/Media Viewing & Editing/PhotoLibrary.swift b/Session/Media Viewing & Editing/PhotoLibrary.swift index 164c8b542..77c9f68be 100644 --- a/Session/Media Viewing & Editing/PhotoLibrary.swift +++ b/Session/Media Viewing & Editing/PhotoLibrary.swift @@ -7,7 +7,7 @@ import Photos import PromiseKit import CoreServices -protocol PhotoLibraryDelegate: class { +protocol PhotoLibraryDelegate: AnyObject { func photoLibraryDidChange(_ photoLibrary: PhotoLibrary) } @@ -47,16 +47,13 @@ class PhotoPickerAssetItem: PhotoGridItem { return .photo } - func asyncThumbnail(completion: @escaping (UIImage?) -> Void) -> UIImage? { - var syncImageResult: UIImage? + func asyncThumbnail(completion: @escaping (UIImage?) -> Void) { var hasLoadedImage = false // Surprisingly, iOS will opportunistically run the completion block sync if the image is // already available. photoCollectionContents.requestThumbnail(for: self.asset, thumbnailSize: photoMediaSize.thumbnailSize) { image, _ in DispatchMainThreadSafe({ - syncImageResult = image - // Once we've _successfully_ completed (e.g. invoked the completion with // a non-nil image), don't invoke the completion again with a nil argument. if !hasLoadedImage || image != nil { @@ -68,7 +65,6 @@ class PhotoPickerAssetItem: PhotoGridItem { } }) } - return syncImageResult } } diff --git a/Session/Meta/SessionApp.swift b/Session/Meta/SessionApp.swift index 1e653f5c7..40258aaeb 100644 --- a/Session/Meta/SessionApp.swift +++ b/Session/Meta/SessionApp.swift @@ -9,29 +9,25 @@ public struct SessionApp { // MARK: - View Convenience Methods - public static func presentConversation(for recipientId: String, action: ConversationViewModel.Action = .none, animated: Bool) { + public static func presentConversation(for threadId: String, action: ConversationViewModel.Action = .none, animated: Bool) { let maybeThread: SessionThread? = GRDBStorage.shared.write { db in - try SessionThread.fetchOrCreate(db, id: recipientId, variant: .contact) + try SessionThread.fetchOrCreate(db, id: threadId, variant: .contact) } guard maybeThread != nil else { return } - self.presentConversation(for: recipientId, action: action, animated: animated) - } - - public static func presentConversation(for threadId: String, animated: Bool) { - guard GRDBStorage.shared.read({ db in try SessionThread.exists(db, id: threadId) }) == true else { - SNLog("Unable to find thread with id:\(threadId)") - return - } - - self.presentConversation(for: threadId, animated: animated) + self.presentConversation( + for: threadId, + action: action, + focusInteractionId: nil, + animated: animated + ) } public static func presentConversation( for threadId: String, - action: ConversationViewModel.Action = .none, - focusInteractionId: Int64? = nil, + action: ConversationViewModel.Action, + focusInteractionId: Int64?, animated: Bool ) { guard Thread.isMainThread else { diff --git a/Session/Notifications/AppNotifications.swift b/Session/Notifications/AppNotifications.swift index 831264ad0..b3e5706e1 100644 --- a/Session/Notifications/AppNotifications.swift +++ b/Session/Notifications/AppNotifications.swift @@ -154,7 +154,7 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { } public func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread, isBackgroundPoll: Bool) { - guard thread.notificationMode != .none else { return } + guard Date().timeIntervalSince1970 < (thread.mutedUntilTimestamp ?? 0) else { return } let isMessageRequest = thread.isMessageRequest(db) @@ -192,7 +192,7 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { // Don't fire the notification if the current user isn't mentioned // and isOnlyNotifyingForMentions is on. - if thread.notificationMode == .mentionsOnly && !interaction.isUserMentioned(db) { + if thread.onlyNotifyForMentions && !interaction.isUserMentioned(db) { return } @@ -213,9 +213,21 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { notificationTitle = (isMessageRequest ? "Session" : senderName) case .closedGroup, .openGroup: - let groupName: String = thread.name(db) + let groupName: String = SessionThread + .displayName( + threadId: thread.id, + variant: thread.variant, + closedGroupName: try? thread.closedGroup + .select(.name) + .asRequest(of: String.self) + .fetchOne(db), + openGroupName: try? thread.openGroup + .select(.name) + .asRequest(of: String.self) + .fetchOne(db) + ) - notificationTitle = (isBackgroundPoll ? groupName: + notificationTitle = (isBackgroundPoll ? groupName : String( format: NotificationStrings.incomingGroupMessageTitleFormat, senderName, @@ -269,7 +281,22 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { switch previewType { case .noNameNoPreview: notificationTitle = nil - case .nameNoPreview, .namePreview: notificationTitle = thread.name(db) + case .nameNoPreview, .namePreview: + notificationTitle = SessionThread.displayName( + threadId: thread.id, + variant: thread.variant, + closedGroupName: try? thread.closedGroup + .select(.name) + .asRequest(of: String.self) + .fetchOne(db), + openGroupName: try? thread.openGroup + .select(.name) + .asRequest(of: String.self) + .fetchOne(db), + isNoteToSelf: (thread.isNoteToSelf(db) == true), + profile: try? Profile.fetchOne(db, id: thread.id) + ) + default: notificationTitle = nil } diff --git a/Session/Shared/ConversationCell.swift b/Session/Shared/ConversationCell.swift index 65d4a0275..8cad0e3d3 100644 --- a/Session/Shared/ConversationCell.swift +++ b/Session/Shared/ConversationCell.swift @@ -400,7 +400,7 @@ final class ConversationCell : UITableViewCell { private func getSnippet(threadInfo: HomeViewModel.ThreadInfo) -> NSMutableAttributedString { let result = NSMutableAttributedString() - if (threadInfo.notificationMode == .none) { + if Date().timeIntervalSince1970 < (threadInfo.mutedUntilTimestamp ?? 0) { result.append(NSAttributedString( string: "\u{e067} ", attributes: [ @@ -409,7 +409,7 @@ final class ConversationCell : UITableViewCell { ] )) } - else if threadInfo.notificationMode == .mentionsOnly { + else if threadInfo.onlyNotifyForMentions { let imageAttachment = NSTextAttachment() imageAttachment.image = UIImage(named: "NotifyMentions.png")?.asTintedImage(color: Colors.unimportant) imageAttachment.bounds = CGRect(x: 0, y: -2, width: Values.smallFontSize, height: Values.smallFontSize) diff --git a/Session/Shared/UserCell.swift b/Session/Shared/UserCell.swift index dea13b9ce..546899e50 100644 --- a/Session/Shared/UserCell.swift +++ b/Session/Shared/UserCell.swift @@ -1,45 +1,50 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + import UIKit +import SessionUIKit +import SignalUtilitiesKit -final class UserCell : UITableViewCell { - var accessory = Accessory.none - var publicKey = "" - var isZombie = false - - // MARK: Accessory +final class UserCell: UITableViewCell { + // MARK: - Accessory + enum Accessory { case none case lock case tick(isSelected: Bool) } - // MARK: Components - private lazy var profilePictureView = ProfilePictureView() + // MARK: - Components + + private lazy var profilePictureView: ProfilePictureView = ProfilePictureView() private lazy var displayNameLabel: UILabel = { - let result = UILabel() + let result: UILabel = UILabel() result.textColor = Colors.text result.font = .boldSystemFont(ofSize: Values.mediumFontSize) result.lineBreakMode = .byTruncatingTail + return result }() private lazy var accessoryImageView: UIImageView = { - let result = UIImageView() + let result: UIImageView = UIImageView() result.contentMode = .scaleAspectFit - let size: CGFloat = 24 - result.set(.width, to: size) - result.set(.height, to: size) + result.set(.width, to: 24) + result.set(.height, to: 24) + return result }() private lazy var separator: UIView = { - let result = UIView() + let result: UIView = UIView() result.backgroundColor = Colors.separator result.set(.height, to: Values.separatorThickness) + return result }() - // MARK: Initialization + // MARK: - Initialization + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) setUpViewHierarchy() @@ -53,19 +58,30 @@ final class UserCell : UITableViewCell { private func setUpViewHierarchy() { // Background color backgroundColor = Colors.cellBackground + // Highlight color let selectedBackgroundView = UIView() selectedBackgroundView.backgroundColor = .clear // Disabled for now self.selectedBackgroundView = selectedBackgroundView + // Profile picture image view let profilePictureViewSize = Values.smallProfilePictureSize profilePictureView.set(.width, to: profilePictureViewSize) profilePictureView.set(.height, to: profilePictureViewSize) profilePictureView.size = profilePictureViewSize + // Main stack view let spacer = UIView.hStretchingSpacer() spacer.widthAnchor.constraint(greaterThanOrEqualToConstant: Values.mediumSpacing).isActive = true - let stackView = UIStackView(arrangedSubviews: [ profilePictureView, UIView.hSpacer(Values.mediumSpacing), displayNameLabel, spacer, accessoryImageView ]) + let stackView = UIStackView( + arrangedSubviews: [ + profilePictureView, + UIView.hSpacer(Values.mediumSpacing), + displayNameLabel, + spacer, + accessoryImageView + ] + ) stackView.axis = .horizontal stackView.alignment = .center stackView.isLayoutMarginsRelativeArrangement = true @@ -73,16 +89,39 @@ final class UserCell : UITableViewCell { contentView.addSubview(stackView) stackView.pin(to: contentView) stackView.set(.width, to: UIScreen.main.bounds.width) + // Set up the separator contentView.addSubview(separator) - separator.pin([ UIView.HorizontalEdge.leading, UIView.VerticalEdge.bottom, UIView.HorizontalEdge.trailing ], to: contentView) + separator.pin( + [ + UIView.HorizontalEdge.leading, + UIView.VerticalEdge.bottom, + UIView.HorizontalEdge.trailing + ], + to: contentView + ) } // MARK: - Updating - func update() { - profilePictureView.update(for: publicKey) - displayNameLabel.text = Profile.displayName(id: publicKey) + func update( + with publicKey: String, + profile: Profile?, + isZombie: Bool, + accessory: Accessory + ) { + profilePictureView.update( + publicKey: publicKey, + profile: profile, + threadVariant: .contact + ) + + displayNameLabel.text = Profile.displayName( + for: .contact, + id: publicKey, + name: profile?.name, + nickname: profile?.nickname + ) switch accessory { case .none: accessoryImageView.isHidden = true @@ -99,7 +138,7 @@ final class UserCell : UITableViewCell { accessoryImageView.tintColor = Colors.text } - let alpha: CGFloat = isZombie ? 0.5 : 1 + let alpha: CGFloat = (isZombie ? 0.5 : 1) [ profilePictureView, displayNameLabel, accessoryImageView ].forEach { $0.alpha = alpha } } } diff --git a/Session/Shared/UserSelectionVC.swift b/Session/Shared/UserSelectionVC.swift index 16e4d2be4..4c025cd1a 100644 --- a/Session/Shared/UserSelectionVC.swift +++ b/Session/Shared/UserSelectionVC.swift @@ -4,32 +4,35 @@ import UIKit import SessionMessagingKit @objc(SNUserSelectionVC) -final class UserSelectionVC : BaseVC, UITableViewDataSource, UITableViewDelegate { +final class UserSelectionVC: BaseVC, UITableViewDataSource, UITableViewDelegate { private let navBarTitle: String private let usersToExclude: Set private let completion: (Set) -> Void private var selectedUsers: Set = [] - private lazy var users: [String] = { - var result = Contact.fetchAllIds() - result.removeAll { usersToExclude.contains($0) } - return result + private lazy var users: [Profile] = { + return Profile + .fetchAllContactProfiles() + .filter { usersToExclude.contains($0.id) } }() - // MARK: Components + // MARK: - Components + @objc private lazy var tableView: UITableView = { - let result = UITableView() + let result: UITableView = UITableView() result.dataSource = self result.delegate = self - result.register(UserCell.self, forCellReuseIdentifier: "UserCell") result.separatorStyle = .none result.backgroundColor = .clear result.showsVerticalScrollIndicator = false result.alwaysBounceVertical = false + result.register(view: UserCell.self) + return result }() - // MARK: Lifecycle + // MARK: - Lifecycle + @objc(initWithTitle:excluding:completion:) init(with title: String, excluding usersToExclude: Set, completion: @escaping (Set) -> Void) { self.navBarTitle = title @@ -51,29 +54,36 @@ final class UserSelectionVC : BaseVC, UITableViewDataSource, UITableViewDelegate tableView.pin(to: view) } - // MARK: Data + // MARK: - UITableViewDataSource + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return users.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "UserCell") as! UserCell - let publicKey = users[indexPath.row] - cell.publicKey = publicKey - let isSelected = selectedUsers.contains(publicKey) - cell.accessory = .tick(isSelected: isSelected) - cell.update() + let cell: UserCell = tableView.dequeue(type: UserCell.self, for: indexPath) + cell.update( + with: users[indexPath.row].id, + profile: users[indexPath.row], + isZombie: false, + accessory: .tick(isSelected: selectedUsers.contains(users[indexPath.row].id)) + ) + return cell } - // MARK: Interaction + // MARK: - Interaction + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - let publicKey = users[indexPath.row] - if !selectedUsers.contains(publicKey) { selectedUsers.insert(publicKey) } else { selectedUsers.remove(publicKey) } - guard let cell = tableView.cellForRow(at: indexPath) as? UserCell else { return } - let isSelected = selectedUsers.contains(publicKey) - cell.accessory = .tick(isSelected: isSelected) - cell.update() + if !selectedUsers.contains(users[indexPath.row].id) { + selectedUsers.insert(users[indexPath.row].id) + } + else { + selectedUsers.remove(users[indexPath.row].id) + } + + tableView.deselectRow(at: indexPath, animated: true) + tableView.reloadRows(at: [indexPath], with: .none) } @objc private func handleDoneButtonTapped() { diff --git a/Session/Utilities/BackgroundPoller.swift b/Session/Utilities/BackgroundPoller.swift index a0c593f39..c9c11b4ed 100644 --- a/Session/Utilities/BackgroundPoller.swift +++ b/Session/Utilities/BackgroundPoller.swift @@ -68,7 +68,8 @@ public final class BackgroundPoller : NSObject { .appending( MessageReceiveJob.Details.MessageInfo( data: try envelope.serializedData(), - serverHash: message.info.hash + serverHash: message.info.hash, + serverExpirationTimestamp: (TimeInterval(message.info.expirationDateMs) / 1000) ) ) diff --git a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacyModels.swift b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacyModels.swift index 693ccde4a..714c6c5de 100644 --- a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacyModels.swift +++ b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacyModels.swift @@ -35,7 +35,9 @@ public enum Legacy { internal static let interactionCollection = "TSInteraction" internal static let attachmentsCollection = "TSAttachements" // Note: This is how it was previously spelt internal static let outgoingReadReceiptManagerCollection = "kOutgoingReadReceiptManagerCollection" - + internal static let receivedMessageTimestampsCollection = "ReceivedMessageTimestampsCollection" + internal static let receivedMessageTimestampsKey = "receivedMessageTimestamps" + internal static let notifyPushServerJobCollection = "NotifyPNServerJobCollection" internal static let messageReceiveJobCollection = "MessageReceiveJobCollection" internal static let messageSendJobCollection = "MessageSendJobCollection" diff --git a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift index 2e208ee71..85da33703 100644 --- a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -49,11 +49,11 @@ enum _001_InitialSetupMigration: Migration { t.column(.shouldBeVisible, .boolean).notNull() t.column(.isPinned, .boolean).notNull() t.column(.messageDraft, .text) - t.column(.notificationMode, .integer) - .notNull() - .defaults(to: SessionThread.NotificationMode.all) t.column(.notificationSound, .integer) t.column(.mutedUntilTimestamp, .double) + t.column(.onlyNotifyForMentions, .boolean) + .notNull() + .defaults(to: false) } try db.create(table: DisappearingMessagesConfiguration.self) { t in @@ -274,12 +274,14 @@ enum _001_InitialSetupMigration: Migration { } try db.create(table: ControlMessageProcessRecord.self) { t in - t.column(.threadId, .text).notNull() - t.column(.sentTimestampMs, .integer).notNull() - t.column(.serverHash, .text).notNull() - t.column(.openGroupMessageServerId, .integer).notNull() + t.column(.threadId, .text) + .notNull() + .indexed() // Quicker querying + t.column(.variant, .integer).notNull() + t.column(.timestampMs, .integer).notNull() + t.column(.serverExpirationTimestamp, .double) - t.uniqueKey([.threadId, .sentTimestampMs, .serverHash, .openGroupMessageServerId]) + t.uniqueKey([.threadId, .variant, .timestampMs]) } } } diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index e508a5262..6ae96c531 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -41,6 +41,7 @@ enum _003_YDBToGRDBMigration: Migration { var attachments: [String: Legacy.Attachment] = [:] var processedAttachmentIds: Set = [] var outgoingReadReceiptsTimestampsMs: [String: Set] = [:] + var receivedMessageTimestamps: Set = [] // Map the Legacy types for the NSKeyedUnarchiver NSKeyedUnarchiver.setClass( @@ -192,6 +193,16 @@ enum _003_YDBToGRDBMigration: Migration { .union(timestampsMs) } + receivedMessageTimestamps = receivedMessageTimestamps.inserting( + contentsOf: transaction + .object( + forKey: Legacy.receivedMessageTimestampsKey, + inCollection: Legacy.receivedMessageTimestampsCollection + ) + .asType([UInt64].self) + .defaulting(to: []) + .asSet() + ) } // We can't properly throw within the 'enumerateKeysAndObjects' block so have to throw here @@ -292,21 +303,16 @@ enum _003_YDBToGRDBMigration: Migration { } let threadVariant: SessionThread.Variant - let notificationMode: SessionThread.NotificationMode + let onlyNotifyForMentions: Bool switch thread { case let groupThread as TSGroupThread: threadVariant = (groupThread.isOpenGroup ? .openGroup : .closedGroup) - notificationMode = (thread.isMuted ? .none : - (groupThread.isOnlyNotifyingForMentions ? - .mentionsOnly : - .all - ) - ) + onlyNotifyForMentions = groupThread.isOnlyNotifyingForMentions default: threadVariant = .contact - notificationMode = (thread.isMuted ? .none : .all) + onlyNotifyForMentions = false } try autoreleasepool { @@ -320,8 +326,8 @@ enum _003_YDBToGRDBMigration: Migration { nil : thread.messageDraft ), - notificationMode: notificationMode, - mutedUntilTimestamp: thread.mutedUntilDate?.timeIntervalSince1970 + mutedUntilTimestamp: thread.mutedUntilDate?.timeIntervalSince1970, + onlyNotifyForMentions: onlyNotifyForMentions ).insert(db) // Disappearing Messages Configuration @@ -564,7 +570,15 @@ enum _003_YDBToGRDBMigration: Migration { // Insert the data let interaction: Interaction = try Interaction( - serverHash: serverHash, + serverHash: { + switch variant { + // Don't store the 'serverHash' for these so sync messages + // are seen as duplicates + case .infoDisappearingMessagesUpdate: return nil + + default: return serverHash + } + }(), threadId: threadId, authorId: authorId, variant: variant, @@ -587,6 +601,17 @@ enum _003_YDBToGRDBMigration: Migration { openGroupWhisperTo: nil // TODO: This in SOGSV4 ).inserted(db) + // Insert a 'ControlMessageProcessRecord' if needed (for duplication prevention) + try ControlMessageProcessRecord( + threadId: threadId, + variant: variant, + timestampMs: Int64(legacyInteraction.timestamp) + )?.insert(db) + + // Remove timestamps we created records for (they will be protected by unique + // constraints so don't need legacy process records) + receivedMessageTimestamps.remove(legacyInteraction.timestamp) + guard let interactionId: Int64 = interaction.id else { // TODO: Is it possible the old database has duplicates which could hit this case? SNLog("[Migration Error] Failed to insert interaction") @@ -777,6 +802,13 @@ enum _003_YDBToGRDBMigration: Migration { } } + // Insert a 'ControlMessageProcessRecord' for any remaining 'receivedMessageTimestamp' + // entries as "legacy" + try ControlMessageProcessRecord.generateLegacyProcessRecords( + db, + receivedMessageTimestamps: receivedMessageTimestamps.map { Int64($0) } + ) + print("RAWR [\(Date().timeIntervalSince1970)] - Process thread inserts - End") // Clear out processed data (give the memory a change to be freed) @@ -801,6 +833,7 @@ enum _003_YDBToGRDBMigration: Migration { interactions = [:] attachments = [:] + receivedMessageTimestamps = [] // MARK: - Process Legacy Jobs @@ -1009,7 +1042,8 @@ enum _003_YDBToGRDBMigration: Migration { messages: [ MessageReceiveJob.Details.MessageInfo( data: legacyJob.data, - serverHash: legacyJob.serverHash + serverHash: legacyJob.serverHash, + serverExpirationTimestamp: (Date().timeIntervalSince1970 + ControlMessageProcessRecord.defaultExpirationSeconds) ) ], isBackgroundPoll: legacyJob.isBackgroundPoll diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift index 64c8e86a3..32fe5a0ba 100644 --- a/SessionMessagingKit/Database/Models/Attachment.swift +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -8,11 +8,16 @@ import SessionUtilitiesKit import AVFAudio import AVFoundation -public struct Attachment: Codable, Identifiable, Equatable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { +public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "attachment" } public static let interactionAttachments = hasOne(InteractionAttachment.self) internal static let quoteForeignKey = ForeignKey([Columns.id], to: [Quote.Columns.attachmentId]) internal static let linkPreviewForeignKey = ForeignKey([Columns.id], to: [LinkPreview.Columns.attachmentId]) + public static let interaction = hasOne( + Interaction.self, + through: interactionAttachments, + using: InteractionAttachment.interaction + ) fileprivate static let quote = belongsTo(Quote.self, using: quoteForeignKey) fileprivate static let linkPreview = belongsTo(LinkPreview.self, using: linkPreviewForeignKey) @@ -195,6 +200,28 @@ public struct Attachment: Codable, Identifiable, Equatable, FetchableRecord, Per self.digest = nil self.caption = nil } + + // MARK: - Custom Database Interaction + + public func delete(_ db: Database) throws -> Bool { + // Delete all associated files + if FileManager.default.fileExists(atPath: thumbnailsDirPath) { + try? FileManager.default.removeItem(atPath: thumbnailsDirPath) + } + + if + let legacyThumbnailPath: String = legacyThumbnailPath, + FileManager.default.fileExists(atPath: legacyThumbnailPath) + { + try? FileManager.default.removeItem(atPath: legacyThumbnailPath) + } + + if let originalFilePath: String = originalFilePath { + try? FileManager.default.removeItem(atPath: originalFilePath) + } + + return try performDelete(db) + } } // MARK: - CustomStringConvertible @@ -623,6 +650,19 @@ extension Attachment { return "\(OWSFileSystem.cachesDirectoryPath())/\(id)-thumbnails" } + var legacyThumbnailPath: String? { + guard + let originalFilePath: String = originalFilePath, + (isImage || isVideo || isAnimated) + else { return nil } + + let fileUrl: URL = URL(fileURLWithPath: originalFilePath) + let filename: String = fileUrl.lastPathComponent.filenameWithoutExtension + let containingDir: String = fileUrl.deletingLastPathComponent().absoluteString + + return "\(containingDir)/\(filename)-signal-ios-thumbnail.jpg" + } + var originalImage: UIImage? { guard let originalFilePath: String = originalFilePath else { return nil } diff --git a/SessionMessagingKit/Database/Models/ClosedGroup.swift b/SessionMessagingKit/Database/Models/ClosedGroup.swift index 60e3e7712..d9c087310 100644 --- a/SessionMessagingKit/Database/Models/ClosedGroup.swift +++ b/SessionMessagingKit/Database/Models/ClosedGroup.swift @@ -8,7 +8,7 @@ public struct ClosedGroup: Codable, Identifiable, FetchableRecord, PersistableRe public static var databaseTableName: String { "closedGroup" } internal static let threadForeignKey = ForeignKey([Columns.threadId], to: [SessionThread.Columns.id]) private static let thread = belongsTo(SessionThread.self, using: threadForeignKey) - private static let keyPairs = hasMany( + internal static let keyPairs = hasMany( ClosedGroupKeyPair.self, using: ClosedGroupKeyPair.closedGroupForeignKey ) diff --git a/SessionMessagingKit/Database/Models/Contact.swift b/SessionMessagingKit/Database/Models/Contact.swift index 92e7a5159..6b047e354 100644 --- a/SessionMessagingKit/Database/Models/Contact.swift +++ b/SessionMessagingKit/Database/Models/Contact.swift @@ -113,28 +113,11 @@ public extension Contact { static func fetchOrCreate(_ db: Database, id: ID) -> Contact { return ((try? fetchOne(db, id: id)) ?? Contact(id: id)) } - - static func fetchAllIds() -> [String] { - return GRDBStorage.shared - .read { db in - let userPublicKey: String = getUserHexEncodedPublicKey(db) - let contacts: [Contact] = try Contact - .filter(Contact.Columns.id != userPublicKey) - .filter(Contact.Columns.didApproveMe == true) - .fetchAll(db) - let profiles: [Profile] = try Profile - .fetchAll(db, ids: contacts.map { $0.id }) - - // Sort the contacts by their displayName value - return profiles - .sorted(by: { lhs, rhs -> Bool in lhs.displayName() < rhs.displayName() }) - .map { $0.id } - } - .defaulting(to: []) - } } // MARK: - Objective-C Support + +// TODO: Remove this when possible @objc(SMKContact) public class SMKContact: NSObject { @objc let isApproved: Bool @@ -158,5 +141,16 @@ public class SMKContact: NSObject { didApproveMe: existingContact?.didApproveMe ?? false ) } + + @objc(isBlockedFor:) + public static func isBlocked(id: String) -> Bool { + return GRDBStorage.shared + .read { db in + try Contact + .select(.isBlocked) + .asRequest(of: Bool.self) + .fetchOne(db) + } + .defaulting(to: false) + } } - diff --git a/SessionMessagingKit/Database/Models/ControlMessageProcessRecord.swift b/SessionMessagingKit/Database/Models/ControlMessageProcessRecord.swift index ae7d02a23..d8780fbc2 100644 --- a/SessionMessagingKit/Database/Models/ControlMessageProcessRecord.swift +++ b/SessionMessagingKit/Database/Models/ControlMessageProcessRecord.swift @@ -4,20 +4,196 @@ import Foundation import GRDB import SessionUtilitiesKit +/// We can rely on the unique constraints within the `Interaction` table to prevent duplicate `VisibleMessage` +/// values from being processed, but some control messages don’t have an associated interaction - this table provides +/// a de-duping mechanism for those messages +/// +/// **Note:** It’s entirely possible for there to be a false-positive with this record where multiple users sent the same +/// type of control message at the same time - this is very unlikely to occur though since unique to the millisecond level public struct ControlMessageProcessRecord: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "controlMessageProcessRecord" } + /// For notifications and migrated timestamps default to '15' days (which is the timeout for messages on the + /// server at the time of writing) + public static let defaultExpirationSeconds: TimeInterval = (15 * 24 * 60 * 60) + public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { case threadId - case sentTimestampMs - case serverHash - case openGroupMessageServerId + case timestampMs + case variant + case serverExpirationTimestamp } - public let threadId: String - public let sentTimestampMs: Int64 - public let serverHash: String - public let openGroupMessageServerId: Int64 + public enum Variant: Int, Codable, CaseIterable, DatabaseValueConvertible { + /// **Note:** This value should only be used for entries created from the initial migration, when inserting + /// new records it will check if there is an existing legacy record and if so it will attempt to create a "legacy" + /// version of the new record to try and trip the unique constraint + case legacyEntry = 0 + + case readReceipt = 1 + case typingIndicator = 2 + case closedGroupControlMessage = 3 + case dataExtractionNotification = 4 + case expirationTimerUpdate = 5 + case configurationMessage = 6 + case unsendRequest = 7 + case messageRequestResponse = 8 + } + /// The id for the thread the control message is associated to + /// + /// **Note:** For user-specific control message (eg. `ConfigurationMessage`) this value will be the + /// users public key + public let threadId: String + + /// The type of control message + /// + /// **Note:** It would be nice to include this in the unique constraint to reduce the likelihood of false positives + /// but this can result in control messages getting re-handled because the variant is unknown in the migration + public let variant: Variant + + /// The timestamp of the control message + public let timestampMs: Int64 + + /// The timestamp for when this message will expire on the server (will be used for garbage collection) + public let serverExpirationTimestamp: TimeInterval? + + // MARK: - Initialization + + public init?( + threadId: String, + message: Message, + serverExpirationTimestamp: TimeInterval?, + isRetry: Bool = false + ) { + // All `VisibleMessage` values will have an associated `Interaction` so just let + // the unique constraints on that table prevent duplicate messages + if message is VisibleMessage { return nil } + + // TODO: Need to allow duplicates for call messages + + // If the message failed to process and we are retrying then there will already + // be a `ControlMessageProcessRecord`, so return nil to prevent the insertion + // causing a unique constraint violation + if isRetry { return nil } + + // Allow '.new' closed group config message duplicates in this case to avoid + // the following situation: + // • The app performed a background poll or received a push notification + // • This method was invoked and the received message timestamps table was updated + // • Processing wasn't finished + // • The user doesn't see the new closed group + if case .new = (message as? ClosedGroupControlMessage)?.kind { return nil } + + // For all other cases we want to prevent duplicate handling of the message (this + // can happen in a number of situations, primarily with sync messages though hence + // why we don't include the 'serverHash' as part of this record + self.threadId = threadId + self.variant = { + switch message { + case is ReadReceipt: return .readReceipt + case is TypingIndicator: return .typingIndicator + case is ClosedGroupControlMessage: return .closedGroupControlMessage + case is DataExtractionNotification: return .dataExtractionNotification + case is ExpirationTimerUpdate: return .expirationTimerUpdate + case is ConfigurationMessage: return .configurationMessage + case is UnsendRequest: return .unsendRequest + case is MessageRequestResponse: return .messageRequestResponse + default: preconditionFailure("[ControlMessageProcessRecord] Unsupported message type") + } + }() + self.timestampMs = Int64(message.sentTimestamp ?? 0) // Default to `0` if not set + self.serverExpirationTimestamp = serverExpirationTimestamp + } + + public func insert(_ db: Database) throws { + // If this isn't a legacy entry then check if there is a single entry and, if so, + // try to create a "legacy entry" version of this record to see if a unique constraint + // conflict occurs + if !threadId.isEmpty && variant != .legacyEntry { + let legacyEntry: ControlMessageProcessRecord? = try? ControlMessageProcessRecord + .filter(Columns.threadId == "") + .filter(Columns.variant == Variant.legacyEntry) + .fetchOne(db) + + if legacyEntry != nil { + try ControlMessageProcessRecord( + threadId: "", + variant: .legacyEntry, + timestampMs: timestampMs, + serverExpirationTimestamp: (legacyEntry?.serverExpirationTimestamp ?? 0) + ).insert(db) + } + } + + try performInsert(db) + } +} + +// MARK: - Migration Extensions + +internal extension ControlMessageProcessRecord { + init?( + threadId: String, + variant: Interaction.Variant, + timestampMs: Int64 + ) { + switch variant { + case .standardOutgoing, .standardIncoming, .standardIncomingDeleted, + .infoClosedGroupCreated: + return nil + + case .infoClosedGroupUpdated, .infoClosedGroupCurrentUserLeft: + self.variant = .closedGroupControlMessage + + case .infoDisappearingMessagesUpdate: + self.variant = .expirationTimerUpdate + + case .infoScreenshotNotification, .infoMediaSavedNotification: + self.variant = .dataExtractionNotification + + case .infoMessageRequestAccepted: + self.variant = .messageRequestResponse + } + + self.threadId = threadId + self.timestampMs = timestampMs + self.serverExpirationTimestamp = (Date().timeIntervalSince1970 + ControlMessageProcessRecord.defaultExpirationSeconds) + } + + /// This method should only be used for records created during migration from the legacy + /// `receivedMessageTimestamps` collection which doesn't include thread or variant info + /// + /// In order to get around this but maintain the unique constraints on everything we create entries for each timestamp + /// for every thread and every timestamp (while this is wildly inefficient there is a garbage collection process which will + /// clean out these excessive entries after `defaultExpirationSeconds`) + static func generateLegacyProcessRecords(_ db: Database, receivedMessageTimestamps: [Int64]) throws { + let defaultExpirationTimestamp: TimeInterval = ( + Date().timeIntervalSince1970 + ControlMessageProcessRecord.defaultExpirationSeconds + ) + + try receivedMessageTimestamps.forEach { timestampMs in + try ControlMessageProcessRecord( + threadId: "", + variant: .legacyEntry, + timestampMs: timestampMs, + serverExpirationTimestamp: defaultExpirationTimestamp + ).insert(db) + } + } + + /// This method should only be called from either the `generateLegacyProcessRecords` method above or + /// within the 'insert' method to maintain the unique constraint + fileprivate init( + threadId: String, + variant: Variant, + timestampMs: Int64, + serverExpirationTimestamp: TimeInterval + ) { + self.threadId = threadId + self.variant = variant + self.timestampMs = timestampMs + self.serverExpirationTimestamp = serverExpirationTimestamp + } } diff --git a/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift b/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift index 13c30a7cd..b809c947d 100644 --- a/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift +++ b/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift @@ -85,12 +85,6 @@ public extension DisappearingMessagesConfiguration { } } - var durationIndex: Int { - return DisappearingMessagesConfiguration.validDurationsSeconds - .firstIndex(of: durationSeconds) - .defaulting(to: 0) - } - var durationString: String { NSString.formatDurationSeconds(UInt32(durationSeconds), useShortFormat: false) } @@ -133,7 +127,98 @@ extension DisappearingMessagesConfiguration { } // MARK: - Objective-C Support + +// TODO: Remove this when possible + @objc(SMKDisappearingMessagesConfiguration) public class SMKDisappearingMessagesConfiguration: NSObject { @objc public static var maxDurationSeconds: UInt = UInt(DisappearingMessagesConfiguration.maxDurationSeconds) + + @objc public static var validDurationsSeconds: [UInt] = DisappearingMessagesConfiguration + .validDurationsSeconds + .map { UInt($0) } + + @objc(isEnabledFor:) + public static func isEnabled(for threadId: String) -> Bool { + return GRDBStorage.shared + .read { db in + try DisappearingMessagesConfiguration + .select(.isEnabled) + .filter(id: threadId) + .asRequest(of: Bool.self) + .fetchOne(db) + } + .defaulting(to: false) + } + + @objc(durationIndexFor:) + public static func durationIndex(for threadId: String) -> Int { + let durationSeconds: TimeInterval = GRDBStorage.shared + .read { db in + try DisappearingMessagesConfiguration + .select(.durationSeconds) + .filter(id: threadId) + .asRequest(of: TimeInterval.self) + .fetchOne(db) + } + .defaulting(to: DisappearingMessagesConfiguration.defaultDuration) + + return DisappearingMessagesConfiguration.validDurationsSeconds + .firstIndex(of: durationSeconds) + .defaulting(to: 0) + } + + @objc(durationStringFor:) + public static func durationString(for index: Int) -> String { + let durationSeconds: TimeInterval = ( + index >= 0 && index < DisappearingMessagesConfiguration.validDurationsSeconds.count ? + DisappearingMessagesConfiguration.validDurationsSeconds[index] : + DisappearingMessagesConfiguration.validDurationsSeconds[0] + ) + + return NSString.formatDurationSeconds(UInt32(durationSeconds), useShortFormat: false) + } + + @objc(update:isEnabled:durationIndex:) + public static func update(_ threadId: String, isEnabled: Bool, durationIndex: Int) { + let durationSeconds: TimeInterval = ( + durationIndex >= 0 && durationIndex < DisappearingMessagesConfiguration.validDurationsSeconds.count ? + DisappearingMessagesConfiguration.validDurationsSeconds[durationIndex] : + DisappearingMessagesConfiguration.validDurationsSeconds[0] + ) + + GRDBStorage.shared.write { db in + guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else { + return + } + + let config: DisappearingMessagesConfiguration = (try DisappearingMessagesConfiguration + .fetchOne(db, id: threadId)? + .with( + isEnabled: isEnabled, + durationSeconds: durationSeconds + ) + .saved(db)) + .defaulting(to: DisappearingMessagesConfiguration.defaultWith(threadId)) + + let interaction: Interaction = try Interaction( + threadId: threadId, + authorId: getUserHexEncodedPublicKey(db), + variant: .infoDisappearingMessagesUpdate, + body: config.messageInfoString(with: nil), + timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)) + ) + .saved(db) + + try MessageSender.send( + db, + message: ExpirationTimerUpdate( + syncTarget: nil, + duration: UInt32(floor(durationSeconds)) + ), + interactionId: interaction.id, + in: thread + ) + } + } } diff --git a/SessionMessagingKit/Database/Models/GroupMember.swift b/SessionMessagingKit/Database/Models/GroupMember.swift index b9024564b..1387d9eda 100644 --- a/SessionMessagingKit/Database/Models/GroupMember.swift +++ b/SessionMessagingKit/Database/Models/GroupMember.swift @@ -56,3 +56,39 @@ public struct GroupMember: Codable, FetchableRecord, PersistableRecord, TableRec self.role = role } } + +// MARK: - Objective-C Support + +// FIXME: Remove when possible + +@objc(SMKGroupMember) +public class SMKGroupMember: NSObject { + @objc(isCurrentUserMemberOf:) + public static func isCurrentUserMember(of groupId: String) -> Bool { + return GRDBStorage.shared.read { db in + let userPublicKey: String = getUserHexEncodedPublicKey(db) + let numEntries: Int = try GroupMember + .filter(GroupMember.Columns.groupId == groupId) + .filter(GroupMember.Columns.profileId == userPublicKey) + .fetchCount(db) + + return (numEntries > 0) + } + .defaulting(to: false) + } + + @objc(isCurrentUserAdminOf:) + public static func isCurrentUserAdmin(of groupId: String) -> Bool { + return GRDBStorage.shared.read { db in + let userPublicKey: String = getUserHexEncodedPublicKey(db) + let numEntries: Int = try GroupMember + .filter(GroupMember.Columns.groupId == groupId) + .filter(GroupMember.Columns.profileId == userPublicKey) + .filter(GroupMember.Columns.role == GroupMember.Role.admin) + .fetchCount(db) + + return (numEntries > 0) + } + .defaulting(to: false) + } +} diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index c18aaba83..0f9371eea 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -13,7 +13,7 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu ) public static let thread = belongsTo(SessionThread.self, using: threadForeignKey) public static let profile = hasOne(Profile.self, using: Profile.interactionForeignKey) - internal static let interactionAttachments = hasMany( + public static let interactionAttachments = hasMany( InteractionAttachment.self, using: InteractionAttachment.interactionForeignKey ) diff --git a/SessionMessagingKit/Database/Models/OpenGroup.swift b/SessionMessagingKit/Database/Models/OpenGroup.swift index 77d75f091..73454bf00 100644 --- a/SessionMessagingKit/Database/Models/OpenGroup.swift +++ b/SessionMessagingKit/Database/Models/OpenGroup.swift @@ -121,5 +121,45 @@ public struct OpenGroup: Codable, Identifiable, FetchableRecord, PersistableReco public extension OpenGroup { static func idFor(room: String, server: String) -> String { return "\(server.lowercased()).\(room)" + +// MARK: - Objective-C Support + +// TODO: Remove this when possible + +@objc(SMKOpenGroup) +public class SMKOpenGroup: NSObject { + @objc(inviteUsers:toOpenGroupFor:) + public static func invite(selectedUsers: Set, threadId: String) { + GRDBStorage.shared.write { db in + guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: threadId) else { return } + + let urlString: String = "\(openGroup.server)/\(openGroup.room)?public_key=\(openGroup.publicKey)" + + try selectedUsers.forEach { userId in + let thread: SessionThread = try SessionThread.fetchOrCreate(db, id: userId, variant: .contact) + + try LinkPreview( + url: urlString, + variant: .openGroupInvitation, + title: openGroup.name + ) + .save(db) + + let interaction: Interaction = try Interaction( + threadId: threadId, + authorId: userId, + variant: .standardOutgoing, + timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)), + linkPreviewUrl: urlString + ) + .saved(db) + + try MessageSender.send( + db, + interaction: interaction, + in: thread + ) + } + } } } diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index 196e5d91e..a7f97da8f 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -10,6 +10,7 @@ public struct Profile: Codable, Identifiable, Equatable, FetchableRecord, Persis internal static let interactionForeignKey = ForeignKey([Columns.id], to: [Interaction.Columns.authorId]) internal static let contactForeignKey = ForeignKey([Columns.id], to: [Contact.Columns.id]) internal static let groupMemberForeignKey = ForeignKey([Columns.id], to: [GroupMember.Columns.profileId]) + internal static let contact = hasOne(Contact.self, using: contactForeignKey) public static let groupMembers = hasMany(GroupMember.self, using: groupMemberForeignKey) public typealias Columns = CodingKeys @@ -204,9 +205,9 @@ public extension Profile { public extension Profile { func with( name: String? = nil, - nickname: Updatable = .existing, - profilePictureUrl: Updatable = .existing, - profilePictureFileName: Updatable = .existing, + nickname: Updatable = .existing, + profilePictureUrl: Updatable = .existing, + profilePictureFileName: Updatable = .existing, profileEncryptionKey: Updatable = .existing ) -> Profile { return Profile( @@ -223,6 +224,22 @@ public extension Profile { // MARK: - GRDB Interactions public extension Profile { + static func fetchAllContactProfiles(excludeCurrentUser: Bool = true) -> [Profile] { + return GRDBStorage.shared + .read { db in + // Sort the contacts by their displayName value + return try Profile + .filter(Profile.Columns.id != (excludeCurrentUser ? "" : getUserHexEncodedPublicKey(db))) + .joining( + required: Profile.contact + .filter(Contact.Columns.didApproveMe == true) + ) + .fetchAll(db) + .sorted(by: { lhs, rhs -> Bool in lhs.displayName() < rhs.displayName() }) + } + .defaulting(to: []) + } + static func displayName(_ db: Database? = nil, id: ID, thread: SessionThread, customFallback: String? = nil) -> String { return displayName( db, @@ -402,8 +419,8 @@ public extension Profile { ) -> String { if let nickname: String = nickname { return nickname } - guard let name: String = name else { - return (customFallback ?? Profile.truncated(id: id, truncating: .start)) + guard let name: String = name, name != id else { + return (customFallback ?? Profile.truncated(id: id, truncating: .middle)) } switch context { @@ -418,6 +435,9 @@ public extension Profile { } // MARK: - Objective-C Support + +// FIXME: Remove when possible + @objc(SMKProfile) public class SMKProfile: NSObject { var id: String @@ -480,4 +500,19 @@ public class SMKProfile: NSObject { @objc public static var localProfileKey: OWSAES256Key? { Profile.fetchOrCreateCurrentUser().profileEncryptionKey } + + @objc(displayNameAfterSavingNickname:forProfileId:) + public static func displayNameAfterSaving(nickname: String?, for profileId: String) -> String { + return GRDBStorage.shared.write { db in + let profile: Profile = Profile.fetchOrCreate(id: profileId) + let targetNickname: String? = ((nickname ?? "").count > 0 ? nickname : nil) + + _ = try profile + .with(nickname: .update(targetNickname)) + .saved(db) + + return (targetNickname ?? profile.name) + } + .defaulting(to: "") + } } diff --git a/SessionMessagingKit/Database/Models/SessionThread.swift b/SessionMessagingKit/Database/Models/SessionThread.swift index fab84db2b..f411e030d 100644 --- a/SessionMessagingKit/Database/Models/SessionThread.swift +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -23,9 +23,9 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord, case shouldBeVisible case isPinned case messageDraft - case notificationMode case notificationSound case mutedUntilTimestamp + case onlyNotifyForMentions } public enum Variant: Int, Codable, DatabaseValueConvertible { @@ -33,12 +33,6 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord, case closedGroup case openGroup } - - public enum NotificationMode: Int, Codable, DatabaseValueConvertible { - case none - case all - case mentionsOnly // Only applicable to group threads - } /// Unique identifier for a thread (formerly known as uniqueId) /// @@ -63,9 +57,6 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord, /// The value the user started entering into the input field before they left the conversation screen public let messageDraft: String? - /// The notification mode this thread is set to - public let notificationMode: NotificationMode - /// The sound which should be used when receiving a notification for this thread /// /// **Note:** If unset this will use the `Preferences.Sound.defaultNotificationSound` @@ -74,6 +65,9 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord, /// Timestamp (seconds since epoch) for when this thread should stop being muted public let mutedUntilTimestamp: TimeInterval? + /// A flag indicating whether the thread should only notify for mentions + public let onlyNotifyForMentions: Bool + // MARK: - Relationships public var contact: QueryInterfaceRequest { @@ -105,9 +99,9 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord, shouldBeVisible: Bool = false, isPinned: Bool = false, messageDraft: String? = nil, - notificationMode: NotificationMode = .all, notificationSound: Preferences.Sound? = nil, - mutedUntilTimestamp: TimeInterval? = nil + mutedUntilTimestamp: TimeInterval? = nil, + onlyNotifyForMentions: Bool = false ) { self.id = id self.variant = variant @@ -115,9 +109,9 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord, self.shouldBeVisible = shouldBeVisible self.isPinned = isPinned self.messageDraft = messageDraft - self.notificationMode = notificationMode self.notificationSound = notificationSound self.mutedUntilTimestamp = mutedUntilTimestamp + self.onlyNotifyForMentions = onlyNotifyForMentions } // MARK: - Custom Database Interaction @@ -146,9 +140,9 @@ public extension SessionThread { shouldBeVisible: (shouldBeVisible ?? self.shouldBeVisible), isPinned: (isPinned ?? self.isPinned), messageDraft: messageDraft, - notificationMode: notificationMode, notificationSound: notificationSound, - mutedUntilTimestamp: mutedUntilTimestamp + mutedUntilTimestamp: mutedUntilTimestamp, + onlyNotifyForMentions: onlyNotifyForMentions ) } } @@ -303,3 +297,66 @@ public extension SessionThread { } } } + +// MARK: - Objective-C Support + +// FIXME: Remove when possible + +@objc(SMKThread) +public class SMKThread: NSObject { + @objc(isThreadMuted:) + public static func isThreadMuted(_ threadId: String) -> Bool { + return GRDBStorage.shared.read { db in + let mutedUntilTimestamp: TimeInterval? = try SessionThread + .select(SessionThread.Columns.mutedUntilTimestamp) + .filter(id: threadId) + .asRequest(of: TimeInterval?.self) + .fetchOne(db) + + return (mutedUntilTimestamp != nil) + } + .defaulting(to: false) + } + + @objc(isOnlyNotifyingForMentions:) + public static func isOnlyNotifyingForMentions(_ threadId: String) -> Bool { + return GRDBStorage.shared.read { db in + return try SessionThread + .select(SessionThread.Columns.onlyNotifyForMentions == true) + .filter(id: threadId) + .asRequest(of: Bool.self) + .fetchOne(db) + } + .defaulting(to: false) + } + + @objc(setIsOnlyNotifyingForMentions:to:) + public static func isOnlyNotifyingForMentions(_ threadId: String, isEnabled: Bool) { + GRDBStorage.shared.write { db in + try SessionThread + .filter(id: threadId) + .updateAll(db, SessionThread.Columns.onlyNotifyForMentions.set(to: isEnabled)) + } + } + + @objc(mutedUntilDateFor:) + public static func mutedUntilDateFor(_ threadId: String) -> Date? { + return GRDBStorage.shared.read { db in + return try SessionThread + .select(SessionThread.Columns.mutedUntilTimestamp) + .filter(id: threadId) + .asRequest(of: TimeInterval.self) + .fetchOne(db) + } + .map { Date(timeIntervalSince1970: $0) } + } + + @objc(updateWithMutedUntilDateTo:forThreadId:) + public static func updateWithMutedUntilDate(to date: Date?, threadId: String) { + GRDBStorage.shared.write { db in + try SessionThread + .filter(id: threadId) + .updateAll(db, SessionThread.Columns.mutedUntilTimestamp.set(to: date?.timeIntervalSince1970)) + } + } +} diff --git a/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift b/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift index 1d1ad1b85..335bcb73c 100644 --- a/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift @@ -40,6 +40,7 @@ public enum MessageReceiveJob: JobExecutor { let (message, proto) = try MessageReceiver.parse( db, data: messageInfo.data, + serverExpirationTimestamp: messageInfo.serverExpirationTimestamp, isRetry: isRetry ) message.serverHash = messageInfo.serverHash @@ -115,13 +116,16 @@ extension MessageReceiveJob { public struct MessageInfo: Codable { public let data: Data public let serverHash: String? + public let serverExpirationTimestamp: TimeInterval? public init( data: Data, - serverHash: String? + serverHash: String?, + serverExpirationTimestamp: TimeInterval? ) { self.data = data self.serverHash = serverHash + self.serverExpirationTimestamp = serverExpirationTimestamp } } diff --git a/SessionMessagingKit/Messages/Control Messages/DataExtractionNotification.swift b/SessionMessagingKit/Messages/Control Messages/DataExtractionNotification.swift index 01557954d..2b57181a3 100644 --- a/SessionMessagingKit/Messages/Control Messages/DataExtractionNotification.swift +++ b/SessionMessagingKit/Messages/Control Messages/DataExtractionNotification.swift @@ -27,7 +27,7 @@ public final class DataExtractionNotification : ControlMessage { // MARK: - Initialization - internal init(kind: Kind) { + public init(kind: Kind) { super.init() self.kind = kind diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift index 30f7ce242..5423294c6 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift @@ -177,9 +177,9 @@ extension MessageReceiver { else { return } _ = try Interaction( - serverHash: message.serverHash, // TODO: Test this? (make sure it won't break anything?) + serverHash: message.serverHash, threadId: thread.id, - authorId: sender, // TODO: Confirm this + authorId: sender, variant: { switch messageKind { case .screenshot: return .infoScreenshotNotification @@ -209,21 +209,16 @@ extension MessageReceiver { .defaulting(to: DisappearingMessagesConfiguration.defaultWith(thread.id)) .with( // If there is no duration then we should disable the expiration timer - isEnabled: (message.duration != nil), + isEnabled: ((message.duration ?? 0) > 0), durationSeconds: ( message.duration.map { TimeInterval($0) } ?? DisappearingMessagesConfiguration.defaultDuration ) ) - .saved(db) // Add an info message for the user - // - // Note: If it's a duplicate message (which the 'ExpirationTimerUpdate' frequently can be) - // then the write transaction will fail meaning the above config update won't be applied - // so we don't need to worry about order-of-execution) _ = try Interaction( - serverHash: message.serverHash, + serverHash: nil, // Intentionally null so sync messages are seen as duplicates threadId: thread.id, authorId: sender, variant: .infoDisappearingMessagesUpdate, @@ -235,6 +230,10 @@ extension MessageReceiver { ), timestampMs: Int64(message.sentTimestamp ?? 0) // Default to `0` if not set ).inserted(db) + + // Finally save the changes to the DisappearingMessagesConfiguration (If it's a duplicate + // then the interaction unique constraint will prevent the code from getting here) + try config.save(db) } // MARK: - Configuration Messages @@ -822,10 +821,12 @@ extension MessageReceiver { let groupAlreadyExisted: Bool = ((try? SessionThread.exists(db, id: groupPublicKey)) ?? false) let thread: SessionThread = try SessionThread .fetchOrCreate(db, id: groupPublicKey, variant: .closedGroup) + .with(shouldBeVisible: true) + .saved(db) let closedGroup: ClosedGroup = try ClosedGroup( threadId: groupPublicKey, name: name, - formationTimestamp: Date().timeIntervalSince1970 + formationTimestamp: (TimeInterval(messageSentTimestamp) / 1000) ).saved(db) // Clear the zombie list if the group wasn't active (ie. had no keys) @@ -845,7 +846,7 @@ extension MessageReceiver { threadId: thread.id, authorId: getUserHexEncodedPublicKey(db), variant: .infoClosedGroupCreated, - timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)) + timestampMs: Int64(messageSentTimestamp) ).inserted(db) } @@ -862,8 +863,6 @@ extension MessageReceiver { ) .save(db) - // Add the group to the user's set of public keys to poll for - Storage.shared.addClosedGroupPublicKey(groupPublicKey, using: transaction) // Store the key pair try ClosedGroupKeyPair( threadId: groupPublicKey, @@ -952,7 +951,7 @@ extension MessageReceiver { guard name != closedGroup.name else { return } _ = try Interaction( - serverHash: message.serverHash, // TODO: Test this? (make sure it won't break anything?) + serverHash: message.serverHash, threadId: thread.id, authorId: sender, variant: .infoClosedGroupUpdated, @@ -1017,7 +1016,7 @@ extension MessageReceiver { guard members != Set(groupMembers.map { $0.profileId }) else { return } _ = try Interaction( - serverHash: message.serverHash, // TODO: Test this? (make sure it won't break anything?) + serverHash: message.serverHash, threadId: thread.id, authorId: sender, variant: .infoClosedGroupUpdated, @@ -1071,6 +1070,7 @@ extension MessageReceiver { _ = try closedGroup .keyPairs .deleteAll(db) + let _ = PushNotificationAPI.performOperation( .unsubscribe, for: id, @@ -1090,7 +1090,7 @@ extension MessageReceiver { guard members != Set(groupMembers.map { $0.profileId }) else { return } _ = try Interaction( - serverHash: message.serverHash, // TODO: Test this? (make sure it won't break anything?) + serverHash: message.serverHash, threadId: thread.id, authorId: sender, variant: (wasCurrentUserRemoved ? .infoClosedGroupCurrentUserLeft : .infoClosedGroupUpdated), @@ -1140,6 +1140,7 @@ extension MessageReceiver { _ = try closedGroup .keyPairs .deleteAll(db) + let _ = PushNotificationAPI.performOperation( .unsubscribe, for: id, @@ -1162,7 +1163,7 @@ extension MessageReceiver { guard updatedMemberIds != Set(members.map { $0.profileId }) else { return } _ = try Interaction( - serverHash: message.serverHash, // TODO: Test this? (make sure it won't break anything?) + serverHash: message.serverHash, threadId: thread.id, authorId: sender, variant: .infoClosedGroupUpdated, @@ -1272,7 +1273,7 @@ extension MessageReceiver { // Get the existing thead and notify the user if let thread: SessionThread = try? SessionThread.fetchOne(db, id: senderId) { _ = try Interaction( - serverHash: message.serverHash, // TODO: Test this? (make sure it won't break anything?) + serverHash: message.serverHash, threadId: thread.id, authorId: senderId, variant: .infoMessageRequestAccepted, diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index 414cb9783..d8615a5ea 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -11,6 +11,7 @@ public enum MessageReceiver { public static func parse( _ db: Database, data: Data, + serverExpirationTimestamp: TimeInterval?, openGroupId: String? = nil, openGroupMessageServerId: UInt64? = nil, isRetry: Bool = false @@ -159,47 +160,22 @@ public enum MessageReceiver { throw MessageReceiverError.invalidMessage } - // If the message failed to process the first time around we retry it later (if the error is retryable). In this case the timestamp - // will already be in the database but we don't want to treat the message as a duplicate. The isRetry flag is a simple workaround - // for this issue. - - switch (isRetry, message, (message as? ClosedGroupControlMessage)?.kind) { - // Allow duplicates in this case to avoid the following situation: - // • The app performed a background poll or received a push notification - // • This method was invoked and the received message timestamps table was updated - // • Processing wasn't finished - // • The user doesn't see the new closed group - case (_, _, .new): break + // Prevent ControlMessages from being handled multiple times if not supported + try ControlMessageProcessRecord( + threadId: { + if let groupPublicKey: String = groupPublicKey { return groupPublicKey } + if let openGroupId: String = openGroupId { return openGroupId } - // All `VisibleMessage` values will have an associated `Interaction` so just let - // the unique constraints on that table prevent duplicate messages - case is (Bool, VisibleMessage, ClosedGroupControlMessage.Kind?): break - - // If the message failed to process and we are retrying then there will already - // be a `ControlMessageProcessRecord`, so just allow this through - case (true, _, _): break - - default: - do { - try ControlMessageProcessRecord( - threadId: { - if let openGroupId: String = openGroupId { - return openGroupId - } - - if let groupPublicKey: String = groupPublicKey { - return groupPublicKey - } - - return sender - }(), - sentTimestampMs: Int64(envelope.timestamp), - serverHash: (message.serverHash ?? ""), - openGroupMessageServerId: (openGroupMessageServerId.map { Int64($0) } ?? 0) - ).insert(db) + switch message { + case let message as VisibleMessage: return (message.syncTarget ?? sender) + case let message as ExpirationTimerUpdate: return (message.syncTarget ?? sender) + default: return sender } - catch { throw MessageReceiverError.duplicateMessage } - } + }(), + message: message, + serverExpirationTimestamp: serverExpirationTimestamp, + isRetry: false + )?.insert(db) // Return return (message, proto) diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index d0e81d4f0..87c64f7bf 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -484,31 +484,23 @@ public final class MessageSender : NSObject { ) } - // Prevent the same ExpirationTimerUpdate to be handled twice - if message is ControlMessage { - try? ControlMessageProcessRecord( - threadId: { - switch destination { - case .contact(let publicKey): return publicKey - case .closedGroup(let groupPublicKey): return groupPublicKey - case .openGroupV2(let room, let server): - return OpenGroup.idFor(room: room, server: server) - - // FIXME: Remove support for V1 SOGS - case .openGroup: return getUserHexEncodedPublicKey(db) - } - }(), - sentTimestampMs: { - if message.openGroupServerMessageId != nil { - return (serverTimestampMs.map { Int64($0) } ?? 0) - } - - return (message.sentTimestamp.map { Int64($0) } ?? 0) - }(), - serverHash: (message.serverHash ?? ""), - openGroupMessageServerId: (message.openGroupServerMessageId.map { Int64($0) } ?? 0) - ).insert(db) - } + // Prevent ControlMessages from being handled multiple times if not supported + try? ControlMessageProcessRecord( + threadId: { + switch destination { + case .contact(let publicKey): return publicKey + case .closedGroup(let groupPublicKey): return groupPublicKey + case .openGroupV2(let room, let server): + return OpenGroup.idFor(room: room, server: server) + + case .openGroup(_, _): return "" // TODO: Remove this after merge + } + }(), + message: message, + serverExpirationTimestamp: (Date().timeIntervalSince1970 + ControlMessageProcessRecord.defaultExpirationSeconds), + isRetry: false + )?.insert(db) + // Sync the message if: // • it's a visible message or an expiration timer update // • the destination was a contact diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift index 4787cf460..7c441a95e 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift @@ -6,41 +6,57 @@ import PromiseKit import SessionSnodeKit @objc(LKClosedGroupPoller) -public final class ClosedGroupPoller : NSObject { - private var isPolling: [String:Bool] = [:] - private var timers: [String:Timer] = [:] - private let internalQueue: DispatchQueue = DispatchQueue(label:"isPollingQueue") +public final class ClosedGroupPoller: NSObject { + private var isPolling: [String: Bool] = [:] + private var timers: [String: Timer] = [:] + private let internalQueue: DispatchQueue = DispatchQueue(label: "isPollingQueue") - // MARK: Settings + // MARK: - Settings + private static let minPollInterval: Double = 2 private static let maxPollInterval: Double = 30 - // MARK: Error - private enum Error : LocalizedError { + // MARK: - Error + + private enum Error: LocalizedError { case insufficientSnodes case pollingCanceled internal var errorDescription: String? { switch self { - case .insufficientSnodes: return "No snodes left to poll." - case .pollingCanceled: return "Polling canceled." + case .insufficientSnodes: return "No snodes left to poll." + case .pollingCanceled: return "Polling canceled." } } } - // MARK: Initialization + // MARK: - Initialization + public static let shared = ClosedGroupPoller() private override init() { } - // MARK: Public API + // MARK: - Public API + @objc public func start() { #if DEBUG assert(Thread.current.isMainThread) // Timers don't do well on background queues #endif - let storage = SNMessagingKitConfiguration.shared.storage - let allGroupPublicKeys = storage.getUserClosedGroupPublicKeys() - allGroupPublicKeys.forEach { startPolling(for: $0) } + + // Fetch all closed groups (excluding any which have no key pairs as the user is + // no longer a member of those + GRDBStorage.shared + .read { db in + try ClosedGroup + .select(.threadId) + .joining(required: ClosedGroup.keyPairs) + .asRequest(of: String.self) + .fetchAll(db) + } + .defaulting(to: []) + .forEach { [weak self] groupPublicKey in + self?.startPolling(for: groupPublicKey) + } } public func startPolling(for groupPublicKey: String) { @@ -53,9 +69,17 @@ public final class ClosedGroupPoller : NSObject { } @objc public func stop() { - let storage = SNMessagingKitConfiguration.shared.storage - let allGroupPublicKeys = storage.getUserClosedGroupPublicKeys() - allGroupPublicKeys.forEach { stopPolling(for: $0) } + GRDBStorage.shared + .read { db in + try ClosedGroup + .select(.threadId) + .asRequest(of: String.self) + .fetchAll(db) + } + .defaulting(to: []) + .forEach { [weak self] groupPublicKey in + self?.stopPolling(for: groupPublicKey) + } } public func stopPolling(for groupPublicKey: String) { @@ -63,29 +87,48 @@ public final class ClosedGroupPoller : NSObject { timers[groupPublicKey]?.invalidate() } - // MARK: Private API + // MARK: - Private API + private func setUpPolling(for groupPublicKey: String) { Threading.pollerQueue.async { - self.poll(groupPublicKey).done(on: Threading.pollerQueue) { [weak self] _ in - self?.pollRecursively(groupPublicKey) - }.catch(on: Threading.pollerQueue) { [weak self] error in - // The error is logged in poll(_:) - self?.pollRecursively(groupPublicKey) - } + self.poll(groupPublicKey) + .done(on: Threading.pollerQueue) { [weak self] _ in + self?.pollRecursively(groupPublicKey) + } + .catch(on: Threading.pollerQueue) { [weak self] error in + // The error is logged in poll(_:) + self?.pollRecursively(groupPublicKey) + } } } private func pollRecursively(_ groupPublicKey: String) { - let groupID = LKGroupUtilities.getEncodedClosedGroupIDAsData(groupPublicKey) - guard isPolling(for: groupPublicKey), - let thread = TSGroupThread.fetch(uniqueId: TSGroupThread.threadId(fromGroupId: groupID)) else { return } + guard + isPolling(for: groupPublicKey), + let thread: SessionThread = GRDBStorage.shared.read({ db in try SessionThread.fetchOne(db, id: groupPublicKey) }) + else { return } + // Get the received date of the last message in the thread. If we don't have any messages yet, pick some - // reasonable fake time interval to use instead. - let lastMessageDate = - (thread.numberOfInteractions() > 0) ? thread.lastInteraction.receivedAtDate() : Date().addingTimeInterval(-5 * 60) - let timeSinceLastMessage = Date().timeIntervalSince(lastMessageDate) - let minPollInterval = ClosedGroupPoller.minPollInterval - let limit: Double = 12 * 60 * 60 + // reasonable fake time interval to use instead + + let lastMessageDate: Date = GRDBStorage.shared + .read { db in + try thread + .interactions + .select(.receivedAtTimestampMs) + .order(Interaction.Columns.timestampMs.desc) + .asRequest(of: Int64.self) + .fetchOne(db) + } + .map { receivedAtTimestampMs -> Date? in + guard receivedAtTimestampMs > 0 else { return nil } + + return Date(timeIntervalSince1970: (TimeInterval(receivedAtTimestampMs) / 1000)) + } + .defaulting(to: Date().addingTimeInterval(-5 * 60)) + let timeSinceLastMessage: TimeInterval = Date().timeIntervalSince(lastMessageDate) + let minPollInterval: Double = ClosedGroupPoller.minPollInterval + let limit: Double = (12 * 60 * 60) let a = (ClosedGroupPoller.maxPollInterval - minPollInterval) / limit let nextPollInterval = a * min(timeSinceLastMessage, limit) + minPollInterval SNLog("Next poll interval for closed group with public key: \(groupPublicKey) is \(nextPollInterval) s.") @@ -133,7 +176,8 @@ public final class ClosedGroupPoller : NSObject { jobDetailMessages.append( MessageReceiveJob.Details.MessageInfo( data: try envelope.serializedData(), - serverHash: message.info.hash + serverHash: message.info.hash, + serverExpirationTimestamp: (TimeInterval(message.info.expirationDateMs) / 1000) ) ) diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift index f64d0c2eb..a58ea941d 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift @@ -124,7 +124,8 @@ public final class Poller : NSObject { .appending( MessageReceiveJob.Details.MessageInfo( data: try envelope.serializedData(), - serverHash: message.info.hash + serverHash: message.info.hash, + serverExpirationTimestamp: (TimeInterval(message.info.expirationDateMs) / 1000) ) ) diff --git a/SessionNotificationServiceExtension/NSENotificationPresenter.swift b/SessionNotificationServiceExtension/NSENotificationPresenter.swift index 5a5343d56..55fe20d65 100644 --- a/SessionNotificationServiceExtension/NSENotificationPresenter.swift +++ b/SessionNotificationServiceExtension/NSENotificationPresenter.swift @@ -7,9 +7,9 @@ import SignalUtilitiesKit import SessionMessagingKit public class NSENotificationPresenter: NSObject, NotificationsProtocol { - + public func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread, isBackgroundPoll: Bool) { - guard thread.notificationMode != .none else { return } + guard Date().timeIntervalSince1970 < (thread.mutedUntilTimestamp ?? 0) else { return } let isMessageRequest = thread.isMessageRequest(db) @@ -48,7 +48,7 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol { var notificationTitle = senderName if thread.variant == .closedGroup || thread.variant == .openGroup { - if thread.notificationMode == .mentionsOnly && !interaction.isUserMentioned(db) { + if thread.onlyNotifyForMentions && !interaction.isUserMentioned(db) { // Ignore PNs if the group is set to only notify for mentions return } diff --git a/SessionNotificationServiceExtension/NotificationServiceExtension.swift b/SessionNotificationServiceExtension/NotificationServiceExtension.swift index a0cdf0845..8d53f5262 100644 --- a/SessionNotificationServiceExtension/NotificationServiceExtension.swift +++ b/SessionNotificationServiceExtension/NotificationServiceExtension.swift @@ -45,7 +45,11 @@ public final class NotificationServiceExtension : UNNotificationServiceExtension // is added to notification center GRDBStorage.shared.write { db in do { - let (message, proto) = try MessageReceiver.parse(db, data: envelopeAsData) + let (message, proto) = try MessageReceiver.parse( + db, + data: envelopeAsData, + serverExpirationTimestamp: (Date().timeIntervalSince1970 + ControlMessageProcessRecord.defaultExpirationSeconds) + ) switch message { case let visibleMessage as VisibleMessage: let interactionId: Int64 = try MessageReceiver.handleVisibleMessage(db, message: visibleMessage, associatedWithProto: proto, openGroupId: nil, isBackgroundPoll: false) diff --git a/SessionUtilitiesKit/General/Set+Utilities.swift b/SessionUtilitiesKit/General/Set+Utilities.swift index 2bc8205ca..5fb2d416b 100644 --- a/SessionUtilitiesKit/General/Set+Utilities.swift +++ b/SessionUtilitiesKit/General/Set+Utilities.swift @@ -12,6 +12,15 @@ public extension Set { return updatedSet } + func inserting(contentsOf value: Set?) -> Set { + guard let value: Set = value else { return self } + + var updatedSet: Set = self + value.forEach { updatedSet.insert($0) } + + return updatedSet + } + func removing(_ value: Element?) -> Set { guard let value: Element = value else { return self } diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift index 0b352f6af..0258dc0e3 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift @@ -330,7 +330,7 @@ public class AttachmentPrepViewController: OWSViewController, PlayerProgressBarD } } - @objc public func videoPlayerDidPlayToCompletion(_ videoPlayer: OWSVideoPlayer) { + public func videoPlayerDidPlayToCompletion(_ videoPlayer: OWSVideoPlayer) { UIView.animate(withDuration: 0.1) { [weak self] in self?.playVideoButton.alpha = 1.0 } diff --git a/SignalUtilitiesKit/Media Viewing & Editing/OWSVideoPlayer.swift b/SignalUtilitiesKit/Media Viewing & Editing/OWSVideoPlayer.swift index 581b861cc..12f1e85f6 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/OWSVideoPlayer.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/OWSVideoPlayer.swift @@ -5,27 +5,21 @@ import Foundation import AVFoundation -@objc -public protocol OWSVideoPlayerDelegate: class { +public protocol OWSVideoPlayerDelegate: AnyObject { func videoPlayerDidPlayToCompletion(_ videoPlayer: OWSVideoPlayer) } -@objc -public class OWSVideoPlayer: NSObject { +public class OWSVideoPlayer { - @objc public let avPlayer: AVPlayer let audioActivity: AudioActivity - @objc public weak var delegate: OWSVideoPlayerDelegate? @objc public init(url: URL) { self.avPlayer = AVPlayer(url: url) self.audioActivity = AudioActivity(audioDescription: "[OWSVideoPlayer] url:\(url)", behavior: .playback) - super.init() - NotificationCenter.default.addObserver(self, selector: #selector(playerItemDidPlayToCompletion(_:)), name: NSNotification.Name.AVPlayerItemDidPlayToEndTime, diff --git a/SignalUtilitiesKit/Messaging/BlockListUIUtils.swift b/SignalUtilitiesKit/Messaging/BlockListUIUtils.swift index 1b3e53877..7f157918a 100644 --- a/SignalUtilitiesKit/Messaging/BlockListUIUtils.swift +++ b/SignalUtilitiesKit/Messaging/BlockListUIUtils.swift @@ -10,15 +10,15 @@ import SessionMessagingKit /// This method shows an alert to unblock a contact in a ContactThread and will update the `isBlocked` flag of the contact if the user decides to continue /// /// **Note:** Make sure to force a config sync in the `completionBlock` if the blocked state was successfully changed - @objc public static func showBlockThreadActionSheet(_ thread: TSContactThread, from viewController: UIViewController, completionBlock: ((Bool) -> ())? = nil) { + @objc public static func showBlockThreadActionSheet(_ threadId: String, from viewController: UIViewController, completionBlock: ((Bool) -> ())? = nil) { let userPublicKey = getUserHexEncodedPublicKey() - guard thread.contactSessionID() != userPublicKey else { + guard threadId != userPublicKey else { completionBlock?(false) return } - let displayName: String = Profile.displayName(id: thread.contactSessionID()) + let displayName: String = Profile.displayName(id: threadId) let actionSheet: UIAlertController = UIAlertController( title: String( format: "BLOCK_LIST_BLOCK_USER_TITLE_FORMAT".localized(), @@ -35,7 +35,7 @@ import SessionMessagingKit GRDBStorage.shared.writeAsync( updates: { db in try? Contact - .fetchOrCreate(db, id: thread.contactSessionID()) + .fetchOrCreate(db, id: threadId) .with(isBlocked: true) .save(db) }, @@ -68,8 +68,8 @@ import SessionMessagingKit /// This method shows an alert to unblock a contact in a ContactThread and will update the `isBlocked` flag of the contact if the user decides to continue /// /// **Note:** Make sure to force a config sync in the `completionBlock` if the blocked state was successfully changed - @objc public static func showUnblockThreadActionSheet(_ thread: TSContactThread, from viewController: UIViewController, completionBlock: ((Bool) -> ())? = nil) { - let displayName: String = Profile.displayName(id: thread.contactSessionID()) + @objc public static func showUnblockThreadActionSheet(_ threadId: String, from viewController: UIViewController, completionBlock: ((Bool) -> ())? = nil) { + let displayName: String = Profile.displayName(id: threadId) let actionSheet: UIAlertController = UIAlertController( title: String( format: "BLOCK_LIST_UNBLOCK_TITLE_FORMAT".localized(), @@ -86,7 +86,7 @@ import SessionMessagingKit GRDBStorage.shared.writeAsync( updates: { db in try? Contact - .fetchOrCreate(db, id: thread.contactSessionID()) + .fetchOrCreate(db, id: threadId) .with(isBlocked: false) .save(db) }, diff --git a/SignalUtilitiesKit/Shared View Controllers/ModalActivityIndicatorViewController.swift b/SignalUtilitiesKit/Shared View Controllers/ModalActivityIndicatorViewController.swift index a38915a40..868d5745c 100644 --- a/SignalUtilitiesKit/Shared View Controllers/ModalActivityIndicatorViewController.swift +++ b/SignalUtilitiesKit/Shared View Controllers/ModalActivityIndicatorViewController.swift @@ -41,8 +41,14 @@ public class ModalActivityIndicatorViewController: OWSViewController { } @objc - public class func present(fromViewController: UIViewController, canCancel: Bool = false, message: String? = nil, - backgroundBlock : @escaping (ModalActivityIndicatorViewController) -> Void) { + public class func present( + fromViewController: UIViewController?, + canCancel: Bool = false, + message: String? = nil, + backgroundBlock: @escaping (ModalActivityIndicatorViewController) -> Void + ) { + guard let fromViewController: UIViewController = fromViewController else { return } + AssertIsOnMainThread() let view = ModalActivityIndicatorViewController(canCancel: canCancel, message: message) diff --git a/SignalUtilitiesKit/Shared Views/GalleryRailView.swift b/SignalUtilitiesKit/Shared Views/GalleryRailView.swift index 013602477..b6f43c54b 100644 --- a/SignalUtilitiesKit/Shared Views/GalleryRailView.swift +++ b/SignalUtilitiesKit/Shared Views/GalleryRailView.swift @@ -5,15 +5,15 @@ import PromiseKit import SessionUIKit -public protocol GalleryRailItemProvider: class { +public protocol GalleryRailItemProvider: AnyObject { var railItems: [GalleryRailItem] { get } } -public protocol GalleryRailItem: class { +public protocol GalleryRailItem: AnyObject { func buildRailItemView() -> UIView } -protocol GalleryRailCellViewDelegate: class { +protocol GalleryRailCellViewDelegate: AnyObject { func didTapGalleryRailCellView(_ galleryRailCellView: GalleryRailCellView) } diff --git a/SignalUtilitiesKit/Utilities/UIView+OWS.swift b/SignalUtilitiesKit/Utilities/UIView+OWS.swift index 2e02932ef..2a8f2c736 100644 --- a/SignalUtilitiesKit/Utilities/UIView+OWS.swift +++ b/SignalUtilitiesKit/Utilities/UIView+OWS.swift @@ -132,26 +132,35 @@ public extension UIView { @objc public extension UIViewController { - public func presentAlert(_ alert: UIAlertController) { + func presentAlert(_ alert: UIAlertController) { self.presentAlert(alert, animated: true) } - public func presentAlert(_ alert: UIAlertController, animated: Bool) { - self.present(alert, - animated: animated, - completion: { - alert.applyAccessibilityIdentifiers() - }) + func presentAlert(_ alert: UIAlertController, animated: Bool) { + if !Thread.isMainThread { + DispatchQueue.main.async { [weak self] in + self?.presentAlert(alert, animated: animated) + } + return + } + + self.present(alert, animated: animated) { + alert.applyAccessibilityIdentifiers() + } } - public func presentAlert(_ alert: UIAlertController, completion: @escaping (() -> Void)) { - self.present(alert, - animated: true, - completion: { - alert.applyAccessibilityIdentifiers() - - completion() - }) + func presentAlert(_ alert: UIAlertController, completion: @escaping (() -> Void)) { + if !Thread.isMainThread { + DispatchQueue.main.async { [weak self] in + self?.presentAlert(alert, completion: completion) + } + return + } + + self.present(alert, animated: true) { + alert.applyAccessibilityIdentifiers() + completion() + } } }