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:
parent
b029728b6c
commit
face9da02b
|
@ -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 */,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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() {
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -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() {}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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() {}
|
||||
}
|
||||
|
|
|
@ -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() {}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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() {}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
]
|
||||
]
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
"""
|
||||
|
|
|
@ -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() })
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue