diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index b408afb78..02ecbcfdd 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -706,6 +706,17 @@ FD6A7A6B2818C17C00035AC1 /* UpdateProfilePictureJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6A7A6A2818C17C00035AC1 /* UpdateProfilePictureJob.swift */; }; FD6A7A6D2818C61500035AC1 /* _002_SetupStandardJobs.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6A7A6C2818C61500035AC1 /* _002_SetupStandardJobs.swift */; }; FD705A92278D051200F16121 /* ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A91278D051200F16121 /* ReusableView.swift */; }; + FD7115EB28C5D78E00B47552 /* ThreadSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7115EA28C5D78E00B47552 /* ThreadSettingsViewModel.swift */; }; + FD7115EE28C5D79B00B47552 /* SettingsAvatarCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7115ED28C5D79B00B47552 /* SettingsAvatarCell.swift */; }; + FD7115F028C5D7DE00B47552 /* SettingHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7115EF28C5D7DE00B47552 /* SettingHeaderView.swift */; }; + FD7115F228C6CB3900B47552 /* _009_AddThreadIdToFTS.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7115F128C6CB3900B47552 /* _009_AddThreadIdToFTS.swift */; }; + FD7115F428C71EB200B47552 /* ThreadDisappearingMessagesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7115F328C71EB200B47552 /* ThreadDisappearingMessagesViewModel.swift */; }; + FD7115F828C8151C00B47552 /* DisposableBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7115F728C8151C00B47552 /* DisposableBarButtonItem.swift */; }; + FD7115FA28C8153400B47552 /* UIBarButtonItem+Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7115F928C8153400B47552 /* UIBarButtonItem+Combine.swift */; }; + FD7115FC28C8155800B47552 /* Publisher+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7115FB28C8155800B47552 /* Publisher+Utilities.swift */; }; + FD7115FE28C8202D00B47552 /* ReplaySubject.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7115FD28C8202D00B47552 /* ReplaySubject.swift */; }; + FD71160028C8253500B47552 /* UIView+Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7115FF28C8253500B47552 /* UIView+Combine.swift */; }; + FD71160228C8255900B47552 /* UIControl+Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD71160128C8255900B47552 /* UIControl+Combine.swift */; }; FD7162DB281B6C440060647B /* TypedTableAlias.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7162DA281B6C440060647B /* TypedTableAlias.swift */; }; FD716E6428502DDD00C96BF4 /* CallManagerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD716E6328502DDD00C96BF4 /* CallManagerProtocol.swift */; }; FD716E6628502EE200C96BF4 /* CurrentCallProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD716E6528502EE200C96BF4 /* CurrentCallProtocol.swift */; }; @@ -814,7 +825,6 @@ FDC6D7602862B3F600B04575 /* Dependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC6D75F2862B3F600B04575 /* Dependencies.swift */; }; FDCDB8DE2810F73B00352A0C /* Differentiable+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDCDB8DD2810F73B00352A0C /* Differentiable+Utilities.swift */; }; FDCDB8E02811007F00352A0C /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDCDB8DF2811007F00352A0C /* HomeViewModel.swift */; }; - FDCDB8E42817819600352A0C /* (null) in Sources */ = {isa = PBXBuildFile; }; FDD2506E283711D600198BDA /* DifferenceKit+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD2506D283711D600198BDA /* DifferenceKit+Utilities.swift */; }; FDD250702837199200198BDA /* GarbageCollectionJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD2506F2837199200198BDA /* GarbageCollectionJob.swift */; }; FDD250722837234B00198BDA /* MediaGalleryNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD250712837234B00198BDA /* MediaGalleryNavigationController.swift */; }; @@ -1785,6 +1795,17 @@ FD6A7A6A2818C17C00035AC1 /* UpdateProfilePictureJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateProfilePictureJob.swift; sourceTree = ""; }; FD6A7A6C2818C61500035AC1 /* _002_SetupStandardJobs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _002_SetupStandardJobs.swift; sourceTree = ""; }; FD705A91278D051200F16121 /* ReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReusableView.swift; sourceTree = ""; }; + FD7115EA28C5D78E00B47552 /* ThreadSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadSettingsViewModel.swift; sourceTree = ""; }; + FD7115ED28C5D79B00B47552 /* SettingsAvatarCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAvatarCell.swift; sourceTree = ""; }; + FD7115EF28C5D7DE00B47552 /* SettingHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingHeaderView.swift; sourceTree = ""; }; + FD7115F128C6CB3900B47552 /* _009_AddThreadIdToFTS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _009_AddThreadIdToFTS.swift; sourceTree = ""; }; + FD7115F328C71EB200B47552 /* ThreadDisappearingMessagesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadDisappearingMessagesViewModel.swift; sourceTree = ""; }; + FD7115F728C8151C00B47552 /* DisposableBarButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisposableBarButtonItem.swift; sourceTree = ""; }; + FD7115F928C8153400B47552 /* UIBarButtonItem+Combine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIBarButtonItem+Combine.swift"; sourceTree = ""; }; + FD7115FB28C8155800B47552 /* Publisher+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Publisher+Utilities.swift"; sourceTree = ""; }; + FD7115FD28C8202D00B47552 /* ReplaySubject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplaySubject.swift; sourceTree = ""; }; + FD7115FF28C8253500B47552 /* UIView+Combine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Combine.swift"; sourceTree = ""; }; + FD71160128C8255900B47552 /* UIControl+Combine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIControl+Combine.swift"; sourceTree = ""; }; FD7162DA281B6C440060647B /* TypedTableAlias.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypedTableAlias.swift; sourceTree = ""; }; FD716E6328502DDD00C96BF4 /* CallManagerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallManagerProtocol.swift; sourceTree = ""; }; FD716E6528502EE200C96BF4 /* CurrentCallProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentCallProtocol.swift; sourceTree = ""; }; @@ -2659,12 +2680,15 @@ C302094625DCDFD3001F572D /* Settings */ = { isa = PBXGroup; children = ( + FD7115EC28C5D79100B47552 /* Views */, 340FC8A0204DAC8D007AEB0F /* OWSConversationSettingsViewController.h */, 340FC89A204DAC8D007AEB0F /* OWSConversationSettingsViewController.m */, 340FC899204DAC8D007AEB0F /* OWSConversationSettingsViewDelegate.h */, B84A89BB25DE328A0040017D /* ProfilePictureVC.swift */, 3427C64120F500DE00EEC730 /* OWSMessageTimerView.h */, 3427C64220F500DF00EEC730 /* OWSMessageTimerView.m */, + FD7115EA28C5D78E00B47552 /* ThreadSettingsViewModel.swift */, + FD7115F328C71EB200B47552 /* ThreadDisappearingMessagesViewModel.swift */, ); path = Settings; sourceTree = ""; @@ -3222,6 +3246,7 @@ isa = PBXGroup; children = ( C3C2A68B255388D500C340D1 /* Meta */, + FD7115F528C8150600B47552 /* Combine */, B8A582AC258C653C00AFD84C /* Crypto */, B8A582AB258C64E800AFD84C /* Database */, B8A582B0258C66C900AFD84C /* General */, @@ -3574,6 +3599,7 @@ FD37EA0E28AB3330003AE748 /* _006_FixHiddenModAdminSupport.swift */, 7B81682728B310D50069F315 /* _007_HomeQueryOptimisationIndexes.swift */, FD09B7E4288670BB00ED0B66 /* _008_EmojiReacts.swift */, + FD7115F128C6CB3900B47552 /* _009_AddThreadIdToFTS.swift */, ); path = Migrations; sourceTree = ""; @@ -3708,6 +3734,8 @@ FD37E9D028A1F2EB003AE748 /* ThemeSelectionView.swift */, FD37E9DA28A244E9003AE748 /* ThemePreviewView.swift */, FD37E9DC28A384EB003AE748 /* PrimaryColorSelectionView.swift */, + FD7115EF28C5D7DE00B47552 /* SettingHeaderView.swift */, + FD7115ED28C5D79B00B47552 /* SettingsAvatarCell.swift */, FD37EA0A28AB12E2003AE748 /* SettingsCell.swift */, FD87DCFF28B820F200AF0F98 /* BlockedContactCell.swift */, ); @@ -3799,6 +3827,34 @@ path = "Shared Models"; sourceTree = ""; }; + FD7115EC28C5D79100B47552 /* Views */ = { + isa = PBXGroup; + children = ( + ); + path = Views; + sourceTree = ""; + }; + FD7115F528C8150600B47552 /* Combine */ = { + isa = PBXGroup; + children = ( + FD7115F628C8150D00B47552 /* Disposable Views */, + FD7115FD28C8202D00B47552 /* ReplaySubject.swift */, + FD7115FB28C8155800B47552 /* Publisher+Utilities.swift */, + FD71160128C8255900B47552 /* UIControl+Combine.swift */, + FD7115F928C8153400B47552 /* UIBarButtonItem+Combine.swift */, + FD7115FF28C8253500B47552 /* UIView+Combine.swift */, + ); + path = Combine; + sourceTree = ""; + }; + FD7115F628C8150D00B47552 /* Disposable Views */ = { + isa = PBXGroup; + children = ( + FD7115F728C8151C00B47552 /* DisposableBarButtonItem.swift */, + ); + path = "Disposable Views"; + sourceTree = ""; + }; FD716E6F28505E5100C96BF4 /* Views */ = { isa = PBXGroup; children = ( @@ -5182,10 +5238,11 @@ FDED2E3C282E1B5D00B2CD2A /* UICollectionView+ReusableView.swift in Sources */, FD9004122818ABDC00ABAAF6 /* Job.swift in Sources */, FD09797927FAB7E800936362 /* ImageFormat.swift in Sources */, - FDCDB8E42817819600352A0C /* (null) in Sources */, + FD7115FE28C8202D00B47552 /* ReplaySubject.swift in Sources */, C32C5DC9256DD935003C73A2 /* ProxiedContentDownloader.swift in Sources */, C3D9E4C02567767F0040E4F3 /* DataSource.m in Sources */, C32C5DD2256DD9E5003C73A2 /* LRUCache.swift in Sources */, + FD71160228C8255900B47552 /* UIControl+Combine.swift in Sources */, FD9004152818B46300ABAAF6 /* JobRunner.swift in Sources */, C3A7211A2558BCA10043A11F /* DiffieHellman.swift in Sources */, C3A7225E2558C38D0043A11F /* Promise+Retaining.swift in Sources */, @@ -5204,6 +5261,7 @@ FD17D7C727F5207C00122BE0 /* DatabaseMigrator+Utilities.swift in Sources */, FD848B9328420164000E298B /* UnicodeScalar+Utilities.swift in Sources */, FD09796B27F6C67500936362 /* Failable.swift in Sources */, + FD7115FA28C8153400B47552 /* UIBarButtonItem+Combine.swift in Sources */, FD705A92278D051200F16121 /* ReusableView.swift in Sources */, FD17D7BA27F51F2100122BE0 /* TargetMigrations.swift in Sources */, FD17D7C327F5204C00122BE0 /* Database+Utilities.swift in Sources */, @@ -5239,8 +5297,10 @@ FDF222092818D2B0000A4995 /* NSAttributedString+Utilities.swift in Sources */, C32C5E0C256DDAFA003C73A2 /* NSRegularExpression+SSK.swift in Sources */, C3BBE0A92554D4DE0050F1E3 /* HTTP.swift in Sources */, + FD71160028C8253500B47552 /* UIView+Combine.swift in Sources */, B8856D23256F116B001CE70E /* Weak.swift in Sources */, FD17D7CD27F546FF00122BE0 /* Setting.swift in Sources */, + FD7115FC28C8155800B47552 /* Publisher+Utilities.swift in Sources */, C32C5A48256DB8F0003C73A2 /* BuildConfiguration.swift in Sources */, FD17D7BF27F51F8200122BE0 /* ColumnExpressible.swift in Sources */, FD17D7E527F6A09900122BE0 /* Identity.swift in Sources */, @@ -5255,6 +5315,7 @@ FD7162DB281B6C440060647B /* TypedTableAlias.swift in Sources */, FDFD645B27F26D4600808CA1 /* Data+Utilities.swift in Sources */, C3A7219A2558C1660043A11F /* AnyPromise+Conversion.swift in Sources */, + FD7115F828C8151C00B47552 /* DisposableBarButtonItem.swift in Sources */, FD17D7E727F6A16700122BE0 /* _003_YDBToGRDBMigration.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -5360,6 +5421,7 @@ FD42F9A8285064B800A0C77D /* PushNotificationAPI.swift in Sources */, FD245C6C2850669200B966DD /* MessageReceiveJob.swift in Sources */, FD83B9CC27D179BC005E1583 /* FSEndpoint.swift in Sources */, + FD7115F228C6CB3900B47552 /* _009_AddThreadIdToFTS.swift in Sources */, FD716E6428502DDD00C96BF4 /* CallManagerProtocol.swift in Sources */, FDC438C727BB6DF000C60D73 /* DirectMessage.swift in Sources */, FDC4384F27B4804F00C60D73 /* Header.swift in Sources */, @@ -5441,6 +5503,7 @@ files = ( FD52090928B59411006098F6 /* ScreenLockUI.swift in Sources */, FDF2220B2818F38D000A4995 /* SessionApp.swift in Sources */, + FD7115EB28C5D78E00B47552 /* ThreadSettingsViewModel.swift in Sources */, B8041AA725C90927003C2166 /* TypingIndicatorCell.swift in Sources */, B8CCF63723961D6D0091D419 /* NewDMVC.swift in Sources */, FDFDE12A282D056B0098B17F /* MediaZoomAnimationController.swift in Sources */, @@ -5528,6 +5591,7 @@ FD716E7128505E5200C96BF4 /* MessageRequestsCell.swift in Sources */, B88FA7F2260C3EB10049422F /* OpenGroupSuggestionGrid.swift in Sources */, FD37EA1928AC5CCA003AE748 /* NotificationSoundViewModel.swift in Sources */, + FD7115EE28C5D79B00B47552 /* SettingsAvatarCell.swift in Sources */, 4CA485BB2232339F004B9E7D /* PhotoCaptureViewController.swift in Sources */, 34330AA31E79686200DF2FB9 /* OWSProgressView.m in Sources */, C328254925CA60E60062D0A7 /* ContextMenuVC+Action.swift in Sources */, @@ -5558,6 +5622,7 @@ 45B5360E206DD8BB00D61655 /* UIResponder+OWS.swift in Sources */, 7B9F71C928470667006DFE7B /* ReactionListSheet.swift in Sources */, 7B7037452834BCC0000DCF35 /* ReactionView.swift in Sources */, + FD7115F428C71EB200B47552 /* ThreadDisappearingMessagesViewModel.swift in Sources */, B8D84ECF25E3108A005A043E /* ExpandingAttachmentsButton.swift in Sources */, 7B7CB190270FB2150079FF93 /* MiniCallView.swift in Sources */, B875885A264503A6000E60D0 /* JoinOpenGroupModal.swift in Sources */, @@ -5585,6 +5650,7 @@ 7B7CB189270430D20079FF93 /* CallMessageView.swift in Sources */, 7B9F71D32852EEE2006DFE7B /* Emoji.swift in Sources */, C328250F25CA06020062D0A7 /* VoiceMessageView.swift in Sources */, + FD7115F028C5D7DE00B47552 /* SettingHeaderView.swift in Sources */, B82B4090239DD75000A248E7 /* RestoreVC.swift in Sources */, 3488F9362191CC4000E524CC /* MediaView.swift in Sources */, B8569AC325CB5D2900DBA3DB /* ConversationVC+Interaction.swift in Sources */, diff --git a/Session/Calls/CallVC+Camera.swift b/Session/Calls/CallVC+Camera.swift index df2dbcfec..475ed4570 100644 --- a/Session/Calls/CallVC+Camera.swift +++ b/Session/Calls/CallVC+Camera.swift @@ -4,17 +4,19 @@ extension CallVC : CameraManagerDelegate { func handleVideoOutputCaptured(sampleBuffer: CMSampleBuffer) { guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return } + let rtcPixelBuffer = RTCCVPixelBuffer(pixelBuffer: pixelBuffer) let timestamp = CMTimeGetSeconds(CMSampleBufferGetPresentationTimeStamp(sampleBuffer)) let timestampNs = Int64(timestamp * 1000000000) let rotation: RTCVideoRotation = { switch UIDevice.current.orientation { - case .landscapeRight: return RTCVideoRotation._90 - case .portraitUpsideDown: return RTCVideoRotation._180 - case .landscapeLeft: return RTCVideoRotation._270 - default: return RTCVideoRotation._0 + case .landscapeRight: return RTCVideoRotation._90 + case .portraitUpsideDown: return RTCVideoRotation._180 + case .landscapeLeft: return RTCVideoRotation._270 + default: return RTCVideoRotation._0 } }() + let frame = RTCVideoFrame(buffer: rtcPixelBuffer, rotation: rotation, timeStampNs: timestampNs) frame.timeStamp = Int32(timestamp) call.webRTCSession.handleLocalFrameCaptured(frame) diff --git a/Session/Calls/CallVC.swift b/Session/Calls/CallVC.swift index e6e5e7edd..b9a41f043 100644 --- a/Session/Calls/CallVC.swift +++ b/Session/Calls/CallVC.swift @@ -552,9 +552,11 @@ final class CallVC: UIViewController, VideoPreviewDelegate { @objc private func minimize() { self.shouldRestartCamera = false + self.conversationVC?.showInputAccessoryView() + let miniCallView = MiniCallView(from: self) miniCallView.show() - self.conversationVC?.showInputAccessoryView() + presentingViewController?.dismiss(animated: true, completion: nil) } diff --git a/Session/Calls/Views & Modals/MiniCallView.swift b/Session/Calls/Views & Modals/MiniCallView.swift index 716694a2f..55c545d83 100644 --- a/Session/Calls/Views & Modals/MiniCallView.swift +++ b/Session/Calls/Views & Modals/MiniCallView.swift @@ -28,7 +28,12 @@ final class MiniCallView: UIView, RTCVideoViewDelegate { }() #if targetEnvironment(simulator) - // Note: 'RTCMTLVideoView' doesn't seem to work on the simulator so use 'RTCEAGLVideoView' instead + /// **Note:** `RTCMTLVideoView` doesn't seem to work on the simulator so use `RTCEAGLVideoView` instead + /// + /// Unfortunately this seems to have some issues on M1 macs where an `EXC_BAD_ACCESS` can be thrown when stopping and + /// starting playback (eg. when swapping to the `MiniCallView` while on a video call, as such there isn't much we can do to + /// resolve this issue but it should only occur on the Simulator on M1 Macs + /// (see https://code.videolan.org/videolan/VLCKit/-/issues/566 for more information) private lazy var remoteVideoView: RTCEAGLVideoView = { let result = RTCEAGLVideoView() result.delegate = self @@ -123,8 +128,8 @@ final class MiniCallView: UIView, RTCVideoViewDelegate { background.pin(to: result) let imageView = UIImageView() + imageView.clipsToBounds = true imageView.layer.cornerRadius = 32 - imageView.layer.masksToBounds = true imageView.contentMode = .scaleAspectFill imageView.image = callVC.call.profilePicture result.addSubview(imageView) diff --git a/Session/Closed Groups/EditClosedGroupVC.swift b/Session/Closed Groups/EditClosedGroupVC.swift index 1abf63ce7..6e12723cc 100644 --- a/Session/Closed Groups/EditClosedGroupVC.swift +++ b/Session/Closed Groups/EditClosedGroupVC.swift @@ -7,7 +7,6 @@ import SessionUIKit import SessionMessagingKit import SignalUtilitiesKit -@objc(SNEditClosedGroupVC) final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate { private struct GroupMemberDisplayInfo: FetchableRecord, Decodable { let profileId: String @@ -30,8 +29,8 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat private lazy var groupNameLabel: UILabel = { let result: UILabel = UILabel() - result.textColor = Colors.text result.font = .boldSystemFont(ofSize: Values.veryLargeFontSize) + result.themeTextColor = .textPrimary result.lineBreakMode = .byTruncatingTail result.textAlignment = .center @@ -59,7 +58,7 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat result.dataSource = self result.delegate = self result.separatorStyle = .none - result.backgroundColor = .clear + result.themeBackgroundColor = .clear result.isScrollEnabled = false result.register(view: UserCell.self) @@ -68,8 +67,7 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat // MARK: - Lifecycle - @objc(initWithThreadId:) - init(with threadId: String) { + init(threadId: String) { self.threadId = threadId super.init(nibName: nil, bundle: nil) @@ -84,14 +82,11 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat setNavBarTitle("Edit Group") - let backButton = UIBarButtonItem(title: "Back", style: .plain, target: nil, action: nil) - backButton.tintColor = Colors.text - navigationItem.backBarButtonItem = backButton - let threadId: String = self.threadId Storage.shared.read { [weak self] db in - self?.userPublicKey = getUserHexEncodedPublicKey(db) + let userPublicKey: String = getUserHexEncodedPublicKey(db) + self?.userPublicKey = userPublicKey self?.name = try ClosedGroup .select(.name) .filter(id: threadId) @@ -123,7 +118,12 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat .map { $0.profileId } .asSet() self?.originalMembersAndZombieIds = uniqueGroupMemberIds - self?.hasContactsToAdd = ((try Profile.fetchCount(db) - uniqueGroupMemberIds.count) > 0) + self?.hasContactsToAdd = ((try? Profile + .allContactProfiles( + excluding: uniqueGroupMemberIds.inserting(userPublicKey) + ) + .fetchCount(db)) + .defaulting(to: 0) > 0) } setUpViewHierarchy() @@ -153,17 +153,11 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat // Members label let membersLabel = UILabel() - membersLabel.textColor = Colors.text membersLabel.font = .systemFont(ofSize: Values.mediumFontSize) + membersLabel.themeTextColor = .textPrimary membersLabel.text = "Members" - // Add members button - 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) - } + addMembersButton.isEnabled = self.hasContactsToAdd // Middle stack view let middleStackView = UIStackView(arrangedSubviews: [ membersLabel, addMembersButton ]) @@ -199,7 +193,8 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat scrollView.pin(to: view) } - // MARK: Table View Data Source / Delegate + // MARK: - Table View Data Source / Delegate + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return membersAndZombies.count } @@ -222,18 +217,23 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { return adminIds.contains(userPublicKey) } - - func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? { + + func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { let profileId: String = self.membersAndZombies[indexPath.row].profileId - let removeAction = UITableViewRowAction(style: .destructive, title: "Remove") { [weak self] _, _ in + let delete: UIContextualAction = UIContextualAction( + style: .destructive, + title: "Remove" + ) { [weak self] _, _, completionHandler in self?.adminIds.remove(profileId) self?.membersAndZombies.remove(at: indexPath.row) self?.handleMembersChanged() + + completionHandler(true) } - removeAction.backgroundColor = Colors.destructive + delete.themeBackgroundColor = .conversationButton_swipeDestructive - return [ removeAction ] + return UISwipeActionsConfiguration(actions: [ delete ]) } // MARK: - Updating @@ -241,7 +241,7 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat private func updateNavigationBarButtons() { if isEditingGroupName { let cancelButton = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(handleCancelGroupNameEditingButtonTapped)) - cancelButton.tintColor = Colors.text + cancelButton.themeTintColor = .textPrimary navigationItem.leftBarButtonItem = cancelButton } else { @@ -249,7 +249,7 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat } let doneButton = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(handleDoneButtonTapped)) - doneButton.tintColor = Colors.text + doneButton.themeTintColor = .textPrimary navigationItem.rightBarButtonItem = doneButton } @@ -305,14 +305,15 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat return showError(title: "vc_create_closed_group_group_name_too_long_error".localized()) } - isEditingGroupName = false - groupNameLabel.text = updatedName + self.isEditingGroupName = false + self.groupNameLabel.text = updatedName self.name = updatedName } @objc private func addMembers() { - let title = "Add Members" + let title: String = "Add Members" + let userPublicKey: String = self.userPublicKey let userSelectionVC: UserSelectionVC = UserSelectionVC( with: title, excluding: membersAndZombies @@ -361,16 +362,15 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat .map { $0.profileId } .asSet() .inserting(contentsOf: self?.adminIds) - self?.hasContactsToAdd = ((try Profile.fetchCount(db) - uniqueGroupMemberIds.count) > 0) + self?.hasContactsToAdd = ((try? Profile + .allContactProfiles( + excluding: uniqueGroupMemberIds.inserting(userPublicKey) + ) + .fetchCount(db)) + .defaulting(to: 0) > 0) } - 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) + self?.addMembersButton.isEnabled = (self?.hasContactsToAdd == true) self?.handleMembersChanged() } diff --git a/Session/Conversations/ConversationSearch.swift b/Session/Conversations/ConversationSearch.swift index 0f97b8644..9789b85db 100644 --- a/Session/Conversations/ConversationSearch.swift +++ b/Session/Conversations/ConversationSearch.swift @@ -1,14 +1,21 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import GRDB import SignalUtilitiesKit +public class StyledSearchController: UISearchController { + public override var preferredStatusBarStyle: UIStatusBarStyle { + return ThemeManager.currentTheme.statusBarStyle + } +} + public class ConversationSearchController: NSObject { public static let minimumSearchTextLength: UInt = 2 private let threadId: String public weak var delegate: ConversationSearchControllerDelegate? - public let uiSearchController: UISearchController = UISearchController(searchResultsController: nil) + public let uiSearchController: UISearchController = StyledSearchController(searchResultsController: nil) public let resultsBar: SearchResultsBar = SearchResultsBar() private var lastSearchText: String? @@ -57,17 +64,31 @@ extension ConversationSearchController: UISearchResultsUpdating { } let threadId: String = self.threadId - let results: [Int64] = Storage.shared.read { db -> [Int64] in - try Interaction.idsForTermWithin( - threadId: threadId, - pattern: try SessionThreadViewModel.pattern(db, searchTerm: searchText) - ) - .fetchAll(db) - } - .defaulting(to: []) - self.resultsBar.updateResults(results: results) - self.delegate?.conversationSearchController(self, didUpdateSearchResults: results, searchText: searchText) + DispatchQueue.global(qos: .default).async { [weak self] in + let results: [Int64]? = Storage.shared.read { db -> [Int64] in + self?.resultsBar.willStartSearching(readConnection: db) + + return try Interaction.idsForTermWithin( + threadId: threadId, + pattern: try SessionThreadViewModel.pattern(db, searchTerm: searchText) + ) + .fetchAll(db) + } + + // If we didn't get results back then we most likely interrupted the query so + // should ignore the results (if there are no results we would succeed and get + // an empty array back) + guard let results: [Int64] = results else { return } + + DispatchQueue.main.async { + guard let strongSelf = self else { return } + + self?.resultsBar.stopLoading() + self?.resultsBar.updateResults(results: results) + self?.delegate?.conversationSearchController(strongSelf, didUpdateSearchResults: results, searchText: searchText) + } + } } } @@ -94,7 +115,9 @@ protocol SearchResultsBarDelegate: AnyObject { } public final class SearchResultsBar: UIView { - private var results: [Int64]? + private var readConnection: Atomic = Atomic(nil) + private var results: Atomic<[Int64]?> = Atomic(nil) + var currentIndex: Int? weak var resultsBarDelegate: SearchResultsBarDelegate? @@ -191,11 +214,10 @@ public final class SearchResultsBar: UIView { label.center(.horizontal, in: self) } - // MARK: - Functions + // MARK: - Actions - @objc - public func handleUpButtonTapped() { - guard let results: [Int64] = results else { return } + @objc public func handleUpButtonTapped() { + guard let results: [Int64] = results.wrappedValue else { return } guard let currentIndex: Int = currentIndex else { return } guard currentIndex + 1 < results.count else { return } @@ -205,10 +227,9 @@ public final class SearchResultsBar: UIView { resultsBarDelegate?.searchResultsBar(self, setCurrentIndex: newIndex, results: results) } - @objc - public func handleDownButtonTapped() { + @objc public func handleDownButtonTapped() { Logger.debug("") - guard let results: [Int64] = results else { return } + guard let results: [Int64] = results.wrappedValue else { return } guard let currentIndex: Int = currentIndex, currentIndex > 0 else { return } let newIndex = currentIndex - 1 @@ -216,8 +237,29 @@ public final class SearchResultsBar: UIView { updateBarItems() resultsBarDelegate?.searchResultsBar(self, setCurrentIndex: newIndex, results: results) } + + // MARK: - Content + + /// This method will be called within a DB read block + func willStartSearching(readConnection: Database) { + let hasNoExistingResults: Bool = (self.results.wrappedValue?.isEmpty != false) + + DispatchQueue.main.async { [weak self] in + if hasNoExistingResults { + self?.label.text = "CONVERSATION_SEARCH_SEARCHING".localized() + } + + self?.startLoading() + } + + self.readConnection.wrappedValue?.interrupt() + self.readConnection.mutate { $0 = readConnection } + } func updateResults(results: [Int64]?) { + // We want to ignore search results that don't match the current searchId (this + // will happen when searching large threads with short terms as the shorter terms + // will take much longer to resolve than the longer terms) currentIndex = { guard let results: [Int64] = results, !results.isEmpty else { return nil } @@ -228,7 +270,8 @@ public final class SearchResultsBar: UIView { return 0 }() - self.results = results + self.readConnection.mutate { $0 = nil } + self.results.mutate { $0 = results } updateBarItems() @@ -238,7 +281,7 @@ public final class SearchResultsBar: UIView { } func updateBarItems() { - guard let results: [Int64] = results else { + guard let results: [Int64] = results.wrappedValue else { label.text = "" downButton.isEnabled = false upButton.isEnabled = false diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 2f79a2b84..1a7e3e38a 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -29,16 +29,25 @@ extension ConversationVC: } @objc func openSettings() { - let settingsVC: OWSConversationSettingsViewController = OWSConversationSettingsViewController() - settingsVC.configure( - withThreadId: viewModel.threadData.threadId, - threadName: viewModel.threadData.displayName, - isClosedGroup: (viewModel.threadData.threadVariant == .closedGroup), - isOpenGroup: (viewModel.threadData.threadVariant == .openGroup), - isNoteToSelf: viewModel.threadData.threadIsNoteToSelf + let viewController: SettingsTableViewController = SettingsTableViewController( + viewModel: ThreadSettingsViewModel( + threadId: self.viewModel.threadData.threadId, + threadVariant: self.viewModel.threadData.threadVariant, + didTriggerSearch: { [weak self] in + DispatchQueue.main.async { + self?.showSearchUI() + self?.popAllConversationSettingsViews { + // Note: Without this delay the search bar doesn't show + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self?.searchController.uiSearchController.searchBar.becomeFirstResponder() + } + } + } + } + ) ) - settingsVC.conversationSettingsViewDelegate = self - navigationController?.pushViewController(settingsVC, animated: true, completion: nil) + + navigationController?.pushViewController(viewController, animated: true) } // MARK: - ScrollToBottomButtonDelegate @@ -65,8 +74,9 @@ extension ConversationVC: self?.dismiss(animated: true) { let navController: OWSNavigationController = OWSNavigationController( rootViewController: SettingsTableViewController( - viewModel: PrivacySettingsViewModel(), - shouldShowCloseButton: true + viewModel: PrivacySettingsViewModel( + shouldShowCloseButton: true + ) ) ) navController.modalPresentationStyle = .fullScreen diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 3e19f6a63..7cc2b3eee 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -8,8 +8,8 @@ import SessionMessagingKit import SessionUtilitiesKit import SignalUtilitiesKit -final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, ConversationSearchControllerDelegate, UITableViewDataSource, UITableViewDelegate { - private static let loadingHeaderHeight: CGFloat = 20 +final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITableViewDataSource, UITableViewDelegate { + private static let loadingHeaderHeight: CGFloat = 40 internal let viewModel: ConversationViewModel private var dataChangeObservable: DatabaseCancellable? @@ -774,25 +774,31 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers numRowsInSections == numItemsInUpdatedData }, then: { [weak self] in - UIView.performWithoutAnimation { - let calculatedRowHeights: CGFloat = (0.. Void)? = nil) { + if presentedViewController != nil { + dismiss(animated: true) { [weak self] in + guard let strongSelf: UIViewController = self else { return } + + self?.navigationController?.popToViewController(strongSelf, animated: true, completion: completionBlock) + } } - - dismiss(animated: true) { - self.navigationController?.popToViewController(self, animated: true, completion: nil) + else { + navigationController?.popToViewController(self, animated: true, completion: completionBlock) } } - + func showSearchUI() { isShowingSearchUI = true @@ -1353,7 +1359,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers // See more https://developer.apple.com/documentation/uikit/uisearchbar/1624283-showscancelbutton?language=objc if UIDevice.current.isIPad { let ipadCancelButton = UIButton() - ipadCancelButton.setTitle("Cancel", for: .normal) + ipadCancelButton.setTitle("cancel".localized(), for: .normal) ipadCancelButton.addTarget(self, action: #selector(hideSearchUI), for: .touchUpInside) ipadCancelButton.setTitleColor(Colors.text, for: .normal) searchBarContainer.addSubview(ipadCancelButton) @@ -1361,7 +1367,8 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers ipadCancelButton.autoVCenterInSuperview() searchBar.autoPinEdgesToSuperviewEdges(with: UIEdgeInsets.zero, excludingEdge: .trailing) searchBar.pin(.trailing, to: .leading, of: ipadCancelButton, withInset: -Values.smallSpacing) - } else { + } + else { searchBar.autoPinEdgesToSuperviewMargins() } @@ -1413,7 +1420,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers func didDismissSearchController(_ searchController: UISearchController) { hideSearchUI() } - + func conversationSearchController(_ conversationSearchController: ConversationSearchController, didUpdateSearchResults results: [Int64]?, searchText: String?) { viewModel.lastSearchedText = searchText tableView.reloadRows(at: tableView.indexPathsForVisibleRows ?? [], with: UITableView.RowAnimation.none) diff --git a/Session/Conversations/Message Cells/Content Views/MediaAlbumView.swift b/Session/Conversations/Message Cells/Content Views/MediaAlbumView.swift index 5600fa62b..1c785fcf5 100644 --- a/Session/Conversations/Message Cells/Content Views/MediaAlbumView.swift +++ b/Session/Conversations/Message Cells/Content Views/MediaAlbumView.swift @@ -35,13 +35,18 @@ public class MediaAlbumView: UIStackView { super.init(frame: .zero) - // UIStackView's backgroundColor property has no effect. - addBackgroundView(withBackgroundColor: Colors.navigationBarBackground) - createContents(maxMessageWidth: maxMessageWidth) } private func createContents(maxMessageWidth: CGFloat) { + let backgroundView: UIView = UIView() + backgroundView.themeBackgroundColor = .backgroundPrimary + addSubview(backgroundView) + + backgroundView.setContentHuggingLow() + backgroundView.setCompressionResistanceLow() + backgroundView.pin(to: backgroundView) + switch itemViews.count { case 0: return owsFailDebug("No item views.") @@ -155,7 +160,7 @@ public class MediaAlbumView: UIStackView { moreItemsView = lastView let tintView = UIView() - tintView.backgroundColor = UIColor(white: 0, alpha: 0.4) + tintView.themeBackgroundColor = .messageBubble_overlay lastView.addSubview(tintView) tintView.autoPinEdgesToSuperviewEdges() @@ -166,11 +171,10 @@ public class MediaAlbumView: UIStackView { format: "MEDIA_GALLERY_MORE_ITEMS_FORMAT".localized(), moreCountText ) - let moreLabel = UILabel() + let moreLabel: UILabel = UILabel() + moreLabel.font = .systemFont(ofSize: 24) moreLabel.text = moreText - moreLabel.textColor = UIColor.ows_white - // We don't want to use dynamic text here. - moreLabel.font = UIFont.systemFont(ofSize: 24) + moreLabel.themeTextColor = .white lastView.addSubview(moreLabel) moreLabel.autoCenterInSuperview() } diff --git a/Session/Conversations/Message Cells/Content Views/MediaLoaderView.swift b/Session/Conversations/Message Cells/Content Views/MediaLoaderView.swift index ee4897e34..b8a56e1d1 100644 --- a/Session/Conversations/Message Cells/Content Views/MediaLoaderView.swift +++ b/Session/Conversations/Message Cells/Content Views/MediaLoaderView.swift @@ -1,13 +1,19 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -final class MediaLoaderView : UIView { +import UIKit +import SessionUIKit + +final class MediaLoaderView: UIView { private let bar = UIView() private lazy var barLeftConstraint = bar.pin(.left, to: .left, of: self) private lazy var barRightConstraint = bar.pin(.right, to: .right, of: self) - // MARK: Lifecycle + // MARK: - Lifecycle + override init(frame: CGRect) { super.init(frame: frame) + setUpViewHierarchy() } @@ -17,9 +23,10 @@ final class MediaLoaderView : UIView { } private func setUpViewHierarchy() { - bar.backgroundColor = Colors.accent + bar.themeBackgroundColor = .primary bar.set(.height, to: 8) addSubview(bar) + barLeftConstraint.isActive = true bar.pin(.top, to: .top, of: self) barRightConstraint.isActive = true @@ -27,7 +34,8 @@ final class MediaLoaderView : UIView { step1() } - // MARK: Animation + // MARK: - Animation + func step1() { barRightConstraint.constant = -bounds.width UIView.animate(withDuration: 0.5, animations: { [weak self] in diff --git a/Session/Conversations/Message Cells/Content Views/MediaView.swift b/Session/Conversations/Message Cells/Content Views/MediaView.swift index 255d65690..556c87d14 100644 --- a/Session/Conversations/Message Cells/Content Views/MediaView.swift +++ b/Session/Conversations/Message Cells/Content Views/MediaView.swift @@ -58,7 +58,7 @@ public class MediaView: UIView { super.init(frame: .zero) - backgroundColor = Colors.unimportant + themeBackgroundColor = .backgroundSecondary clipsToBounds = true createContents() @@ -117,7 +117,7 @@ public class MediaView: UIView { return } - backgroundColor = (isDarkMode ? .ows_gray90 : .ows_gray05) + themeBackgroundColor = .backgroundSecondary let loader = MediaLoaderView() addSubview(loader) loader.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.bottom, UIView.HorizontalEdge.right ], to: self) @@ -150,7 +150,7 @@ public class MediaView: UIView { // some performance cost. animatedImageView.layer.minificationFilter = .trilinear animatedImageView.layer.magnificationFilter = .trilinear - animatedImageView.backgroundColor = Colors.unimportant + animatedImageView.themeBackgroundColor = .backgroundSecondary animatedImageView.isHidden = !attachment.isValid addSubview(animatedImageView) animatedImageView.autoPinEdgesToSuperviewEdges() @@ -207,7 +207,7 @@ public class MediaView: UIView { // some performance cost. stillImageView.layer.minificationFilter = .trilinear stillImageView.layer.magnificationFilter = .trilinear - stillImageView.backgroundColor = Colors.unimportant + stillImageView.themeBackgroundColor = .backgroundSecondary stillImageView.isHidden = !attachment.isValid addSubview(stillImageView) stillImageView.autoPinEdgesToSuperviewEdges() @@ -266,7 +266,7 @@ public class MediaView: UIView { // some performance cost. stillImageView.layer.minificationFilter = .trilinear stillImageView.layer.magnificationFilter = .trilinear - stillImageView.backgroundColor = Colors.unimportant + stillImageView.themeBackgroundColor = .backgroundSecondary stillImageView.isHidden = !attachment.isValid addSubview(stillImageView) @@ -356,20 +356,19 @@ public class MediaView: UIView { case .missing: return } - backgroundColor = (isDarkMode ? .ows_gray90 : .ows_gray05) + themeBackgroundColor = .backgroundSecondary // For failed ougoing messages add an overlay to make the icon more visible if isOutgoing { let attachmentOverlayView: UIView = UIView() - attachmentOverlayView.backgroundColor = Colors.navigationBarBackground - .withAlphaComponent(Values.lowOpacity) + attachmentOverlayView.themeBackgroundColor = .messageBubble_overlay addSubview(attachmentOverlayView) attachmentOverlayView.pin(to: self) } let iconView = UIImageView(image: icon.withRenderingMode(.alwaysTemplate)) - iconView.tintColor = Colors.text - .withAlphaComponent(Values.mediumOpacity) + iconView.themeTintColor = .textPrimary + iconView.alpha = Values.mediumOpacity addSubview(iconView) iconView.autoCenterInSuperview() } diff --git a/Session/Conversations/Message Cells/Content Views/QuoteView.swift b/Session/Conversations/Message Cells/Content Views/QuoteView.swift index 09ab4ff1b..4f2183236 100644 --- a/Session/Conversations/Message Cells/Content Views/QuoteView.swift +++ b/Session/Conversations/Message Cells/Content Views/QuoteView.swift @@ -191,15 +191,13 @@ final class QuoteView: UIView { bodyLabel.numberOfLines = 0 bodyLabel.lineBreakMode = .byTruncatingTail - let isOutgoing = (direction == .outgoing) + let targetThemeColor: ThemeValue = (direction == .outgoing ? + .messageBubble_outgoingText : + .messageBubble_incomingText + ) bodyLabel.font = .systemFont(ofSize: Values.smallFontSize) ThemeManager.onThemeChange(observer: bodyLabel) { [weak bodyLabel] theme, primaryColor in - let targetThemeColor: ThemeValue = (direction == .outgoing ? - .messageBubble_outgoingText : - .messageBubble_incomingText - ) - guard let textColor: UIColor = theme.colors[targetThemeColor] else { return } bodyLabel?.attributedText = body @@ -209,8 +207,9 @@ final class QuoteView: UIView { threadVariant: threadVariant, currentUserPublicKey: currentUserPublicKey, currentUserBlindedPublicKey: currentUserBlindedPublicKey, - isOutgoingMessage: isOutgoing, + isOutgoingMessage: (direction == .outgoing), textColor: textColor, + theme: theme, primaryColor: primaryColor, attributes: [ .foregroundColor: textColor @@ -229,42 +228,37 @@ final class QuoteView: UIView { let bodyLabelSize = bodyLabel.systemLayoutSizeFitting(availableSpace) var authorLabelHeight: CGFloat? - if threadVariant == .openGroup || threadVariant == .closedGroup { - let isCurrentUser: Bool = [ - currentUserPublicKey, - currentUserBlindedPublicKey, - ] - .compactMap { $0 } - .asSet() - .contains(authorId) - let authorLabel = UILabel() - authorLabel.font = .boldSystemFont(ofSize: Values.smallFontSize) - authorLabel.text = (isCurrentUser ? - "MEDIA_GALLERY_SENDER_NAME_YOU".localized() : - Profile.displayName( - id: authorId, - threadVariant: threadVariant - ) + let isCurrentUser: Bool = [ + currentUserPublicKey, + currentUserBlindedPublicKey, + ] + .compactMap { $0 } + .asSet() + .contains(authorId) + let authorLabel = UILabel() + authorLabel.font = .boldSystemFont(ofSize: Values.smallFontSize) + authorLabel.text = (isCurrentUser ? + "MEDIA_GALLERY_SENDER_NAME_YOU".localized() : + Profile.displayName( + id: authorId, + threadVariant: threadVariant ) - authorLabel.themeTextColor = .messageBubble_outgoingText - authorLabel.lineBreakMode = .byTruncatingTail - - let authorLabelSize = authorLabel.systemLayoutSizeFitting(availableSpace) - authorLabel.set(.height, to: authorLabelSize.height) - authorLabelHeight = authorLabelSize.height - - let labelStackView = UIStackView(arrangedSubviews: [ authorLabel, bodyLabel ]) - labelStackView.axis = .vertical - labelStackView.spacing = labelStackViewSpacing - labelStackView.distribution = .equalCentering - labelStackView.set(.width, to: max(bodyLabelSize.width, authorLabelSize.width)) - labelStackView.isLayoutMarginsRelativeArrangement = true - labelStackView.layoutMargins = UIEdgeInsets(top: labelStackViewVMargin, left: 0, bottom: labelStackViewVMargin, right: 0) - mainStackView.addArrangedSubview(labelStackView) - } - else { - mainStackView.addArrangedSubview(bodyLabel) - } + ) + authorLabel.themeTextColor = targetThemeColor + authorLabel.lineBreakMode = .byTruncatingTail + + let authorLabelSize = authorLabel.systemLayoutSizeFitting(availableSpace) + authorLabel.set(.height, to: authorLabelSize.height) + authorLabelHeight = authorLabelSize.height + + let labelStackView = UIStackView(arrangedSubviews: [ authorLabel, bodyLabel ]) + labelStackView.axis = .vertical + labelStackView.spacing = labelStackViewSpacing + labelStackView.distribution = .equalCentering + labelStackView.set(.width, to: max(bodyLabelSize.width, authorLabelSize.width)) + labelStackView.isLayoutMarginsRelativeArrangement = true + labelStackView.layoutMargins = UIEdgeInsets(top: labelStackViewVMargin, left: 0, bottom: labelStackViewVMargin, right: 0) + mainStackView.addArrangedSubview(labelStackView) // Constraints contentView.addSubview(mainStackView) diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index 94b7980ef..2c666337d 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -725,21 +725,23 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { } func highlight() { - // FIXME: This will have issues with themes - let shawdowColour = (isLightMode ? UIColor.black.cgColor : Colors.accent.cgColor) + let shadowColor: ThemeValue = (ThemeManager.currentTheme.interfaceStyle == .light ? + .black : + .primary + ) let opacity: Float = (isLightMode ? 0.5 : 1) DispatchQueue.main.async { [weak self] in let oldMasksToBounds: Bool = (self?.layer.masksToBounds ?? false) self?.layer.masksToBounds = false - self?.bubbleBackgroundView.setShadow(radius: 10, opacity: opacity, offset: .zero, color: shawdowColour) + self?.bubbleBackgroundView.setShadow(radius: 10, opacity: opacity, offset: .zero, color: shadowColor) UIView.animate( withDuration: 1.6, delay: 0, options: .curveEaseInOut, animations: { - self?.bubbleBackgroundView.setShadow(radius: 0, opacity: 0, offset: .zero, color: UIColor.clear.cgColor) + self?.bubbleBackgroundView.setShadow(radius: 0, opacity: 0, offset: .zero, color: .clear) }, completion: { _ in self?.layer.masksToBounds = oldMasksToBounds @@ -1036,6 +1038,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { currentUserBlindedPublicKey: cellViewModel.currentUserBlindedPublicKey, isOutgoingMessage: isOutgoing, textColor: actualTextColor, + theme: theme, primaryColor: primaryColor, attributes: [ .foregroundColor: actualTextColor, diff --git a/Session/Conversations/Settings/OWSConversationSettingsViewController.h b/Session/Conversations/Settings/OWSConversationSettingsViewController.h deleted file mode 100644 index d2970deca..000000000 --- a/Session/Conversations/Settings/OWSConversationSettingsViewController.h +++ /dev/null @@ -1,24 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "OWSConversationSettingsViewDelegate.h" -#import "OWSTableViewController.h" -#import - -NS_ASSUME_NONNULL_BEGIN - -@class TSThread; -@class YapDatabaseConnection; - -@interface OWSConversationSettingsViewController : OWSTableViewController - -@property (nonatomic, weak) id conversationSettingsViewDelegate; - -@property (nonatomic) BOOL showVerificationOnAppear; - -- (void)configureWithThreadId:(NSString *)threadId threadName:(NSString *)threadName isClosedGroup:(BOOL)isClosedGroup isOpenGroup:(BOOL)isOpenGroup isNoteToSelf:(BOOL)isNoteToSelf; - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Conversations/Settings/OWSConversationSettingsViewController.m b/Session/Conversations/Settings/OWSConversationSettingsViewController.m deleted file mode 100644 index ea4106e91..000000000 --- a/Session/Conversations/Settings/OWSConversationSettingsViewController.m +++ /dev/null @@ -1,963 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "OWSConversationSettingsViewController.h" -#import "Session-Swift.h" -#import "UIFont+OWS.h" -#import "UIView+OWS.h" -#import -#import -#import -#import - -@import ContactsUI; -@import PromiseKit; - -NS_ASSUME_NONNULL_BEGIN - -CGFloat kIconViewLength = 24; - -@interface OWSConversationSettingsViewController () - -@property (nonatomic) NSString *threadId; -@property (nonatomic) NSString *threadName; -@property (nonatomic) BOOL isNoteToSelf; -@property (nonatomic) BOOL isClosedGroup; -@property (nonatomic) BOOL isOpenGroup; -@property (nonatomic) NSArray *disappearingMessagesDurations; - -@property (nonatomic) BOOL originalIsDisappearingMessagesEnabled; -@property (nonatomic) NSInteger originalDisappearingMessagesDurationIndex; -@property (nonatomic) BOOL isDisappearingMessagesEnabled; -@property (nonatomic) NSInteger disappearingMessagesDurationIndex; - -@property (nonatomic, readonly) UIImageView *avatarView; -@property (nonatomic, readonly) UILabel *disappearingMessagesDurationLabel; -@property (nonatomic) UILabel *displayNameLabel; -@property (nonatomic) SNTextField *displayNameTextField; -@property (nonatomic) UIView *displayNameContainer; -@property (nonatomic) BOOL isEditingDisplayName; - -@end - -#pragma mark - - -@implementation OWSConversationSettingsViewController - -- (instancetype)init -{ - self = [super init]; - if (!self) { - return self; - } - - return self; -} - -- (nullable instancetype)initWithCoder:(NSCoder *)aDecoder -{ - self = [super initWithCoder:aDecoder]; - if (!self) { - return self; - } - - return self; -} - -- (instancetype)initWithNibName:(nullable NSString *)nibNameOrNil bundle:(nullable NSBundle *)nibBundleOrNil -{ - self = [super initWithNibName:nibNameOrNil bundle:nibBundleOrNil]; - if (!self) { - return self; - } - - return self; -} - -#pragma mark - -- (void)configureWithThreadId:(NSString *)threadId threadName:(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"]; - } - else { - self.threadName = threadName; - } -} - -#pragma mark - ContactEditingDelegate - -- (void)didFinishEditingContact -{ - [self updateTableContents]; - - OWSLogDebug(@""); - [self dismissViewControllerAnimated:NO completion:nil]; -} - -#pragma mark - CNContactViewControllerDelegate - -- (void)contactViewController:(CNContactViewController *)viewController - didCompleteWithContact:(nullable CNContact *)contact -{ - [self updateTableContents]; - - if (contact) { - // Saving normally returns you to the "Show Contact" view - // which we're not interested in, so we skip it here. There is - // an unfortunate blip of the "Show Contact" view on slower devices. - OWSLogDebug(@"completed editing contact."); - [self dismissViewControllerAnimated:NO completion:nil]; - } else { - OWSLogDebug(@"canceled editing contact."); - [self dismissViewControllerAnimated:YES completion:nil]; - } -} - -#pragma mark - View Lifecycle - -- (void)viewDidLoad -{ - [super viewDidLoad]; - - self.displayNameLabel = [UILabel new]; - self.displayNameLabel.textColor = LKColors.text; - 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"; - self.displayNameTextField.alpha = 0; - - 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.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 = [SMKDisappearingMessagesConfiguration validDurationsSeconds]; - self.isDisappearingMessagesEnabled = [SMKDisappearingMessagesConfiguration isEnabledFor: self.threadId]; - self.disappearingMessagesDurationIndex = [SMKDisappearingMessagesConfiguration durationIndexFor: self.threadId]; - self.originalIsDisappearingMessagesEnabled = self.isDisappearingMessagesEnabled; - self.originalDisappearingMessagesDurationIndex = self.disappearingMessagesDurationIndex; - - [self updateTableContents]; - - NSString *title; - 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.isClosedGroup && !self.isOpenGroup) { - [self updateNavBarButtons]; - } -} - -- (void)updateTableContents -{ - OWSTableContents *contents = [OWSTableContents new]; - contents.title = NSLocalizedString(@"CONVERSATION_SETTINGS", @"title for conversation settings screen"); - - __weak OWSConversationSettingsViewController *weakSelf = self; - - OWSTableSection *section = [OWSTableSection new]; - - section.customHeaderView = [self mainSectionHeader]; - section.customHeaderHeight = @(UITableViewAutomaticDimension); - - // Copy Session ID - if (!self.isClosedGroup && !self.isOpenGroup) { - [section addItem:[OWSTableItem itemWithCustomCellBlock:^{ - return [weakSelf - disclosureCellWithName:NSLocalizedString(@"vc_conversation_settings_copy_session_id_button_title", "") - iconName:@"ic_copy" - accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(OWSConversationSettingsViewController, @"copy_session_id")]; - } - actionBlock:^{ - [weakSelf copySessionID]; - }]]; - } - - // All media - [section addItem:[OWSTableItem itemWithCustomCellBlock:^{ - return [weakSelf - disclosureCellWithName:MediaStrings.allMedia - iconName:@"actionsheet_camera_roll_black" - accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(OWSConversationSettingsViewController, @"all_media")]; - } actionBlock:^{ - [weakSelf showMediaGallery]; - }]]; - - // Invite button - if (self.isOpenGroup) { - [section addItem:[OWSTableItem itemWithCustomCellBlock:^{ - return [weakSelf - disclosureCellWithName:NSLocalizedString(@"vc_conversation_settings_invite_button_title", "") - iconName:@"ic_plus_24" - accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(OWSConversationSettingsViewController, @"invite")]; - } actionBlock:^{ - [weakSelf inviteUsersToOpenGroup]; - }]]; - } - - // Search - [section addItem:[OWSTableItem itemWithCustomCellBlock:^{ - NSString *title = NSLocalizedString(@"CONVERSATION_SETTINGS_SEARCH", - @"Table cell label in conversation settings which returns the user to the " - @"conversation with 'search mode' activated"); - return [weakSelf - disclosureCellWithName:title - iconName:@"conversation_settings_search" - accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(OWSConversationSettingsViewController, @"search")]; - } actionBlock:^{ - [weakSelf tappedConversationSearch]; - }]]; - - // Disappearing messages - if (![self isOpenGroup] && ![SMKContact isBlockedFor:self.threadId]) { - [section addItem:[OWSTableItem itemWithCustomCellBlock:^{ - UITableViewCell *cell = [OWSTableItem newCell]; - OWSConversationSettingsViewController *strongSelf = weakSelf; - OWSCAssertDebug(strongSelf); - cell.preservesSuperviewLayoutMargins = YES; - cell.contentView.preservesSuperviewLayoutMargins = YES; - cell.selectionStyle = UITableViewCellSelectionStyleNone; - - NSString *iconName - = (strongSelf.isDisappearingMessagesEnabled ? @"ic_timer" : @"ic_timer_disabled"); - UIImageView *iconView = [strongSelf viewForIconWithName:iconName]; - - UILabel *rowLabel = [UILabel new]; - rowLabel.text = NSLocalizedString( - @"DISAPPEARING_MESSAGES", @"table cell label in conversation settings"); - rowLabel.textColor = LKColors.text; - rowLabel.font = [UIFont systemFontOfSize:LKValues.mediumFontSize]; - rowLabel.lineBreakMode = NSLineBreakByTruncatingTail; - - UISwitch *switchView = [UISwitch new]; - switchView.on = strongSelf.isDisappearingMessagesEnabled; - [switchView addTarget:strongSelf action:@selector(disappearingMessagesSwitchValueDidChange:) - forControlEvents:UIControlEventValueChanged]; - - UIStackView *topRow = - [[UIStackView alloc] initWithArrangedSubviews:@[ iconView, rowLabel, switchView ]]; - topRow.spacing = strongSelf.iconSpacing; - topRow.alignment = UIStackViewAlignmentCenter; - [cell.contentView addSubview:topRow]; - [topRow autoPinEdgesToSuperviewMarginsExcludingEdge:ALEdgeBottom]; - - UILabel *subtitleLabel = [UILabel new]; - NSString *displayName; - if (self.isClosedGroup || self.isOpenGroup) { - displayName = @"the group"; - } else { - 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; - subtitleLabel.font = [UIFont systemFontOfSize:LKValues.smallFontSize]; - subtitleLabel.numberOfLines = 0; - subtitleLabel.lineBreakMode = NSLineBreakByWordWrapping; - [cell.contentView addSubview:subtitleLabel]; - [subtitleLabel autoPinEdge:ALEdgeTop toEdge:ALEdgeBottom ofView:topRow withOffset:8]; - [subtitleLabel autoPinEdge:ALEdgeLeading toEdge:ALEdgeLeading ofView:rowLabel]; - [subtitleLabel autoPinTrailingToSuperviewMargin]; - [subtitleLabel autoPinBottomToSuperviewMargin]; - - cell.userInteractionEnabled = !strongSelf.hasLeftGroup; - - cell.accessibilityIdentifier = ACCESSIBILITY_IDENTIFIER_WITH_NAME(OWSConversationSettingsViewController, @"disappearing_messages"); - - return cell; - } customRowHeight:UITableViewAutomaticDimension actionBlock:nil]]; - - if (self.isDisappearingMessagesEnabled) { - [section addItem:[OWSTableItem itemWithCustomCellBlock:^{ - UITableViewCell *cell = [OWSTableItem newCell]; - OWSConversationSettingsViewController *strongSelf = weakSelf; - OWSCAssertDebug(strongSelf); - cell.preservesSuperviewLayoutMargins = YES; - cell.contentView.preservesSuperviewLayoutMargins = YES; - cell.selectionStyle = UITableViewCellSelectionStyleNone; - - UIImageView *iconView = [strongSelf viewForIconWithName:@"ic_timer"]; - - UILabel *rowLabel = strongSelf.disappearingMessagesDurationLabel; - [strongSelf updateDisappearingMessagesDurationLabel]; - rowLabel.textColor = LKColors.text; - rowLabel.font = [UIFont systemFontOfSize:LKValues.mediumFontSize]; - // don't truncate useful duration info which is in the tail - rowLabel.lineBreakMode = NSLineBreakByTruncatingHead; - - UIStackView *topRow = - [[UIStackView alloc] initWithArrangedSubviews:@[ iconView, rowLabel ]]; - topRow.spacing = strongSelf.iconSpacing; - topRow.alignment = UIStackViewAlignmentCenter; - [cell.contentView addSubview:topRow]; - [topRow autoPinEdgesToSuperviewMarginsExcludingEdge:ALEdgeBottom]; - - UISlider *slider = [UISlider new]; - slider.maximumValue = (float)(strongSelf.disappearingMessagesDurations.count - 1); - slider.minimumValue = 0; - slider.tintColor = LKColors.accent; - slider.continuous = NO; - slider.value = strongSelf.disappearingMessagesDurationIndex; - [slider addTarget:strongSelf action:@selector(durationSliderDidChange:) - forControlEvents:UIControlEventValueChanged]; - [cell.contentView addSubview:slider]; - [slider autoPinEdge:ALEdgeTop toEdge:ALEdgeBottom ofView:topRow withOffset:6]; - [slider autoPinEdge:ALEdgeLeading toEdge:ALEdgeLeading ofView:rowLabel]; - [slider autoPinTrailingToSuperviewMargin]; - [slider autoPinBottomToSuperviewMargin]; - - cell.userInteractionEnabled = !strongSelf.hasLeftGroup; - - cell.accessibilityIdentifier = ACCESSIBILITY_IDENTIFIER_WITH_NAME( - OWSConversationSettingsViewController, @"disappearing_messages_duration"); - - return cell; - } customRowHeight:UITableViewAutomaticDimension actionBlock:nil]]; - } - } - - [contents addSection:section]; - - // Closed group settings - __block BOOL isUserMember = NO; - if (self.isClosedGroup || self.isOpenGroup) { - isUserMember = [SMKGroupMember isCurrentUserMemberOf:self.threadId]; - } - if (self.isClosedGroup && isUserMember) { - [section addItem:[OWSTableItem itemWithCustomCellBlock:^{ - UITableViewCell *cell = - [weakSelf disclosureCellWithName:NSLocalizedString(@"EDIT_GROUP_ACTION", @"table cell label in conversation settings") - iconName:@"table_ic_group_edit" - accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(OWSConversationSettingsViewController, @"edit_group")]; - cell.userInteractionEnabled = !weakSelf.hasLeftGroup; - return cell; - } actionBlock:^{ - [weakSelf editGroup]; - }]]; - [section addItem:[OWSTableItem itemWithCustomCellBlock:^{ - UITableViewCell *cell = - [weakSelf disclosureCellWithName:NSLocalizedString(@"LEAVE_GROUP_ACTION", @"table cell label in conversation settings") - iconName:@"table_ic_group_leave" - accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(OWSConversationSettingsViewController, @"leave_group")]; - cell.userInteractionEnabled = !weakSelf.hasLeftGroup; - - return cell; - } actionBlock:^{ - [weakSelf didTapLeaveGroup]; - }]]; - } - - if (!self.isNoteToSelf) { - // Notification sound - [section addItem:[OWSTableItem itemWithCustomCellBlock:^{ - UITableViewCell *cell = - [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleValue1 reuseIdentifier:nil]; - [OWSTableItem configureCell:cell]; - OWSConversationSettingsViewController *strongSelf = weakSelf; - OWSCAssertDebug(strongSelf); - cell.preservesSuperviewLayoutMargins = YES; - cell.contentView.preservesSuperviewLayoutMargins = YES; - - UIImageView *iconView = [strongSelf viewForIconWithName:@"table_ic_notification_sound"]; - - UILabel *rowLabel = [UILabel new]; - rowLabel.text = NSLocalizedString(@"SETTINGS_ITEM_NOTIFICATION_SOUND", - @"Label for settings view that allows user to change the notification sound."); - rowLabel.textColor = LKColors.text; - rowLabel.font = [UIFont systemFontOfSize:LKValues.mediumFontSize]; - rowLabel.lineBreakMode = NSLineBreakByTruncatingTail; - - UIStackView *contentRow = - [[UIStackView alloc] initWithArrangedSubviews:@[ iconView, rowLabel ]]; - contentRow.spacing = strongSelf.iconSpacing; - contentRow.alignment = UIStackViewAlignmentCenter; - [cell.contentView addSubview:contentRow]; - [contentRow autoPinEdgesToSuperviewMargins]; - - NSInteger sound = [SMKSound notificationSoundFor:strongSelf.threadId]; - cell.detailTextLabel.text = [SMKSound displayNameFor:sound]; - - cell.accessibilityIdentifier = ACCESSIBILITY_IDENTIFIER_WITH_NAME( - OWSConversationSettingsViewController, @"notifications"); - - return cell; - } - customRowHeight:UITableViewAutomaticDimension - actionBlock:^{ - // FIXME: Remove `threadId` once we ditch the per-thread notification sound - UIViewController *viewController = [OWSNotificationSoundSettings createWith:weakSelf.threadId]; - [weakSelf.navigationController pushViewController:viewController animated:YES]; - }]]; - - if (self.isClosedGroup || self.isOpenGroup) { - // Notification Settings - [section addItem:[OWSTableItem itemWithCustomCellBlock:^{ - UITableViewCell *cell = [OWSTableItem newCell]; - OWSConversationSettingsViewController *strongSelf = weakSelf; - OWSCAssertDebug(strongSelf); - cell.preservesSuperviewLayoutMargins = YES; - cell.contentView.preservesSuperviewLayoutMargins = YES; - cell.selectionStyle = UITableViewCellSelectionStyleNone; - - UIImageView *iconView = [strongSelf viewForIconWithName:@"NotifyMentions"]; - - UILabel *rowLabel = [UILabel new]; - rowLabel.text = NSLocalizedString(@"vc_conversation_settings_notify_for_mentions_only_title", @""); - rowLabel.textColor = LKColors.text; - rowLabel.font = [UIFont systemFontOfSize:LKValues.mediumFontSize]; - rowLabel.lineBreakMode = NSLineBreakByTruncatingTail; - - UISwitch *switchView = [UISwitch new]; - switchView.on = [SMKThread isOnlyNotifyingForMentions:strongSelf.threadId]; - [switchView addTarget:strongSelf action:@selector(notifyForMentionsOnlySwitchValueDidChange:) - forControlEvents:UIControlEventValueChanged]; - - UIStackView *topRow = - [[UIStackView alloc] initWithArrangedSubviews:@[ iconView, rowLabel, switchView ]]; - topRow.spacing = strongSelf.iconSpacing; - topRow.alignment = UIStackViewAlignmentCenter; - [cell.contentView addSubview:topRow]; - [topRow autoPinEdgesToSuperviewMarginsExcludingEdge:ALEdgeBottom]; - - UILabel *subtitleLabel = [UILabel new]; - subtitleLabel.text = NSLocalizedString(@"vc_conversation_settings_notify_for_mentions_only_explanation", @""); - subtitleLabel.textColor = LKColors.text; - subtitleLabel.font = [UIFont systemFontOfSize:LKValues.smallFontSize]; - subtitleLabel.numberOfLines = 0; - subtitleLabel.lineBreakMode = NSLineBreakByWordWrapping; - [cell.contentView addSubview:subtitleLabel]; - [subtitleLabel autoPinEdge:ALEdgeTop toEdge:ALEdgeBottom ofView:topRow withOffset:8]; - [subtitleLabel autoPinEdge:ALEdgeLeading toEdge:ALEdgeLeading ofView:rowLabel]; - [subtitleLabel autoPinTrailingToSuperviewMargin]; - [subtitleLabel autoPinBottomToSuperviewMargin]; - - cell.userInteractionEnabled = !strongSelf.hasLeftGroup; - - cell.accessibilityIdentifier = ACCESSIBILITY_IDENTIFIER_WITH_NAME(OWSConversationSettingsViewController, @"notify_for_mentions_only"); - - return cell; - } customRowHeight:UITableViewAutomaticDimension actionBlock:nil]]; - } - - // Mute thread - [section addItem:[OWSTableItem itemWithCustomCellBlock:^{ - OWSConversationSettingsViewController *strongSelf = weakSelf; - if (!strongSelf) { return [UITableViewCell new]; } - - NSString *cellTitle = NSLocalizedString(@"CONVERSATION_SETTINGS_MUTE_LABEL", @"label for 'mute thread' cell in conversation settings"); - UITableViewCell *cell = [strongSelf disclosureCellWithName:cellTitle iconName:@"Mute" - accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(OWSConversationSettingsViewController, @"mute")]; - - cell.selectionStyle = UITableViewCellSelectionStyleNone; - - UISwitch *muteConversationSwitch = [UISwitch new]; - NSDate *mutedUntilDate = [SMKThread mutedUntilDateFor:strongSelf.threadId]; - NSDate *now = [NSDate date]; - muteConversationSwitch.on = (mutedUntilDate != nil && [mutedUntilDate timeIntervalSinceDate:now] > 0); - [muteConversationSwitch addTarget:strongSelf action:@selector(handleMuteSwitchToggled:) - forControlEvents:UIControlEventValueChanged]; - cell.accessoryView = muteConversationSwitch; - - return cell; - } actionBlock:nil]]; - } - - // Block contact - if (!self.isNoteToSelf && !self.isClosedGroup && !self.isOpenGroup) { - [section addItem:[OWSTableItem itemWithCustomCellBlock:^{ - OWSConversationSettingsViewController *strongSelf = weakSelf; - if (!strongSelf) { return [UITableViewCell new]; } - - NSString *cellTitle = NSLocalizedString(@"CONVERSATION_SETTINGS_BLOCK_THIS_USER", @"table cell label in conversation settings"); - UITableViewCell *cell = [strongSelf disclosureCellWithName:cellTitle iconName:@"table_ic_block" - accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(OWSConversationSettingsViewController, @"block")]; - - cell.selectionStyle = UITableViewCellSelectionStyleNone; - - UISwitch *blockConversationSwitch = [UISwitch new]; - blockConversationSwitch.on = [SMKContact isBlockedFor:strongSelf.threadId]; - [blockConversationSwitch addTarget:strongSelf action:@selector(blockConversationSwitchDidChange:) - forControlEvents:UIControlEventValueChanged]; - cell.accessoryView = blockConversationSwitch; - - return cell; - } actionBlock:nil]]; - } - - self.contents = contents; -} - -- (CGFloat)iconSpacing -{ - return 12.f; -} - -- (UITableViewCell *)cellWithName:(NSString *)name iconName:(NSString *)iconName -{ - OWSAssertDebug(iconName.length > 0); - UIImageView *iconView = [self viewForIconWithName:iconName]; - return [self cellWithName:name iconView:iconView]; -} - -- (UITableViewCell *)cellWithName:(NSString *)name iconView:(UIView *)iconView -{ - OWSAssertDebug(name.length > 0); - - UITableViewCell *cell = [OWSTableItem newCell]; - cell.preservesSuperviewLayoutMargins = YES; - cell.contentView.preservesSuperviewLayoutMargins = YES; - - UILabel *rowLabel = [UILabel new]; - rowLabel.text = name; - rowLabel.textColor = LKColors.text; - rowLabel.font = [UIFont systemFontOfSize:LKValues.mediumFontSize]; - rowLabel.lineBreakMode = NSLineBreakByTruncatingTail; - - UIStackView *contentRow = [[UIStackView alloc] initWithArrangedSubviews:@[ iconView, rowLabel ]]; - contentRow.spacing = self.iconSpacing; - - [cell.contentView addSubview:contentRow]; - [contentRow autoPinEdgesToSuperviewMargins]; - - return cell; -} - -- (UITableViewCell *)disclosureCellWithName:(NSString *)name - iconName:(NSString *)iconName - accessibilityIdentifier:(NSString *)accessibilityIdentifier -{ - UITableViewCell *cell = [self cellWithName:name iconName:iconName]; - cell.accessibilityIdentifier = accessibilityIdentifier; - return cell; -} - -- (UITableViewCell *)labelCellWithName:(NSString *)name - iconName:(NSString *)iconName - accessibilityIdentifier:(NSString *)accessibilityIdentifier -{ - UITableViewCell *cell = [self cellWithName:name iconName:iconName]; - cell.accessoryType = UITableViewCellAccessoryNone; - cell.accessibilityIdentifier = accessibilityIdentifier; - return cell; -} - -- (void)showProfilePicture:(UITapGestureRecognizer *)tapGesture -{ - LKProfilePictureView *profilePictureView = (LKProfilePictureView *)tapGesture.view; - UIImage *image = [profilePictureView getProfilePicture]; - if (image == nil) { return; } - NSString *title = (self.threadName != nil && self.threadName.length > 0) ? self.threadName : @"Anonymous"; - SNProfilePictureVC *profilePictureVC = [[SNProfilePictureVC alloc] initWithImage:image title:title]; - UINavigationController *navController = [[UINavigationController alloc] initWithRootViewController:profilePictureVC]; - navController.modalPresentationStyle = UIModalPresentationFullScreen; - [self presentViewController:navController animated:YES completion:nil]; -} - -- (UIView *)mainSectionHeader -{ - UITapGestureRecognizer *profilePictureTapGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(showProfilePicture:)]; - LKProfilePictureView *profilePictureView = [LKProfilePictureView new]; - CGFloat size = LKValues.largeProfilePictureSize; - profilePictureView.size = size; - [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.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.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.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 = self.threadId; - subtitleView.textAlignment = NSTextAlignmentCenter; - [stackView addArrangedSubview:subtitleView]; - } - - [profilePictureView updateForThreadId:self.threadId]; - - return stackView; -} - -- (UIImageView *)viewForIconWithName:(NSString *)iconName -{ - UIImage *icon = [UIImage imageNamed:iconName]; - - OWSAssertDebug(icon); - UIImageView *iconView = [UIImageView new]; - iconView.image = [icon imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]; - iconView.tintColor = LKColors.text; - iconView.contentMode = UIViewContentModeScaleAspectFit; - iconView.layer.minificationFilter = kCAFilterTrilinear; - iconView.layer.magnificationFilter = kCAFilterTrilinear; - - [iconView autoSetDimensionsToSize:CGSizeMake(kIconViewLength, kIconViewLength)]; - - return iconView; -} - -- (void)viewWillAppear:(BOOL)animated -{ - [super viewWillAppear:animated]; - - NSIndexPath *_Nullable selectedPath = [self.tableView indexPathForSelectedRow]; - if (selectedPath) { - // HACK to unselect rows when swiping back - // http://stackoverflow.com/questions/19379510/uitableviewcell-doesnt-get-deselected-when-swiping-back-quickly - [self.tableView deselectRowAtIndexPath:selectedPath animated:animated]; - } - - [self updateTableContents]; -} - -- (void)viewWillDisappear:(BOOL)animated -{ - [super viewWillDisappear:animated]; - - // 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; - } - - [SMKDisappearingMessagesConfiguration - update:self.threadId - isEnabled: self.isDisappearingMessagesEnabled - durationIndex: self.disappearingMessagesDurationIndex - ]; -} - -#pragma mark - Actions - -- (void)editGroup -{ - SNEditClosedGroupVC *editClosedGroupVC = [[SNEditClosedGroupVC alloc] initWithThreadId:self.threadId]; - [self.navigationController pushViewController:editClosedGroupVC animated:YES completion:nil]; -} - -- (void)didTapLeaveGroup -{ - NSString *message; - 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 - preferredStyle:UIAlertControllerStyleAlert]; - - UIAlertAction *leaveAction = [UIAlertAction - actionWithTitle:NSLocalizedString(@"LEAVE_BUTTON_TITLE", @"Confirmation button within contextual alert") - accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, @"leave_group_confirm") - style:UIAlertActionStyleDestructive - handler:^(UIAlertAction *_Nonnull action) { - [self leaveGroup]; - }]; - [alert addAction:leaveAction]; - [alert addAction:[OWSAlerts cancelAction]]; - - [self presentAlert:alert]; -} - -- (BOOL)hasLeftGroup -{ - if (self.isClosedGroup) { - return ![SMKGroupMember isCurrentUserMemberOf:self.threadId]; - } - - return NO; -} - -- (void)leaveGroup -{ - if (self.isClosedGroup) { - [[SMKMessageSender leaveClosedGroupWithPublicKey:self.threadId] retainUntilComplete]; - } - - [self.navigationController popViewControllerAnimated:YES]; -} - -- (void)disappearingMessagesSwitchValueDidChange:(UISwitch *)sender -{ - UISwitch *disappearingMessagesSwitch = (UISwitch *)sender; - - [self toggleDisappearingMessages:disappearingMessagesSwitch.isOn]; - - [self updateTableContents]; -} - -- (void)handleMuteSwitchToggled:(id)sender -{ - UISwitch *uiSwitch = (UISwitch *)sender; - if (uiSwitch.isOn) { - [SMKThread updateWithMutedUntilDateTo:[NSDate distantFuture] forThreadId:self.threadId]; - } else { - [SMKThread updateWithMutedUntilDateTo:nil forThreadId:self.threadId]; - } -} - -- (void)blockConversationSwitchDidChange:(id)sender -{ - if (![sender isKindOfClass:[UISwitch class]]) { - OWSFailDebug(@"Unexpected sender for block user switch: %@", sender); - } - if (self.isClosedGroup || self.isOpenGroup) { - OWSFailDebug(@"unexpected group thread"); - } - UISwitch *blockConversationSwitch = (UISwitch *)sender; - - BOOL isCurrentlyBlocked = [SMKContact isBlockedFor:self.threadId]; - - __weak OWSConversationSettingsViewController *weakSelf = self; - if (blockConversationSwitch.isOn) { - OWSAssertDebug(!isCurrentlyBlocked); - if (isCurrentlyBlocked) { - return; - } - [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) { - [SMKMessageSender forceSyncConfigurationNow]; - } - - [weakSelf updateTableContents]; - }]; - - } else { - OWSAssertDebug(isCurrentlyBlocked); - if (!isCurrentlyBlocked) { - return; - } - [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) { - [SMKMessageSender forceSyncConfigurationNow]; - } - - [weakSelf updateTableContents]; - }]; - } -} - -- (void)toggleDisappearingMessages:(BOOL)flag -{ - self.isDisappearingMessagesEnabled = flag; - - [self updateTableContents]; -} - -- (void)durationSliderDidChange:(UISlider *)slider -{ - // snap the slider to a valid value - NSInteger index = (NSInteger)(slider.value + 0.5); - [slider setValue:index animated:YES]; - self.disappearingMessagesDurationIndex = index; - - [self updateDisappearingMessagesDurationLabel]; -} - -- (void)updateDisappearingMessagesDurationLabel -{ - if (self.isDisappearingMessagesEnabled) { - NSString *keepForFormat = @"Disappear after %@"; - 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"); - } - - [self.disappearingMessagesDurationLabel setNeedsLayout]; - [self.disappearingMessagesDurationLabel.superview setNeedsLayout]; -} - -- (void)copySessionID -{ - UIPasteboard.generalPasteboard.string = self.threadId; -} - -- (void)inviteUsersToOpenGroup -{ - NSString *threadId = self.threadId; - SNUserSelectionVC *userSelectionVC = [[SNUserSelectionVC alloc] initWithTitle:NSLocalizedString(@"vc_conversation_settings_invite_button_title", @"") - excluding:[NSSet new] - completion:^(NSSet *selectedUsers) { - [SMKOpenGroup inviteUsers:selectedUsers toOpenGroupFor:threadId]; - }]; - [self.navigationController pushViewController:userSelectionVC animated:YES]; -} - -- (void)showMediaGallery -{ - OWSLogDebug(@""); - - OWSAssertDebug([self.navigationController isKindOfClass:[OWSNavigationController class]]); - [SNMediaGallery pushTileViewWithSliderEnabledForThreadId:self.threadId isClosedGroup:self.isClosedGroup isOpenGroup:self.isOpenGroup fromNavController:(OWSNavigationController *)self.navigationController]; -} - -- (void)tappedConversationSearch -{ - [self.conversationSettingsViewDelegate conversationSettingsDidRequestConversationSearch:self]; -} - -- (void)notifyForMentionsOnlySwitchValueDidChange:(id)sender -{ - UISwitch *uiSwitch = (UISwitch *)sender; - BOOL isEnabled = uiSwitch.isOn; - - [SMKThread setIsOnlyNotifyingForMentions:self.threadId to:isEnabled]; -} - -- (void)hideEditNameUI -{ - self.isEditingDisplayName = NO; -} - -- (void)showEditNameUI -{ - self.isEditingDisplayName = YES; -} - -- (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; - }]; - if (self.isEditingDisplayName) { - [self.displayNameTextField becomeFirstResponder]; - } else { - [self.displayNameTextField resignFirstResponder]; - } -} - -- (void)saveName -{ - if (self.isClosedGroup || self.isOpenGroup) { return; } - - NSString *text = [self.displayNameTextField.text stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet]; - self.displayNameLabel.text = [SMKProfile displayNameAfterSavingNickname:text forProfileId:self.threadId]; - [self hideEditNameUI]; -} - -- (void)updateNavBarButtons -{ - if (self.isEditingDisplayName) { - UIBarButtonItem *cancelButton = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemCancel target:self action:@selector(hideEditNameUI)]; - cancelButton.tintColor = LKColors.text; - cancelButton.accessibilityLabel = @"Cancel button"; - cancelButton.isAccessibilityElement = YES; - self.navigationItem.leftBarButtonItem = cancelButton; - UIBarButtonItem *doneButton = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemDone target:self action:@selector(saveName)]; - doneButton.tintColor = LKColors.text; - doneButton.accessibilityLabel = @"Done button"; - doneButton.isAccessibilityElement = YES; - self.navigationItem.rightBarButtonItem = doneButton; - } else { - self.navigationItem.leftBarButtonItem = nil; - UIBarButtonItem *editButton = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemEdit target:self action:@selector(showEditNameUI)]; - editButton.tintColor = LKColors.text; - editButton.accessibilityLabel = @"Done button"; - editButton.isAccessibilityElement = YES; - self.navigationItem.rightBarButtonItem = editButton; - } -} - -#pragma mark - Notifications - -// FIXME: When this screen gets refactored, make sure to observe changes for relevant profile image updates -- (void)otherUsersProfileDidChange:(NSNotification *)notification -{ - NSString *recipientId = @"";//notification.userInfo[NSNotification.profileRecipientIdKey]; - OWSAssertDebug(recipientId.length > 0); - - if (recipientId.length > 0 && !self.isClosedGroup && !self.isOpenGroup && self.threadId == recipientId) { - DispatchMainThreadSafe(^{ - [self updateTableContents]; - }); - } -} - -#pragma mark - OWSSheetViewController - -- (void)sheetViewControllerRequestedDismiss:(OWSSheetViewController *)sheetViewController -{ - [self dismissViewControllerAnimated:YES completion:nil]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Conversations/Settings/OWSConversationSettingsViewDelegate.h b/Session/Conversations/Settings/OWSConversationSettingsViewDelegate.h deleted file mode 100644 index b9fcaa2c0..000000000 --- a/Session/Conversations/Settings/OWSConversationSettingsViewDelegate.h +++ /dev/null @@ -1,16 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -NS_ASSUME_NONNULL_BEGIN - -@class OWSConversationSettingsViewController; -@class TSGroupModel; - -@protocol OWSConversationSettingsViewDelegate - -- (void)conversationSettingsDidRequestConversationSearch:(OWSConversationSettingsViewController *)conversationSettingsViewController; - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Conversations/Settings/ProfilePictureVC.swift b/Session/Conversations/Settings/ProfilePictureVC.swift index 41524e640..e84fd8386 100644 --- a/Session/Conversations/Settings/ProfilePictureVC.swift +++ b/Session/Conversations/Settings/ProfilePictureVC.swift @@ -1,15 +1,63 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import YYImage +import SessionUIKit /// Shown when the user taps a profile picture in the conversation settings. -@objc(SNProfilePictureVC) final class ProfilePictureVC: BaseVC { - private let image: UIImage + private let image: UIImage? + private let animatedImage: YYImage? private let snTitle: String - @objc init(image: UIImage, title: String) { + private var imageSize: CGFloat { (UIScreen.main.bounds.width - (2 * Values.largeSpacing)) } + + // MARK: - UI + + private lazy var fallbackView: UIView = { + let result: UIView = UIView() + result.clipsToBounds = true + result.themeBackgroundColor = .backgroundSecondary + result.layer.cornerRadius = (imageSize / 2) + result.isHidden = ( + image != nil || + animatedImage != nil + ) + result.set(.width, to: imageSize) + result.set(.height, to: imageSize) + + return result + }() + + private lazy var imageView: UIImageView = { + let result: UIImageView = UIImageView(image: image) + result.clipsToBounds = true + result.contentMode = .scaleAspectFill + result.layer.cornerRadius = (imageSize / 2) + result.isHidden = (image == nil) + result.set(.width, to: imageSize) + result.set(.height, to: imageSize) + + return result + }() + + private lazy var animatedImageView: YYAnimatedImageView = { + let result: YYAnimatedImageView = YYAnimatedImageView(image: animatedImage) + result.clipsToBounds = true + result.contentMode = .scaleAspectFill + result.layer.cornerRadius = (imageSize / 2) + result.isHidden = (animatedImage == nil) + result.set(.width, to: imageSize) + result.set(.height, to: imageSize) + + return result + }() + + // MARK: - Initialization + + init(image: UIImage?, animatedImage: YYImage?, title: String) { self.image = image + self.animatedImage = animatedImage self.snTitle = title super.init(nibName: nil, bundle: nil) @@ -24,23 +72,28 @@ final class ProfilePictureVC: BaseVC { } override func viewDidLoad() { - view.backgroundColor = .clear + view.themeBackgroundColor = .backgroundPrimary setNavBarTitle(snTitle) // Close button - let closeButton = UIBarButtonItem(image: #imageLiteral(resourceName: "X"), style: .plain, target: self, action: #selector(close)) - closeButton.tintColor = Colors.text + let closeButton = UIBarButtonItem( + image: #imageLiteral(resourceName: "X").withRenderingMode(.alwaysTemplate), + style: .plain, + target: self, + action: #selector(close) + ) + closeButton.themeTintColor = .textPrimary navigationItem.leftBarButtonItem = closeButton - // Image view - let imageView = UIImageView(image: image) - let size = UIScreen.main.bounds.width - 2 * Values.largeSpacing - imageView.set(.width, to: size) - imageView.set(.height, to: size) - imageView.layer.cornerRadius = size / 2 - imageView.layer.masksToBounds = true + + view.addSubview(fallbackView) view.addSubview(imageView) + view.addSubview(animatedImageView) + + fallbackView.center(in: view) imageView.center(in: view) + animatedImageView.center(in: view) + // Gesture recognizer let swipeGestureRecognizer = UISwipeGestureRecognizer(target: self, action: #selector(close)) swipeGestureRecognizer.direction = .down diff --git a/Session/Conversations/Settings/ThreadDisappearingMessagesViewModel.swift b/Session/Conversations/Settings/ThreadDisappearingMessagesViewModel.swift new file mode 100644 index 000000000..2fe576dd6 --- /dev/null +++ b/Session/Conversations/Settings/ThreadDisappearingMessagesViewModel.swift @@ -0,0 +1,196 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Combine +import GRDB +import DifferenceKit +import SessionUIKit +import SessionMessagingKit +import SessionUtilitiesKit + +class ThreadDisappearingMessagesViewModel: SettingsTableViewModel { + // MARK: - Config + + enum NavButton: Equatable { + case cancel + case save + } + + public enum Section: SettingSection { + case content + } + + public struct Item: Equatable, Hashable, Differentiable { + let title: String + + public var differenceIdentifier: String { title } + } + + // MARK: - Variables + + private let threadId: String + private let config: DisappearingMessagesConfiguration + private var storedSelection: TimeInterval + private var currentSelection: CurrentValueSubject + + // MARK: - Initialization + + init(threadId: String, config: DisappearingMessagesConfiguration) { + self.threadId = threadId + self.config = config + self.storedSelection = (config.isEnabled ? config.durationSeconds : 0) + self.currentSelection = CurrentValueSubject(self.storedSelection) + } + + // MARK: - Navigation + + override var leftNavItems: AnyPublisher<[NavItem]?, Never> { + Just([ + NavItem( + id: .cancel, + systemItem: .cancel, + accessibilityIdentifier: "Cancel button" + ) + ]).eraseToAnyPublisher() + } + + override var rightNavItems: AnyPublisher<[NavItem]?, Never> { + currentSelection + .removeDuplicates() + .map { [weak self] currentSelection in (self?.storedSelection != currentSelection) } + .map { isChanged in + guard isChanged else { return [] } + + return [ + NavItem( + id: .save, + systemItem: .save, + accessibilityIdentifier: "Save button" + ) + ] + } + .eraseToAnyPublisher() + } + + override var closeScreen: AnyPublisher { + navItemTapped + .handleEvents(receiveOutput: { [weak self] navItemId in + switch navItemId { + case .save: self?.saveChanges() + default: break + } + self?.setIsEditing(true) + }) + .map { _ in false } + .eraseToAnyPublisher() + } + + // MARK: - Content + + override var title: String { "DISAPPEARING_MESSAGES".localized() } + + private var _settingsData: [SectionModel] = [] + public override var settingsData: [SectionModel] { _settingsData } + + public override var observableSettingsData: ObservableData { _observableSettingsData } + + /// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise + /// performance https://github.com/groue/GRDB.swift#valueobservation-performance + /// + /// **Note:** This observation will be triggered twice immediately (and be de-duped by the `removeDuplicates`) + /// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own + /// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`) + /// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this + private lazy var _observableSettingsData: ObservableData = ValueObservation + .trackingConstantRegion { [weak self, config = self.config] db -> [SectionModel] in + return [ + SectionModel( + model: .content, + elements: [ + SettingInfo( + id: Item(title: "DISAPPEARING_MESSAGES_OFF".localized()), + title: "DISAPPEARING_MESSAGES_OFF".localized(), + action: .listSelection( + isSelected: { (self?.currentSelection.value == 0) }, + storedSelection: (self?.config.isEnabled == false), + shouldAutoSave: false, + selectValue: { self?.currentSelection.send(0) } + ) + ) + ].appending( + contentsOf: DisappearingMessagesConfiguration.validDurationsSeconds + .map { duration in + let title: String = NSString.formatDurationSeconds( + UInt32(duration), + useShortFormat: false + ) + + return SettingInfo( + id: Item(title: title), + title: title, + action: .listSelection( + isSelected: { (self?.currentSelection.value == duration) }, + storedSelection: (self?.config.durationSeconds == duration), + shouldAutoSave: false, + selectValue: { self?.currentSelection.send(duration) } + ) + ) + } + ) + ) + ] + } + .removeDuplicates() + + // MARK: - Functions + + public override func updateSettings(_ updatedSettings: [SectionModel]) { + self._settingsData = updatedSettings + } + + private func saveChanges() { + let threadId: String = self.threadId + let currentSelection: TimeInterval = self.currentSelection.value + let updatedConfig: DisappearingMessagesConfiguration = self.config + .with( + isEnabled: (currentSelection != 0), + durationSeconds: currentSelection + ) + + guard self.config != updatedConfig else { return } + + Storage.shared.writeAsync { db in + guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else { + return + } + + let config: DisappearingMessagesConfiguration = try DisappearingMessagesConfiguration + .fetchOne(db, id: threadId) + .defaulting(to: DisappearingMessagesConfiguration.defaultWith(threadId)) + .with( + isEnabled: (currentSelection != 0), + durationSeconds: currentSelection + ) + .saved(db) + + let interaction: Interaction = try Interaction( + threadId: threadId, + authorId: getUserHexEncodedPublicKey(db), + variant: .infoDisappearingMessagesUpdate, + body: config.messageInfoString(with: nil), + timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)) + ) + .inserted(db) + + try MessageSender.send( + db, + message: ExpirationTimerUpdate( + syncTarget: nil, + duration: UInt32(floor(updatedConfig.isEnabled ? updatedConfig.durationSeconds : 0)) + ), + interactionId: interaction.id, + in: thread + ) + } + } +} diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift new file mode 100644 index 000000000..18e40b0ef --- /dev/null +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -0,0 +1,628 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Combine +import GRDB +import DifferenceKit +import SessionUIKit +import SessionMessagingKit +import SignalUtilitiesKit +import SessionUtilitiesKit + +class ThreadSettingsViewModel: SettingsTableViewModel { + // MARK: - Config + + enum NavState { + case standard + case editing + } + + enum NavButton: Equatable { + case edit + case cancel + case done + } + + public enum Section: SettingSection { + case content + } + + public enum Setting: Differentiable { + case threadInfo + case copyThreadId + case allMedia + case searchConversation + case addToOpenGroup + case disappearingMessages + case disappearingMessagesDuration + case editGroup + case leaveGroup + case notificationSound + case notificationMentionsOnly + case notificationMute + case blockUser + } + + // MARK: - Variables + + private let threadId: String + private let threadVariant: SessionThread.Variant + private let didTriggerSearch: () -> () + private var oldDisplayName: String? + private var editedDisplayName: String? + + // MARK: - Initialization + + init(threadId: String, threadVariant: SessionThread.Variant, didTriggerSearch: @escaping () -> ()) { + self.threadId = threadId + self.threadVariant = threadVariant + self.didTriggerSearch = didTriggerSearch + self.oldDisplayName = (threadVariant != .contact ? + nil : + Storage.shared.read { db in + try Profile + .filter(id: threadId) + .select(.nickname) + .asRequest(of: String.self) + .fetchOne(db) + } + ) + } + + // MARK: - Navigation + + lazy var navState: AnyPublisher = { + Publishers + .MergeMany( + isEditing + .filter { $0 } + .map { _ in .editing } + .eraseToAnyPublisher(), + navItemTapped + .filter { $0 == .edit } + .map { _ in .editing } + .handleEvents(receiveOutput: { [weak self] _ in + self?.setIsEditing(true) + }) + .eraseToAnyPublisher(), + navItemTapped + .filter { $0 == .cancel } + .map { _ in .standard } + .handleEvents(receiveOutput: { [weak self] _ in + self?.setIsEditing(false) + self?.editedDisplayName = self?.oldDisplayName + }) + .eraseToAnyPublisher(), + navItemTapped + .filter { $0 == .done } + .filter { [weak self] _ in self?.threadVariant == .contact } + .handleEvents(receiveOutput: { [weak self] _ in + self?.setIsEditing(false) + + guard + let threadId: String = self?.threadId, + let editedDisplayName: String = self?.editedDisplayName + else { return } + + let updatedNickname: String = editedDisplayName + .trimmingCharacters(in: .whitespacesAndNewlines) + self?.oldDisplayName = (updatedNickname.isEmpty ? nil : editedDisplayName) + + Storage.shared.writeAsync { db in + try Profile + .filter(id: threadId) + .updateAll( + db, + Profile.Columns.nickname + .set(to: (updatedNickname.isEmpty ? nil : editedDisplayName)) + ) + } + }) + .map { _ in .standard } + .eraseToAnyPublisher() + ) + .removeDuplicates() + .prepend(.standard) // Initial value + .eraseToAnyPublisher() + }() + + override var leftNavItems: AnyPublisher<[NavItem]?, Never> { + navState + .map { [weak self] navState -> [NavItem] in + // Only show the 'Edit' button if it's a contact thread + guard self?.threadVariant == .contact else { return [] } + guard navState == .editing else { return [] } + + return [ + NavItem( + id: .cancel, + systemItem: .cancel, + accessibilityIdentifier: "Cancel button" + ) + ] + } + .eraseToAnyPublisher() + } + + override var rightNavItems: AnyPublisher<[NavItem]?, Never> { + navState + .map { [weak self] navState -> [NavItem] in + // Only show the 'Edit' button if it's a contact thread + guard self?.threadVariant == .contact else { return [] } + + switch navState { + case .editing: + return [ + NavItem( + id: .done, + systemItem: .done, + accessibilityIdentifier: "Done button" + ) + ] + + case .standard: + return [ + NavItem( + id: .edit, + systemItem: .edit, + accessibilityIdentifier: "Edit button" + ) + ] + } + } + .eraseToAnyPublisher() + } + + // MARK: - Content + + override var title: String { + switch threadVariant { + case .contact: return "vc_settings_title".localized() + case .closedGroup, .openGroup: return "vc_group_settings_title".localized() + } + } + + private var _settingsData: [SectionModel] = [] + public override var settingsData: [SectionModel] { _settingsData } + + public override var observableSettingsData: ObservableData { _observableSettingsData } + + /// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise + /// performance https://github.com/groue/GRDB.swift#valueobservation-performance + /// + /// **Note:** This observation will be triggered twice immediately (and be de-duped by the `removeDuplicates`) + /// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own + /// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`) + /// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this + private lazy var _observableSettingsData: ObservableData = ValueObservation + .trackingConstantRegion { [weak self, threadId = self.threadId, threadVariant = self.threadVariant] db -> [SectionModel] in + let userPublicKey: String = getUserHexEncodedPublicKey(db) + let maybeThreadViewModel: SessionThreadViewModel? = try SessionThreadViewModel + .conversationSettingsQuery(threadId: threadId, userPublicKey: userPublicKey) + .fetchOne(db) + + guard let threadViewModel: SessionThreadViewModel = maybeThreadViewModel else { return [] } + + // Additional Queries + let fallbackSound: Preferences.Sound = db[.defaultNotificationSound] + .defaulting(to: Preferences.Sound.defaultNotificationSound) + let notificationSound: Preferences.Sound = try SessionThread + .filter(id: threadId) + .select(.notificationSound) + .asRequest(of: Preferences.Sound.self) + .fetchOne(db) + .defaulting(to: fallbackSound) + let disappearingMessagesConfig: DisappearingMessagesConfiguration = try DisappearingMessagesConfiguration + .fetchOne(db, id: threadId) + .defaulting(to: DisappearingMessagesConfiguration.defaultWith(threadId)) + let currentUserIsClosedGroupMember: Bool = ( + threadVariant == .closedGroup && + threadViewModel.currentUserIsClosedGroupMember == true + ) + + return [ + SectionModel( + model: .content, + elements: [ + SettingInfo( + id: .threadInfo, + title: threadViewModel.displayName, + action: .threadInfo( + threadViewModel: threadViewModel, + createAvatarTapDestination: { [weak self] in + guard + threadVariant == .contact, + let profileData: Data = ProfileManager.profileAvatar(id: threadId) + else { return nil } + + let format: ImageFormat = profileData.guessedImageFormat + let navController: UINavigationController = UINavigationController( + rootViewController: ProfilePictureVC( + image: (format == .gif || format == .webp ? + nil : + UIImage(data: profileData) + ), + animatedImage: (format != .gif && format != .webp ? + nil : + YYImage(data: profileData) + ), + title: threadViewModel.displayName + ) + ) + navController.modalPresentationStyle = .fullScreen + + return navController + }, + titleTapped: { [weak self] in self?.setIsEditing(true) }, + titleChanged: { [weak self] text in self?.editedDisplayName = text } + ) + ), + + (threadVariant == .closedGroup ? nil : + SettingInfo( + id: .copyThreadId, + icon: UIImage(named: "ic_copy")? + .withRenderingMode(.alwaysTemplate), + title: (threadVariant == .openGroup ? + "COPY_GROUP_URL".localized() : + "vc_conversation_settings_copy_session_id_button_title".localized() + ), + accessibilityIdentifier: "\(ThreadSettingsViewModel.self).copy_thread_id", + action: .trigger(showChevron: false) { + UIPasteboard.general.string = threadId + } + ) + ), + + SettingInfo( + id: .allMedia, + icon: UIImage(named: "actionsheet_camera_roll_black")? + .withRenderingMode(.alwaysTemplate), + title: MediaStrings.allMedia, + accessibilityIdentifier: "\(ThreadSettingsViewModel.self).all_media", + action: .push(showChevron: false) { + return MediaGalleryViewModel.createTileViewController( + threadId: threadId, + threadVariant: threadVariant, + focusedAttachmentId: nil + ) + } + ), + + SettingInfo( + id: .searchConversation, + icon: UIImage(named: "conversation_settings_search")? + .withRenderingMode(.alwaysTemplate), + title: "CONVERSATION_SETTINGS_SEARCH".localized(), + accessibilityIdentifier: "\(ThreadSettingsViewModel.self).search", + action: .trigger(showChevron: false) { [weak self] in + self?.didTriggerSearch() + } + ), + + (threadVariant != .openGroup ? nil : + SettingInfo( + id: .addToOpenGroup, + icon: UIImage(named: "ic_plus_24")? + .withRenderingMode(.alwaysTemplate), + title: "vc_conversation_settings_invite_button_title".localized(), + accessibilityIdentifier: "\(ThreadSettingsViewModel.self).add_to_open_group", + action: .push(showChevron: false) { + return UserSelectionVC( + with: "vc_conversation_settings_invite_button_title".localized(), + excluding: Set() + ) { [weak self] selectedUsers in + self?.addUsersToOpenGoup(selectedUsers: selectedUsers) + } + } + ) + ), + + (threadVariant == .openGroup || threadViewModel.threadIsBlocked == true ? nil : + SettingInfo( + id: .disappearingMessages, + icon: UIImage( + named: (disappearingMessagesConfig.isEnabled ? + "ic_timer" : + "ic_timer_disabled" + ) + )?.withRenderingMode(.alwaysTemplate), + title: "DISAPPEARING_MESSAGES".localized(), + subtitle: { + guard threadId != userPublicKey else { + return "When enabled, messages will disappear after they have been seen." + } + + let customDisplayName: String = { + switch threadVariant { + case .closedGroup, .openGroup: return "the group" + case .contact: return threadViewModel.displayName + } + }() + + return String( + format: "When enabled, messages between you and %@ will disappear after they have been seen.", + arguments: [customDisplayName] + ) + }(), + accessibilityIdentifier: "\(ThreadSettingsViewModel.self).disappearing_messages", + action: .generalEnum( + title: (disappearingMessagesConfig.isEnabled ? + disappearingMessagesConfig.durationString : + "DISAPPEARING_MESSAGES_OFF".localized() + ), + createUpdateScreen: { + SettingsTableViewController( + viewModel: ThreadDisappearingMessagesViewModel( + threadId: threadId, + config: disappearingMessagesConfig + ) + ) + } + ) + ) + ), + + (!currentUserIsClosedGroupMember ? nil : + SettingInfo( + id: .editGroup, + icon: UIImage(named: "table_ic_group_edit")? + .withRenderingMode(.alwaysTemplate), + title: "EDIT_GROUP_ACTION".localized(), + accessibilityIdentifier: "\(ThreadSettingsViewModel.self).edit_group", + action: .push(showChevron: false) { + EditClosedGroupVC(threadId: threadId) + } + ) + ), + + (!currentUserIsClosedGroupMember ? nil : + SettingInfo( + id: .leaveGroup, + icon: UIImage(named: "table_ic_group_leave")? + .withRenderingMode(.alwaysTemplate), + title: "LEAVE_GROUP_ACTION".localized(), + accessibilityIdentifier: "\(ThreadSettingsViewModel.self).leave_group", + action: .present { + ConfirmationModal( + info: ConfirmationModal.Info( + title: "CONFIRM_LEAVE_GROUP_TITLE".localized(), + explanation: (currentUserIsClosedGroupMember ? + "Because you are the creator of this group it will be deleted for everyone. This cannot be undone." : + "CONFIRM_LEAVE_GROUP_DESCRIPTION".localized() + ), + confirmTitle: "LEAVE_BUTTON_TITLE".localized(), + confirmStyle: .danger, + cancelStyle: .textPrimary + ) { _ in + Storage.shared.writeAsync { db in + try MessageSender.leave(db, groupPublicKey: threadId) + } + } + ) + } + ) + ), + + (threadViewModel.threadIsNoteToSelf ? nil : + SettingInfo( + id: .notificationSound, + icon: UIImage(named: "table_ic_notification_sound")? + .withRenderingMode(.alwaysTemplate), + title: "SETTINGS_ITEM_NOTIFICATION_SOUND".localized(), + action: .generalEnum( + title: notificationSound.displayName, + createUpdateScreen: { + SettingsTableViewController( + viewModel: NotificationSoundViewModel(threadId: threadId) + ) + } + ) + ) + ), + + (threadVariant == .contact ? nil : + SettingInfo( + id: .notificationMentionsOnly, + icon: UIImage(named: "NotifyMentions")? + .withRenderingMode(.alwaysTemplate), + title: "vc_conversation_settings_notify_for_mentions_only_title".localized(), + subtitle: "vc_conversation_settings_notify_for_mentions_only_explanation".localized(), + accessibilityIdentifier: "\(ThreadSettingsViewModel.self).notify_for_mentions_only", + action: .customToggle( + value: (threadViewModel.threadOnlyNotifyForMentions == true), + isEnabled: ( + threadViewModel.threadVariant != .closedGroup || + currentUserIsClosedGroupMember + ) + ) { newValue in + Storage.shared.writeAsync { db in + try SessionThread + .filter(id: threadId) + .updateAll( + db, + SessionThread.Columns.onlyNotifyForMentions.set(to: newValue) + ) + } + } + ) + ), + + (threadViewModel.threadIsNoteToSelf ? nil : + SettingInfo( + id: .notificationMute, + icon: UIImage(named: "Mute")? + .withRenderingMode(.alwaysTemplate), + title: "CONVERSATION_SETTINGS_MUTE_LABEL".localized(), + accessibilityIdentifier: "\(ThreadSettingsViewModel.self).mute", + action: .customToggle( + value: (threadViewModel.threadMutedUntilTimestamp != nil), + isEnabled: ( + threadViewModel.threadVariant != .closedGroup || + currentUserIsClosedGroupMember + ) + ) { newValue in + Storage.shared.writeAsync { db in + try SessionThread + .filter(id: threadId) + .updateAll( + db, + SessionThread.Columns.mutedUntilTimestamp.set( + to: (newValue ? + Date.distantFuture.timeIntervalSince1970 : + nil + ) + ) + ) + } + } + ) + ), + + (threadViewModel.threadIsNoteToSelf || threadVariant != .contact ? nil : + SettingInfo( + id: .blockUser, + icon: UIImage(named: "table_ic_block")? + .withRenderingMode(.alwaysTemplate), + title: "CONVERSATION_SETTINGS_BLOCK_THIS_USER".localized(), + accessibilityIdentifier: "\(ThreadSettingsViewModel.self).block", + action: .customToggle( + value: (threadViewModel.threadIsBlocked == true), + confirmationInfo: ConfirmationModal.Info( + title: { + guard threadViewModel.threadIsBlocked == true else { + return String( + format: "BLOCK_LIST_BLOCK_USER_TITLE_FORMAT".localized(), + threadViewModel.displayName + ) + } + + return String( + format: "BLOCK_LIST_UNBLOCK_TITLE_FORMAT".localized(), + threadViewModel.displayName + ) + }(), + explanation: (threadViewModel.threadIsBlocked == true ? + nil : + "BLOCK_USER_BEHAVIOR_EXPLANATION".localized() + ), + confirmTitle: (threadViewModel.threadIsBlocked == true ? + "BLOCK_LIST_UNBLOCK_BUTTON".localized() : + "BLOCK_LIST_BLOCK_BUTTON".localized() + ), + confirmStyle: .danger, + cancelStyle: .textPrimary + ) { viewController in + let isBlocked: Bool = (threadViewModel.threadIsBlocked == true) + + self?.updateBlockedState( + from: isBlocked, + isBlocked: !isBlocked, + threadId: threadId, + displayName: threadViewModel.displayName, + viewController: viewController + ) + } + ) + ) + ) + ].compactMap { $0 } + ) + ] + } + .removeDuplicates() + + // MARK: - Functions + + public override func updateSettings(_ updatedSettings: [SectionModel]) { + self._settingsData = updatedSettings + } + + private func addUsersToOpenGoup(selectedUsers: Set) { + let threadId: String = self.threadId + + Storage.shared.writeAsync { db in + guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: threadId) else { return } + + let urlString: String = "\(openGroup.server)/\(openGroup.roomToken)?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: thread.id, + authorId: userId, + variant: .standardOutgoing, + timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)), + expiresInSeconds: try? DisappearingMessagesConfiguration + .select(.durationSeconds) + .filter(id: userId) + .filter(DisappearingMessagesConfiguration.Columns.isEnabled == true) + .asRequest(of: TimeInterval.self) + .fetchOne(db), + linkPreviewUrl: urlString + ) + .inserted(db) + + try MessageSender.send( + db, + interaction: interaction, + in: thread + ) + } + } + } + + private func updateBlockedState( + from oldBlockedState: Bool, + isBlocked: Bool, + threadId: String, + displayName: String, + viewController: UIViewController + ) { + guard oldBlockedState != isBlocked else { return } + + Storage.shared.writeAsync( + updates: { db in + try Contact + .fetchOrCreate(db, id: threadId) + .with(isBlocked: .updateTo(isBlocked)) + .save(db) + }, + completion: { db, _ in + try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() + + DispatchQueue.main.async { + let modal: ConfirmationModal = ConfirmationModal( + info: ConfirmationModal.Info( + title: (oldBlockedState == false ? + "BLOCK_LIST_VIEW_BLOCKED_ALERT_TITLE".localized() : + String( + format: "BLOCK_LIST_VIEW_UNBLOCKED_ALERT_TITLE_FORMAT".localized(), + displayName + ) + ), + explanation: (oldBlockedState == false ? + String( + format: "BLOCK_LIST_VIEW_BLOCKED_ALERT_MESSAGE_FORMAT".localized(), + displayName + ) : + nil + ), + cancelTitle: "BUTTON_OK".localized(), + cancelStyle: .textPrimary + ) + ) + viewController.present(modal, animated: true) + } + } + ) + } +} diff --git a/Session/Home/GlobalSearch/GlobalSearchViewController.swift b/Session/Home/GlobalSearch/GlobalSearchViewController.swift index f71187288..c0af10cbd 100644 --- a/Session/Home/GlobalSearch/GlobalSearchViewController.swift +++ b/Session/Home/GlobalSearch/GlobalSearchViewController.swift @@ -31,6 +31,7 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo return [ result.map { ArraySection(model: .contactsAndGroups, elements: [$0]) } ] .compactMap { $0 } }() + private var readConnection: Atomic = Atomic(nil) private lazy var searchResultSet: [SectionModel] = self.defaultSearchResults private var termForCurrentSearchResultSet: String = "" private var lastSearchText: String? @@ -156,53 +157,61 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo lastSearchText = searchText - let result: Result<[SectionModel], Error>? = Storage.shared.read { db -> Result<[SectionModel], Error> in - do { - let userPublicKey: String = getUserHexEncodedPublicKey(db) - let contactsAndGroupsResults: [SessionThreadViewModel] = try SessionThreadViewModel - .contactsAndGroupsQuery( - userPublicKey: userPublicKey, - pattern: try SessionThreadViewModel.pattern(db, searchTerm: searchText), - searchTerm: searchText - ) - .fetchAll(db) - let messageResults: [SessionThreadViewModel] = try SessionThreadViewModel - .messagesQuery( - userPublicKey: userPublicKey, - pattern: try SessionThreadViewModel.pattern(db, searchTerm: searchText) - ) - .fetchAll(db) + DispatchQueue.global(qos: .default).async { [weak self] in + self?.readConnection.wrappedValue?.interrupt() + + let result: Result<[SectionModel], Error>? = Storage.shared.read { db -> Result<[SectionModel], Error> in + self?.readConnection.mutate { $0 = db } - return .success([ - ArraySection(model: .contactsAndGroups, elements: contactsAndGroupsResults), - ArraySection(model: .messages, elements: messageResults) - ]) + do { + let userPublicKey: String = getUserHexEncodedPublicKey(db) + let contactsAndGroupsResults: [SessionThreadViewModel] = try SessionThreadViewModel + .contactsAndGroupsQuery( + userPublicKey: userPublicKey, + pattern: try SessionThreadViewModel.pattern(db, searchTerm: searchText), + searchTerm: searchText + ) + .fetchAll(db) + let messageResults: [SessionThreadViewModel] = try SessionThreadViewModel + .messagesQuery( + userPublicKey: userPublicKey, + pattern: try SessionThreadViewModel.pattern(db, searchTerm: searchText) + ) + .fetchAll(db) + + return .success([ + ArraySection(model: .contactsAndGroups, elements: contactsAndGroupsResults), + ArraySection(model: .messages, elements: messageResults) + ]) + } + catch { + return .failure(error) + } } - catch { - return .failure(error) + + DispatchQueue.main.async { + switch result { + case .success(let sections): + let hasResults: Bool = ( + !searchText.isEmpty && + (sections.map { $0.elements.count }.reduce(0, +) > 0) + ) + + self?.termForCurrentSearchResultSet = searchText + self?.searchResultSet = [ + (hasResults ? nil : [ArraySection(model: .noResults, elements: [SessionThreadViewModel(unreadCount: 0)])]), + (hasResults ? sections : nil) + ] + .compactMap { $0 } + .flatMap { $0 } + self?.isLoading = false + self?.reloadTableData() + self?.refreshTimer = nil + + default: break + } } } - - switch result { - case .success(let sections): - let hasResults: Bool = ( - !searchText.isEmpty && - (sections.map { $0.elements.count }.reduce(0, +) > 0) - ) - - self.termForCurrentSearchResultSet = searchText - self.searchResultSet = [ - (hasResults ? nil : [ArraySection(model: .noResults, elements: [SessionThreadViewModel(unreadCount: 0)])]), - (hasResults ? sections : nil) - ] - .compactMap { $0 } - .flatMap { $0 } - self.isLoading = false - self.reloadTableData() - self.refreshTimer = nil - - default: break - } } @objc func cancel() { diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index a2da18e46..7c5b2f7f5 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -8,7 +8,7 @@ import SessionUtilitiesKit import SignalUtilitiesKit final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConversationButtonSetDelegate, SeedReminderViewDelegate { - private static let loadingHeaderHeight: CGFloat = 20 + private static let loadingHeaderHeight: CGFloat = 40 private let viewModel: HomeViewModel = HomeViewModel() private var dataChangeObservable: DatabaseCancellable? diff --git a/Session/Home/Message Requests/MessageRequestsViewController.swift b/Session/Home/Message Requests/MessageRequestsViewController.swift index 48d15e2bd..410ff3416 100644 --- a/Session/Home/Message Requests/MessageRequestsViewController.swift +++ b/Session/Home/Message Requests/MessageRequestsViewController.swift @@ -8,7 +8,7 @@ import SessionMessagingKit import SignalUtilitiesKit class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDataSource { - private static let loadingHeaderHeight: CGFloat = 20 + private static let loadingHeaderHeight: CGFloat = 40 private let viewModel: MessageRequestsViewModel = MessageRequestsViewModel() private var dataChangeObservable: DatabaseCancellable? diff --git a/Session/Media Viewing & Editing/MediaTileViewController.swift b/Session/Media Viewing & Editing/MediaTileViewController.swift index 1085b574a..fb0f2c3e2 100644 --- a/Session/Media Viewing & Editing/MediaTileViewController.swift +++ b/Session/Media Viewing & Editing/MediaTileViewController.swift @@ -495,7 +495,7 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour guard let viewControllers: [UIViewController] = self.navigationController?.viewControllers, viewControllers.count > 1, - viewControllers[viewControllers.count - 2] is OWSConversationSettingsViewController + (viewControllers[viewControllers.count - 2] as? SettingsViewModelAccessible)?.viewModelType == ThreadSettingsViewModel.self else { return } let detailViewController: UIViewController? = MediaGalleryViewModel.createDetailViewController( diff --git a/Session/Meta/Signal-Bridging-Header.h b/Session/Meta/Signal-Bridging-Header.h index ffe99002a..4e89dff43 100644 --- a/Session/Meta/Signal-Bridging-Header.h +++ b/Session/Meta/Signal-Bridging-Header.h @@ -11,7 +11,6 @@ #import "AVAudioSession+OWS.h" #import "OWSAudioPlayer.h" #import "OWSBezierPathView.h" -#import "OWSConversationSettingsViewController.h" #import "OWSMessageTimerView.h" #import "OWSNavigationController.h" #import "OWSProgressView.h" diff --git a/Session/Meta/Translations/de.lproj/Localizable.strings b/Session/Meta/Translations/de.lproj/Localizable.strings index 0acc2180b..418fc2e98 100644 --- a/Session/Meta/Translations/de.lproj/Localizable.strings +++ b/Session/Meta/Translations/de.lproj/Localizable.strings @@ -130,6 +130,8 @@ "CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE" = "Dies kann nicht rückgängig gemacht werden."; /* Title for the 'conversation delete confirmation' alert. */ "CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE" = "Unterhaltung löschen?"; +/* keyboard toolbar label when starting to search with no current results */ +"CONVERSATION_SEARCH_SEARCHING" = "Searching..."; /* keyboard toolbar label when no messages match the search string */ "CONVERSATION_SEARCH_NO_RESULTS" = "Keine Treffer"; /* keyboard toolbar label when exactly 1 message matches the search string */ @@ -510,6 +512,7 @@ "vc_join_public_chat_scan_qr_code_explanation" = "Scannen Sie den QR-Code der offenen Gruppe, der Sie beitreten möchten."; "vc_enter_chat_url_text_field_hint" = "Geben Sie eine offene Gruppen-URL ein."; "vc_settings_title" = "Einstellungen"; +"vc_group_settings_title" = "Group Settings"; "vc_settings_display_name_text_field_hint" = "Geben Sie einen Anzeigenamen ein."; "vc_settings_display_name_missing_error" = "Bitte wählen Sie einen Anzeigenamen."; "vc_settings_display_name_too_long_error" = "Bitte wählen Sie einen kürzeren Anzeigenamen."; @@ -776,3 +779,5 @@ "modal_permission_camera" = "camera"; "modal_permission_microphone" = "microphone"; "modal_permission_library" = "library"; +"DISAPPEARING_MESSAGES_OFF" = "Off"; +"COPY_GROUP_URL" = "Copy Group URL"; diff --git a/Session/Meta/Translations/en.lproj/Localizable.strings b/Session/Meta/Translations/en.lproj/Localizable.strings index 279954916..849f8568f 100644 --- a/Session/Meta/Translations/en.lproj/Localizable.strings +++ b/Session/Meta/Translations/en.lproj/Localizable.strings @@ -130,6 +130,8 @@ "CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE" = "This cannot be undone."; /* Title for the 'conversation delete confirmation' alert. */ "CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE" = "Delete Conversation?"; +/* keyboard toolbar label when starting to search with no current results */ +"CONVERSATION_SEARCH_SEARCHING" = "Searching..."; /* keyboard toolbar label when no messages match the search string */ "CONVERSATION_SEARCH_NO_RESULTS" = "No matches"; /* keyboard toolbar label when exactly 1 message matches the search string */ @@ -510,6 +512,7 @@ "vc_join_public_chat_scan_qr_code_explanation" = "Scan the QR code of the open group you'd like to join"; "vc_enter_chat_url_text_field_hint" = "Enter an open group URL"; "vc_settings_title" = "Settings"; +"vc_group_settings_title" = "Group Settings"; "vc_settings_display_name_text_field_hint" = "Enter a display name"; "vc_settings_display_name_missing_error" = "Please pick a display name"; "vc_settings_display_name_too_long_error" = "Please pick a shorter display name"; @@ -776,3 +779,5 @@ "modal_permission_camera" = "camera"; "modal_permission_microphone" = "microphone"; "modal_permission_library" = "library"; +"DISAPPEARING_MESSAGES_OFF" = "Off"; +"COPY_GROUP_URL" = "Copy Group URL"; diff --git a/Session/Meta/Translations/es.lproj/Localizable.strings b/Session/Meta/Translations/es.lproj/Localizable.strings index 8408afc10..ad5befb72 100644 --- a/Session/Meta/Translations/es.lproj/Localizable.strings +++ b/Session/Meta/Translations/es.lproj/Localizable.strings @@ -130,6 +130,8 @@ "CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE" = "Este paso no se puede deshacer."; /* Title for the 'conversation delete confirmation' alert. */ "CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE" = "¿Eliminar conversación?"; +/* keyboard toolbar label when starting to search with no current results */ +"CONVERSATION_SEARCH_SEARCHING" = "Searching..."; /* keyboard toolbar label when no messages match the search string */ "CONVERSATION_SEARCH_NO_RESULTS" = "Sin resultados"; /* keyboard toolbar label when exactly 1 message matches the search string */ @@ -510,6 +512,7 @@ "vc_join_public_chat_scan_qr_code_explanation" = "Escanea el código QR del grupo abierto al que quieras unirte"; "vc_enter_chat_url_text_field_hint" = "Ingresa una URL de grupo abierto"; "vc_settings_title" = "Ajustes"; +"vc_group_settings_title" = "Group Settings"; "vc_settings_display_name_text_field_hint" = "Ingresa un nombre para mostrar"; "vc_settings_display_name_missing_error" = "Por favor, elige un nombre para mostrar"; "vc_settings_display_name_too_long_error" = "Por favor, elige un nombre para mostrar más corto"; @@ -776,3 +779,5 @@ "modal_permission_camera" = "camera"; "modal_permission_microphone" = "microphone"; "modal_permission_library" = "library"; +"DISAPPEARING_MESSAGES_OFF" = "Off"; +"COPY_GROUP_URL" = "Copy Group URL"; diff --git a/Session/Meta/Translations/fa.lproj/Localizable.strings b/Session/Meta/Translations/fa.lproj/Localizable.strings index 0f0e9919b..6c6ba1ded 100644 --- a/Session/Meta/Translations/fa.lproj/Localizable.strings +++ b/Session/Meta/Translations/fa.lproj/Localizable.strings @@ -130,6 +130,8 @@ "CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE" = "این نمیتواند انجام نشود."; /* Title for the 'conversation delete confirmation' alert. */ "CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE" = "گفتگو حذف شود؟"; +/* keyboard toolbar label when starting to search with no current results */ +"CONVERSATION_SEARCH_SEARCHING" = "Searching..."; /* keyboard toolbar label when no messages match the search string */ "CONVERSATION_SEARCH_NO_RESULTS" = "مشابهی یافت نشد"; /* keyboard toolbar label when exactly 1 message matches the search string */ @@ -510,6 +512,7 @@ "vc_join_public_chat_scan_qr_code_explanation" = "کد QR متعلق به گروه باز را که می‌خواهید به آن بپیوندید، اسکن کنید"; "vc_enter_chat_url_text_field_hint" = "یک URL گروه باز وارد کنید"; "vc_settings_title" = "تنظیمات"; +"vc_group_settings_title" = "Group Settings"; "vc_settings_display_name_text_field_hint" = "یک نام نمایشی را وارد کنید"; "vc_settings_display_name_missing_error" = "لطفا یک نام نمایشی را انتخاب کنید"; "vc_settings_display_name_too_long_error" = "لطفا یک نام نمایشی کوتاه‌تر انتخاب کنید"; @@ -776,3 +779,5 @@ "modal_permission_camera" = "camera"; "modal_permission_microphone" = "microphone"; "modal_permission_library" = "library"; +"DISAPPEARING_MESSAGES_OFF" = "Off"; +"COPY_GROUP_URL" = "Copy Group URL"; diff --git a/Session/Meta/Translations/fi.lproj/Localizable.strings b/Session/Meta/Translations/fi.lproj/Localizable.strings index f9668ec25..7caa36964 100644 --- a/Session/Meta/Translations/fi.lproj/Localizable.strings +++ b/Session/Meta/Translations/fi.lproj/Localizable.strings @@ -130,6 +130,8 @@ "CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE" = "Tätä ei voida perua."; /* Title for the 'conversation delete confirmation' alert. */ "CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE" = "Poistetaanko keskustelu?"; +/* keyboard toolbar label when starting to search with no current results */ +"CONVERSATION_SEARCH_SEARCHING" = "Searching..."; /* keyboard toolbar label when no messages match the search string */ "CONVERSATION_SEARCH_NO_RESULTS" = "Ei tuloksia"; /* keyboard toolbar label when exactly 1 message matches the search string */ @@ -510,6 +512,7 @@ "vc_join_public_chat_scan_qr_code_explanation" = "Skannaa sen julkisen ryhmän QR-koodi, johon haluaisit liittyä"; "vc_enter_chat_url_text_field_hint" = "Syötä julkisen ryhmän URL"; "vc_settings_title" = "Asetukset"; +"vc_group_settings_title" = "Group Settings"; "vc_settings_display_name_text_field_hint" = "Anna julkinen nimi"; "vc_settings_display_name_missing_error" = "Ole hyvä ja valitse julkinen nimi"; "vc_settings_display_name_too_long_error" = "Ole hyvä ja valitse lyhyempi julkinen nimi"; @@ -776,3 +779,5 @@ "modal_permission_camera" = "camera"; "modal_permission_microphone" = "microphone"; "modal_permission_library" = "library"; +"DISAPPEARING_MESSAGES_OFF" = "Off"; +"COPY_GROUP_URL" = "Copy Group URL"; diff --git a/Session/Meta/Translations/fr.lproj/Localizable.strings b/Session/Meta/Translations/fr.lproj/Localizable.strings index 754b5913e..2bcaf674a 100644 --- a/Session/Meta/Translations/fr.lproj/Localizable.strings +++ b/Session/Meta/Translations/fr.lproj/Localizable.strings @@ -130,6 +130,8 @@ "CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE" = "Cette action est irréversible."; /* Title for the 'conversation delete confirmation' alert. */ "CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE" = "Supprimer la conversation ?"; +/* keyboard toolbar label when starting to search with no current results */ +"CONVERSATION_SEARCH_SEARCHING" = "Searching..."; /* keyboard toolbar label when no messages match the search string */ "CONVERSATION_SEARCH_NO_RESULTS" = "Aucune correspondance"; /* keyboard toolbar label when exactly 1 message matches the search string */ @@ -510,6 +512,7 @@ "vc_join_public_chat_scan_qr_code_explanation" = "Scannez le code QR du groupe public que vous souhaitez rejoindre"; "vc_enter_chat_url_text_field_hint" = "Saisissez une URL de groupe public"; "vc_settings_title" = "Paramètres"; +"vc_group_settings_title" = "Group Settings"; "vc_settings_display_name_text_field_hint" = "Saisissez un nom d'utilisateur"; "vc_settings_display_name_missing_error" = "Veuillez choisir un nom d'utilisateur"; "vc_settings_display_name_too_long_error" = "Veuillez choisir un nom d'utilisateur plus court"; @@ -776,3 +779,5 @@ "modal_permission_camera" = "camera"; "modal_permission_microphone" = "microphone"; "modal_permission_library" = "library"; +"DISAPPEARING_MESSAGES_OFF" = "Off"; +"COPY_GROUP_URL" = "Copy Group URL"; diff --git a/Session/Meta/Translations/hi.lproj/Localizable.strings b/Session/Meta/Translations/hi.lproj/Localizable.strings index 57a2402ee..525521ace 100644 --- a/Session/Meta/Translations/hi.lproj/Localizable.strings +++ b/Session/Meta/Translations/hi.lproj/Localizable.strings @@ -130,6 +130,8 @@ "CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE" = "This cannot be undone."; /* Title for the 'conversation delete confirmation' alert. */ "CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE" = "Delete Conversation?"; +/* keyboard toolbar label when starting to search with no current results */ +"CONVERSATION_SEARCH_SEARCHING" = "Searching..."; /* keyboard toolbar label when no messages match the search string */ "CONVERSATION_SEARCH_NO_RESULTS" = "No matches"; /* keyboard toolbar label when exactly 1 message matches the search string */ @@ -510,6 +512,7 @@ "vc_join_public_chat_scan_qr_code_explanation" = "Scan the QR code of the open group you'd like to join"; "vc_enter_chat_url_text_field_hint" = "Enter an open group URL"; "vc_settings_title" = "Settings"; +"vc_group_settings_title" = "Group Settings"; "vc_settings_display_name_text_field_hint" = "Enter a display name"; "vc_settings_display_name_missing_error" = "Please pick a display name"; "vc_settings_display_name_too_long_error" = "Please pick a shorter display name"; @@ -776,3 +779,5 @@ "modal_permission_camera" = "camera"; "modal_permission_microphone" = "microphone"; "modal_permission_library" = "library"; +"DISAPPEARING_MESSAGES_OFF" = "Off"; +"COPY_GROUP_URL" = "Copy Group URL"; diff --git a/Session/Meta/Translations/hr.lproj/Localizable.strings b/Session/Meta/Translations/hr.lproj/Localizable.strings index 0d4e3e802..011864002 100644 --- a/Session/Meta/Translations/hr.lproj/Localizable.strings +++ b/Session/Meta/Translations/hr.lproj/Localizable.strings @@ -130,6 +130,8 @@ "CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE" = "Ovaj je postupak nepovratan."; /* Title for the 'conversation delete confirmation' alert. */ "CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE" = "Obriši razgovor?"; +/* keyboard toolbar label when starting to search with no current results */ +"CONVERSATION_SEARCH_SEARCHING" = "Searching..."; /* keyboard toolbar label when no messages match the search string */ "CONVERSATION_SEARCH_NO_RESULTS" = "Nema podudaranja"; /* keyboard toolbar label when exactly 1 message matches the search string */ @@ -510,6 +512,7 @@ "vc_join_public_chat_scan_qr_code_explanation" = "Skenirajte QR kôd otvorene grupe kojoj se želite pridružiti"; "vc_enter_chat_url_text_field_hint" = "Unesite poveznicu otvorene grupe"; "vc_settings_title" = "Postavke"; +"vc_group_settings_title" = "Group Settings"; "vc_settings_display_name_text_field_hint" = "Unesite ime za prikaz"; "vc_settings_display_name_missing_error" = "Molimo odaberite svoje ime za prikaz"; "vc_settings_display_name_too_long_error" = "Odaberite kraće ime za prikaz"; @@ -776,3 +779,5 @@ "modal_permission_camera" = "camera"; "modal_permission_microphone" = "microphone"; "modal_permission_library" = "library"; +"DISAPPEARING_MESSAGES_OFF" = "Off"; +"COPY_GROUP_URL" = "Copy Group URL"; diff --git a/Session/Meta/Translations/id-ID.lproj/Localizable.strings b/Session/Meta/Translations/id-ID.lproj/Localizable.strings index 7e88af45f..1709b02dc 100644 --- a/Session/Meta/Translations/id-ID.lproj/Localizable.strings +++ b/Session/Meta/Translations/id-ID.lproj/Localizable.strings @@ -130,6 +130,8 @@ "CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE" = "Tindakan ini tidak dapat dibatalkan."; /* Title for the 'conversation delete confirmation' alert. */ "CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE" = "Hapus Percakapan?"; +/* keyboard toolbar label when starting to search with no current results */ +"CONVERSATION_SEARCH_SEARCHING" = "Searching..."; /* keyboard toolbar label when no messages match the search string */ "CONVERSATION_SEARCH_NO_RESULTS" = "Tidak ada yang cocok"; /* keyboard toolbar label when exactly 1 message matches the search string */ @@ -510,6 +512,7 @@ "vc_join_public_chat_scan_qr_code_explanation" = "Pindai kode QR grup terbuka yang ingin anda masuki"; "vc_enter_chat_url_text_field_hint" = "Masukkan sebuah URL grup terbuka"; "vc_settings_title" = "Pengaturan"; +"vc_group_settings_title" = "Group Settings"; "vc_settings_display_name_text_field_hint" = "Masukkan nama"; "vc_settings_display_name_missing_error" = "Pilih sebuah nama"; "vc_settings_display_name_too_long_error" = "Nama yang dibuat terlalu panjang"; @@ -776,3 +779,5 @@ "modal_permission_camera" = "camera"; "modal_permission_microphone" = "microphone"; "modal_permission_library" = "library"; +"DISAPPEARING_MESSAGES_OFF" = "Off"; +"COPY_GROUP_URL" = "Copy Group URL"; diff --git a/Session/Meta/Translations/it.lproj/Localizable.strings b/Session/Meta/Translations/it.lproj/Localizable.strings index ad823d66f..7b633915f 100644 --- a/Session/Meta/Translations/it.lproj/Localizable.strings +++ b/Session/Meta/Translations/it.lproj/Localizable.strings @@ -130,6 +130,8 @@ "CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE" = "Non potrà essere annullato."; /* Title for the 'conversation delete confirmation' alert. */ "CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE" = "Elimina conversazione?"; +/* keyboard toolbar label when starting to search with no current results */ +"CONVERSATION_SEARCH_SEARCHING" = "Searching..."; /* keyboard toolbar label when no messages match the search string */ "CONVERSATION_SEARCH_NO_RESULTS" = "Nessun risultato"; /* keyboard toolbar label when exactly 1 message matches the search string */ @@ -510,6 +512,7 @@ "vc_join_public_chat_scan_qr_code_explanation" = "Scansiona il codice QR del gruppo aperto a cui desideri partecipare"; "vc_enter_chat_url_text_field_hint" = "Inserisci l'URL di un gruppo aperto"; "vc_settings_title" = "Impostazioni"; +"vc_group_settings_title" = "Group Settings"; "vc_settings_display_name_text_field_hint" = "Inserisci il nome da visualizzare"; "vc_settings_display_name_missing_error" = "Scegli il nome da visualizzare"; "vc_settings_display_name_too_long_error" = "Scegli un nome più breve"; @@ -776,3 +779,5 @@ "modal_permission_camera" = "camera"; "modal_permission_microphone" = "microphone"; "modal_permission_library" = "library"; +"DISAPPEARING_MESSAGES_OFF" = "Off"; +"COPY_GROUP_URL" = "Copy Group URL"; diff --git a/Session/Meta/Translations/ja.lproj/Localizable.strings b/Session/Meta/Translations/ja.lproj/Localizable.strings index b328df64e..b938cc8ca 100644 --- a/Session/Meta/Translations/ja.lproj/Localizable.strings +++ b/Session/Meta/Translations/ja.lproj/Localizable.strings @@ -130,6 +130,8 @@ "CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE" = "消去すると元に戻せません"; /* Title for the 'conversation delete confirmation' alert. */ "CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE" = "消去しますか?"; +/* keyboard toolbar label when starting to search with no current results */ +"CONVERSATION_SEARCH_SEARCHING" = "Searching..."; /* keyboard toolbar label when no messages match the search string */ "CONVERSATION_SEARCH_NO_RESULTS" = "見つかりません"; /* keyboard toolbar label when exactly 1 message matches the search string */ @@ -510,6 +512,7 @@ "vc_join_public_chat_scan_qr_code_explanation" = "参加したい公開グループの QR コードをスキャンする"; "vc_enter_chat_url_text_field_hint" = "公開グループの URL を入力する"; "vc_settings_title" = "設定"; +"vc_group_settings_title" = "Group Settings"; "vc_settings_display_name_text_field_hint" = "表示名を入力してください"; "vc_settings_display_name_missing_error" = "表示名を選択してください"; "vc_settings_display_name_too_long_error" = "短い表示名を選択してください"; @@ -776,3 +779,5 @@ "modal_permission_camera" = "camera"; "modal_permission_microphone" = "microphone"; "modal_permission_library" = "library"; +"DISAPPEARING_MESSAGES_OFF" = "Off"; +"COPY_GROUP_URL" = "Copy Group URL"; diff --git a/Session/Meta/Translations/nl.lproj/Localizable.strings b/Session/Meta/Translations/nl.lproj/Localizable.strings index 5fbe03114..88734066b 100644 --- a/Session/Meta/Translations/nl.lproj/Localizable.strings +++ b/Session/Meta/Translations/nl.lproj/Localizable.strings @@ -130,6 +130,8 @@ "CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE" = "Dit kan niet ongedaan worden gemaakt."; /* Title for the 'conversation delete confirmation' alert. */ "CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE" = "Gesprek verwijderen?"; +/* keyboard toolbar label when starting to search with no current results */ +"CONVERSATION_SEARCH_SEARCHING" = "Searching..."; /* keyboard toolbar label when no messages match the search string */ "CONVERSATION_SEARCH_NO_RESULTS" = "Geen overeenkomsten"; /* keyboard toolbar label when exactly 1 message matches the search string */ @@ -510,6 +512,7 @@ "vc_join_public_chat_scan_qr_code_explanation" = "Scan de QR-code van de open groep waar u zich bij wilt aansluiten"; "vc_enter_chat_url_text_field_hint" = "Voer een open groep URL in"; "vc_settings_title" = "Instellingen"; +"vc_group_settings_title" = "Group Settings"; "vc_settings_display_name_text_field_hint" = "Kies een weergavenaam"; "vc_settings_display_name_missing_error" = "Voer a. u. b. een weergave naam in"; "vc_settings_display_name_too_long_error" = "Kies een kortere weergavenaam"; @@ -776,3 +779,5 @@ "modal_permission_camera" = "camera"; "modal_permission_microphone" = "microphone"; "modal_permission_library" = "library"; +"DISAPPEARING_MESSAGES_OFF" = "Off"; +"COPY_GROUP_URL" = "Copy Group URL"; diff --git a/Session/Meta/Translations/pl.lproj/Localizable.strings b/Session/Meta/Translations/pl.lproj/Localizable.strings index c87a66f57..06a6c9e9b 100644 --- a/Session/Meta/Translations/pl.lproj/Localizable.strings +++ b/Session/Meta/Translations/pl.lproj/Localizable.strings @@ -130,6 +130,8 @@ "CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE" = "Tego nie można cofnąć."; /* Title for the 'conversation delete confirmation' alert. */ "CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE" = "Usunąć konwersację?"; +/* keyboard toolbar label when starting to search with no current results */ +"CONVERSATION_SEARCH_SEARCHING" = "Searching..."; /* keyboard toolbar label when no messages match the search string */ "CONVERSATION_SEARCH_NO_RESULTS" = "Brak wyników"; /* keyboard toolbar label when exactly 1 message matches the search string */ @@ -510,6 +512,7 @@ "vc_join_public_chat_scan_qr_code_explanation" = "Zeskanuj kod QR otwartej grupy, do której chcesz dołączyć"; "vc_enter_chat_url_text_field_hint" = "Wprowadź adres URL otwartej grupy"; "vc_settings_title" = "Ustawienia"; +"vc_group_settings_title" = "Group Settings"; "vc_settings_display_name_text_field_hint" = "Wprowadź wyświetlaną nazwe"; "vc_settings_display_name_missing_error" = "Wybierz wyświetlaną nazwę"; "vc_settings_display_name_too_long_error" = "Wybierz krótszą nazwę wyświetlaną"; @@ -776,3 +779,5 @@ "modal_permission_camera" = "camera"; "modal_permission_microphone" = "microphone"; "modal_permission_library" = "library"; +"DISAPPEARING_MESSAGES_OFF" = "Off"; +"COPY_GROUP_URL" = "Copy Group URL"; diff --git a/Session/Meta/Translations/pt_BR.lproj/Localizable.strings b/Session/Meta/Translations/pt_BR.lproj/Localizable.strings index 1b328af59..5932c113c 100644 --- a/Session/Meta/Translations/pt_BR.lproj/Localizable.strings +++ b/Session/Meta/Translations/pt_BR.lproj/Localizable.strings @@ -130,6 +130,8 @@ "CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE" = "Isso não pode ser desfeito."; /* Title for the 'conversation delete confirmation' alert. */ "CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE" = "Excluir conversa?"; +/* keyboard toolbar label when starting to search with no current results */ +"CONVERSATION_SEARCH_SEARCHING" = "Searching..."; /* keyboard toolbar label when no messages match the search string */ "CONVERSATION_SEARCH_NO_RESULTS" = "Sem resultados"; /* keyboard toolbar label when exactly 1 message matches the search string */ @@ -510,6 +512,7 @@ "vc_join_public_chat_scan_qr_code_explanation" = "Escaneie o código QR do grupo aberto no qual você deseja entrar"; "vc_enter_chat_url_text_field_hint" = "Digite a URL do grupo aberto"; "vc_settings_title" = "Configurações"; +"vc_group_settings_title" = "Group Settings"; "vc_settings_display_name_text_field_hint" = "Digite um nome de exibição"; "vc_settings_display_name_missing_error" = "Escolha um nome de exibição"; "vc_settings_display_name_too_long_error" = "Escolha um nome de exibição mais curto"; @@ -776,3 +779,5 @@ "modal_permission_camera" = "camera"; "modal_permission_microphone" = "microphone"; "modal_permission_library" = "library"; +"DISAPPEARING_MESSAGES_OFF" = "Off"; +"COPY_GROUP_URL" = "Copy Group URL"; diff --git a/Session/Meta/Translations/ru.lproj/Localizable.strings b/Session/Meta/Translations/ru.lproj/Localizable.strings index b09c1f7cd..6cdf42210 100644 --- a/Session/Meta/Translations/ru.lproj/Localizable.strings +++ b/Session/Meta/Translations/ru.lproj/Localizable.strings @@ -130,6 +130,8 @@ "CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE" = "Это не может быть отменено."; /* Title for the 'conversation delete confirmation' alert. */ "CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE" = "Удалить разговор?"; +/* keyboard toolbar label when starting to search with no current results */ +"CONVERSATION_SEARCH_SEARCHING" = "Searching..."; /* keyboard toolbar label when no messages match the search string */ "CONVERSATION_SEARCH_NO_RESULTS" = "Нет совпадений"; /* keyboard toolbar label when exactly 1 message matches the search string */ @@ -510,6 +512,7 @@ "vc_join_public_chat_scan_qr_code_explanation" = "Отсканируйте QR-код открытой группы, в которую вы хотите вступить"; "vc_enter_chat_url_text_field_hint" = "Введите URL открытой группы"; "vc_settings_title" = "Настройки"; +"vc_group_settings_title" = "Group Settings"; "vc_settings_display_name_text_field_hint" = "Введите отображаемое имя"; "vc_settings_display_name_missing_error" = "Пожалуйста, выберите отображаемое имя"; "vc_settings_display_name_too_long_error" = "Пожалуйста, выберите более короткое отображаемое имя"; @@ -776,3 +779,5 @@ "modal_permission_camera" = "camera"; "modal_permission_microphone" = "microphone"; "modal_permission_library" = "library"; +"DISAPPEARING_MESSAGES_OFF" = "Off"; +"COPY_GROUP_URL" = "Copy Group URL"; diff --git a/Session/Meta/Translations/si.lproj/Localizable.strings b/Session/Meta/Translations/si.lproj/Localizable.strings index ad7e3220d..1dabbbe49 100644 --- a/Session/Meta/Translations/si.lproj/Localizable.strings +++ b/Session/Meta/Translations/si.lproj/Localizable.strings @@ -130,6 +130,8 @@ "CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE" = "This cannot be undone."; /* Title for the 'conversation delete confirmation' alert. */ "CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE" = "Delete Conversation?"; +/* keyboard toolbar label when starting to search with no current results */ +"CONVERSATION_SEARCH_SEARCHING" = "Searching..."; /* keyboard toolbar label when no messages match the search string */ "CONVERSATION_SEARCH_NO_RESULTS" = "No matches"; /* keyboard toolbar label when exactly 1 message matches the search string */ @@ -510,6 +512,7 @@ "vc_join_public_chat_scan_qr_code_explanation" = "Scan the QR code of the open group you'd like to join"; "vc_enter_chat_url_text_field_hint" = "Enter an open group URL"; "vc_settings_title" = "සැකසුම්"; +"vc_group_settings_title" = "Group Settings"; "vc_settings_display_name_text_field_hint" = "Enter a display name"; "vc_settings_display_name_missing_error" = "Please pick a display name"; "vc_settings_display_name_too_long_error" = "Please pick a shorter display name"; @@ -776,3 +779,5 @@ "modal_permission_camera" = "camera"; "modal_permission_microphone" = "microphone"; "modal_permission_library" = "library"; +"DISAPPEARING_MESSAGES_OFF" = "Off"; +"COPY_GROUP_URL" = "Copy Group URL"; diff --git a/Session/Meta/Translations/sk.lproj/Localizable.strings b/Session/Meta/Translations/sk.lproj/Localizable.strings index e45f0a9d4..30cfe8de4 100644 --- a/Session/Meta/Translations/sk.lproj/Localizable.strings +++ b/Session/Meta/Translations/sk.lproj/Localizable.strings @@ -130,6 +130,8 @@ "CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE" = "Táto akcia sa nedá vrátiť."; /* Title for the 'conversation delete confirmation' alert. */ "CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE" = "Zmazať konverzáciu?"; +/* keyboard toolbar label when starting to search with no current results */ +"CONVERSATION_SEARCH_SEARCHING" = "Searching..."; /* keyboard toolbar label when no messages match the search string */ "CONVERSATION_SEARCH_NO_RESULTS" = "Žiadne zhody"; /* keyboard toolbar label when exactly 1 message matches the search string */ @@ -510,6 +512,7 @@ "vc_join_public_chat_scan_qr_code_explanation" = "Oskenujte QR kód skupiny ku ktorej sa chcete pripojiť"; "vc_enter_chat_url_text_field_hint" = "Zadajte URL otvorenej skupiny"; "vc_settings_title" = "Nastavenia"; +"vc_group_settings_title" = "Group Settings"; "vc_settings_display_name_text_field_hint" = "Zadajte zobrazované meno"; "vc_settings_display_name_missing_error" = "Zvoľte prosím zobrazované meno"; "vc_settings_display_name_too_long_error" = "Zvoľte prosím kratšie zobrazované meno"; @@ -776,3 +779,5 @@ "modal_permission_camera" = "camera"; "modal_permission_microphone" = "microphone"; "modal_permission_library" = "library"; +"DISAPPEARING_MESSAGES_OFF" = "Off"; +"COPY_GROUP_URL" = "Copy Group URL"; diff --git a/Session/Meta/Translations/sv.lproj/Localizable.strings b/Session/Meta/Translations/sv.lproj/Localizable.strings index d7acbbfdf..3ec3f2071 100644 --- a/Session/Meta/Translations/sv.lproj/Localizable.strings +++ b/Session/Meta/Translations/sv.lproj/Localizable.strings @@ -130,6 +130,8 @@ "CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE" = "Detta kan inte ångras."; /* Title for the 'conversation delete confirmation' alert. */ "CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE" = "Radera konversation?"; +/* keyboard toolbar label when starting to search with no current results */ +"CONVERSATION_SEARCH_SEARCHING" = "Searching..."; /* keyboard toolbar label when no messages match the search string */ "CONVERSATION_SEARCH_NO_RESULTS" = "Inga träffar"; /* keyboard toolbar label when exactly 1 message matches the search string */ @@ -510,6 +512,7 @@ "vc_join_public_chat_scan_qr_code_explanation" = "Skanna QR-koden för den öppna grupp du vill ansluta till"; "vc_enter_chat_url_text_field_hint" = "Ange en öppen grupp-URL"; "vc_settings_title" = "Inställningar"; +"vc_group_settings_title" = "Group Settings"; "vc_settings_display_name_text_field_hint" = "Ange ett visningsnamn"; "vc_settings_display_name_missing_error" = "Vänligen välj ett visningsnamn"; "vc_settings_display_name_too_long_error" = "Vänligen välj ett kortare visningsnamn"; @@ -776,3 +779,5 @@ "modal_permission_camera" = "camera"; "modal_permission_microphone" = "microphone"; "modal_permission_library" = "library"; +"DISAPPEARING_MESSAGES_OFF" = "Off"; +"COPY_GROUP_URL" = "Copy Group URL"; diff --git a/Session/Meta/Translations/th.lproj/Localizable.strings b/Session/Meta/Translations/th.lproj/Localizable.strings index ae6865b2f..bc06b4290 100644 --- a/Session/Meta/Translations/th.lproj/Localizable.strings +++ b/Session/Meta/Translations/th.lproj/Localizable.strings @@ -130,6 +130,8 @@ "CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE" = "การกระทำนี้ไม่สามารถยกเลิกได้"; /* Title for the 'conversation delete confirmation' alert. */ "CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE" = "ลบการสนทนาไหม"; +/* keyboard toolbar label when starting to search with no current results */ +"CONVERSATION_SEARCH_SEARCHING" = "Searching..."; /* keyboard toolbar label when no messages match the search string */ "CONVERSATION_SEARCH_NO_RESULTS" = "ไม่​มี​รายการ​ตรง​กัน"; /* keyboard toolbar label when exactly 1 message matches the search string */ @@ -510,6 +512,7 @@ "vc_join_public_chat_scan_qr_code_explanation" = "แสกน QR โค้ดของกลุ่มสาธารณะ"; "vc_enter_chat_url_text_field_hint" = "ป้อนลินค์ URL ของกลุ่มสถาณนะ"; "vc_settings_title" = "ตั้งค่า"; +"vc_group_settings_title" = "Group Settings"; "vc_settings_display_name_text_field_hint" = "เลือกชื่อที่ใช้แสดง"; "vc_settings_display_name_missing_error" = "ขอเลือกชื่อที่ใช้แสดง"; "vc_settings_display_name_too_long_error" = "ขอเลือกชื่อสั้นกว่า"; @@ -776,3 +779,5 @@ "modal_permission_camera" = "camera"; "modal_permission_microphone" = "microphone"; "modal_permission_library" = "library"; +"DISAPPEARING_MESSAGES_OFF" = "Off"; +"COPY_GROUP_URL" = "Copy Group URL"; diff --git a/Session/Meta/Translations/vi-VN.lproj/Localizable.strings b/Session/Meta/Translations/vi-VN.lproj/Localizable.strings index c8773d794..e15f6dd2d 100644 --- a/Session/Meta/Translations/vi-VN.lproj/Localizable.strings +++ b/Session/Meta/Translations/vi-VN.lproj/Localizable.strings @@ -130,6 +130,8 @@ "CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE" = "Tác vụ này không thể hoàn tất."; /* Title for the 'conversation delete confirmation' alert. */ "CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE" = "Xóa cuộc hội thoại?"; +/* keyboard toolbar label when starting to search with no current results */ +"CONVERSATION_SEARCH_SEARCHING" = "Searching..."; /* keyboard toolbar label when no messages match the search string */ "CONVERSATION_SEARCH_NO_RESULTS" = "Không trùng khớp"; /* keyboard toolbar label when exactly 1 message matches the search string */ @@ -510,6 +512,7 @@ "vc_join_public_chat_scan_qr_code_explanation" = " Quét mã QR của nhóm mở mà bạn muốn tham gia"; "vc_enter_chat_url_text_field_hint" = "Nhập URL của nhóm mở"; "vc_settings_title" = "Cài đặt"; +"vc_group_settings_title" = "Group Settings"; "vc_settings_display_name_text_field_hint" = "Nhập tên hiển thị"; "vc_settings_display_name_missing_error" = "Vui lòng chọn một tên hiển thị "; "vc_settings_display_name_too_long_error" = "Vui lòng chọn một tên hiển thị ngắn hơn "; @@ -776,3 +779,5 @@ "modal_permission_camera" = "camera"; "modal_permission_microphone" = "microphone"; "modal_permission_library" = "library"; +"DISAPPEARING_MESSAGES_OFF" = "Off"; +"COPY_GROUP_URL" = "Copy Group URL"; diff --git a/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings b/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings index 0d2b7bdd3..d90342854 100644 --- a/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings +++ b/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings @@ -130,6 +130,8 @@ "CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE" = "此操作無法復原。"; /* Title for the 'conversation delete confirmation' alert. */ "CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE" = "刪除對話?"; +/* keyboard toolbar label when starting to search with no current results */ +"CONVERSATION_SEARCH_SEARCHING" = "Searching..."; /* keyboard toolbar label when no messages match the search string */ "CONVERSATION_SEARCH_NO_RESULTS" = "沒有相符項目"; /* keyboard toolbar label when exactly 1 message matches the search string */ @@ -510,6 +512,7 @@ "vc_join_public_chat_scan_qr_code_explanation" = "掃描開放式群組的 QR Code 來加入"; "vc_enter_chat_url_text_field_hint" = "請輸入開放式群組的網址"; "vc_settings_title" = "設定"; +"vc_group_settings_title" = "Group Settings"; "vc_settings_display_name_text_field_hint" = "輸入一個名稱"; "vc_settings_display_name_missing_error" = "請選擇一個名稱"; "vc_settings_display_name_too_long_error" = "請選擇一個較短的名稱"; @@ -776,3 +779,5 @@ "modal_permission_camera" = "camera"; "modal_permission_microphone" = "microphone"; "modal_permission_library" = "library"; +"DISAPPEARING_MESSAGES_OFF" = "Off"; +"COPY_GROUP_URL" = "Copy Group URL"; diff --git a/Session/Meta/Translations/zh_CN.lproj/Localizable.strings b/Session/Meta/Translations/zh_CN.lproj/Localizable.strings index 029a1b23e..410588d66 100644 --- a/Session/Meta/Translations/zh_CN.lproj/Localizable.strings +++ b/Session/Meta/Translations/zh_CN.lproj/Localizable.strings @@ -130,6 +130,8 @@ "CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE" = "该操作无法撤销。"; /* Title for the 'conversation delete confirmation' alert. */ "CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE" = "删除会话?"; +/* keyboard toolbar label when starting to search with no current results */ +"CONVERSATION_SEARCH_SEARCHING" = "Searching..."; /* keyboard toolbar label when no messages match the search string */ "CONVERSATION_SEARCH_NO_RESULTS" = "没有结果"; /* keyboard toolbar label when exactly 1 message matches the search string */ @@ -510,6 +512,7 @@ "vc_join_public_chat_scan_qr_code_explanation" = "扫描您想加入的公开群组的二维码"; "vc_enter_chat_url_text_field_hint" = "输入公开群组链接"; "vc_settings_title" = "设置"; +"vc_group_settings_title" = "Group Settings"; "vc_settings_display_name_text_field_hint" = "输入您想显示的名称"; "vc_settings_display_name_missing_error" = "请设定一个名称"; "vc_settings_display_name_too_long_error" = "请设定一个较短的名称"; @@ -776,3 +779,5 @@ "modal_permission_camera" = "camera"; "modal_permission_microphone" = "microphone"; "modal_permission_library" = "library"; +"DISAPPEARING_MESSAGES_OFF" = "Off"; +"COPY_GROUP_URL" = "Copy Group URL"; diff --git a/Session/Settings/BlockedContactsViewController.swift b/Session/Settings/BlockedContactsViewController.swift index 240f71ed0..3496401c4 100644 --- a/Session/Settings/BlockedContactsViewController.swift +++ b/Session/Settings/BlockedContactsViewController.swift @@ -8,7 +8,7 @@ import SessionMessagingKit import SignalUtilitiesKit class BlockedContactsViewController: BaseVC, UITableViewDelegate, UITableViewDataSource { - private static let loadingHeaderHeight: CGFloat = 20 + private static let loadingHeaderHeight: CGFloat = 40 private let viewModel: BlockedContactsViewModel = BlockedContactsViewModel() private var dataChangeObservable: DatabaseCancellable? diff --git a/Session/Settings/ConversationSettingsViewModel.swift b/Session/Settings/ConversationSettingsViewModel.swift index 90600115b..b90af7b29 100644 --- a/Session/Settings/ConversationSettingsViewModel.swift +++ b/Session/Settings/ConversationSettingsViewModel.swift @@ -7,7 +7,7 @@ import SessionUIKit import SessionMessagingKit import SessionUtilitiesKit -class ConversationSettingsViewModel: SettingsTableViewModel { +class ConversationSettingsViewModel: SettingsTableViewModel { // MARK: - Section public enum Section: SettingSection { @@ -15,11 +15,18 @@ class ConversationSettingsViewModel: SettingsTableViewModel { +class HelpViewModel: SettingsTableViewModel { // MARK: - Section public enum Section: SettingSection { @@ -17,7 +17,7 @@ class HelpViewModel: SettingsTableViewModel { +class NotificationContentViewModel: SettingsTableViewModel { // MARK: - Section public enum Section: SettingSection { case content - - var title: String { return "" } // No title } // MARK: - Content @@ -67,6 +65,4 @@ class NotificationContentViewModel: SettingsTableViewModel { - // MARK: - Section +class NotificationSettingsViewModel: SettingsTableViewModel { + // MARK: - Config public enum Section: SettingSection { case strategy case style case content - var title: String { + var title: String? { switch self { case .strategy: return "NOTIFICATIONS_SECTION_STRATEGY".localized() case .style: return "NOTIFICATIONS_SECTION_STYLE".localized() - case .content: return "" // No title + case .content: return nil + } + } + + var style: SettingSectionHeaderStyle { + switch self { + case .content: return .padding + default: return .title } } } @@ -139,6 +146,4 @@ class NotificationSettingsViewModel: SettingsTableViewModel { +class NotificationSoundViewModel: SettingsTableViewModel { + // MARK: - Config + + enum NavButton: Equatable { + case cancel + case save + } + + public enum Section: SettingSection { + case content + } + // FIXME: Remove `threadId` once we ditch the per-thread notification sound private let threadId: String? private var audioPlayer: OWSAudioPlayer? - private var currentSelection: Preferences.Sound? + private var storedSelection: Preferences.Sound? + private var currentSelection: CurrentValueSubject = CurrentValueSubject(nil) // MARK: - Initialization @@ -24,12 +37,47 @@ class NotificationSoundViewModel: SettingsTableViewModel { + Just([ + NavItem( + id: .cancel, + systemItem: .cancel, + accessibilityIdentifier: "Cancel button" + ) + ]).eraseToAnyPublisher() + } + + override var rightNavItems: AnyPublisher<[NavItem]?, Never> { + currentSelection + .removeDuplicates() + .map { [weak self] currentSelection in (self?.storedSelection != currentSelection) } + .map { isChanged in + guard isChanged else { return [] } + + return [ + NavItem( + id: .save, + systemItem: .save, + accessibilityIdentifier: "Save button" + ) + ] + } + .eraseToAnyPublisher() + } + + override var closeScreen: AnyPublisher { + navItemTapped + .handleEvents(receiveOutput: { [weak self] navItemId in + switch navItemId { + case .save: self?.saveChanges() + default: break + } + self?.setIsEditing(true) + }) + .map { _ in false } + .eraseToAnyPublisher() } // MARK: - Content @@ -50,8 +98,23 @@ class NotificationSoundViewModel: SettingsTableViewModel [SectionModel] in - self?.currentSelection = (self?.currentSelection ?? db[.defaultNotificationSound]) - .defaulting(to: .defaultNotificationSound) + self?.storedSelection = try { + guard let threadId: String = self?.threadId else { + return db[.defaultNotificationSound] + .defaulting(to: .defaultNotificationSound) + } + + return try SessionThread + .filter(id: threadId) + .select(.notificationSound) + .asRequest(of: Preferences.Sound.self) + .fetchOne(db) + .defaulting( + to: db[.defaultNotificationSound] + .defaulting(to: .defaultNotificationSound) + ) + }() + self?.currentSelection.send(self?.currentSelection.value ?? self?.storedSelection) return [ SectionModel( @@ -71,22 +134,19 @@ class NotificationSoundViewModel: SettingsTableViewModel UIViewController { - return SettingsTableViewController( - viewModel: NotificationSoundViewModel(threadId: threadId) - ) - } -} diff --git a/Session/Settings/PrivacySettingsViewModel.swift b/Session/Settings/PrivacySettingsViewModel.swift index 3e8d2cf29..91b1127ee 100644 --- a/Session/Settings/PrivacySettingsViewModel.swift +++ b/Session/Settings/PrivacySettingsViewModel.swift @@ -7,8 +7,18 @@ import SessionUIKit import SessionMessagingKit import SessionUtilitiesKit -class PrivacySettingsViewModel: SettingsTableViewModel { - // MARK: - Section +class PrivacySettingsViewModel: SettingsTableViewModel { + // MARK: - Initialization + + init(shouldShowCloseButton: Bool = false) { + super.init(closeNavItemId: (shouldShowCloseButton ? NavButton.close : nil)) + } + + // MARK: - Config + + enum NavButton: Equatable { + case close + } public enum Section: SettingSection { case screenLock @@ -18,16 +28,23 @@ class PrivacySettingsViewModel: SettingsTableViewModel: BaseVC, UITableViewDataSource, UITableViewDelegate { - typealias SectionModel = SettingsTableViewModel.SectionModel +protocol SettingsViewModelAccessible { + var viewModelType: AnyObject.Type { get } +} + +class SettingsTableViewController: BaseVC, UITableViewDataSource, UITableViewDelegate, SettingsViewModelAccessible { + typealias SectionModel = SettingsTableViewModel.SectionModel - private let viewModel: SettingsTableViewModel - private let shouldShowCloseButton: Bool + private let viewModel: SettingsTableViewModel private var dataChangeObservable: DatabaseCancellable? private var hasLoadedInitialSettingsData: Bool = false + private var disposables: Set = Set() + + public var viewModelType: AnyObject.Type { return type(of: viewModel) } // MARK: - Components @@ -24,6 +31,7 @@ class SettingsTableViewController, shouldShowCloseButton: Bool = false) { + init(viewModel: SettingsTableViewModel) { self.viewModel = viewModel - self.shouldShowCloseButton = shouldShowCloseButton super.init(nibName: nil, bundle: nil) } @@ -68,6 +75,7 @@ class SettingsTableViewController DisposableBarButtonItem in + let buttonItem: DisposableBarButtonItem = item.createBarButtonItem() + buttonItem.themeTintColor = .textPrimary + + buttonItem.tapPublisher + .map { _ in item.id } + .sink(into: self?.viewModel.navItemTapped) + .store(in: &buttonItem.disposables) + + return buttonItem + } + }, + animated: true + ) + } + .store(in: &disposables) + + viewModel.rightNavItems + .receiveOnMainImmediately() + .sink { [weak self] maybeItems in + self?.navigationItem.setRightBarButtonItems( + maybeItems.map { items in + items.map { item -> DisposableBarButtonItem in + let buttonItem: DisposableBarButtonItem = item.createBarButtonItem() + buttonItem.themeTintColor = .textPrimary + + buttonItem.tapPublisher + .map { _ in item.id } + .sink(into: self?.viewModel.navItemTapped) + .store(in: &buttonItem.disposables) + + return buttonItem + } + }, + animated: true + ) + } + .store(in: &disposables) - let saveButton: UIBarButtonItem = UIBarButtonItem(barButtonSystemItem: .save, target: self, action: #selector(saveButtonPressed)) - saveButton.themeTintColor = .textPrimary - navigationItem.rightBarButtonItem = (isStoredSelected ? nil : saveButton) + viewModel.closeScreen + .receive(on: DispatchQueue.main) + .sink { [weak self] shouldDismiss in + guard shouldDismiss else { + self?.navigationController?.popViewController(animated: true) + return + } + + self?.navigationController?.dismiss(animated: true) + } + .store(in: &disposables) } // MARK: - UITableViewDataSource @@ -211,30 +258,75 @@ class SettingsTableViewController = section.elements[indexPath.row] - let cell: SettingsCell = tableView.dequeue(type: SettingsCell.self, for: indexPath) - cell.update( - title: settingInfo.title, - subtitle: settingInfo.subtitle, - subtitleExtraViewGenerator: settingInfo.subtitleExtraViewGenerator, - action: settingInfo.action, - extraActionTitle: settingInfo.extraActionTitle, - onExtraAction: settingInfo.onExtraAction, - isFirstInSection: (indexPath.row == 0), - isLastInSection: (indexPath.row == (section.elements.count - 1)) - ) - - return cell + switch settingInfo.action { + case .threadInfo(let threadViewModel, let style, let createAvatarTapDestination, let titleTapped, let titleChanged): + let cell: SettingsAvatarCell = tableView.dequeue(type: SettingsAvatarCell.self, for: indexPath) + cell.update( + threadViewModel: threadViewModel, + style: style + ) + cell.update(isEditing: self.isEditing, animated: false) + + cell.profilePictureTapPublisher + .sink(receiveValue: { [weak self] _ in + guard let viewController: UIViewController = createAvatarTapDestination?() else { + return + } + + self?.present(viewController, animated: true, completion: nil) + }) + .store(in: &cell.disposables) + + cell.displayNameTapPublisher + .filter { _ in threadViewModel.threadVariant == .contact } + .sink(receiveValue: { _ in titleTapped?() }) + .store(in: &cell.disposables) + + cell.textPublisher + .sink(receiveValue: { text in titleChanged?(text) }) + .store(in: &cell.disposables) + + return cell + + default: + let cell: SettingsCell = tableView.dequeue(type: SettingsCell.self, for: indexPath) + cell.update( + icon: settingInfo.icon, + title: settingInfo.title, + subtitle: settingInfo.subtitle, + alignment: settingInfo.alignment, + accessibilityIdentifier: settingInfo.accessibilityIdentifier, + subtitleExtraViewGenerator: settingInfo.subtitleExtraViewGenerator, + action: settingInfo.action, + extraActionTitle: settingInfo.extraActionTitle, + onExtraAction: settingInfo.onExtraAction, + isFirstInSection: (indexPath.row == 0), + isLastInSection: (indexPath.row == (section.elements.count - 1)) + ) + cell.update(isEditing: self.isEditing, animated: false) + + return cell + } } func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { let section: SectionModel = viewModel.settingsData[section] - let view: SettingHeaderView = tableView.dequeueHeaderFooterView(type: SettingHeaderView.self) - view.update( - with: section.model.title, - hasSeparator: (section.elements.first?.action.shouldHaveBackground != false) - ) - return view + switch section.model.style { + case .none: + let result: UIView = UIView() + result.set(.height, to: 0) + return result + + case .padding, .title: + let result: SettingHeaderView = tableView.dequeueHeaderFooterView(type: SettingHeaderView.self) + result.update( + with: section.model.title, + hasSeparator: (section.elements.first?.action.shouldHaveBackground != false) + ) + + return result + } } // MARK: - UITableViewDelegate @@ -254,7 +346,9 @@ class SettingsTableViewController = section.elements[indexPath.row] switch settingInfo.action { - case .trigger(let action): + case .threadInfo: break + + case .trigger(_, let action): action() case .rightButtonAction(_, let action): @@ -264,12 +358,15 @@ class SettingsTableViewController () = { [weak self] in + self?.manuallyReload( + indexPath: indexPath, + section: section, + settingInfo: settingInfo + .with( + action: .customToggle( + value: updatedValue, + isEnabled: isEnabled, + onChange: onChange + ) + ) + ) + onChange?(updatedValue) + + // In this case we need to restart the database observation to force a re-query as + // the change here might not actually trigger a database update so the content wouldn't + // be updated + self?.stopObservingChanges() + self?.startObservingChanges() + } + + guard + let confirmationInfo: ConfirmationModal.Info = confirmationInfo, + confirmationInfo.stateToShow.shouldShow(for: value) + else { + performChange() + return + } + + // Show a confirmation modal before continuing + let confirmationModal: ConfirmationModal = ConfirmationModal( + info: confirmationInfo + .with(onConfirm: { [weak self] _ in + performChange() + + self?.dismiss(animated: true) { + guard let strongSelf: UIViewController = self else { return } + + confirmationInfo.onConfirm?(strongSelf) + } + }) + ) + present(confirmationModal, animated: true, completion: nil) + + case .push(_, _, _, let createDestination), .settingEnum(_, _, let createDestination), .generalEnum(_, let createDestination): let viewController: UIViewController = createDestination() navigationController?.pushViewController(viewController, animated: true) + case .present(let createDestination): + let viewController: UIViewController = createDestination() + + if UIDevice.current.isIPad { + viewController.popoverPresentationController?.permittedArrowDirections = [] + viewController.popoverPresentationController?.sourceView = self.view + viewController.popoverPresentationController?.sourceRect = self.view.bounds + } + + navigationController?.present(viewController, animated: true) + case .listSelection(_, _, let shouldAutoSave, let selectValue): let maybeOldSelection: (Int, SettingInfo)? = section.elements .enumerated() @@ -306,7 +462,6 @@ class SettingsTableViewController { +class SettingsTableViewModel { typealias SectionModel = ArraySection> typealias ObservableData = ValueObservation>> + var closeNavItemId: NavItemId? + + // MARK: - Initialization + + /// Provide a `closeNavItemId` in order to show a close button + init(closeNavItemId: NavItemId? = nil) { + self.closeNavItemId = closeNavItemId + } + + // MARK: - Input + + let navItemTapped: PassthroughSubject = PassthroughSubject() + private let _isEditing: CurrentValueSubject = CurrentValueSubject(false) + lazy var isEditing: AnyPublisher = _isEditing + .removeDuplicates() + .shareReplay(1) + + // MARK: - Navigation + + open var leftNavItems: AnyPublisher<[NavItem]?, Never> { + guard let closeNavItemId: NavItemId = self.closeNavItemId else { + return Just(nil).eraseToAnyPublisher() + } + + return Just([ + NavItem( + id: closeNavItemId, + image: UIImage(named: "X")? + .withRenderingMode(.alwaysTemplate), + style: .plain, + accessibilityIdentifier: "Close Button" + ) + ]).eraseToAnyPublisher() + } + + open var rightNavItems: AnyPublisher<[NavItem]?, Never> { Just(nil).eraseToAnyPublisher() } + + open var closeScreen: AnyPublisher { + navItemTapped + .filter { [weak self] itemId in itemId == self?.closeNavItemId } + .map { _ in true } + .eraseToAnyPublisher() + } + + // MARK: - Content + open var title: String { preconditionFailure("abstract class - override in subclass") } open var settingsData: [SectionModel] { preconditionFailure("abstract class - override in subclass") } open var observableSettingsData: ObservableData { @@ -20,23 +68,102 @@ class SettingsTableViewModel DisposableBarButtonItem { + guard let systemItem: UIBarButtonItem.SystemItem = systemItem else { + return DisposableBarButtonItem( + image: image, + style: style, + target: nil, + action: nil, + accessibilityIdentifier: accessibilityIdentifier + ) + } + + return DisposableBarButtonItem( + barButtonSystemItem: systemItem, + target: nil, + action: nil, + accessibilityIdentifier: accessibilityIdentifier + ) + } + } +} + +// MARK: - SettingSectionHeaderStyle + +public enum SettingSectionHeaderStyle: Differentiable { + case none + case title + case padding +} + // MARK: - SettingSection protocol SettingSection: Differentiable { - var title: String { get } + var title: String? { get } + var style: SettingSectionHeaderStyle { get } +} + +extension SettingSection { + var title: String? { nil } + var style: SettingSectionHeaderStyle { .none } } // MARK: - SettingInfo struct SettingInfo: Equatable, Hashable, Differentiable { let id: ID + let icon: UIImage? let title: String let subtitle: String? + let alignment: NSTextAlignment + let accessibilityIdentifier: String? let action: SettingsAction let subtitleExtraViewGenerator: (() -> UIView)? let extraActionTitle: ((Theme, Theme.PrimaryColor) -> NSAttributedString)? @@ -46,16 +173,22 @@ struct SettingInfo: Equatable, Hashable, Differen init( id: ID, + icon: UIImage? = nil, title: String, subtitle: String? = nil, + alignment: NSTextAlignment = .left, + accessibilityIdentifier: String? = nil, subtitleExtraViewGenerator: (() -> UIView)? = nil, action: SettingsAction, extraActionTitle: ((Theme, Theme.PrimaryColor) -> NSAttributedString)? = nil, onExtraAction: (() -> Void)? = nil ) { self.id = id + self.icon = icon self.title = title self.subtitle = subtitle + self.alignment = alignment + self.accessibilityIdentifier = accessibilityIdentifier self.subtitleExtraViewGenerator = subtitleExtraViewGenerator self.action = action self.extraActionTitle = extraActionTitle @@ -68,42 +201,92 @@ struct SettingInfo: Equatable, Hashable, Differen func hash(into hasher: inout Hasher) { id.hash(into: &hasher) + icon.hash(into: &hasher) title.hash(into: &hasher) subtitle.hash(into: &hasher) + alignment.hash(into: &hasher) + accessibilityIdentifier.hash(into: &hasher) action.hash(into: &hasher) } static func == (lhs: SettingInfo, rhs: SettingInfo) -> Bool { return ( lhs.id == rhs.id && + lhs.icon == rhs.icon && lhs.title == rhs.title && lhs.subtitle == rhs.subtitle && + lhs.alignment == rhs.alignment && + lhs.accessibilityIdentifier == rhs.accessibilityIdentifier && lhs.action == rhs.action ) } + + // MARK: - Mutation + + func with(action: SettingsAction) -> SettingInfo { + return SettingInfo( + id: self.id, + icon: self.icon, + title: self.title, + subtitle: self.subtitle, + alignment: self.alignment, + accessibilityIdentifier: self.accessibilityIdentifier, + subtitleExtraViewGenerator: self.subtitleExtraViewGenerator, + action: action, + extraActionTitle: self.extraActionTitle, + onExtraAction: self.onExtraAction + ) + } } // MARK: - SettingsAction public enum SettingsAction: Hashable, Equatable { + case threadInfo( + threadViewModel: SessionThreadViewModel, + style: ThreadInfoStyle = ThreadInfoStyle(), + createAvatarTapDestination: (() -> UIViewController?)? = nil, + titleTapped: (() -> Void)? = nil, + titleChanged: ((String) -> Void)? = nil + ) case userDefaultsBool( defaults: UserDefaults, key: String, + isEnabled: Bool = true, onChange: (() -> Void)? ) case settingBool( key: Setting.BoolKey, - confirmationInfo: ConfirmationModal.Info? + confirmationInfo: ConfirmationModal.Info?, + isEnabled: Bool = true + ) + case customToggle( + value: Bool, + isEnabled: Bool = true, + confirmationInfo: ConfirmationModal.Info? = nil, + onChange: ((Bool) -> Void)? = nil ) case settingEnum( key: String, title: String?, createUpdateScreen: () -> UIViewController ) + case generalEnum( + title: String?, + createUpdateScreen: () -> UIViewController + ) - case trigger(action: () -> Void) - case push(createDestination: () -> UIViewController) - case dangerPush(createDestination: () -> UIViewController) + case trigger( + showChevron: Bool = true, + action: () -> Void + ) + case push( + showChevron: Bool = true, + textColor: ThemeValue = .textPrimary, + shouldHaveBackground: Bool = true, + createDestination: () -> UIViewController + ) + case present(createDestination: () -> UIViewController) case listSelection( isSelected: () -> Bool, storedSelection: Bool, @@ -117,13 +300,16 @@ public enum SettingsAction: Hashable, Equatable { private var actionName: String { switch self { + case .threadInfo: return "threadInfo" case .userDefaultsBool: return "userDefaultsBool" case .settingBool: return "settingBool" + case .customToggle: return "customToggle" case .settingEnum: return "settingEnum" + case .generalEnum: return "generalEnum" case .trigger: return "trigger" case .push: return "push" - case .dangerPush: return "dangerPush" + case .present: return "present" case .listSelection: return "listSelection" case .rightButtonAction: return "rightButtonAction" } @@ -131,7 +317,8 @@ public enum SettingsAction: Hashable, Equatable { var shouldHaveBackground: Bool { switch self { - case .dangerPush: return false + case .threadInfo: return false + case .push(_, _, let shouldHaveBackground, _): return shouldHaveBackground default: return true } } @@ -176,32 +363,76 @@ public enum SettingsAction: Hashable, Equatable { actionName.hash(into: &hasher) switch self { - case .userDefaultsBool(_, let key, _): key.hash(into: &hasher) - case .settingBool(let key, let confirmationInfo): + case .threadInfo(let threadViewModel, let style, _, _, _): + threadViewModel.hash(into: &hasher) + style.hash(into: &hasher) + + case .userDefaultsBool(_, let key, let isEnabled, _): key.hash(into: &hasher) + isEnabled.hash(into: &hasher) + + case .settingBool(let key, let confirmationInfo, let isEnabled): + key.hash(into: &hasher) + confirmationInfo.hash(into: &hasher) + isEnabled.hash(into: &hasher) + + case .customToggle(let value, let isEnabled, let confirmationInfo, _): + value.hash(into: &hasher) + isEnabled.hash(into: &hasher) confirmationInfo.hash(into: &hasher) case .settingEnum(let key, let title, _): key.hash(into: &hasher) title.hash(into: &hasher) + case .generalEnum(let title, _): + title.hash(into: &hasher) + + case .trigger(let showChevron, _): + showChevron.hash(into: &hasher) + + case .push(let showChevron, let textColor, let shouldHaveBackground, _): + showChevron.hash(into: &hasher) + textColor.hash(into: &hasher) + shouldHaveBackground.hash(into: &hasher) + + case .present(_): break + case .listSelection(let isSelected, let storedSelection, let shouldAutoSave, _): isSelected().hash(into: &hasher) storedSelection.hash(into: &hasher) shouldAutoSave.hash(into: &hasher) - - default: break + + case .rightButtonAction(let title, _): + title.hash(into: &hasher) } } public static func == (lhs: SettingsAction, rhs: SettingsAction) -> Bool { switch (lhs, rhs) { - case (.userDefaultsBool(_, let lhsKey, _), .userDefaultsBool(_, let rhsKey, _)): - return (lhsKey == rhsKey) - - case (.settingBool(let lhsKey, let lhsConfirmationInfo), .settingBool(let rhsKey, let rhsConfirmationInfo)): + case (.threadInfo(let lhsThreadViewModel, let lhsStyle, _, _, _), .threadInfo(let rhsThreadViewModel, let rhsStyle, _, _, _)): + return ( + lhsThreadViewModel == rhsThreadViewModel && + lhsStyle == rhsStyle + ) + + case (.userDefaultsBool(_, let lhsKey, let lhsIsEnabled, _), .userDefaultsBool(_, let rhsKey, let rhsIsEnabled, _)): return ( lhsKey == rhsKey && + lhsIsEnabled == rhsIsEnabled + ) + + case (.settingBool(let lhsKey, let lhsConfirmationInfo, let lhsIsEnabled), .settingBool(let rhsKey, let rhsConfirmationInfo, let rhsIsEnabled)): + return ( + lhsKey == rhsKey && + lhsConfirmationInfo == rhsConfirmationInfo && + lhsIsEnabled == rhsIsEnabled + ) + + case (.customToggle(let lhsValue, let lhsIsEnabled, let lhsConfirmationInfo, _), .customToggle(let rhsValue, let rhsIsEnabled, let rhsConfirmationInfo, _)): + return ( + lhsValue == rhsValue && + lhsIsEnabled == rhsIsEnabled && lhsConfirmationInfo == rhsConfirmationInfo ) @@ -211,6 +442,21 @@ public enum SettingsAction: Hashable, Equatable { lhsTitle == rhsTitle ) + case (.generalEnum(let lhsTitle, _), .generalEnum(let rhsTitle, _)): + return (lhsTitle == rhsTitle) + + case (.trigger(let lhsShowChevron, _), .trigger(let rhsShowChevron, _)): + return (lhsShowChevron == rhsShowChevron) + + case (.push(let lhsShowChevron, let lhsTextColor, let lhsHasBackground, _), .push(let rhsShowChevron, let rhsTextColor, let rhsHasBackground, _)): + return ( + lhsShowChevron == rhsShowChevron && + lhsTextColor == rhsTextColor && + lhsHasBackground == rhsHasBackground + ) + + case (.present(_), .present(_)): return true + case (.listSelection(let lhsIsSelected, let lhsStoredSelection, let lhsShouldAutoSave, _), .listSelection(let rhsIsSelected, let rhsStoredSelection, let rhsShouldAutoSave, _)): return ( lhsIsSelected() == rhsIsSelected() && @@ -218,7 +464,47 @@ public enum SettingsAction: Hashable, Equatable { lhsShouldAutoSave == rhsShouldAutoSave ) + case (.rightButtonAction(let lhsTitle, _), .rightButtonAction(let rhsTitle, _)): + return (lhsTitle == rhsTitle) + default: return false } } } + +// MARK: - ThreadInfoStyle + +public struct ThreadInfoStyle: Hashable, Equatable { + public enum Style: Hashable, Equatable { + case small + case monoSmall + case monoLarge + } + + public struct Action: Hashable, Equatable { + let title: String + let run: () -> () + + public func hash(into hasher: inout Hasher) { + title.hash(into: &hasher) + } + + public static func == (lhs: Action, rhs: Action) -> Bool { + return (lhs.title == rhs.title) + } + } + + public let separatorTitle: String? + public let descriptionStyle: Style + public let descriptionActions: [Action] + + public init( + separatorTitle: String? = nil, + descriptionStyle: Style = .monoSmall, + descriptionActions: [Action] = [] + ) { + self.separatorTitle = separatorTitle + self.descriptionStyle = descriptionStyle + self.descriptionActions = descriptionActions + } +} diff --git a/Session/Settings/Views/SettingHeaderView.swift b/Session/Settings/Views/SettingHeaderView.swift new file mode 100644 index 000000000..eab92396e --- /dev/null +++ b/Session/Settings/Views/SettingHeaderView.swift @@ -0,0 +1,83 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import SessionUIKit + +class SettingHeaderView: UITableViewHeaderFooterView { + private lazy var emptyHeightConstraint: NSLayoutConstraint = self.heightAnchor + .constraint(equalToConstant: (Values.verySmallSpacing * 2)) + private lazy var filledHeightConstraint: NSLayoutConstraint = self.heightAnchor + .constraint(greaterThanOrEqualToConstant: Values.mediumSpacing) + + // MARK: - UI + + private let stackView: UIStackView = { + let result: UIStackView = UIStackView() + result.translatesAutoresizingMaskIntoConstraints = false + result.axis = .vertical + result.distribution = .fill + result.alignment = .fill + result.isLayoutMarginsRelativeArrangement = true + + return result + }() + + private let titleLabel: UILabel = { + let result: UILabel = UILabel() + result.translatesAutoresizingMaskIntoConstraints = false + result.font = .systemFont(ofSize: Values.mediumFontSize) + result.themeTextColor = .textSecondary + + return result + }() + + private let separator: UIView = UIView.separator() + + // MARK: - Initialization + + override init(reuseIdentifier: String?) { + super.init(reuseIdentifier: reuseIdentifier) + + self.backgroundView = UIView() + self.backgroundView?.themeBackgroundColor = .backgroundPrimary + + addSubview(stackView) + addSubview(separator) + + stackView.addArrangedSubview(titleLabel) + + setupLayout() + } + + required init?(coder: NSCoder) { + fatalError("use init(reuseIdentifier:) instead") + } + + private func setupLayout() { + stackView.pin(to: self) + + separator.pin(.left, to: .left, of: self) + separator.pin(.right, to: .right, of: self) + separator.pin(.bottom, to: .bottom, of: self) + } + + // MARK: - Content + + public func update(with title: String?, hasSeparator: Bool) { + let titleIsEmpty: Bool = (title ?? "").isEmpty + + titleLabel.text = title + titleLabel.isHidden = titleIsEmpty + stackView.layoutMargins = UIEdgeInsets( + top: (titleIsEmpty ? Values.verySmallSpacing : Values.mediumSpacing), + left: Values.largeSpacing, + bottom: (titleIsEmpty ? Values.verySmallSpacing : Values.mediumSpacing), + right: Values.largeSpacing + ) + emptyHeightConstraint.isActive = titleIsEmpty + filledHeightConstraint.isActive = !titleIsEmpty + separator.isHidden = !hasSeparator + + self.layoutIfNeeded() + } +} diff --git a/Session/Settings/Views/SettingsAvatarCell.swift b/Session/Settings/Views/SettingsAvatarCell.swift new file mode 100644 index 000000000..1fda06e35 --- /dev/null +++ b/Session/Settings/Views/SettingsAvatarCell.swift @@ -0,0 +1,302 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import Combine +import SessionUIKit +import SessionUtilitiesKit +import SignalUtilitiesKit + +class SettingsAvatarCell: UITableViewCell { + var disposables: Set = Set() + private var originalInputValue: String? + + // MARK: - Initialization + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + setupViewHierarchy() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + + setupViewHierarchy() + } + + // MARK: - UI + + private let stackView: UIStackView = { + let stackView: UIStackView = UIStackView() + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.spacing = Values.mediumSpacing + stackView.alignment = .center + stackView.distribution = .equalSpacing + + let horizontalSpacing: CGFloat = (UIScreen.main.bounds.size.height < 568 ? + Values.largeSpacing : + Values.veryLargeSpacing + ) + stackView.layoutMargins = UIEdgeInsets( + top: Values.mediumSpacing, + leading: horizontalSpacing, + bottom: Values.mediumSpacing, + trailing: horizontalSpacing + ) + stackView.isLayoutMarginsRelativeArrangement = true + + return stackView + }() + + fileprivate let profilePictureView: ProfilePictureView = { + let view: ProfilePictureView = ProfilePictureView() + view.translatesAutoresizingMaskIntoConstraints = false + view.size = Values.largeProfilePictureSize + + return view + }() + + fileprivate let displayNameContainer: UIView = { + let view: UIView = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.accessibilityLabel = "Edit name text field" + view.isAccessibilityElement = true + + return view + }() + + private lazy var displayNameLabel: UILabel = { + let label: UILabel = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = .ows_mediumFont(withSize: Values.veryLargeFontSize) + label.themeTextColor = .textPrimary + label.textAlignment = .center + label.lineBreakMode = .byTruncatingTail + label.numberOfLines = 0 + + return label + }() + + fileprivate let displayNameTextField: UITextField = { + let textField: TextField = TextField(placeholder: "Enter a name", usesDefaultHeight: false) + textField.translatesAutoresizingMaskIntoConstraints = false + textField.textAlignment = .center + textField.accessibilityLabel = "Edit name text field" + textField.alpha = 0 + + return textField + }() + + private let descriptionSeparator: Separator = { + let result: Separator = Separator() + result.isHidden = true + + return result + }() + + private let descriptionLabel: SRCopyableLabel = { + let label: SRCopyableLabel = SRCopyableLabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.themeTextColor = .textPrimary + label.textAlignment = .center + label.lineBreakMode = .byCharWrapping + label.numberOfLines = 0 + + return label + }() + + private let descriptionActionStackView: UIStackView = { + let stackView: UIStackView = UIStackView() + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .horizontal + stackView.alignment = .center + stackView.distribution = .fillEqually + stackView.spacing = (UIDevice.current.isIPad ? Values.iPadButtonSpacing : Values.mediumSpacing) + + if (UIDevice.current.isIPad) { + stackView.layoutMargins = UIEdgeInsets( + top: 0, + left: Values.iPadButtonContainerMargin, + bottom: 0, + right: Values.iPadButtonContainerMargin + ) + stackView.isLayoutMarginsRelativeArrangement = true + } + + return stackView + }() + + private func setupViewHierarchy() { + self.themeBackgroundColor = nil + self.selectedBackgroundView = UIView() + + contentView.addSubview(stackView) + + stackView.addArrangedSubview(profilePictureView) + stackView.addArrangedSubview(displayNameContainer) + stackView.addArrangedSubview(descriptionSeparator) + stackView.addArrangedSubview(descriptionLabel) + stackView.addArrangedSubview(descriptionActionStackView) + + displayNameContainer.addSubview(displayNameLabel) + displayNameContainer.addSubview(displayNameTextField) + + setupLayout() + } + + // MARK: - Layout + + private func setupLayout() { + stackView.pin(to: contentView) + + profilePictureView.set(.width, to: profilePictureView.size) + profilePictureView.set(.height, to: profilePictureView.size) + + displayNameLabel.pin(to: displayNameContainer) + displayNameTextField.center(in: displayNameContainer) + displayNameTextField.widthAnchor + .constraint( + lessThanOrEqualTo: stackView.widthAnchor, + constant: -(stackView.layoutMargins.left + stackView.layoutMargins.right) + ) + .isActive = true + + descriptionSeparator.set( + .width, + to: .width, + of: stackView, + withOffset: -(stackView.layoutMargins.left + stackView.layoutMargins.right) + ) + descriptionActionStackView.set( + .width, + to: .width, + of: stackView, + withOffset: -(stackView.layoutMargins.left + stackView.layoutMargins.right) + ) + } + + // MARK: - Content + + override func prepareForReuse() { + super.prepareForReuse() + + self.disposables = Set() + self.originalInputValue = nil + self.displayNameLabel.text = nil + self.displayNameTextField.text = nil + self.descriptionLabel.font = .ows_lightFont(withSize: Values.smallFontSize) + self.descriptionLabel.text = nil + + self.descriptionSeparator.isHidden = true + self.descriptionActionStackView.arrangedSubviews.forEach { $0.removeFromSuperview() } + } + + func update( + threadViewModel: SessionThreadViewModel, + style: ThreadInfoStyle + ) { + profilePictureView.update( + publicKey: threadViewModel.threadId, + profile: threadViewModel.profile, + additionalProfile: threadViewModel.additionalProfile, + threadVariant: threadViewModel.threadVariant, + openGroupProfilePictureData: threadViewModel.openGroupProfilePictureData, + useFallbackPicture: ( + threadViewModel.threadVariant == .openGroup && + threadViewModel.openGroupProfilePictureData == nil + ), + showMultiAvatarForClosedGroup: true + ) + + originalInputValue = threadViewModel.profile?.nickname + displayNameLabel.text = { + guard !threadViewModel.threadIsNoteToSelf else { + guard let profile: Profile = threadViewModel.profile else { + return Profile.truncated(id: threadViewModel.threadId, truncating: .middle) + } + + return profile.displayName() + } + + return threadViewModel.displayName + }() + descriptionLabel.font = { + switch style.descriptionStyle { + case .small: return .ows_lightFont(withSize: Values.smallFontSize) + case .monoSmall: return Fonts.spaceMono(ofSize: Values.smallFontSize) + case .monoLarge: return Fonts.spaceMono( + ofSize: (isIPhone5OrSmaller ? Values.mediumFontSize : Values.largeFontSize) + ) + } + }() + descriptionLabel.text = threadViewModel.threadId + descriptionLabel.isHidden = (threadViewModel.threadVariant != .contact) + descriptionLabel.isUserInteractionEnabled = ( + threadViewModel.threadVariant == .contact || + threadViewModel.threadVariant == .openGroup + ) + displayNameTextField.text = threadViewModel.profile?.nickname + descriptionSeparator.update(title: style.separatorTitle) + descriptionSeparator.isHidden = (style.separatorTitle == nil) + + style.descriptionActions.forEach { action in + let result: OutlineButton = OutlineButton(style: .regular, size: .medium) + result.setTitle(action.title, for: UIControl.State.normal) + result.tapPublisher + .receive(on: DispatchQueue.main) + .sink(receiveValue: { _ in action.run() }) + .store(in: &self.disposables) + + descriptionActionStackView.addArrangedSubview(result) + } + descriptionActionStackView.isHidden = style.descriptionActions.isEmpty + } + + func update(isEditing: Bool, animated: Bool) { + let changes = { [weak self] in + self?.displayNameLabel.alpha = (isEditing ? 0 : 1) + self?.displayNameTextField.alpha = (isEditing ? 1 : 0) + } + let completion: (Bool) -> Void = { [weak self] complete in + self?.displayNameTextField.text = self?.originalInputValue + } + + if animated { + UIView.animate(withDuration: 0.25, animations: changes, completion: completion) + } + else { + changes() + completion(true) + } + + if isEditing { + displayNameTextField.becomeFirstResponder() + } + else { + displayNameTextField.resignFirstResponder() + } + } +} + +// MARK: - Compose + +extension CombineCompatible where Self: SettingsAvatarCell { + var textPublisher: AnyPublisher { + return self.displayNameTextField.publisher(for: .editingChanged) + .map { textField -> String in (textField.text ?? "") } + .eraseToAnyPublisher() + } + + var displayNameTapPublisher: AnyPublisher { + return self.displayNameContainer.tapPublisher + .map { _ in () } + .eraseToAnyPublisher() + } + + var profilePictureTapPublisher: AnyPublisher { + return self.profilePictureView.tapPublisher + .map { _ in () } + .eraseToAnyPublisher() + } +} diff --git a/Session/Settings/Views/SettingsCell.swift b/Session/Settings/Views/SettingsCell.swift index dd5b874e7..d2c6939a4 100644 --- a/Session/Settings/Views/SettingsCell.swift +++ b/Session/Settings/Views/SettingsCell.swift @@ -24,8 +24,8 @@ class SettingsCell: UITableViewCell { let result: UIStackView = UIStackView() result.translatesAutoresizingMaskIntoConstraints = false result.axis = .horizontal - result.distribution = .equalSpacing - result.alignment = .fill + result.distribution = .fill + result.alignment = .center result.spacing = Values.mediumSpacing result.isLayoutMarginsRelativeArrangement = true result.layoutMargins = UIEdgeInsets( @@ -38,6 +38,18 @@ class SettingsCell: UITableViewCell { return result }() + private let iconImageView: UIImageView = { + let result: UIImageView = UIImageView() + result.translatesAutoresizingMaskIntoConstraints = false + result.contentMode = .scaleAspectFit + result.themeTintColor = .textPrimary + result.layer.minificationFilter = .trilinear + result.layer.magnificationFilter = .trilinear + result.isHidden = true + + return result + }() + private let titleStackView: UIStackView = { let result: UIStackView = UIStackView() result.translatesAutoresizingMaskIntoConstraints = false @@ -183,27 +195,23 @@ class SettingsCell: UITableViewCell { } private func setupViewHierarchy() { - // Highlight color - let selectedBackgroundView = UIView() - selectedBackgroundView.themeBackgroundColor = .settings_tabHighlight - self.selectedBackgroundView = selectedBackgroundView - contentView.addSubview(topSeparator) contentView.addSubview(contentStackView) contentView.addSubview(botSeparator) + contentStackView.addArrangedSubview(iconImageView) contentStackView.addArrangedSubview(titleStackView) contentStackView.addArrangedSubview(actionContainerView) + contentStackView.addArrangedSubview(pushChevronImageView) + contentStackView.addArrangedSubview(toggleSwitch) + contentStackView.addArrangedSubview(tickImageView) titleStackView.addArrangedSubview(titleLabel) titleStackView.addArrangedSubview(subtitleLabel) titleStackView.addArrangedSubview(extraActionButton) - actionContainerView.addSubview(pushChevronImageView) - actionContainerView.addSubview(toggleSwitch) actionContainerView.addSubview(dropDownImageView) actionContainerView.addSubview(dropDownLabel) - actionContainerView.addSubview(tickImageView) actionContainerView.addSubview(rightActionButtonContainerView) rightActionButtonContainerView.addSubview(rightActionButtonLabel) @@ -218,33 +226,41 @@ class SettingsCell: UITableViewCell { contentStackView.pin(to: contentView) - pushChevronImageView.center(.vertical, in: actionContainerView) - pushChevronImageView.pin(.right, to: .right, of: actionContainerView) + titleLabel.setCompressionResistanceHorizontalLow() + subtitleLabel.setCompressionResistanceHorizontalLow() - actionContainerView.widthAnchor - .constraint(greaterThanOrEqualTo: toggleSwitch.widthAnchor) - .isActive = true + iconImageView.set(.width, to: 24) + iconImageView.set(.height, to: 24) + + pushChevronImageView.setContentHuggingHigh() + pushChevronImageView.setCompressionResistanceHigh() + + toggleSwitch.setContentHuggingHigh() toggleSwitch.setCompressionResistanceHigh() - toggleSwitch.center(.vertical, in: actionContainerView) - toggleSwitch.pin(.right, to: .right, of: actionContainerView) - dropDownLabel.setCompressionResistanceHigh() - dropDownLabel.center(.vertical, in: actionContainerView) - dropDownLabel.pin(.right, to: .right, of: actionContainerView) + tickImageView.setContentHuggingHigh() + tickImageView.setCompressionResistanceHigh() + + actionContainerView.setContentHuggingHigh() + actionContainerView.setCompressionResistanceHigh() + actionContainerView.set(.height, to: .height, of: contentStackView) dropDownImageView.center(.vertical, in: actionContainerView) dropDownImageView.pin(.left, to: .left, of: actionContainerView) - dropDownImageView.pin(.right, to: .left, of: dropDownLabel, withInset: -Values.verySmallSpacing) dropDownImageView.set(.width, to: 10) dropDownImageView.set(.height, to: 10) - tickImageView.center(.vertical, in: actionContainerView) - tickImageView.pin(.right, to: .right, of: actionContainerView) + dropDownLabel.setContentHuggingHigh() + dropDownLabel.setCompressionResistanceHigh() + dropDownLabel.center(.vertical, in: actionContainerView) + dropDownLabel.pin(.left, to: .right, of: dropDownImageView, withInset: Values.verySmallSpacing) + dropDownLabel.pin(.right, to: .right, of: actionContainerView) rightActionButtonContainerView.center(.vertical, in: actionContainerView) rightActionButtonContainerView.pin(.left, to: .left, of: actionContainerView) rightActionButtonContainerView.pin(.right, to: .right, of: actionContainerView) + rightActionButtonLabel.setContentHuggingHigh() rightActionButtonLabel.setCompressionResistanceHigh() rightActionButtonLabel.pin(to: rightActionButtonContainerView, withInset: Values.smallSpacing) @@ -253,66 +269,19 @@ class SettingsCell: UITableViewCell { botSeparator.pin(.bottom, to: .bottom, of: contentView) } - // MARK: - Content - - override func prepareForReuse() { - super.prepareForReuse() + override func layoutSubviews() { + super.layoutSubviews() - self.themeBackgroundColor = nil - self.selectedBackgroundView = nil - self.instanceView = UIView() - self.onExtraAction = nil - - titleLabel.text = "" - titleLabel.themeTextColor = .textPrimary - subtitleLabel.text = "" - dropDownLabel.text = "" - - topSeparator.isHidden = true - subtitleLabel.isHidden = true - extraActionButton.isHidden = true - actionContainerView.isHidden = true - pushChevronImageView.isHidden = true - toggleSwitch.isHidden = true - dropDownImageView.isHidden = true - dropDownLabel.isHidden = true - tickImageView.isHidden = true - tickImageView.alpha = 1 - rightActionButtonContainerView.isHidden = true - botSeparator.isHidden = true - - subtitleExtraView?.removeFromSuperview() - subtitleExtraView = nil - } - - public func update( - title: String, - subtitle: String?, - subtitleExtraViewGenerator: (() -> UIView)?, - action: SettingsAction, - extraActionTitle: ((Theme, Theme.PrimaryColor) -> NSAttributedString)?, - onExtraAction: (() -> Void)?, - isFirstInSection: Bool, - isLastInSection: Bool - ) { - self.instanceView = UIView() - self.subtitleExtraView = subtitleExtraViewGenerator?() - self.onExtraAction = onExtraAction - - // Left content - titleLabel.text = title - subtitleLabel.text = subtitle - subtitleLabel.isHidden = (subtitle == nil) - extraActionButton.isHidden = (extraActionTitle == nil) + // Need to force the contentStackView to layout if needed as it might not have updated it's + // sizing yet + self.contentStackView.layoutIfNeeded() // Position the 'subtitleExtraView' at the end of the last line of text if let subtitleExtraView: UIView = self.subtitleExtraView, - let subtitle: String = subtitle, + let subtitle: String = subtitleLabel.text, let font: UIFont = subtitleLabel.font { - self.layoutIfNeeded() - let layoutManager: NSLayoutManager = NSLayoutManager() let textStorage = NSTextStorage( attributedString: NSAttributedString( @@ -337,6 +306,9 @@ class SettingsCell: UITableViewCell { actualGlyphRange: &glyphRange ) let lastGlyphRect: CGRect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) + + // Remove and re-add the 'subtitleExtraView' to clear any old constraints + subtitleExtraView.removeFromSuperview() contentView.addSubview(subtitleExtraView) subtitleExtraView.pin( @@ -349,9 +321,72 @@ class SettingsCell: UITableViewCell { .left, to: .left, of: subtitleLabel, - withInset: lastGlyphRect.minX + 5 + withInset: lastGlyphRect.maxX ) } + } + + // MARK: - Content + + override func prepareForReuse() { + super.prepareForReuse() + + self.themeBackgroundColor = nil + self.selectedBackgroundView = nil + self.instanceView = UIView() + self.onExtraAction = nil + self.accessibilityIdentifier = nil + + iconImageView.image = nil + titleLabel.text = "" + titleLabel.themeTextColor = .textPrimary + subtitleLabel.text = "" + dropDownLabel.text = "" + + topSeparator.isHidden = true + iconImageView.isHidden = true + subtitleLabel.isHidden = true + extraActionButton.isHidden = true + actionContainerView.isHidden = true + pushChevronImageView.isHidden = true + toggleSwitch.isHidden = true + dropDownImageView.isHidden = true + dropDownLabel.isHidden = true + tickImageView.isHidden = true + tickImageView.alpha = 1 + rightActionButtonContainerView.isHidden = true + botSeparator.isHidden = true + + subtitleExtraView?.removeFromSuperview() + subtitleExtraView = nil + } + + public func update( + icon: UIImage?, + title: String, + subtitle: String?, + alignment: NSTextAlignment, + accessibilityIdentifier: String?, + subtitleExtraViewGenerator: (() -> UIView)?, + action: SettingsAction, + extraActionTitle: ((Theme, Theme.PrimaryColor) -> NSAttributedString)?, + onExtraAction: (() -> Void)?, + isFirstInSection: Bool, + isLastInSection: Bool + ) { + self.instanceView = UIView() + self.subtitleExtraView = subtitleExtraViewGenerator?() + self.onExtraAction = onExtraAction + self.accessibilityIdentifier = accessibilityIdentifier + + // Left content + iconImageView.image = icon + iconImageView.isHidden = (icon == nil) + titleLabel.text = title + titleLabel.textAlignment = alignment + subtitleLabel.text = subtitle + subtitleLabel.isHidden = (subtitle == nil) + extraActionButton.isHidden = (extraActionTitle == nil) // Separator/background Visibility if action.shouldHaveBackground { @@ -367,11 +402,23 @@ class SettingsCell: UITableViewCell { botSeparator.isHidden = true } + // Highlight + let selectedBackgroundView = UIView() + selectedBackgroundView.themeBackgroundColor = .settings_tabHighlight + self.selectedBackgroundView = selectedBackgroundView + // Action Behaviours switch action { - case .userDefaultsBool(let defaults, let key, _): - actionContainerView.isHidden = false + case .threadInfo: break + + case .userDefaultsBool(let defaults, let key, let isEnabled, _): toggleSwitch.isHidden = false + toggleSwitch.isEnabled = isEnabled + + // Remove the selection view if the setting is disabled + if !isEnabled { + self.selectedBackgroundView = UIView() + } let newValue: Bool = defaults.bool(forKey: key) @@ -379,34 +426,52 @@ class SettingsCell: UITableViewCell { toggleSwitch.setOn(newValue, animated: true) } - case .settingBool(let key, _): - actionContainerView.isHidden = false + case .settingBool(let key, _, let isEnabled): toggleSwitch.isHidden = false + toggleSwitch.isEnabled = isEnabled + + // Remove the selection view if the setting is disabled + if !isEnabled { + self.selectedBackgroundView = UIView() + } let newValue: Bool = Storage.shared[key] if newValue != toggleSwitch.isOn { toggleSwitch.setOn(newValue, animated: true) } + + case .customToggle(let value, let isEnabled, _, _): + toggleSwitch.isHidden = false + toggleSwitch.isEnabled = isEnabled + + // Remove the selection view if the setting is disabled + if !isEnabled { + self.selectedBackgroundView = UIView() + } + + if value != toggleSwitch.isOn { + toggleSwitch.setOn(value, animated: true) + } - case .settingEnum(_, let value, _): + case .settingEnum(_, let value, _), .generalEnum(let value, _): actionContainerView.isHidden = false dropDownImageView.isHidden = false dropDownLabel.isHidden = false dropDownLabel.text = value case .listSelection(let isSelected, let storedSelection, _, _): - actionContainerView.isHidden = false tickImageView.isHidden = (!isSelected() && !storedSelection) tickImageView.alpha = (!isSelected() && storedSelection ? 0.3 : 1) - case .trigger, .push: - actionContainerView.isHidden = false - pushChevronImageView.isHidden = false + case .trigger(let showChevron, _): + pushChevronImageView.isHidden = !showChevron + + case .push(let showChevron, let textColor, _, _): + titleLabel.themeTextColor = textColor + pushChevronImageView.isHidden = !showChevron - case .dangerPush: - titleLabel.themeTextColor = .danger - actionContainerView.isHidden = false + case .present(_): break case .rightButtonAction(let title, _): actionContainerView.isHidden = false @@ -425,6 +490,8 @@ class SettingsCell: UITableViewCell { } } + public func update(isEditing: Bool, animated: Bool) {} + // MARK: - Interaction override func setHighlighted(_ highlighted: Bool, animated: Bool) { diff --git a/Session/Shared/UserSelectionVC.swift b/Session/Shared/UserSelectionVC.swift index 632a25b14..519f6e734 100644 --- a/Session/Shared/UserSelectionVC.swift +++ b/Session/Shared/UserSelectionVC.swift @@ -1,9 +1,9 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import SessionUIKit import SessionMessagingKit -@objc(SNUserSelectionVC) final class UserSelectionVC: BaseVC, UITableViewDataSource, UITableViewDelegate { private let navBarTitle: String private let usersToExclude: Set @@ -22,7 +22,7 @@ final class UserSelectionVC: BaseVC, UITableViewDataSource, UITableViewDelegate result.dataSource = self result.delegate = self result.separatorStyle = .none - result.backgroundColor = .clear + result.themeBackgroundColor = .clear result.showsVerticalScrollIndicator = false result.alwaysBounceVertical = false result.register(view: UserCell.self) @@ -32,16 +32,16 @@ final class UserSelectionVC: BaseVC, UITableViewDataSource, UITableViewDelegate // MARK: - Lifecycle - @objc(initWithTitle:excluding:completion:) init(with title: String, excluding usersToExclude: Set, completion: @escaping (Set) -> Void) { self.navBarTitle = title self.usersToExclude = usersToExclude self.completion = completion + super.init(nibName: nil, bundle: nil) } - required init?(coder: NSCoder) { preconditionFailure("Use UserSelectionVC.init(excluding:) instead.") } - override init(nibName: String?, bundle: Bundle?) { preconditionFailure("Use UserSelectionVC.init(excluding:) instead.") } + required init?(coder: NSCoder) { preconditionFailure("Use init(excluding:) instead.") } + override init(nibName: String?, bundle: Bundle?) { preconditionFailure("Use init(excluding:) instead.") } override func viewDidLoad() { super.viewDidLoad() diff --git a/Session/Utilities/MentionUtilities.swift b/Session/Utilities/MentionUtilities.swift index bbf033c5e..737856878 100644 --- a/Session/Utilities/MentionUtilities.swift +++ b/Session/Utilities/MentionUtilities.swift @@ -20,6 +20,7 @@ public enum MentionUtilities { currentUserBlindedPublicKey: currentUserBlindedPublicKey, isOutgoingMessage: false, textColor: .black, + theme: .classicDark, primaryColor: Theme.PrimaryColor.green, attributes: [:] ).string @@ -32,6 +33,7 @@ public enum MentionUtilities { currentUserBlindedPublicKey: String?, isOutgoingMessage: Bool, textColor: UIColor, + theme: Theme, primaryColor: Theme.PrimaryColor, attributes: [NSAttributedString.Key: Any] ) -> NSAttributedString { @@ -102,7 +104,10 @@ public enum MentionUtilities { else { result.addAttribute( .foregroundColor, - value: (isOutgoingMessage ? primaryColor.color : textColor), + value: (isOutgoingMessage || theme.interfaceStyle == .light ? + textColor : + primaryColor.color + ), range: mention.range ) } diff --git a/SessionMessagingKit/Configuration.swift b/SessionMessagingKit/Configuration.swift index 54e1b0258..0ca75f7b4 100644 --- a/SessionMessagingKit/Configuration.swift +++ b/SessionMessagingKit/Configuration.swift @@ -22,7 +22,8 @@ public enum SNMessagingKit { // Just to make the external API nice _007_HomeQueryOptimisationIndexes.self ], [ - _008_EmojiReacts.self + _008_EmojiReacts.self, + _009_AddThreadIdToFTS.self ] ] ) diff --git a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift index 385eb2260..0672f87f5 100644 --- a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -10,11 +10,13 @@ enum _001_InitialSetupMigration: Migration { static let needsConfigSync: Bool = false static let minExpectedRunDuration: TimeInterval = 0.1 - static func migrate(_ db: Database) throws { + public static let fullTextSearchTokenizer: FTS5TokenizerDescriptor = { // Define the tokenizer to be used in all the FTS tables // https://github.com/groue/GRDB.swift/blob/master/Documentation/FullTextSearch.md#fts5-tokenizers - let fullTextSearchTokenizer: FTS5TokenizerDescriptor = .porter(wrapping: .unicode61()) - + return .porter(wrapping: .unicode61()) + }() + + static func migrate(_ db: Database) throws { try db.create(table: Contact.self) { t in t.column(.id, .text) .notNull() @@ -50,7 +52,7 @@ enum _001_InitialSetupMigration: Migration { /// Create a full-text search table synchronized with the Profile table try db.create(virtualTable: Profile.fullTextSearchTableName, using: FTS5()) { t in t.synchronize(withTable: Profile.databaseTableName) - t.tokenizer = fullTextSearchTokenizer + t.tokenizer = _001_InitialSetupMigration.fullTextSearchTokenizer t.column(Profile.Columns.nickname.name) t.column(Profile.Columns.name.name) @@ -97,7 +99,7 @@ enum _001_InitialSetupMigration: Migration { /// Create a full-text search table synchronized with the ClosedGroup table try db.create(virtualTable: ClosedGroup.fullTextSearchTableName, using: FTS5()) { t in t.synchronize(withTable: ClosedGroup.databaseTableName) - t.tokenizer = fullTextSearchTokenizer + t.tokenizer = _001_InitialSetupMigration.fullTextSearchTokenizer t.column(ClosedGroup.Columns.name.name) } @@ -148,7 +150,7 @@ enum _001_InitialSetupMigration: Migration { /// Create a full-text search table synchronized with the OpenGroup table try db.create(virtualTable: OpenGroup.fullTextSearchTableName, using: FTS5()) { t in t.synchronize(withTable: OpenGroup.databaseTableName) - t.tokenizer = fullTextSearchTokenizer + t.tokenizer = _001_InitialSetupMigration.fullTextSearchTokenizer t.column(OpenGroup.Columns.name.name) } @@ -259,7 +261,7 @@ enum _001_InitialSetupMigration: Migration { /// Create a full-text search table synchronized with the Interaction table try db.create(virtualTable: Interaction.fullTextSearchTableName, using: FTS5()) { t in t.synchronize(withTable: Interaction.databaseTableName) - t.tokenizer = fullTextSearchTokenizer + t.tokenizer = _001_InitialSetupMigration.fullTextSearchTokenizer t.column(Interaction.Columns.body.name) } diff --git a/SessionMessagingKit/Database/Migrations/_009_AddThreadIdToFTS.swift b/SessionMessagingKit/Database/Migrations/_009_AddThreadIdToFTS.swift new file mode 100644 index 000000000..e20c62700 --- /dev/null +++ b/SessionMessagingKit/Database/Migrations/_009_AddThreadIdToFTS.swift @@ -0,0 +1,33 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +/// This migration recreates the interaction FTS table and adds the threadId so we can do a performant in-conversation +/// searh (currently it's much slower than the global search) +enum _009_AddThreadIdToFTS: Migration { + static let target: TargetMigrations.Identifier = .messagingKit + static let identifier: String = "AddThreadIdToFTS" + static let needsConfigSync: Bool = false + static let minExpectedRunDuration: TimeInterval = 3 + + static func migrate(_ db: Database) throws { + // Can't actually alter a virtual table in SQLite so we need to drop and recreate it, + // luckily this is actually pretty quick + if try db.tableExists(Interaction.fullTextSearchTableName) { + try db.drop(table: Interaction.fullTextSearchTableName) + try db.dropFTS5SynchronizationTriggers(forTable: Interaction.fullTextSearchTableName) + } + + try db.create(virtualTable: Interaction.fullTextSearchTableName, using: FTS5()) { t in + t.synchronize(withTable: Interaction.databaseTableName) + t.tokenizer = _001_InitialSetupMigration.fullTextSearchTokenizer + + t.column(Interaction.Columns.body.name) + t.column(Interaction.Columns.threadId.name) + } + + Storage.update(progress: 1, for: self, in: target) // In case this is the last migration + } +} diff --git a/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift b/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift index 689162b2f..0cf05d63c 100644 --- a/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift +++ b/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift @@ -4,7 +4,7 @@ import Foundation import GRDB import SessionUtilitiesKit -public struct DisappearingMessagesConfiguration: Codable, Identifiable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { +public struct DisappearingMessagesConfiguration: Codable, Identifiable, Equatable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "disappearingMessagesConfiguration" } internal static let threadForeignKey = ForeignKey([Columns.threadId], to: [SessionThread.Columns.id]) private static let thread = belongsTo(SessionThread.self, using: threadForeignKey) diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index 641d33d75..933a86b9f 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -556,15 +556,16 @@ public extension Interaction { static func idsForTermWithin(threadId: String, pattern: FTS5Pattern) -> SQLRequest { let interaction: TypedTableAlias = TypedTableAlias() let interactionFullTextSearch: SQL = SQL(stringLiteral: Interaction.fullTextSearchTableName) + let threadIdLiteral: SQL = SQL(stringLiteral: Interaction.Columns.threadId.name) let request: SQLRequest = """ SELECT \(interaction[.id]) FROM \(Interaction.self) JOIN \(interactionFullTextSearch) ON ( \(interactionFullTextSearch).rowid = \(interaction.alias[Column.rowID]) AND + \(SQL("\(interactionFullTextSearch).\(threadIdLiteral) = \(threadId)")) AND \(interactionFullTextSearch).\(SQL(stringLiteral: Interaction.Columns.body.name)) MATCH \(pattern) ) - WHERE \(SQL("\(interaction[.threadId]) = \(threadId)")) ORDER BY \(interaction[.timestampMs].desc) """ diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index e57a95422..b1bca2204 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -193,19 +193,24 @@ public extension Profile { // MARK: - GRDB Interactions public extension Profile { + static func allContactProfiles(excluding idsToExclude: Set = []) -> QueryInterfaceRequest { + return Profile + .filter(!idsToExclude.contains(Profile.Columns.id)) + .joining( + required: Profile.contact + .filter(Contact.Columns.isApproved == true) + .filter(Contact.Columns.didApproveMe == true) + ) + } + static func fetchAllContactProfiles(excluding: Set = [], excludeCurrentUser: Bool = true) -> [Profile] { return Storage.shared .read { db in - let idsToExclude: Set = excluding - .inserting(excludeCurrentUser ? getUserHexEncodedPublicKey(db) : nil) - // Sort the contacts by their displayName value - return try Profile - .filter(!idsToExclude.contains(Profile.Columns.id)) - .joining( - required: Profile.contact - .filter(Contact.Columns.isApproved == true) - .filter(Contact.Columns.didApproveMe == true) + try Profile + .allContactProfiles( + excluding: excluding + .inserting(excludeCurrentUser ? getUserHexEncodedPublicKey(db) : nil) ) .fetchAll(db) .sorted(by: { lhs, rhs -> Bool in lhs.displayName() < rhs.displayName() }) diff --git a/SessionMessagingKit/Messages/Control Messages/ExpirationTimerUpdate.swift b/SessionMessagingKit/Messages/Control Messages/ExpirationTimerUpdate.swift index 18379089d..3d7aadda5 100644 --- a/SessionMessagingKit/Messages/Control Messages/ExpirationTimerUpdate.swift +++ b/SessionMessagingKit/Messages/Control Messages/ExpirationTimerUpdate.swift @@ -20,7 +20,7 @@ public final class ExpirationTimerUpdate: ControlMessage { // MARK: - Initialization - internal init(syncTarget: String?, duration: UInt32) { + public init(syncTarget: String?, duration: UInt32) { super.init() self.syncTarget = syncTarget diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index 454456724..6167128d4 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -813,8 +813,9 @@ public extension SessionThreadViewModel { } } - static func conversationSettingsProfileQuery(threadId: String, userPublicKey: String) -> AdaptedFetchRequest> { + static func conversationSettingsQuery(threadId: String, userPublicKey: String) -> AdaptedFetchRequest> { let thread: TypedTableAlias = TypedTableAlias() + let contact: TypedTableAlias = TypedTableAlias() let closedGroup: TypedTableAlias = TypedTableAlias() let groupMember: TypedTableAlias = TypedTableAlias() let openGroup: TypedTableAlias = TypedTableAlias() @@ -827,7 +828,7 @@ public extension SessionThreadViewModel { /// parse and might throw /// /// Explicitly set default values for the fields ignored for search results - let numColumnsBeforeProfiles: Int = 6 + let numColumnsBeforeProfiles: Int = 9 let request: SQLRequest = """ SELECT \(thread.alias[Column.rowID]) AS \(ViewModel.rowIdKey), @@ -835,21 +836,35 @@ public extension SessionThreadViewModel { \(thread[.variant]) AS \(ViewModel.threadVariantKey), \(thread[.creationDateTimestamp]) AS \(ViewModel.threadCreationDateTimestampKey), - false AS \(ViewModel.threadIsNoteToSelfKey), - false AS \(ViewModel.threadIsPinnedKey), + (\(SQL("\(thread[.id]) = \(userPublicKey)"))) AS \(ViewModel.threadIsNoteToSelfKey), + + \(thread[.isPinned]) AS \(ViewModel.threadIsPinnedKey), + \(contact[.isBlocked]) AS \(ViewModel.threadIsBlockedKey), + \(thread[.mutedUntilTimestamp]) AS \(ViewModel.threadMutedUntilTimestampKey), + \(thread[.onlyNotifyForMentions]) AS \(ViewModel.threadOnlyNotifyForMentionsKey), \(ViewModel.contactProfileKey).*, \(ViewModel.closedGroupProfileFrontKey).*, \(ViewModel.closedGroupProfileBackKey).*, \(ViewModel.closedGroupProfileBackFallbackKey).*, + + \(closedGroup[.name]) AS \(ViewModel.closedGroupNameKey), + (\(groupMember[.profileId]) IS NOT NULL) AS \(ViewModel.currentUserIsClosedGroupMemberKey), + \(openGroup[.name]) AS \(ViewModel.openGroupNameKey), \(openGroup[.imageData]) AS \(ViewModel.openGroupProfilePictureDataKey), - + \(SQL("\(userPublicKey)")) AS \(ViewModel.currentUserPublicKeyKey) FROM \(SessionThread.self) + LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) LEFT JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(thread[.id]) - LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id]) + LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) + LEFT JOIN \(GroupMember.self) ON ( + \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND + \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND + \(SQL("\(groupMember[.profileId]) = \(userPublicKey)")) + ) LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON ( \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) = ( @@ -907,7 +922,7 @@ public extension SessionThreadViewModel { // MARK: - Search Queries public extension SessionThreadViewModel { - fileprivate static let searchResultsLimit: Int = 500 + static let searchResultsLimit: Int = 500 static func searchTermParts(_ searchTerm: String) -> [String] { /// Process the search term in order to extract the parts of the search pattern we want diff --git a/SessionMessagingKit/Utilities/Preferences.swift b/SessionMessagingKit/Utilities/Preferences.swift index 4d39be274..edf1f2813 100644 --- a/SessionMessagingKit/Utilities/Preferences.swift +++ b/SessionMessagingKit/Utilities/Preferences.swift @@ -319,66 +319,3 @@ public enum Preferences { return true } } - -// MARK: - Objective C Support - -// FIXME: Remove the below when OWSConversationSettingsViewController no longer nees SMKSound -@objc(SMKSound) -public class SMKSound: NSObject { - @objc public static var notificationSounds: [Int] = Preferences.Sound.notificationSounds.map { $0.rawValue } - - @objc public static func displayName(for sound: Int) -> String { - return (Preferences.Sound(rawValue: sound) ?? Preferences.Sound.default).displayName - } - - @objc public static func isNote(_ sound: Int) -> Bool { - return (sound == Preferences.Sound.note.rawValue) - } - - @objc public static func audioPlayer(for sound: Int, audioBehavior: OWSAudioBehavior) -> OWSAudioPlayer? { - guard let sound: Preferences.Sound = Preferences.Sound(rawValue: sound) else { return nil } - - return Preferences.Sound.audioPlayer(for: sound, behaviour: audioBehavior) - } - - @objc public static var defaultNotificationSound: Int { - return Storage.shared[.defaultNotificationSound] - .defaulting(to: Preferences.Sound.defaultNotificationSound) - .rawValue - } - - @objc public static func setGlobalNotificationSound(_ sound: Int) { - guard let sound: Preferences.Sound = Preferences.Sound(rawValue: sound) else { return } - - Storage.shared.write { db in - db[.defaultNotificationSound] = sound - } - } - - @objc public static func notificationSound(for threadId: String?) -> Int { - guard let threadId: String = threadId else { return defaultNotificationSound } - - return (Storage.shared - .read { db in - try Preferences.Sound - .fetchOne( - db, - SessionThread - .select(.notificationSound) - .filter(id: threadId) - ) - }? - .rawValue) - .defaulting(to: defaultNotificationSound) - } - - @objc public static func setNotificationSound(_ sound: Int, forThreadId threadId: String) { - guard let sound: Preferences.Sound = Preferences.Sound(rawValue: sound) else { return } - - Storage.shared.write { db in - try SessionThread - .filter(id: threadId) - .updateAll(db, SessionThread.Columns.notificationSound.set(to: sound)) - } - } -} diff --git a/SessionUIKit/Components/SearchBar.swift b/SessionUIKit/Components/SearchBar.swift index 830f0973f..1adf660d2 100644 --- a/SessionUIKit/Components/SearchBar.swift +++ b/SessionUIKit/Components/SearchBar.swift @@ -1,3 +1,5 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + import UIKit public final class SearchBar : UISearchBar { @@ -18,17 +20,30 @@ public extension UISearchBar { func setUpSessionStyle() { searchBarStyle = .minimal // Hide the border around the search bar barStyle = .black // Use Apple's black design as a base - tintColor = Colors.text // The cursor color - let searchImage = #imageLiteral(resourceName: "searchbar_search").withTint(Colors.searchBarPlaceholder)! + themeTintColor = .textPrimary // The cursor color + + let searchImage: UIImage = #imageLiteral(resourceName: "searchbar_search").withRenderingMode(.alwaysTemplate) setImage(searchImage, for: .search, state: .normal) - let clearImage = #imageLiteral(resourceName: "searchbar_clear").withTint(Colors.searchBarPlaceholder)! + searchTextField.leftView?.themeTintColor = .textSecondary + + let clearImage: UIImage = #imageLiteral(resourceName: "searchbar_clear").withRenderingMode(.alwaysTemplate) setImage(clearImage, for: .clear, state: .normal) + let searchTextField: UITextField = self.searchTextField - searchTextField.backgroundColor = Colors.searchBarBackground // The search bar background color - searchTextField.textColor = Colors.text - searchTextField.attributedPlaceholder = NSAttributedString(string: "Search", attributes: [ .foregroundColor : Colors.searchBarPlaceholder ]) + searchTextField.themeBackgroundColor = .messageBubble_overlay // The search bar background color + searchTextField.themeTextColor = .textPrimary setPositionAdjustment(UIOffset(horizontal: 4, vertical: 0), for: UISearchBar.Icon.search) searchTextPositionAdjustment = UIOffset(horizontal: 2, vertical: 0) setPositionAdjustment(UIOffset(horizontal: -4, vertical: 0), for: UISearchBar.Icon.clear) + + ThemeManager.onThemeChange(observer: searchTextField) { [weak searchTextField] theme, _ in + guard let textColor: UIColor = theme.colors[.textSecondary] else { return } + + searchTextField?.attributedPlaceholder = NSAttributedString( + string: "Search", + attributes: [ + .foregroundColor: textColor + ]) + } } } diff --git a/SessionUIKit/Components/Separator.swift b/SessionUIKit/Components/Separator.swift index 3527cde89..7fc9b30a3 100644 --- a/SessionUIKit/Components/Separator.swift +++ b/SessionUIKit/Components/Separator.swift @@ -27,21 +27,25 @@ public final class Separator: UIView { // MARK: - Initialization - public init(title: String) { + public init(title: String? = nil) { super.init(frame: CGRect.zero) setUpViewHierarchy(title: title) } public override init(frame: CGRect) { - preconditionFailure("Use init(title:) instead.") + super.init(frame: frame) + + setUpViewHierarchy(title: nil) } public required init?(coder: NSCoder) { - preconditionFailure("Use init(title:) instead.") + super.init(coder: coder) + + setUpViewHierarchy(title: nil) } - private func setUpViewHierarchy(title: String) { + private func setUpViewHierarchy(title: String?) { titleLabel.text = title addSubview(titleLabel) @@ -77,4 +81,8 @@ public final class Separator: UIView { lineLayer.path = path.cgPath } + + public func update(title: String?) { + titleLabel.text = title + } } diff --git a/SessionUIKit/Style Guide/ThemeManager.swift b/SessionUIKit/Style Guide/ThemeManager.swift index c6d65086a..5f53f66b8 100644 --- a/SessionUIKit/Style Guide/ThemeManager.swift +++ b/SessionUIKit/Style Guide/ThemeManager.swift @@ -225,6 +225,13 @@ public enum ThemeManager { } private static func updateAllUI() { + guard Thread.isMainThread else { + DispatchQueue.main.async { + updateAllUI() + } + return + } + ThemeManager.uiRegistry.objectEnumerator()?.forEach { applier in (applier as? ThemeApplier)?.apply(theme: currentTheme) } diff --git a/SessionUtilitiesKit/Combine/Disposable Views/DisposableBarButtonItem.swift b/SessionUtilitiesKit/Combine/Disposable Views/DisposableBarButtonItem.swift new file mode 100644 index 000000000..6e1d53f49 --- /dev/null +++ b/SessionUtilitiesKit/Combine/Disposable Views/DisposableBarButtonItem.swift @@ -0,0 +1,8 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import Combine + +public class DisposableBarButtonItem: UIBarButtonItem { + public var disposables: Set = Set() +} diff --git a/SessionUtilitiesKit/Combine/Publisher+Utilities.swift b/SessionUtilitiesKit/Combine/Publisher+Utilities.swift new file mode 100644 index 000000000..7178c712b --- /dev/null +++ b/SessionUtilitiesKit/Combine/Publisher+Utilities.swift @@ -0,0 +1,57 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Combine + +public protocol CombineCompatible {} + +public extension Publisher { + /// Provides a subject that shares a single subscription to the upstream publisher and replays at most + /// `bufferSize` items emitted by that publisher + /// - Parameter bufferSize: limits the number of items that can be replayed + func shareReplay(_ bufferSize: Int) -> AnyPublisher { + return multicast(subject: ReplaySubject(bufferSize)) + .autoconnect() + .eraseToAnyPublisher() + } + + func sink(into subject: PassthroughSubject, includeCompletions: Bool = false) -> AnyCancellable { + return sink( + receiveCompletion: { completion in + guard includeCompletions else { return } + + subject.send(completion: completion) + }, + receiveValue: { value in subject.send(value) } + ) + } + + /// The standard `.receive(on: DispatchQueue.main)` seems to ocassionally dispatch to the + /// next run loop before emitting data, this method checks if it's running on the main thread already and + /// if so just emits directly rather than routing via `.receive(on:)` + func receiveOnMainImmediately() -> AnyPublisher { + return self + .flatMap { value -> AnyPublisher in + guard Thread.isMainThread else { + return Just(value) + .setFailureType(to: Failure.self) + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + } + + return Just(value) + .setFailureType(to: Failure.self) + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } +} + +// MARK: - Convenience + +public extension Publisher { + func sink(into subject: PassthroughSubject?, includeCompletions: Bool = false) -> AnyCancellable { + guard let targetSubject: PassthroughSubject = subject else { return AnyCancellable {} } + + return sink(into: targetSubject, includeCompletions: includeCompletions) + } +} diff --git a/SessionUtilitiesKit/Combine/ReplaySubject.swift b/SessionUtilitiesKit/Combine/ReplaySubject.swift new file mode 100644 index 000000000..7648fa989 --- /dev/null +++ b/SessionUtilitiesKit/Combine/ReplaySubject.swift @@ -0,0 +1,107 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Combine + +/// A subject that stores the last `bufferSize` emissions and emits them for every new subscriber +/// +/// Note: This implementation was found here: https://github.com/sgl0v/OnSwiftWings +public final class ReplaySubject: Subject { + private var buffer: [Output] = [Output]() + private let bufferSize: Int + private let lock: NSRecursiveLock = NSRecursiveLock() + + private var subscriptions = [ReplaySubjectSubscription]() + private var completion: Subscribers.Completion? + + // MARK: - Initialization + + init(_ bufferSize: Int = 0) { + self.bufferSize = bufferSize + } + + // MARK: - Subject Methods + + /// Sends a value to the subscriber + public func send(_ value: Output) { + lock.lock(); defer { lock.unlock() } + + buffer.append(value) + buffer = buffer.suffix(bufferSize) + subscriptions.forEach { $0.receive(value) } + } + + /// Sends a completion signal to the subscriber + public func send(completion: Subscribers.Completion) { + lock.lock(); defer { lock.unlock() } + + self.completion = completion + subscriptions.forEach { subscription in subscription.receive(completion: completion) } + } + + /// Provides this Subject an opportunity to establish demand for any new upstream subscriptions + public func send(subscription: Subscription) { + lock.lock(); defer { lock.unlock() } + + subscription.request(.unlimited) + } + + /// This function is called to attach the specified `Subscriber` to the`Publisher + public func receive(subscriber: S) where S: Subscriber, Failure == S.Failure, Output == S.Input { + lock.lock(); defer { lock.unlock() } + + let subscription = ReplaySubjectSubscription(downstream: AnySubscriber(subscriber)) + subscriber.receive(subscription: subscription) + subscriptions.append(subscription) + subscription.replay(buffer, completion: completion) + } +} + +// MARK: - + +public final class ReplaySubjectSubscription: Subscription { + private let downstream: AnySubscriber + private var isCompleted: Bool = false + private var demand: Subscribers.Demand = .none + + // MARK: - Initialization + + init(downstream: AnySubscriber) { + self.downstream = downstream + } + + // MARK: - Subscription + + public func request(_ newDemand: Subscribers.Demand) { + demand += newDemand + } + + public func cancel() { + isCompleted = true + } + + // MARK: - Functions + + public func receive(_ value: Output) { + guard !isCompleted, demand > 0 else { return } + + demand += downstream.receive(value) + demand -= 1 + } + + public func receive(completion: Subscribers.Completion) { + guard !isCompleted else { return } + + isCompleted = true + downstream.receive(completion: completion) + } + + public func replay(_ values: [Output], completion: Subscribers.Completion?) { + guard !isCompleted else { return } + + values.forEach { value in receive(value) } + + if let completion = completion { + receive(completion: completion) + } + } +} diff --git a/SessionUtilitiesKit/Combine/UIBarButtonItem+Combine.swift b/SessionUtilitiesKit/Combine/UIBarButtonItem+Combine.swift new file mode 100644 index 000000000..33d862329 --- /dev/null +++ b/SessionUtilitiesKit/Combine/UIBarButtonItem+Combine.swift @@ -0,0 +1,72 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import Combine + +// MARK: - + +public extension UIBarButtonItem { + final class Subscription: Combine.Subscription where SubscriberType.Input == Input { + private var subscriber: SubscriberType? + private let input: Input + + // MARK: - Initialization + + public init(subscriber: SubscriberType, input: Input) { + self.subscriber = subscriber + self.input = input + + input.target = self + input.action = #selector(eventHandler) + } + + // MARK: - Subscriber + + // Do nothing as we only want to send events when they occur + public func request(_ demand: Subscribers.Demand) {} + + // MARK: - Cancellable + + public func cancel() { + subscriber = nil + } + + // MARK: - Internal Functions + + @objc private func eventHandler() { + _ = subscriber?.receive(input) + } + } + + // MARK: - + + struct Publisher: Combine.Publisher { + public typealias Output = Output + public typealias Failure = Never + + let output: Output + + // MARK: - Initialization + + public init(output: Output) { + self.output = output + } + + // MARK: - Publisher + + public func receive(subscriber: S) where S : Subscriber, Never == S.Failure, Output == S.Input { + let subscription: Subscription = Subscription(subscriber: subscriber, input: output) + subscriber.receive(subscription: subscription) + } + } +} + +// MARK: - CombineCompatible + +extension UIBarButtonItem: CombineCompatible {} + +extension CombineCompatible where Self: UIBarButtonItem { + public var tapPublisher: UIBarButtonItem.Publisher { + return UIBarButtonItem.Publisher(output: self) + } +} diff --git a/SessionUtilitiesKit/Combine/UIControl+Combine.swift b/SessionUtilitiesKit/Combine/UIControl+Combine.swift new file mode 100644 index 000000000..5df243e74 --- /dev/null +++ b/SessionUtilitiesKit/Combine/UIControl+Combine.swift @@ -0,0 +1,71 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import Combine + +// MARK: - + +public extension UIControl { + final class Subscription: Combine.Subscription where SubscriberType.Input == Input { + private var subscriber: SubscriberType? + private let input: Input + + // MARK: - Initialization + + public init(subscriber: SubscriberType, input: Input, event: UIControl.Event) { + self.subscriber = subscriber + self.input = input + + input.addTarget(self, action: #selector(eventHandler), for: event) + } + + // MARK: - Subscriber + + // Do nothing as we only want to send events when they occur + public func request(_ demand: Subscribers.Demand) {} + + // MARK: - Cancellable + + public func cancel() { + subscriber = nil + } + + // MARK: - Internal Functions + + @objc private func eventHandler() { + _ = subscriber?.receive(input) + } + } + + // MARK: - + + struct Publisher: Combine.Publisher { + public typealias Output = Output + public typealias Failure = Never + + let output: Output + let controlEvents: UIControl.Event + + // MARK: - Initialization + + public init(output: Output, events: UIControl.Event) { + self.output = output + self.controlEvents = events + } + + // MARK: - Publisher + + public func receive(subscriber: S) where S : Subscriber, Never == S.Failure, Output == S.Input { + let subscription: Subscription = Subscription(subscriber: subscriber, input: output, event: controlEvents) + subscriber.receive(subscription: subscription) + } + } +} + +// MARK: - CombineCompatible + +extension CombineCompatible where Self: UIControl { + public func publisher(for events: UIControl.Event) -> UIControl.Publisher { + return UIControl.Publisher(output: self, events: events) + } +} diff --git a/SessionUtilitiesKit/Combine/UIView+Combine.swift b/SessionUtilitiesKit/Combine/UIView+Combine.swift new file mode 100644 index 000000000..13c3e0f96 --- /dev/null +++ b/SessionUtilitiesKit/Combine/UIView+Combine.swift @@ -0,0 +1,79 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import Combine + +// MARK: - + +public extension UIView { + final class Subscription: Combine.Subscription where SubscriberType.Input == Input { + private var subscriber: SubscriberType? + private var tapGestureRecognizer: UITapGestureRecognizer? + private let input: Input + + // MARK: - Initialization + + public init(subscriber: SubscriberType, input: Input) { + self.subscriber = subscriber + self.input = input + + let tapGestureRecognizer: UITapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(eventHandler)) + self.tapGestureRecognizer = tapGestureRecognizer + input.addGestureRecognizer(tapGestureRecognizer) + } + + // MARK: - Subscriber + + // Do nothing as we only want to send events when they occur + public func request(_ demand: Subscribers.Demand) {} + + // MARK: - Cancellable + + public func cancel() { + if let tapGestureRecognizer: UITapGestureRecognizer = self.tapGestureRecognizer { + input.removeGestureRecognizer(tapGestureRecognizer) + } + + subscriber = nil + tapGestureRecognizer = nil + } + + // MARK: - Internal Functions + + @objc private func eventHandler() { + _ = subscriber?.receive(input) + } + } + + // MARK: - + + struct Publisher: Combine.Publisher { + public typealias Output = Output + public typealias Failure = Never + + let output: Output + + // MARK: - Initialization + + public init(output: Output) { + self.output = output + } + + // MARK: - Publisher + + public func receive(subscriber: S) where S : Subscriber, Never == S.Failure, Output == S.Input { + let subscription: Subscription = Subscription(subscriber: subscriber, input: output) + subscriber.receive(subscription: subscription) + } + } +} + +// MARK: - CombineCompatible + +extension UIView: CombineCompatible {} + +extension CombineCompatible where Self: UIView { + public var tapPublisher: UIView.Publisher { + return UIView.Publisher(output: self) + } +} diff --git a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift index ac96a8dc6..569c281df 100644 --- a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift +++ b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift @@ -532,6 +532,7 @@ public class PagedDatabaseObserver: TransactionObserver where for: targetId, tableName: pagedTableName, idColumn: idColumnName, + requiredJoinSQL: joinSQL, orderSQL: orderSQL, filterSQL: filterSQL ) @@ -581,6 +582,7 @@ public class PagedDatabaseObserver: TransactionObserver where for: targetId, tableName: pagedTableName, idColumn: idColumnName, + requiredJoinSQL: joinSQL, orderSQL: orderSQL, filterSQL: filterSQL ) diff --git a/SessionUtilitiesKit/Database/Utilities/Database+Utilities.swift b/SessionUtilitiesKit/Database/Utilities/Database+Utilities.swift index 278f52766..d736dfc35 100644 --- a/SessionUtilitiesKit/Database/Utilities/Database+Utilities.swift +++ b/SessionUtilitiesKit/Database/Utilities/Database+Utilities.swift @@ -30,4 +30,8 @@ public extension Database { func makeFTS5Pattern(rawPattern: String, forTable table: T.Type) throws -> FTS5Pattern where T: TableRecord, T: ColumnExpressible { return try makeFTS5Pattern(rawPattern: rawPattern, forTable: table.databaseTableName) } + + func interrupt() { + sqlite3_interrupt(sqliteConnection) + } } diff --git a/SessionUtilitiesKit/General/UIView+OWS.h b/SessionUtilitiesKit/General/UIView+OWS.h index 738c9779b..e010b7d1c 100644 --- a/SessionUtilitiesKit/General/UIView+OWS.h +++ b/SessionUtilitiesKit/General/UIView+OWS.h @@ -140,16 +140,6 @@ CGFloat ScaleFromIPhone5(CGFloat iPhone5Value); #pragma mark - -@interface UIStackView (OWS) - -- (UIView *)addBackgroundViewWithBackgroundColor:(UIColor *)backgroundColor; - -- (UIView *)addBorderViewWithColor:(UIColor *)color strokeWidth:(CGFloat)strokeWidth cornerRadius:(CGFloat)cornerRadius; - -@end - -#pragma mark - - @interface UIAlertAction (OWS) + (instancetype)actionWithTitle:(nullable NSString *)title diff --git a/SessionUtilitiesKit/General/UIView+OWS.m b/SessionUtilitiesKit/General/UIView+OWS.m index aca47ba45..63d50459c 100644 --- a/SessionUtilitiesKit/General/UIView+OWS.m +++ b/SessionUtilitiesKit/General/UIView+OWS.m @@ -484,40 +484,6 @@ CGFloat ScaleFromIPhone5(CGFloat iPhone5Value) #pragma mark - -@implementation UIStackView (OWS) - -- (UIView *)addBackgroundViewWithBackgroundColor:(UIColor *)backgroundColor -{ - UIView *subview = [UIView new]; - subview.backgroundColor = backgroundColor; - [self addSubview:subview]; - [subview autoPinEdgesToSuperviewEdges]; - [subview setCompressionResistanceLow]; - [subview setContentHuggingLow]; - [self sendSubviewToBack:subview]; - return subview; -} - -- (UIView *)addBorderViewWithColor:(UIColor *)color strokeWidth:(CGFloat)strokeWidth cornerRadius:(CGFloat)cornerRadius -{ - UIView *borderView = [UIView new]; - borderView.userInteractionEnabled = NO; - borderView.backgroundColor = UIColor.clearColor; - borderView.opaque = NO; - borderView.layer.borderColor = color.CGColor; - borderView.layer.borderWidth = strokeWidth; - borderView.layer.cornerRadius = cornerRadius; - [self addSubview:borderView]; - [borderView autoPinEdgesToSuperviewEdges]; - [borderView setCompressionResistanceLow]; - [borderView setContentHuggingLow]; - return borderView; -} - -@end - -#pragma mark - - @implementation UIAlertAction (OWS) + (instancetype)actionWithTitle:(nullable NSString *)title diff --git a/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift b/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift index ec39aed80..a56866903 100644 --- a/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift +++ b/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift @@ -131,35 +131,7 @@ public final class ProfilePictureView: UIView { additionalProfilePlaceholderImageView.pin(.top, to: .top, of: additionalImageContainerView, withInset: 3) additionalProfilePlaceholderImageView.pin(.left, to: .left, of: additionalImageContainerView) additionalProfilePlaceholderImageView.pin(.right, to: .right, of: additionalImageContainerView) - additionalProfilePlaceholderImageView.pin(.bottom, to: .bottom, of: additionalImageContainerView, withInset: 3) - } - - // FIXME: Remove this once we refactor the OWSConversationSettingsViewController to Swift (use the HomeViewModel approach) - @objc(updateForThreadId:) - public func update(forThreadId threadId: String?) { - guard - let threadId: String = threadId, - let viewModel: SessionThreadViewModel = Storage.shared.read({ db -> SessionThreadViewModel? in - let userPublicKey: String = getUserHexEncodedPublicKey(db) - - return try SessionThreadViewModel - .conversationSettingsProfileQuery(threadId: threadId, userPublicKey: userPublicKey) - .fetchOne(db) - }) - else { return } - - update( - publicKey: viewModel.threadId, - profile: viewModel.profile, - additionalProfile: viewModel.additionalProfile, - threadVariant: viewModel.threadVariant, - openGroupProfilePictureData: viewModel.openGroupProfilePictureData, - useFallbackPicture: ( - viewModel.threadVariant == .openGroup && - viewModel.openGroupProfilePictureData == nil - ), - showMultiAvatarForClosedGroup: true - ) + additionalProfilePlaceholderImageView.pin(.bottom, to: .bottom, of: additionalImageContainerView, withInset: 5) } public func update( diff --git a/SignalUtilitiesKit/Utilities/UIView+OWS.swift b/SignalUtilitiesKit/Utilities/UIView+OWS.swift index 0d4542538..7a3dba20e 100644 --- a/SignalUtilitiesKit/Utilities/UIView+OWS.swift +++ b/SignalUtilitiesKit/Utilities/UIView+OWS.swift @@ -3,6 +3,7 @@ // import Foundation +import SessionUIKit public extension UIEdgeInsets { init(top: CGFloat, leading: CGFloat, bottom: CGFloat, trailing: CGFloat) { @@ -107,9 +108,16 @@ public extension UIView { constraints.append(subview.autoMatch(.height, to: .height, of: self, withMultiplier: 1.0, relation: .lessThanOrEqual)) return constraints } +} - func setShadow(radius: CGFloat = 2.0, opacity: Float = 0.66, offset: CGSize = .zero, color: CGColor = UIColor.black.cgColor) { - layer.shadowColor = color +public extension UIView { + func setShadow( + radius: CGFloat = 2.0, + opacity: Float = 0.66, + offset: CGSize = .zero, + color: ThemeValue = .black + ) { + layer.themeShadowColor = color layer.shadowRadius = radius layer.shadowOpacity = opacity layer.shadowOffset = offset @@ -376,29 +384,39 @@ public extension UIBarButtonItem { self.init(image: image, style: style, target: target, action: action) self.accessibilityIdentifier = accessibilityIdentifier + self.accessibilityLabel = accessibilityIdentifier + self.isAccessibilityElement = true } convenience init(image: UIImage?, landscapeImagePhone: UIImage?, style: UIBarButtonItem.Style, target: Any?, action: Selector?, accessibilityIdentifier: String) { self.init(image: image, landscapeImagePhone: landscapeImagePhone, style: style, target: target, action: action) self.accessibilityIdentifier = accessibilityIdentifier + self.accessibilityLabel = accessibilityIdentifier + self.isAccessibilityElement = true } convenience init(title: String?, style: UIBarButtonItem.Style, target: Any?, action: Selector?, accessibilityIdentifier: String) { self.init(title: title, style: style, target: target, action: action) self.accessibilityIdentifier = accessibilityIdentifier + self.accessibilityLabel = accessibilityIdentifier + self.isAccessibilityElement = true } convenience init(barButtonSystemItem systemItem: UIBarButtonItem.SystemItem, target: Any?, action: Selector?, accessibilityIdentifier: String) { self.init(barButtonSystemItem: systemItem, target: target, action: action) self.accessibilityIdentifier = accessibilityIdentifier + self.accessibilityLabel = accessibilityIdentifier + self.isAccessibilityElement = true } convenience init(customView: UIView, accessibilityIdentifier: String) { self.init(customView: customView) self.accessibilityIdentifier = accessibilityIdentifier + self.accessibilityLabel = accessibilityIdentifier + self.isAccessibilityElement = true } }