Fixed search performance, started styling in-conversaiton settings

Fixed a bug where the scroll to bottom button wasn't working
Fixed an issue where searching was running on the main thread (which could cause UI issues)
Updated the searching to interrupt the previous query when the search term changes
Updated the in-conversation settings to be use the new config-based approach (deleted the OWSConversationSettingsViewController)
This commit is contained in:
Morgan Pretty 2022-09-07 17:37:01 +10:00
parent b029728b6c
commit face9da02b
84 changed files with 3147 additions and 1754 deletions

View File

@ -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 = "<group>"; };
FD6A7A6C2818C61500035AC1 /* _002_SetupStandardJobs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _002_SetupStandardJobs.swift; sourceTree = "<group>"; };
FD705A91278D051200F16121 /* ReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReusableView.swift; sourceTree = "<group>"; };
FD7115EA28C5D78E00B47552 /* ThreadSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadSettingsViewModel.swift; sourceTree = "<group>"; };
FD7115ED28C5D79B00B47552 /* SettingsAvatarCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAvatarCell.swift; sourceTree = "<group>"; };
FD7115EF28C5D7DE00B47552 /* SettingHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingHeaderView.swift; sourceTree = "<group>"; };
FD7115F128C6CB3900B47552 /* _009_AddThreadIdToFTS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _009_AddThreadIdToFTS.swift; sourceTree = "<group>"; };
FD7115F328C71EB200B47552 /* ThreadDisappearingMessagesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadDisappearingMessagesViewModel.swift; sourceTree = "<group>"; };
FD7115F728C8151C00B47552 /* DisposableBarButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisposableBarButtonItem.swift; sourceTree = "<group>"; };
FD7115F928C8153400B47552 /* UIBarButtonItem+Combine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIBarButtonItem+Combine.swift"; sourceTree = "<group>"; };
FD7115FB28C8155800B47552 /* Publisher+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Publisher+Utilities.swift"; sourceTree = "<group>"; };
FD7115FD28C8202D00B47552 /* ReplaySubject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplaySubject.swift; sourceTree = "<group>"; };
FD7115FF28C8253500B47552 /* UIView+Combine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Combine.swift"; sourceTree = "<group>"; };
FD71160128C8255900B47552 /* UIControl+Combine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIControl+Combine.swift"; sourceTree = "<group>"; };
FD7162DA281B6C440060647B /* TypedTableAlias.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypedTableAlias.swift; sourceTree = "<group>"; };
FD716E6328502DDD00C96BF4 /* CallManagerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallManagerProtocol.swift; sourceTree = "<group>"; };
FD716E6528502EE200C96BF4 /* CurrentCallProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentCallProtocol.swift; sourceTree = "<group>"; };
@ -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 = "<group>";
@ -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 = "<group>";
@ -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 = "<group>";
};
FD7115EC28C5D79100B47552 /* Views */ = {
isa = PBXGroup;
children = (
);
path = Views;
sourceTree = "<group>";
};
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 = "<group>";
};
FD7115F628C8150D00B47552 /* Disposable Views */ = {
isa = PBXGroup;
children = (
FD7115F728C8151C00B47552 /* DisposableBarButtonItem.swift */,
);
path = "Disposable Views";
sourceTree = "<group>";
};
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 */,

View File

@ -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)

View File

@ -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)
}

View File

@ -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)

View File

@ -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()
}

View File

@ -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<Database?> = 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

View File

@ -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

View File

@ -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..<itemChangeInfo.visibleIndexPath.row)
.reduce(into: 0) { result, next in
result += (self?.tableView
.rectForRow(
at: IndexPath(
row: next,
section: itemChangeInfo.visibleIndexPath.section
// Only recalculate the contentOffset when loading new data if the amount of data
// loaded was smaller than 2 pages (this will prevent calculating the frames of
// a large number of cells when getting search results which are very far away
// only to instantly start scrolling making the calculation redundant)
if (abs(itemChangeInfo.visibleIndexPath.row - itemChangeInfo.oldVisibleIndexPath.row) <= (ConversationViewModel.pageSize * 2)) {
UIView.performWithoutAnimation {
let calculatedRowHeights: CGFloat = (0..<itemChangeInfo.visibleIndexPath.row)
.reduce(into: 0) { result, next in
result += (self?.tableView
.rectForRow(
at: IndexPath(
row: next,
section: itemChangeInfo.visibleIndexPath.section
)
)
)
.height)
.defaulting(to: 0)
}
let newTargetHeight: CGFloat? = self?.tableView
.rectForRow(at: itemChangeInfo.visibleIndexPath)
.height
let heightDiff: CGFloat = (oldCellHeight - (newTargetHeight ?? oldCellHeight))
self?.tableView.contentOffset.y += (calculatedRowHeights - heightDiff)
.height)
.defaulting(to: 0)
}
let newTargetHeight: CGFloat? = self?.tableView
.rectForRow(at: itemChangeInfo.visibleIndexPath)
.height
let heightDiff: CGFloat = (oldCellHeight - (newTargetHeight ?? oldCellHeight))
self?.tableView.contentOffset.y += (calculatedRowHeights - heightDiff)
}
}
if let focusedInteractionId: Int64 = self?.focusedInteractionId {
@ -1320,19 +1326,19 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
// MARK: - Search
func conversationSettingsDidRequestConversationSearch(_ conversationSettingsViewController: OWSConversationSettingsViewController) {
showSearchUI()
guard presentedViewController != nil else {
self.navigationController?.popToViewController(self, animated: true, completion: nil)
return
func popAllConversationSettingsViews(completion completionBlock: (() -> 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)

View File

@ -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()
}

View File

@ -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

View File

@ -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()
}

View File

@ -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)

View File

@ -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,

View File

@ -1,24 +0,0 @@
//
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
#import "OWSConversationSettingsViewDelegate.h"
#import "OWSTableViewController.h"
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
@class TSThread;
@class YapDatabaseConnection;
@interface OWSConversationSettingsViewController : OWSTableViewController
@property (nonatomic, weak) id<OWSConversationSettingsViewDelegate> 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

View File

@ -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 <Curve25519Kit/Curve25519.h>
#import <SignalCoreKit/NSDate+OWS.h>
#import <SignalUtilitiesKit/SignalUtilitiesKit-Swift.h>
#import <SignalUtilitiesKit/UIUtil.h>
@import ContactsUI;
@import PromiseKit;
NS_ASSUME_NONNULL_BEGIN
CGFloat kIconViewLength = 24;
@interface OWSConversationSettingsViewController () <OWSSheetViewControllerDelegate>
@property (nonatomic) NSString *threadId;
@property (nonatomic) NSString *threadName;
@property (nonatomic) BOOL isNoteToSelf;
@property (nonatomic) BOOL isClosedGroup;
@property (nonatomic) BOOL isOpenGroup;
@property (nonatomic) NSArray<NSNumber *> *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<NSString *> *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

View File

@ -1,16 +0,0 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
NS_ASSUME_NONNULL_BEGIN
@class OWSConversationSettingsViewController;
@class TSGroupModel;
@protocol OWSConversationSettingsViewDelegate <NSObject>
- (void)conversationSettingsDidRequestConversationSearch:(OWSConversationSettingsViewController *)conversationSettingsViewController;
@end
NS_ASSUME_NONNULL_END

View File

@ -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

View File

@ -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<ThreadDisappearingMessagesViewModel.NavButton, ThreadDisappearingMessagesViewModel.Section, ThreadDisappearingMessagesViewModel.Item> {
// 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<TimeInterval, Never>
// 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<Bool, Never> {
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
)
}
}
}

View File

@ -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<ThreadSettingsViewModel.NavButton, ThreadSettingsViewModel.Section, ThreadSettingsViewModel.Setting> {
// 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<NavState, Never> = {
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<String>) {
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)
}
}
)
}
}

View File

@ -31,6 +31,7 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
return [ result.map { ArraySection(model: .contactsAndGroups, elements: [$0]) } ]
.compactMap { $0 }
}()
private var readConnection: Atomic<Database?> = 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() {

View File

@ -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?

View File

@ -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?

View File

@ -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(

View File

@ -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"

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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?

View File

@ -7,7 +7,7 @@ import SessionUIKit
import SessionMessagingKit
import SessionUtilitiesKit
class ConversationSettingsViewModel: SettingsTableViewModel<ConversationSettingsViewModel.Section, ConversationSettingsViewModel.Section> {
class ConversationSettingsViewModel: SettingsTableViewModel<NoNav, ConversationSettingsViewModel.Section, ConversationSettingsViewModel.Section> {
// MARK: - Section
public enum Section: SettingSection {
@ -15,11 +15,18 @@ class ConversationSettingsViewModel: SettingsTableViewModel<ConversationSettings
case audioMessages
case blockedContacts
var title: String {
var title: String? {
switch self {
case .messageTrimming: return "CONVERSATION_SETTINGS_SECTION_MESSAGE_TRIMMING".localized()
case .audioMessages: return "CONVERSATION_SETTINGS_SECTION_AUDIO_MESSAGES".localized()
case .blockedContacts: return "" // No title
case .blockedContacts: return nil
}
}
var style: SettingSectionHeaderStyle {
switch self {
case .blockedContacts: return .padding
default: return .title
}
}
}
@ -71,9 +78,11 @@ class ConversationSettingsViewModel: SettingsTableViewModel<ConversationSettings
SettingInfo(
id: .blockedContacts,
title: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_TITLE".localized(),
action: .dangerPush(createDestination: {
BlockedContactsViewController()
})
action: .push(
showChevron: false,
textColor: .danger,
shouldHaveBackground: false
) { BlockedContactsViewController() }
)
]
)
@ -86,6 +95,4 @@ class ConversationSettingsViewModel: SettingsTableViewModel<ConversationSettings
public override func updateSettings(_ updatedSettings: [SectionModel]) {
self._settingsData = updatedSettings
}
public override func saveChanges() {}
}

View File

@ -7,7 +7,7 @@ import SessionUIKit
import SessionMessagingKit
import SessionUtilitiesKit
class HelpViewModel: SettingsTableViewModel<HelpViewModel.Section, HelpViewModel.Section> {
class HelpViewModel: SettingsTableViewModel<NoNav, HelpViewModel.Section, HelpViewModel.Section> {
// MARK: - Section
public enum Section: SettingSection {
@ -17,7 +17,7 @@ class HelpViewModel: SettingsTableViewModel<HelpViewModel.Section, HelpViewModel
case faq
case support
var title: String { "" } // No titles
var style: SettingSectionHeaderStyle { .padding }
}
// MARK: - Content
@ -127,8 +127,6 @@ class HelpViewModel: SettingsTableViewModel<HelpViewModel.Section, HelpViewModel
self._settingsData = updatedSettings
}
public override func saveChanges() {}
public static func shareLogs(
viewControllerToDismiss: UIViewController? = nil,
targetView: UIView? = nil,

View File

@ -7,13 +7,11 @@ import SessionUIKit
import SessionMessagingKit
import SessionUtilitiesKit
class NotificationContentViewModel: SettingsTableViewModel<NotificationSettingsViewModel.Section, Preferences.NotificationPreviewType> {
class NotificationContentViewModel: SettingsTableViewModel<NoNav, NotificationSettingsViewModel.Section, Preferences.NotificationPreviewType> {
// MARK: - Section
public enum Section: SettingSection {
case content
var title: String { return "" } // No title
}
// MARK: - Content
@ -67,6 +65,4 @@ class NotificationContentViewModel: SettingsTableViewModel<NotificationSettingsV
public override func updateSettings(_ updatedSettings: [SectionModel]) {
self._settingsData = updatedSettings
}
public override func saveChanges() {}
}

View File

@ -7,19 +7,26 @@ import SessionUIKit
import SessionMessagingKit
import SessionUtilitiesKit
class NotificationSettingsViewModel: SettingsTableViewModel<NotificationSettingsViewModel.Section, NotificationSettingsViewModel.Setting> {
// MARK: - Section
class NotificationSettingsViewModel: SettingsTableViewModel<NoNav, NotificationSettingsViewModel.Section, NotificationSettingsViewModel.Setting> {
// 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<NotificationSettings
public override func updateSettings(_ updatedSettings: [SectionModel]) {
self._settingsData = updatedSettings
}
public override func saveChanges() {}
}

View File

@ -1,17 +1,30 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import Combine
import GRDB
import DifferenceKit
import SessionUIKit
import SessionMessagingKit
import SessionUtilitiesKit
class NotificationSoundViewModel: SettingsTableViewModel<NotificationSettingsViewModel.Section, Preferences.Sound> {
class NotificationSoundViewModel: SettingsTableViewModel<NotificationSoundViewModel.NavButton, NotificationSettingsViewModel.Section, Preferences.Sound> {
// 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<Preferences.Sound?, Never> = CurrentValueSubject(nil)
// MARK: - Initialization
@ -24,12 +37,47 @@ class NotificationSoundViewModel: SettingsTableViewModel<NotificationSettingsVie
self.audioPlayer = nil
}
// MARK: - Section
// MARK: - Navigation
public enum Section: SettingSection {
case content
var title: String { return "" } // No title
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<Bool, Never> {
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<NotificationSettingsVie
/// 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] db -> [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<NotificationSettingsVie
return sound.displayName
}(),
action: .listSelection(
isSelected: { (self?.currentSelection == sound) },
storedSelection: (
sound == db[.defaultNotificationSound]
.defaulting(to: .defaultNotificationSound)
),
isSelected: { (self?.currentSelection.value == sound) },
storedSelection: (self?.storedSelection == sound),
shouldAutoSave: false,
selectValue: {
self?.currentSelection = sound
self?.currentSelection.send(sound)
// Play the sound (to prevent UI lag we dispatch this to the next
// run loop
DispatchQueue.main.async {
self?.audioPlayer?.stop()
self?.audioPlayer = SMKSound.audioPlayer(
for: sound.rawValue,
audioBehavior: .playback
self?.audioPlayer = Preferences.Sound.audioPlayer(
for: sound,
behavior: .playback
)
self?.audioPlayer?.isLooping = false
self?.audioPlayer?.play()
@ -106,22 +166,23 @@ class NotificationSoundViewModel: SettingsTableViewModel<NotificationSettingsVie
self._settingsData = updatedSettings
}
public override func saveChanges() {
guard let currentSelection: Preferences.Sound = self.currentSelection else { return }
private func saveChanges() {
guard let currentSelection: Preferences.Sound = self.currentSelection.value else { return }
let threadId: String? = self.threadId
Storage.shared.write { db in
db[.defaultNotificationSound] = currentSelection
guard let threadId: String = threadId else {
db[.defaultNotificationSound] = currentSelection
return
}
try SessionThread
.filter(id: threadId)
.updateAll(
db,
SessionThread.Columns.notificationSound.set(to: currentSelection)
)
}
}
}
// MARK: - Objective-C Support
// FIXME: Remove this once we ditch the per-thread notification sound
@objc class OWSNotificationSoundSettings: NSObject {
@objc static func create(with threadId: String?) -> UIViewController {
return SettingsTableViewController(
viewModel: NotificationSoundViewModel(threadId: threadId)
)
}
}

View File

@ -7,8 +7,18 @@ import SessionUIKit
import SessionMessagingKit
import SessionUtilitiesKit
class PrivacySettingsViewModel: SettingsTableViewModel<PrivacySettingsViewModel.Section, PrivacySettingsViewModel.Section> {
// MARK: - Section
class PrivacySettingsViewModel: SettingsTableViewModel<PrivacySettingsViewModel.NavButton, PrivacySettingsViewModel.Section, PrivacySettingsViewModel.Section> {
// 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<PrivacySettingsViewModel.
case linkPreviews
case calls
var title: String {
var title: String? {
switch self {
case .screenLock: return "PRIVACY_SECTION_SCREEN_SECURITY".localized()
case .screenshotNotifications: return "" // No title
case .screenshotNotifications: return nil
case .readReceipts: return "PRIVACY_SECTION_READ_RECEIPTS".localized()
case .typingIndicators: return "PRIVACY_SECTION_TYPING_INDICATORS".localized()
case .linkPreviews: return "PRIVACY_SECTION_LINK_PREVIEWS".localized()
case .calls: return "PRIVACY_SECTION_CALLS".localized()
}
}
var style: SettingSectionHeaderStyle {
switch self {
case .screenshotNotifications: return .padding
default: return .title
}
}
}
// MARK: - Content
@ -159,6 +176,4 @@ class PrivacySettingsViewModel: SettingsTableViewModel<PrivacySettingsViewModel.
public override func updateSettings(_ updatedSettings: [SectionModel]) {
self._settingsData = updatedSettings
}
public override func saveChanges() {}
}

View File

@ -1,19 +1,26 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import Combine
import GRDB
import DifferenceKit
import SessionUIKit
import SessionUtilitiesKit
import SignalUtilitiesKit
class SettingsTableViewController<Section: SettingSection, SettingItem: Hashable & Differentiable>: BaseVC, UITableViewDataSource, UITableViewDelegate {
typealias SectionModel = SettingsTableViewModel<Section, SettingItem>.SectionModel
protocol SettingsViewModelAccessible {
var viewModelType: AnyObject.Type { get }
}
class SettingsTableViewController<NavItemId: Equatable, Section: SettingSection, SettingItem: Hashable & Differentiable>: BaseVC, UITableViewDataSource, UITableViewDelegate, SettingsViewModelAccessible {
typealias SectionModel = SettingsTableViewModel<NavItemId, Section, SettingItem>.SectionModel
private let viewModel: SettingsTableViewModel<Section, SettingItem>
private let shouldShowCloseButton: Bool
private let viewModel: SettingsTableViewModel<NavItemId, Section, SettingItem>
private var dataChangeObservable: DatabaseCancellable?
private var hasLoadedInitialSettingsData: Bool = false
private var disposables: Set<AnyCancellable> = Set()
public var viewModelType: AnyObject.Type { return type(of: viewModel) }
// MARK: - Components
@ -24,6 +31,7 @@ class SettingsTableViewController<Section: SettingSection, SettingItem: Hashable
result.backgroundColor = .clear
result.showsVerticalScrollIndicator = false
result.showsHorizontalScrollIndicator = false
result.register(view: SettingsAvatarCell.self)
result.register(view: SettingsCell.self)
result.registerHeaderFooterView(view: SettingHeaderView.self)
result.dataSource = self
@ -38,9 +46,8 @@ class SettingsTableViewController<Section: SettingSection, SettingItem: Hashable
// MARK: - Initialization
init(viewModel: SettingsTableViewModel<Section, SettingItem>, shouldShowCloseButton: Bool = false) {
init(viewModel: SettingsTableViewModel<NavItemId, Section, SettingItem>) {
self.viewModel = viewModel
self.shouldShowCloseButton = shouldShowCloseButton
super.init(nibName: nil, bundle: nil)
}
@ -68,6 +75,7 @@ class SettingsTableViewController<Section: SettingSection, SettingItem: Hashable
view.addSubview(tableView)
setupLayout()
setupBinding()
// Notifications
NotificationCenter.default.addObserver(
@ -142,9 +150,6 @@ class SettingsTableViewController<Section: SettingSection, SettingItem: Hashable
return
}
// Navigation bar
updateNavigation(updatedData)
// Reload the table content (animate changes after the first load)
tableView.reload(
using: StagedChangeset(source: viewModel.settingsData, target: updatedData),
@ -160,41 +165,83 @@ class SettingsTableViewController<Section: SettingSection, SettingItem: Hashable
}
}
private func updateNavigation(_ data: [SectionModel]) {
guard
case .listSelection(_, _, let shouldAutoSave, _) = data.first?.elements.first?.action,
!shouldAutoSave
else {
navigationItem.leftBarButtonItem = {
guard shouldShowCloseButton else { return nil }
// MARK: - Binding
private func setupBinding() {
viewModel.isEditing
.receive(on: DispatchQueue.main)
.sink { [weak self] isEditing in
self?.setEditing(isEditing, animated: true)
return UIBarButtonItem(
image: UIImage(named: "X")?.withRenderingMode(.alwaysTemplate),
style: .plain,
target: self,
action: #selector(closePressed)
)
}()
navigationItem.rightBarButtonItem = nil
return
}
let isStoredSelected: Bool = (data.first?.elements ?? []).contains { info in
switch info.action {
case .listSelection(let isSelected, let storedSelection, _, _):
return (isSelected() && storedSelection)
default: return false
self?.tableView.visibleCells.forEach { cell in
switch cell {
case let settingsCell as SettingsCell:
settingsCell.update(isEditing: isEditing, animated: true)
case let avatarCell as SettingsAvatarCell:
avatarCell.update(isEditing: isEditing, animated: true)
default: break
}
}
}
}
.store(in: &disposables)
let cancelButton: UIBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelButtonPressed))
cancelButton.themeTintColor = .textPrimary
navigationItem.leftBarButtonItem = cancelButton
viewModel.leftNavItems
.receiveOnMainImmediately()
.sink { [weak self] maybeItems in
self?.navigationItem.setLeftBarButtonItems(
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)
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: SettingSection, SettingItem: Hashable
let section: SectionModel = viewModel.settingsData[indexPath.section]
let settingInfo: SettingInfo<SettingItem> = 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: SettingSection, SettingItem: Hashable
let settingInfo: SettingInfo<SettingItem> = 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<Section: SettingSection, SettingItem: Hashable
action(cell.rightActionButtonContainerView)
case .userDefaultsBool(let defaults, let key, let onChange):
case .userDefaultsBool(let defaults, let key, let isEnabled, let onChange):
guard isEnabled else { return }
defaults.set(!defaults.bool(forKey: key), forKey: key)
manuallyReload(indexPath: indexPath, section: section, settingInfo: settingInfo)
onChange?()
case .settingBool(let key, let confirmationInfo):
case .settingBool(let key, let confirmationInfo, let isEnabled):
guard isEnabled else { return }
guard
let confirmationInfo: ConfirmationModal.Info = confirmationInfo,
confirmationInfo.stateToShow.shouldShow(for: Storage.shared[key])
@ -290,11 +387,70 @@ class SettingsTableViewController<Section: SettingSection, SettingItem: Hashable
)
present(confirmationModal, animated: true, completion: nil)
case .push(let createDestination), .dangerPush(let createDestination),
.settingEnum(_, _, let createDestination):
case .customToggle(let value, let isEnabled, let confirmationInfo, let onChange):
guard isEnabled else { return }
let updatedValue: Bool = !value
let performChange: () -> () = { [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<SettingItem>)? = section.elements
.enumerated()
@ -306,7 +462,6 @@ class SettingsTableViewController<Section: SettingSection, SettingItem: Hashable
})
selectValue()
updateNavigation(viewModel.settingsData)
manuallyReload(indexPath: indexPath, section: section, settingInfo: settingInfo)
// Update the old selection as well
@ -335,8 +490,11 @@ class SettingsTableViewController<Section: SettingSection, SettingItem: Hashable
// Try update the existing cell to have a nice animation instead of reloading the cell
if let existingCell: SettingsCell = tableView.cellForRow(at: indexPath) as? SettingsCell {
existingCell.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,
@ -349,99 +507,4 @@ class SettingsTableViewController<Section: SettingSection, SettingItem: Hashable
tableView.reloadRows(at: [indexPath], with: .none)
}
}
// MARK: - NavigationActions
@objc private func closePressed() {
navigationController?.dismiss(animated: true)
}
@objc private func cancelButtonPressed() {
navigationController?.popViewController(animated: true)
}
@objc private func saveButtonPressed() {
viewModel.saveChanges()
navigationController?.popViewController(animated: true)
}
}
// MARK: - SettingHeaderView
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("init(coder:) has not been implemented")
}
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
fileprivate func update(with title: String, hasSeparator: Bool) {
titleLabel.text = title
titleLabel.isHidden = title.isEmpty
stackView.layoutMargins = UIEdgeInsets(
top: (title.isEmpty ? Values.verySmallSpacing : Values.mediumSpacing),
left: Values.largeSpacing,
bottom: (title.isEmpty ? Values.verySmallSpacing : Values.mediumSpacing),
right: Values.largeSpacing
)
emptyHeightConstraint.isActive = title.isEmpty
filledHeightConstraint.isActive = !title.isEmpty
separator.isHidden = !hasSeparator
self.layoutIfNeeded()
}
}

View File

@ -1,15 +1,63 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import UIKit.UIImage
import Combine
import GRDB
import DifferenceKit
import SessionUIKit
import SessionMessagingKit
import SessionUtilitiesKit
class SettingsTableViewModel<Section: SettingSection, SettingItem: Hashable & Differentiable> {
class SettingsTableViewModel<NavItemId: Equatable, Section: SettingSection, SettingItem: Hashable & Differentiable> {
typealias SectionModel = ArraySection<Section, SettingInfo<SettingItem>>
typealias ObservableData = ValueObservation<ValueReducers.RemoveDuplicates<ValueReducers.Fetch<[SectionModel]>>>
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<NavItemId, Never> = PassthroughSubject()
private let _isEditing: CurrentValueSubject<Bool, Never> = CurrentValueSubject(false)
lazy var isEditing: AnyPublisher<Bool, Never> = _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<Bool, Never> {
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<Section: SettingSection, SettingItem: Hashable & Di
preconditionFailure("abstract class - override in subclass")
}
func saveChanges() {
preconditionFailure("abstract class - override in subclass")
func setIsEditing(_ isEditing: Bool) {
_isEditing.send(isEditing)
}
}
// MARK: - NavItem
public enum NoNav: Equatable {}
extension SettingsTableViewModel {
public struct NavItem {
let id: NavItemId
let image: UIImage?
let style: UIBarButtonItem.Style
let systemItem: UIBarButtonItem.SystemItem?
let accessibilityIdentifier: String
// MARK: - Initialization
public init(
id: NavItemId,
systemItem: UIBarButtonItem.SystemItem?,
accessibilityIdentifier: String
) {
self.id = id
self.image = nil
self.style = .plain
self.systemItem = systemItem
self.accessibilityIdentifier = accessibilityIdentifier
}
public init(
id: NavItemId,
image: UIImage?,
style: UIBarButtonItem.Style,
accessibilityIdentifier: String
) {
self.id = id
self.image = image
self.style = style
self.systemItem = nil
self.accessibilityIdentifier = accessibilityIdentifier
}
// MARK: - Functions
public func createBarButtonItem() -> 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<ID: Hashable & Differentiable>: 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<ID: Hashable & Differentiable>: 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<ID: Hashable & Differentiable>: 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<ID>, rhs: SettingInfo<ID>) -> 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
}
}

View File

@ -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()
}
}

View File

@ -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<AnyCancellable> = 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<String, Never> {
return self.displayNameTextField.publisher(for: .editingChanged)
.map { textField -> String in (textField.text ?? "") }
.eraseToAnyPublisher()
}
var displayNameTapPublisher: AnyPublisher<Void, Never> {
return self.displayNameContainer.tapPublisher
.map { _ in () }
.eraseToAnyPublisher()
}
var profilePictureTapPublisher: AnyPublisher<Void, Never> {
return self.profilePictureView.tapPublisher
.map { _ in () }
.eraseToAnyPublisher()
}
}

View File

@ -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) {

View File

@ -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<String>
@ -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<String>, completion: @escaping (Set<String>) -> 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()

View File

@ -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
)
}

View File

@ -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
]
]
)

View File

@ -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)
}

View File

@ -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
}
}

View File

@ -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)

View File

@ -556,15 +556,16 @@ public extension Interaction {
static func idsForTermWithin(threadId: String, pattern: FTS5Pattern) -> SQLRequest<Int64> {
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let interactionFullTextSearch: SQL = SQL(stringLiteral: Interaction.fullTextSearchTableName)
let threadIdLiteral: SQL = SQL(stringLiteral: Interaction.Columns.threadId.name)
let request: SQLRequest<Int64> = """
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)
"""

View File

@ -193,19 +193,24 @@ public extension Profile {
// MARK: - GRDB Interactions
public extension Profile {
static func allContactProfiles(excluding idsToExclude: Set<String> = []) -> QueryInterfaceRequest<Profile> {
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<String> = [], excludeCurrentUser: Bool = true) -> [Profile] {
return Storage.shared
.read { db in
let idsToExclude: Set<String> = 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() })

View File

@ -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

View File

@ -813,8 +813,9 @@ public extension SessionThreadViewModel {
}
}
static func conversationSettingsProfileQuery(threadId: String, userPublicKey: String) -> AdaptedFetchRequest<SQLRequest<SessionThreadViewModel>> {
static func conversationSettingsQuery(threadId: String, userPublicKey: String) -> AdaptedFetchRequest<SQLRequest<SessionThreadViewModel>> {
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
let contact: TypedTableAlias<Contact> = TypedTableAlias()
let closedGroup: TypedTableAlias<ClosedGroup> = TypedTableAlias()
let groupMember: TypedTableAlias<GroupMember> = TypedTableAlias()
let openGroup: TypedTableAlias<OpenGroup> = 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<ViewModel> = """
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

View File

@ -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))
}
}
}

View File

@ -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
])
}
}
}

View File

@ -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
}
}

View File

@ -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)
}

View File

@ -0,0 +1,8 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import Combine
public class DisposableBarButtonItem: UIBarButtonItem {
public var disposables: Set<AnyCancellable> = Set()
}

View File

@ -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<Output, Failure> {
return multicast(subject: ReplaySubject(bufferSize))
.autoconnect()
.eraseToAnyPublisher()
}
func sink(into subject: PassthroughSubject<Output, Failure>, 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<Output, Failure> {
return self
.flatMap { value -> AnyPublisher<Output, Failure> 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<Output, Failure>?, includeCompletions: Bool = false) -> AnyCancellable {
guard let targetSubject: PassthroughSubject<Output, Failure> = subject else { return AnyCancellable {} }
return sink(into: targetSubject, includeCompletions: includeCompletions)
}
}

View File

@ -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<Output, Failure: Error>: Subject {
private var buffer: [Output] = [Output]()
private let bufferSize: Int
private let lock: NSRecursiveLock = NSRecursiveLock()
private var subscriptions = [ReplaySubjectSubscription<Output, Failure>]()
private var completion: Subscribers.Completion<Failure>?
// 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<Failure>) {
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<S>(subscriber: S) where S: Subscriber, Failure == S.Failure, Output == S.Input {
lock.lock(); defer { lock.unlock() }
let subscription = ReplaySubjectSubscription<Output, Failure>(downstream: AnySubscriber(subscriber))
subscriber.receive(subscription: subscription)
subscriptions.append(subscription)
subscription.replay(buffer, completion: completion)
}
}
// MARK: -
public final class ReplaySubjectSubscription<Output, Failure: Error>: Subscription {
private let downstream: AnySubscriber<Output, Failure>
private var isCompleted: Bool = false
private var demand: Subscribers.Demand = .none
// MARK: - Initialization
init(downstream: AnySubscriber<Output, Failure>) {
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<Failure>) {
guard !isCompleted else { return }
isCompleted = true
downstream.receive(completion: completion)
}
public func replay(_ values: [Output], completion: Subscribers.Completion<Failure>?) {
guard !isCompleted else { return }
values.forEach { value in receive(value) }
if let completion = completion {
receive(completion: completion)
}
}
}

View File

@ -0,0 +1,72 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import Combine
// MARK: -
public extension UIBarButtonItem {
final class Subscription<SubscriberType: Subscriber, Input: UIBarButtonItem>: 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<Output: UIBarButtonItem>: 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<S>(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<Self> {
return UIBarButtonItem.Publisher(output: self)
}
}

View File

@ -0,0 +1,71 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import Combine
// MARK: -
public extension UIControl {
final class Subscription<SubscriberType: Subscriber, Input: UIControl>: 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<Output: UIControl>: 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<S>(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<Self> {
return UIControl.Publisher(output: self, events: events)
}
}

View File

@ -0,0 +1,79 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import Combine
// MARK: -
public extension UIView {
final class Subscription<SubscriberType: Subscriber, Input: UIView>: 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<Output: UIView>: 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<S>(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<Self> {
return UIView.Publisher(output: self)
}
}

View File

@ -532,6 +532,7 @@ public class PagedDatabaseObserver<ObservedTable, T>: TransactionObserver where
for: targetId,
tableName: pagedTableName,
idColumn: idColumnName,
requiredJoinSQL: joinSQL,
orderSQL: orderSQL,
filterSQL: filterSQL
)
@ -581,6 +582,7 @@ public class PagedDatabaseObserver<ObservedTable, T>: TransactionObserver where
for: targetId,
tableName: pagedTableName,
idColumn: idColumnName,
requiredJoinSQL: joinSQL,
orderSQL: orderSQL,
filterSQL: filterSQL
)

View File

@ -30,4 +30,8 @@ public extension Database {
func makeFTS5Pattern<T>(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)
}
}

View File

@ -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

View File

@ -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

View File

@ -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(

View File

@ -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
}
}