mirror of
https://github.com/oxen-io/session-ios.git
synced 2023-12-13 21:30:14 +01:00
Merge branch 'theming' into ipad-landscape-support
This commit is contained in:
commit
3dfa3ac5ee
443 changed files with 24702 additions and 23341 deletions
15
Podfile
15
Podfile
|
@ -26,6 +26,13 @@ abstract_target 'GlobalDependencies' do
|
|||
pod 'YYImage/libwebp', git: 'https://github.com/signalapp/YYImage'
|
||||
pod 'ZXingObjC'
|
||||
pod 'DifferenceKit'
|
||||
|
||||
target 'SessionTests' do
|
||||
inherit! :complete
|
||||
|
||||
pod 'Quick'
|
||||
pod 'Nimble'
|
||||
end
|
||||
end
|
||||
|
||||
# Dependencies to be included only in all extensions/frameworks
|
||||
|
@ -85,11 +92,13 @@ abstract_target 'GlobalDependencies' do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
target 'SessionUIKit' do
|
||||
pod 'GRDB.swift/SQLCipher'
|
||||
pod 'DifferenceKit'
|
||||
end
|
||||
end
|
||||
|
||||
# No dependencies for this
|
||||
target 'SessionUIKit'
|
||||
|
||||
# Actions to perform post-install
|
||||
post_install do |installer|
|
||||
enable_whole_module_optimization_for_crypto_swift(installer)
|
||||
|
|
|
@ -242,6 +242,6 @@ SPEC CHECKSUMS:
|
|||
YYImage: f1ddd15ac032a58b78bbed1e012b50302d318331
|
||||
ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb
|
||||
|
||||
PODFILE CHECKSUM: 0e694576fbda3c10bbc762998183d97142b85896
|
||||
PODFILE CHECKSUM: 430e3b57d986dc8890415294fc6cf5e4eabfce3e
|
||||
|
||||
COCOAPODS: 1.11.3
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -20,6 +20,20 @@
|
|||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "NO"
|
||||
buildForProfiling = "NO"
|
||||
buildForArchiving = "NO"
|
||||
buildForAnalyzing = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "FD71160828D00BAE00B47552"
|
||||
BuildableName = "SessionTests.xctest"
|
||||
BlueprintName = "SessionTests"
|
||||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
|
@ -128,6 +142,18 @@
|
|||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES"
|
||||
testExecutionOrdering = "random">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "FD71160828D00BAE00B47552"
|
||||
BuildableName = "SessionTests.xctest"
|
||||
BlueprintName = "SessionTests"
|
||||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import MediaPlayer
|
||||
import WebRTC
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
import UIKit
|
||||
import MediaPlayer
|
||||
|
||||
final class CallVC: UIViewController, VideoPreviewDelegate {
|
||||
let call: SessionCall
|
||||
|
@ -19,36 +21,52 @@ final class CallVC: UIViewController, VideoPreviewDelegate {
|
|||
return result
|
||||
}()
|
||||
|
||||
// MARK: UI Components
|
||||
// MARK: - UI Components
|
||||
|
||||
private lazy var localVideoView: LocalVideoView = {
|
||||
let result = LocalVideoView()
|
||||
result.clipsToBounds = true
|
||||
result.themeBackgroundColor = .backgroundSecondary
|
||||
result.isHidden = !call.isVideoEnabled
|
||||
result.layer.cornerRadius = UIDevice.current.isIPad ? 20 : 10
|
||||
result.layer.masksToBounds = true
|
||||
result.set(.width, to: LocalVideoView.width)
|
||||
result.set(.height, to: LocalVideoView.height)
|
||||
result.makeViewDraggable()
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var remoteVideoView: RemoteVideoView = {
|
||||
let result = RemoteVideoView()
|
||||
result.alpha = 0
|
||||
result.backgroundColor = .black
|
||||
result.themeBackgroundColor = .backgroundPrimary
|
||||
result.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleRemoteVieioViewTapped)))
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var fadeView: UIView = {
|
||||
let height: CGFloat = ((UIApplication.shared.keyWindow?.safeAreaInsets.top).map { $0 + Values.veryLargeSpacing } ?? 64)
|
||||
|
||||
let result = UIView()
|
||||
let height: CGFloat = 64
|
||||
var frame = UIScreen.main.bounds
|
||||
frame.size.height = height
|
||||
|
||||
let layer = CAGradientLayer()
|
||||
layer.frame = frame
|
||||
layer.colors = [ UIColor(hex: 0x000000).withAlphaComponent(0.4).cgColor, UIColor(hex: 0x000000).withAlphaComponent(0).cgColor ]
|
||||
result.layer.insertSublayer(layer, at: 0)
|
||||
result.set(.height, to: height)
|
||||
|
||||
ThemeManager.onThemeChange(observer: result) { [weak layer] theme, _ in
|
||||
guard let backgroundPrimary: UIColor = theme.color(for: .backgroundPrimary) else { return }
|
||||
|
||||
layer?.colors = [
|
||||
backgroundPrimary.withAlphaComponent(0.4).cgColor,
|
||||
backgroundPrimary.withAlphaComponent(0).cgColor
|
||||
]
|
||||
}
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
|
@ -61,42 +79,60 @@ final class CallVC: UIViewController, VideoPreviewDelegate {
|
|||
result.layer.cornerRadius = radius
|
||||
result.layer.masksToBounds = true
|
||||
result.contentMode = .scaleAspectFill
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var minimizeButton: UIButton = {
|
||||
let result = UIButton(type: .custom)
|
||||
result.setImage(
|
||||
UIImage(named: "Minimize")?
|
||||
.withRenderingMode(.alwaysTemplate),
|
||||
for: .normal
|
||||
)
|
||||
result.themeTintColor = .textPrimary
|
||||
result.addTarget(self, action: #selector(minimize), for: UIControl.Event.touchUpInside)
|
||||
|
||||
result.isHidden = !call.hasConnected
|
||||
let image = UIImage(named: "Minimize")!.withTint(.white)
|
||||
result.setImage(image, for: UIControl.State.normal)
|
||||
result.set(.width, to: 60)
|
||||
result.set(.height, to: 60)
|
||||
result.addTarget(self, action: #selector(minimize), for: UIControl.Event.touchUpInside)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var answerButton: UIButton = {
|
||||
let result = UIButton(type: .custom)
|
||||
result.isHidden = call.hasStartedConnecting
|
||||
let image = UIImage(named: "AnswerCall")!.withTint(.white)
|
||||
result.setImage(image, for: UIControl.State.normal)
|
||||
result.set(.width, to: 60)
|
||||
result.set(.height, to: 60)
|
||||
result.backgroundColor = Colors.accent
|
||||
result.setImage(
|
||||
UIImage(named: "AnswerCall")?
|
||||
.withRenderingMode(.alwaysTemplate),
|
||||
for: .normal
|
||||
)
|
||||
result.themeTintColor = .white
|
||||
result.themeBackgroundColor = .callAccept_background
|
||||
result.layer.cornerRadius = 30
|
||||
result.addTarget(self, action: #selector(answerCall), for: UIControl.Event.touchUpInside)
|
||||
|
||||
result.isHidden = call.hasStartedConnecting
|
||||
result.set(.width, to: 60)
|
||||
result.set(.height, to: 60)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var hangUpButton: UIButton = {
|
||||
let result = UIButton(type: .custom)
|
||||
let image = UIImage(named: "EndCall")!.withTint(.white)
|
||||
result.setImage(image, for: UIControl.State.normal)
|
||||
result.set(.width, to: 60)
|
||||
result.set(.height, to: 60)
|
||||
result.backgroundColor = Colors.destructive
|
||||
result.setImage(
|
||||
UIImage(named: "EndCall")?
|
||||
.withRenderingMode(.alwaysTemplate),
|
||||
for: .normal
|
||||
)
|
||||
result.themeTintColor = .white
|
||||
result.themeBackgroundColor = .callDecline_background
|
||||
result.layer.cornerRadius = 30
|
||||
result.addTarget(self, action: #selector(endCall), for: UIControl.Event.touchUpInside)
|
||||
result.set(.width, to: 60)
|
||||
result.set(.height, to: 60)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
|
@ -104,58 +140,83 @@ final class CallVC: UIViewController, VideoPreviewDelegate {
|
|||
let result = UIStackView(arrangedSubviews: [hangUpButton, answerButton])
|
||||
result.axis = .horizontal
|
||||
result.spacing = Values.veryLargeSpacing * 2 + 40
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var switchCameraButton: UIButton = {
|
||||
let result = UIButton(type: .custom)
|
||||
result.isEnabled = call.isVideoEnabled
|
||||
let image = UIImage(named: "SwitchCamera")!.withTint(.white)
|
||||
result.setImage(image, for: UIControl.State.normal)
|
||||
result.set(.width, to: 60)
|
||||
result.set(.height, to: 60)
|
||||
result.backgroundColor = UIColor(hex: 0x1F1F1F)
|
||||
result.setImage(
|
||||
UIImage(named: "SwitchCamera")?
|
||||
.withRenderingMode(.alwaysTemplate),
|
||||
for: .normal
|
||||
)
|
||||
result.themeTintColor = .textPrimary
|
||||
result.themeBackgroundColor = .backgroundSecondary
|
||||
result.layer.cornerRadius = 30
|
||||
result.addTarget(self, action: #selector(switchCamera), for: UIControl.Event.touchUpInside)
|
||||
result.set(.width, to: 60)
|
||||
result.set(.height, to: 60)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var switchAudioButton: UIButton = {
|
||||
let result = UIButton(type: .custom)
|
||||
let image = UIImage(named: "AudioOff")!.withTint(.white)
|
||||
result.setImage(image, for: UIControl.State.normal)
|
||||
result.set(.width, to: 60)
|
||||
result.set(.height, to: 60)
|
||||
result.backgroundColor = call.isMuted ? Colors.destructive : UIColor(hex: 0x1F1F1F)
|
||||
result.setImage(
|
||||
UIImage(named: "AudioOff")?
|
||||
.withRenderingMode(.alwaysTemplate),
|
||||
for: .normal
|
||||
)
|
||||
result.themeTintColor = (call.isMuted ?
|
||||
.white :
|
||||
.textPrimary
|
||||
)
|
||||
result.themeBackgroundColor = (call.isMuted ?
|
||||
.danger :
|
||||
.backgroundSecondary
|
||||
)
|
||||
result.layer.cornerRadius = 30
|
||||
result.addTarget(self, action: #selector(switchAudio), for: UIControl.Event.touchUpInside)
|
||||
result.set(.width, to: 60)
|
||||
result.set(.height, to: 60)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var videoButton: UIButton = {
|
||||
let result = UIButton(type: .custom)
|
||||
let image = UIImage(named: "VideoCall")?.withRenderingMode(.alwaysTemplate)
|
||||
result.setImage(image, for: UIControl.State.normal)
|
||||
result.set(.width, to: 60)
|
||||
result.set(.height, to: 60)
|
||||
result.tintColor = .white
|
||||
result.backgroundColor = UIColor(hex: 0x1F1F1F)
|
||||
result.setImage(
|
||||
UIImage(named: "VideoCall")?
|
||||
.withRenderingMode(.alwaysTemplate),
|
||||
for: .normal
|
||||
)
|
||||
result.themeTintColor = .textPrimary
|
||||
result.themeBackgroundColor = .backgroundSecondary
|
||||
result.layer.cornerRadius = 30
|
||||
result.addTarget(self, action: #selector(operateCamera), for: UIControl.Event.touchUpInside)
|
||||
result.set(.width, to: 60)
|
||||
result.set(.height, to: 60)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var volumeView: MPVolumeView = {
|
||||
let result = MPVolumeView()
|
||||
let image = UIImage(named: "Speaker")?.withRenderingMode(.alwaysTemplate)
|
||||
result.showsVolumeSlider = false
|
||||
result.showsRouteButton = true
|
||||
result.setRouteButtonImage(image, for: UIControl.State.normal)
|
||||
result.setRouteButtonImage(
|
||||
UIImage(named: "Speaker")?
|
||||
.withRenderingMode(.alwaysTemplate),
|
||||
for: .normal
|
||||
)
|
||||
result.themeTintColor = .textPrimary
|
||||
result.themeBackgroundColor = .backgroundSecondary
|
||||
result.layer.cornerRadius = 30
|
||||
result.set(.width, to: 60)
|
||||
result.set(.height, to: 60)
|
||||
result.tintColor = .white
|
||||
result.backgroundColor = UIColor(hex: 0x1F1F1F)
|
||||
result.layer.cornerRadius = 30
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
|
@ -163,37 +224,43 @@ final class CallVC: UIViewController, VideoPreviewDelegate {
|
|||
let result = UIStackView(arrangedSubviews: [switchCameraButton, videoButton, switchAudioButton, volumeView])
|
||||
result.axis = .horizontal
|
||||
result.spacing = Values.veryLargeSpacing
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var titleLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
result.textColor = .white
|
||||
let result: UILabel = UILabel()
|
||||
result.font = .boldSystemFont(ofSize: Values.veryLargeFontSize)
|
||||
result.themeTextColor = .textPrimary
|
||||
result.textAlignment = .center
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var callInfoLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
result.isHidden = call.hasConnected
|
||||
result.textColor = .white
|
||||
let result: UILabel = UILabel()
|
||||
result.font = .boldSystemFont(ofSize: Values.veryLargeFontSize)
|
||||
result.themeTextColor = .textPrimary
|
||||
result.textAlignment = .center
|
||||
result.isHidden = call.hasConnected
|
||||
|
||||
if call.hasStartedConnecting { result.text = "Connecting..." }
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var callDurationLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
result.isHidden = true
|
||||
result.textColor = .white
|
||||
result.font = .boldSystemFont(ofSize: Values.veryLargeFontSize)
|
||||
result.themeTextColor = .textPrimary
|
||||
result.textAlignment = .center
|
||||
result.isHidden = true
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Lifecycle
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(for call: SessionCall) {
|
||||
self.call = call
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
@ -208,6 +275,7 @@ final class CallVC: UIViewController, VideoPreviewDelegate {
|
|||
UIView.animate(withDuration: 0.25) {
|
||||
self.remoteVideoView.alpha = isEnabled ? 1 : 0
|
||||
}
|
||||
|
||||
if self.callInfoLabel.alpha < 0.5 {
|
||||
UIView.animate(withDuration: 0.25) {
|
||||
self.operationPanel.alpha = 1
|
||||
|
@ -217,45 +285,60 @@ final class CallVC: UIViewController, VideoPreviewDelegate {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.call.hasStartedConnectingDidChange = {
|
||||
DispatchQueue.main.async {
|
||||
self.callInfoLabel.text = "Connecting..."
|
||||
self.answerButton.alpha = 0
|
||||
UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 1, initialSpringVelocity: 1, options: .curveEaseIn, animations: {
|
||||
self.answerButton.isHidden = true
|
||||
}, completion: nil)
|
||||
|
||||
UIView.animate(
|
||||
withDuration: 0.5,
|
||||
delay: 0,
|
||||
usingSpringWithDamping: 1,
|
||||
initialSpringVelocity: 1,
|
||||
options: .curveEaseIn,
|
||||
animations: { [weak self] in
|
||||
self?.answerButton.isHidden = true
|
||||
},
|
||||
completion: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
self.call.hasConnectedDidChange = {
|
||||
|
||||
self.call.hasConnectedDidChange = { [weak self] in
|
||||
DispatchQueue.main.async {
|
||||
CallRingTonePlayer.shared.stopPlayingRingTone()
|
||||
self.callInfoLabel.text = "Connected"
|
||||
self.minimizeButton.isHidden = false
|
||||
self.durationTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
|
||||
self.updateDuration()
|
||||
|
||||
self?.callInfoLabel.text = "Connected"
|
||||
self?.minimizeButton.isHidden = false
|
||||
self?.durationTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
|
||||
self?.updateDuration()
|
||||
}
|
||||
self.callInfoLabel.isHidden = true
|
||||
self.callDurationLabel.isHidden = false
|
||||
self?.callInfoLabel.isHidden = true
|
||||
self?.callDurationLabel.isHidden = false
|
||||
}
|
||||
}
|
||||
self.call.hasEndedDidChange = {
|
||||
|
||||
self.call.hasEndedDidChange = { [weak self] in
|
||||
DispatchQueue.main.async {
|
||||
self.durationTimer?.invalidate()
|
||||
self.durationTimer = nil
|
||||
self.handleEndCallMessage()
|
||||
self?.durationTimer?.invalidate()
|
||||
self?.durationTimer = nil
|
||||
self?.handleEndCallMessage()
|
||||
}
|
||||
}
|
||||
self.call.hasStartedReconnecting = {
|
||||
|
||||
self.call.hasStartedReconnecting = { [weak self] in
|
||||
DispatchQueue.main.async {
|
||||
self.callInfoLabel.isHidden = false
|
||||
self.callDurationLabel.isHidden = true
|
||||
self.callInfoLabel.text = "Reconnecting..."
|
||||
self?.callInfoLabel.isHidden = false
|
||||
self?.callDurationLabel.isHidden = true
|
||||
self?.callInfoLabel.text = "Reconnecting..."
|
||||
}
|
||||
}
|
||||
self.call.hasReconnected = {
|
||||
|
||||
self.call.hasReconnected = { [weak self] in
|
||||
DispatchQueue.main.async {
|
||||
self.callInfoLabel.isHidden = true
|
||||
self.callDurationLabel.isHidden = false
|
||||
self?.callInfoLabel.isHidden = true
|
||||
self?.callDurationLabel.isHidden = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -264,19 +347,24 @@ final class CallVC: UIViewController, VideoPreviewDelegate {
|
|||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .black
|
||||
|
||||
view.themeBackgroundColor = .backgroundPrimary
|
||||
|
||||
setUpViewHierarchy()
|
||||
|
||||
if shouldRestartCamera { cameraManager.prepare() }
|
||||
|
||||
touch(call.videoCapturer)
|
||||
titleLabel.text = self.call.contactName
|
||||
AppEnvironment.shared.callManager.startCall(call) { error in
|
||||
AppEnvironment.shared.callManager.startCall(call) { [weak self] error in
|
||||
DispatchQueue.main.async {
|
||||
if let _ = error {
|
||||
self.callInfoLabel.text = "Can't start a call."
|
||||
self.endCall()
|
||||
} else {
|
||||
self.callInfoLabel.text = "Ringing..."
|
||||
self.answerButton.isHidden = true
|
||||
self?.callInfoLabel.text = "Can't start a call."
|
||||
self?.endCall()
|
||||
}
|
||||
else {
|
||||
self?.callInfoLabel.text = "Ringing..."
|
||||
self?.answerButton.isHidden = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -305,41 +393,50 @@ final class CallVC: UIViewController, VideoPreviewDelegate {
|
|||
// Profile picture container
|
||||
let profilePictureContainer = UIView()
|
||||
view.addSubview(profilePictureContainer)
|
||||
|
||||
// Remote video view
|
||||
call.attachRemoteVideoRenderer(remoteVideoView)
|
||||
view.addSubview(remoteVideoView)
|
||||
remoteVideoView.translatesAutoresizingMaskIntoConstraints = false
|
||||
remoteVideoView.pin(to: view)
|
||||
|
||||
// Local video view
|
||||
call.attachLocalVideoRenderer(localVideoView)
|
||||
|
||||
// Fade view
|
||||
view.addSubview(fadeView)
|
||||
fadeView.translatesAutoresizingMaskIntoConstraints = false
|
||||
fadeView.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.top, UIView.HorizontalEdge.right ], to: view)
|
||||
|
||||
// Minimize button
|
||||
view.addSubview(minimizeButton)
|
||||
minimizeButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
minimizeButton.pin(.left, to: .left, of: view)
|
||||
minimizeButton.pin(.top, to: .top, of: view, withInset: 32)
|
||||
|
||||
// Title label
|
||||
view.addSubview(titleLabel)
|
||||
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
titleLabel.center(.vertical, in: minimizeButton)
|
||||
titleLabel.center(.horizontal, in: view)
|
||||
|
||||
// Response Panel
|
||||
view.addSubview(responsePanel)
|
||||
responsePanel.center(.horizontal, in: view)
|
||||
responsePanel.pin(.bottom, to: .bottom, of: view, withInset: -Values.newConversationButtonBottomOffset)
|
||||
responsePanel.pin(.bottom, to: .bottom, of: view.safeAreaLayoutGuide, withInset: -Values.smallSpacing)
|
||||
|
||||
// Operation Panel
|
||||
view.addSubview(operationPanel)
|
||||
operationPanel.center(.horizontal, in: view)
|
||||
operationPanel.pin(.bottom, to: .top, of: responsePanel, withInset: -Values.veryLargeSpacing)
|
||||
|
||||
// Profile picture view
|
||||
profilePictureContainer.pin(.top, to: .bottom, of: fadeView)
|
||||
profilePictureContainer.pin(.bottom, to: .top, of: operationPanel)
|
||||
profilePictureContainer.pin([ UIView.HorizontalEdge.left, UIView.HorizontalEdge.right ], to: view)
|
||||
profilePictureContainer.addSubview(profilePictureView)
|
||||
profilePictureView.center(in: profilePictureContainer)
|
||||
|
||||
// Call info label
|
||||
let callInfoLabelContainer = UIView()
|
||||
view.addSubview(callInfoLabelContainer)
|
||||
|
@ -355,25 +452,28 @@ final class CallVC: UIViewController, VideoPreviewDelegate {
|
|||
}
|
||||
|
||||
private func addLocalVideoView() {
|
||||
let safeAreaInsets = UIApplication.shared.keyWindow!.safeAreaInsets
|
||||
let window = CurrentAppContext().mainWindow!
|
||||
window.addSubview(localVideoView)
|
||||
let safeAreaInsets = UIApplication.shared.keyWindow?.safeAreaInsets
|
||||
CurrentAppContext().mainWindow?.addSubview(localVideoView)
|
||||
localVideoView.autoPinEdge(toSuperviewEdge: .right, withInset: Values.smallSpacing)
|
||||
let topMargin = safeAreaInsets.top + Values.veryLargeSpacing
|
||||
let topMargin = (safeAreaInsets?.top ?? 0) + Values.veryLargeSpacing
|
||||
localVideoView.autoPinEdge(toSuperviewEdge: .top, withInset: topMargin)
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
if (call.isVideoEnabled && shouldRestartCamera) { cameraManager.start() }
|
||||
|
||||
shouldRestartCamera = true
|
||||
addLocalVideoView()
|
||||
remoteVideoView.alpha = call.isRemoteVideoEnabled ? 1 : 0
|
||||
remoteVideoView.alpha = (call.isRemoteVideoEnabled ? 1 : 0)
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
|
||||
if (call.isVideoEnabled && shouldRestartCamera) { cameraManager.stop() }
|
||||
|
||||
localVideoView.removeFromSuperview()
|
||||
}
|
||||
|
||||
|
@ -386,9 +486,10 @@ final class CallVC: UIViewController, VideoPreviewDelegate {
|
|||
|
||||
@objc func didChangeDeviceOrientation(notification: Notification) {
|
||||
if UIDevice.current.isIPad { return }
|
||||
|
||||
|
||||
func rotateAllButtons(rotationAngle: CGFloat) {
|
||||
let transform = CGAffineTransform(rotationAngle: rotationAngle)
|
||||
|
||||
UIView.animate(withDuration: 0.2) {
|
||||
self.answerButton.transform = transform
|
||||
self.hangUpButton.transform = transform
|
||||
|
@ -400,16 +501,11 @@ final class CallVC: UIViewController, VideoPreviewDelegate {
|
|||
}
|
||||
|
||||
switch UIDevice.current.orientation {
|
||||
case .portrait:
|
||||
rotateAllButtons(rotationAngle: 0)
|
||||
case .portraitUpsideDown:
|
||||
rotateAllButtons(rotationAngle: .pi)
|
||||
case .landscapeLeft:
|
||||
rotateAllButtons(rotationAngle: .halfPi)
|
||||
case .landscapeRight:
|
||||
rotateAllButtons(rotationAngle: .pi + .halfPi)
|
||||
default:
|
||||
break
|
||||
case .portrait: rotateAllButtons(rotationAngle: 0)
|
||||
case .portraitUpsideDown: rotateAllButtons(rotationAngle: .pi)
|
||||
case .landscapeLeft: rotateAllButtons(rotationAngle: .halfPi)
|
||||
case .landscapeRight: rotateAllButtons(rotationAngle: .pi + .halfPi)
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -422,39 +518,42 @@ final class CallVC: UIViewController, VideoPreviewDelegate {
|
|||
SNLog("[Calls] Ending call.")
|
||||
self.callInfoLabel.isHidden = false
|
||||
self.callDurationLabel.isHidden = true
|
||||
callInfoLabel.text = "Call Ended"
|
||||
self.callInfoLabel.text = "Call Ended"
|
||||
|
||||
UIView.animate(withDuration: 0.25) {
|
||||
self.remoteVideoView.alpha = 0
|
||||
self.operationPanel.alpha = 1
|
||||
self.responsePanel.alpha = 1
|
||||
self.callInfoLabel.alpha = 1
|
||||
}
|
||||
Timer.scheduledTimer(withTimeInterval: 2, repeats: false) { _ in
|
||||
self.conversationVC?.showInputAccessoryView()
|
||||
self.presentingViewController?.dismiss(animated: true, completion: nil)
|
||||
|
||||
Timer.scheduledTimer(withTimeInterval: 2, repeats: false) { [weak self] _ in
|
||||
self?.conversationVC?.showInputAccessoryView()
|
||||
self?.presentingViewController?.dismiss(animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func answerCall() {
|
||||
AppEnvironment.shared.callManager.answerCall(call) { error in
|
||||
AppEnvironment.shared.callManager.answerCall(call) { [weak self] error in
|
||||
DispatchQueue.main.async {
|
||||
if let _ = error {
|
||||
self.callInfoLabel.text = "Can't answer the call."
|
||||
self.endCall()
|
||||
self?.callInfoLabel.text = "Can't answer the call."
|
||||
self?.endCall()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func endCall() {
|
||||
AppEnvironment.shared.callManager.endCall(call) { error in
|
||||
AppEnvironment.shared.callManager.endCall(call) { [weak self] error in
|
||||
if let _ = error {
|
||||
self.call.endSessionCall()
|
||||
self?.call.endSessionCall()
|
||||
AppEnvironment.shared.callManager.reportCurrentCallEnded(reason: nil)
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.conversationVC?.showInputAccessoryView()
|
||||
self.presentingViewController?.dismiss(animated: true, completion: nil)
|
||||
self?.conversationVC?.showInputAccessoryView()
|
||||
self?.presentingViewController?.dismiss(animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -464,26 +563,31 @@ final class CallVC: UIViewController, VideoPreviewDelegate {
|
|||
duration += 1
|
||||
}
|
||||
|
||||
// MARK: Minimize to a floating view
|
||||
// MARK: - Minimize to a floating view
|
||||
|
||||
@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)
|
||||
}
|
||||
|
||||
// MARK: Video and Audio
|
||||
// MARK: - Video and Audio
|
||||
|
||||
@objc private func operateCamera() {
|
||||
if (call.isVideoEnabled) {
|
||||
localVideoView.isHidden = true
|
||||
cameraManager.stop()
|
||||
videoButton.tintColor = .white
|
||||
videoButton.backgroundColor = UIColor(hex: 0x1F1F1F)
|
||||
videoButton.themeTintColor = .textPrimary
|
||||
videoButton.themeBackgroundColor = .backgroundSecondary
|
||||
switchCameraButton.isEnabled = false
|
||||
call.isVideoEnabled = false
|
||||
} else {
|
||||
guard requestCameraPermissionIfNeeded() else { return }
|
||||
}
|
||||
else {
|
||||
guard Permissions.requestCameraPermissionIfNeeded() else { return }
|
||||
let previewVC = VideoPreviewVC()
|
||||
previewVC.delegate = self
|
||||
present(previewVC, animated: true, completion: nil)
|
||||
|
@ -494,8 +598,8 @@ final class CallVC: UIViewController, VideoPreviewDelegate {
|
|||
localVideoView.isHidden = false
|
||||
cameraManager.prepare()
|
||||
cameraManager.start()
|
||||
videoButton.tintColor = UIColor(hex: 0x1F1F1F)
|
||||
videoButton.backgroundColor = .white
|
||||
videoButton.themeTintColor = .backgroundSecondary
|
||||
videoButton.themeBackgroundColor = .textPrimary
|
||||
switchCameraButton.isEnabled = true
|
||||
call.isVideoEnabled = true
|
||||
}
|
||||
|
@ -506,10 +610,13 @@ final class CallVC: UIViewController, VideoPreviewDelegate {
|
|||
|
||||
@objc private func switchAudio() {
|
||||
if call.isMuted {
|
||||
switchAudioButton.backgroundColor = UIColor(hex: 0x1F1F1F)
|
||||
switchAudioButton.themeTintColor = .textPrimary
|
||||
switchAudioButton.themeBackgroundColor = .backgroundSecondary
|
||||
call.isMuted = false
|
||||
} else {
|
||||
switchAudioButton.backgroundColor = Colors.destructive
|
||||
}
|
||||
else {
|
||||
switchAudioButton.themeTintColor = .white
|
||||
switchAudioButton.themeBackgroundColor = .danger
|
||||
call.isMuted = true
|
||||
}
|
||||
}
|
||||
|
@ -519,41 +626,48 @@ final class CallVC: UIViewController, VideoPreviewDelegate {
|
|||
let currentRoute = currentSession.currentRoute
|
||||
if let currentOutput = currentRoute.outputs.first {
|
||||
if let latestKnownAudioOutputDeviceName = latestKnownAudioOutputDeviceName, currentOutput.portName == latestKnownAudioOutputDeviceName { return }
|
||||
|
||||
latestKnownAudioOutputDeviceName = currentOutput.portName
|
||||
|
||||
switch currentOutput.portType {
|
||||
case .builtInSpeaker:
|
||||
let image = UIImage(named: "Speaker")?.withRenderingMode(.alwaysTemplate)
|
||||
volumeView.setRouteButtonImage(image, for: .normal)
|
||||
volumeView.tintColor = UIColor(hex: 0x1F1F1F)
|
||||
volumeView.backgroundColor = .white
|
||||
case .headphones:
|
||||
let image = UIImage(named: "Headsets")?.withRenderingMode(.alwaysTemplate)
|
||||
volumeView.setRouteButtonImage(image, for: .normal)
|
||||
volumeView.tintColor = UIColor(hex: 0x1F1F1F)
|
||||
volumeView.backgroundColor = .white
|
||||
case .bluetoothLE: fallthrough
|
||||
case .bluetoothA2DP:
|
||||
let image = UIImage(named: "Bluetooth")?.withRenderingMode(.alwaysTemplate)
|
||||
volumeView.setRouteButtonImage(image, for: .normal)
|
||||
volumeView.tintColor = UIColor(hex: 0x1F1F1F)
|
||||
volumeView.backgroundColor = .white
|
||||
case .bluetoothHFP:
|
||||
let image = UIImage(named: "Airpods")?.withRenderingMode(.alwaysTemplate)
|
||||
volumeView.setRouteButtonImage(image, for: .normal)
|
||||
volumeView.tintColor = UIColor(hex: 0x1F1F1F)
|
||||
volumeView.backgroundColor = .white
|
||||
case .builtInReceiver: fallthrough
|
||||
default:
|
||||
let image = UIImage(named: "Speaker")?.withRenderingMode(.alwaysTemplate)
|
||||
volumeView.setRouteButtonImage(image, for: .normal)
|
||||
volumeView.tintColor = .white
|
||||
volumeView.backgroundColor = UIColor(hex: 0x1F1F1F)
|
||||
case .builtInSpeaker:
|
||||
let image = UIImage(named: "Speaker")?.withRenderingMode(.alwaysTemplate)
|
||||
volumeView.setRouteButtonImage(image, for: .normal)
|
||||
volumeView.themeTintColor = .backgroundSecondary
|
||||
volumeView.themeBackgroundColor = .textPrimary
|
||||
|
||||
case .headphones:
|
||||
let image = UIImage(named: "Headsets")?.withRenderingMode(.alwaysTemplate)
|
||||
volumeView.setRouteButtonImage(image, for: .normal)
|
||||
volumeView.themeTintColor = .backgroundSecondary
|
||||
volumeView.themeBackgroundColor = .textPrimary
|
||||
|
||||
case .bluetoothLE: fallthrough
|
||||
case .bluetoothA2DP:
|
||||
let image = UIImage(named: "Bluetooth")?.withRenderingMode(.alwaysTemplate)
|
||||
volumeView.setRouteButtonImage(image, for: .normal)
|
||||
volumeView.themeTintColor = .backgroundSecondary
|
||||
volumeView.themeBackgroundColor = .textPrimary
|
||||
|
||||
case .bluetoothHFP:
|
||||
let image = UIImage(named: "Airpods")?.withRenderingMode(.alwaysTemplate)
|
||||
volumeView.setRouteButtonImage(image, for: .normal)
|
||||
volumeView.themeTintColor = .backgroundSecondary
|
||||
volumeView.themeBackgroundColor = .textPrimary
|
||||
|
||||
case .builtInReceiver: fallthrough
|
||||
default:
|
||||
let image = UIImage(named: "Speaker")?.withRenderingMode(.alwaysTemplate)
|
||||
volumeView.setRouteButtonImage(image, for: .normal)
|
||||
volumeView.themeTintColor = .backgroundSecondary
|
||||
volumeView.themeBackgroundColor = .textPrimary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handleRemoteVieioViewTapped(gesture: UITapGestureRecognizer) {
|
||||
let isHidden = callDurationLabel.alpha < 0.5
|
||||
|
||||
UIView.animate(withDuration: 0.5) {
|
||||
self.operationPanel.alpha = isHidden ? 1 : 0
|
||||
self.responsePanel.alpha = isHidden ? 1 : 0
|
||||
|
|
|
@ -47,16 +47,24 @@ final class CameraManager : NSObject {
|
|||
|
||||
func start() {
|
||||
guard !isCapturing else { return }
|
||||
print("[Calls] Starting camera.")
|
||||
isCapturing = true
|
||||
captureSession.startRunning()
|
||||
|
||||
// Note: The 'startRunning' task is blocking so we want to do it on a non-main thread
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
print("[Calls] Starting camera.")
|
||||
self?.isCapturing = true
|
||||
self?.captureSession.startRunning()
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
guard isCapturing else { return }
|
||||
print("[Calls] Stopping camera.")
|
||||
isCapturing = false
|
||||
captureSession.stopRunning()
|
||||
|
||||
// Note: The 'stopRunning' task is blocking so we want to do it on a non-main thread
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
print("[Calls] Stopping camera.")
|
||||
self?.isCapturing = false
|
||||
self?.captureSession.stopRunning()
|
||||
}
|
||||
}
|
||||
|
||||
func switchCamera() {
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import WebRTC
|
||||
import SessionUIKit
|
||||
|
||||
public protocol VideoPreviewDelegate : AnyObject {
|
||||
public protocol VideoPreviewDelegate: AnyObject {
|
||||
func cameraDidConfirmTurningOn()
|
||||
}
|
||||
|
||||
|
@ -11,61 +14,89 @@ class VideoPreviewVC: UIViewController, CameraManagerDelegate {
|
|||
lazy var cameraManager: CameraManager = {
|
||||
let result = CameraManager()
|
||||
result.delegate = self
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: UI Components
|
||||
// MARK: - UI Components
|
||||
|
||||
private lazy var renderView: RenderView = {
|
||||
let result = RenderView()
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var fadeView: UIView = {
|
||||
let height: CGFloat = ((UIApplication.shared.keyWindow?.safeAreaInsets.top).map { $0 + Values.veryLargeSpacing } ?? 64)
|
||||
|
||||
let result = UIView()
|
||||
let height: CGFloat = 64
|
||||
var frame = UIScreen.main.bounds
|
||||
frame.size.height = height
|
||||
|
||||
let layer = CAGradientLayer()
|
||||
layer.frame = frame
|
||||
layer.colors = [ UIColor(hex: 0x000000).withAlphaComponent(0.4).cgColor, UIColor(hex: 0x000000).withAlphaComponent(0).cgColor ]
|
||||
result.layer.insertSublayer(layer, at: 0)
|
||||
result.set(.height, to: height)
|
||||
|
||||
ThemeManager.onThemeChange(observer: result) { [weak layer] theme, _ in
|
||||
guard let backgroundPrimary: UIColor = theme.color(for: .backgroundPrimary) else { return }
|
||||
|
||||
layer?.colors = [
|
||||
backgroundPrimary.withAlphaComponent(0.4).cgColor,
|
||||
backgroundPrimary.withAlphaComponent(0).cgColor
|
||||
]
|
||||
}
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var closeButton: UIButton = {
|
||||
let result = UIButton(type: .custom)
|
||||
let image = UIImage(named: "X")!.withTint(.white)
|
||||
result.setImage(image, for: UIControl.State.normal)
|
||||
result.setImage(
|
||||
UIImage(named: "X")?
|
||||
.withRenderingMode(.alwaysTemplate),
|
||||
for: .normal
|
||||
)
|
||||
result.themeTintColor = .textPrimary
|
||||
result.addTarget(self, action: #selector(cancel), for: UIControl.Event.touchUpInside)
|
||||
result.set(.width, to: 60)
|
||||
result.set(.height, to: 60)
|
||||
result.addTarget(self, action: #selector(cancel), for: UIControl.Event.touchUpInside)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var confirmButton: UIButton = {
|
||||
let result = UIButton(type: .custom)
|
||||
let image = UIImage(named: "Check")!.withTint(.white)
|
||||
result.setImage(image, for: UIControl.State.normal)
|
||||
result.setImage(
|
||||
UIImage(named: "Check")?
|
||||
.withRenderingMode(.alwaysTemplate),
|
||||
for: .normal
|
||||
)
|
||||
result.themeTintColor = .textPrimary
|
||||
result.addTarget(self, action: #selector(confirm), for: UIControl.Event.touchUpInside)
|
||||
result.set(.width, to: 60)
|
||||
result.set(.height, to: 60)
|
||||
result.addTarget(self, action: #selector(confirm), for: UIControl.Event.touchUpInside)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var titleLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
result.text = "Preview"
|
||||
result.textColor = .white
|
||||
result.font = .boldSystemFont(ofSize: Values.veryLargeFontSize)
|
||||
result.text = "Preview"
|
||||
result.themeTextColor = .textPrimary
|
||||
result.textAlignment = .center
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Lifecycle
|
||||
// MARK: - Lifecycle
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .black
|
||||
|
||||
view.themeBackgroundColor = .backgroundPrimary
|
||||
|
||||
setUpViewHierarchy()
|
||||
cameraManager.prepare()
|
||||
}
|
||||
|
@ -75,20 +106,24 @@ class VideoPreviewVC: UIViewController, CameraManagerDelegate {
|
|||
view.addSubview(renderView)
|
||||
renderView.translatesAutoresizingMaskIntoConstraints = false
|
||||
renderView.pin(to: view)
|
||||
|
||||
// Fade view
|
||||
view.addSubview(fadeView)
|
||||
fadeView.translatesAutoresizingMaskIntoConstraints = false
|
||||
fadeView.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.top, UIView.HorizontalEdge.right ], to: view)
|
||||
|
||||
// Close button
|
||||
view.addSubview(closeButton)
|
||||
closeButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
closeButton.pin(.left, to: .left, of: view)
|
||||
closeButton.center(.vertical, in: fadeView)
|
||||
|
||||
// Confirm button
|
||||
view.addSubview(confirmButton)
|
||||
confirmButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
confirmButton.pin(.right, to: .right, of: view)
|
||||
confirmButton.center(.vertical, in: fadeView)
|
||||
|
||||
// Title label
|
||||
view.addSubview(titleLabel)
|
||||
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
@ -98,15 +133,18 @@ class VideoPreviewVC: UIViewController, CameraManagerDelegate {
|
|||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
cameraManager.start()
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
|
||||
cameraManager.stop()
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
// MARK: - Interaction
|
||||
|
||||
@objc func confirm() {
|
||||
delegate?.cameraDidConfirmTurningOn()
|
||||
self.dismiss(animated: true, completion: nil)
|
||||
|
@ -116,7 +154,8 @@ class VideoPreviewVC: UIViewController, CameraManagerDelegate {
|
|||
self.dismiss(animated: true, completion: nil)
|
||||
}
|
||||
|
||||
// MARK: CameraManagerDelegate
|
||||
// MARK: - CameraManagerDelegate
|
||||
|
||||
func handleVideoOutputCaptured(sampleBuffer: CMSampleBuffer) {
|
||||
renderView.enqueue(sampleBuffer: sampleBuffer)
|
||||
}
|
||||
|
|
|
@ -6,64 +6,91 @@ import SessionUIKit
|
|||
final class CallMissedTipsModal: Modal {
|
||||
private let caller: String
|
||||
|
||||
// MARK: - UI
|
||||
|
||||
private lazy var tipsIconContainerView: UIView = UIView()
|
||||
|
||||
private lazy var tipsIconImageView: UIImageView = {
|
||||
let result: UIImageView = UIImageView(
|
||||
image: UIImage(named: "Tips")?.withRenderingMode(.alwaysTemplate)
|
||||
)
|
||||
result.themeTintColor = .textPrimary
|
||||
result.set(.width, to: 19)
|
||||
result.set(.height, to: 28)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var titleLabel: UILabel = {
|
||||
let result: UILabel = UILabel()
|
||||
result.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
result.text = "modal_call_missed_tips_title".localized()
|
||||
result.themeTextColor = .textPrimary
|
||||
result.textAlignment = .center
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var messageLabel: UILabel = {
|
||||
let result: UILabel = UILabel()
|
||||
result.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
result.text = String(format: "modal_call_missed_tips_explanation".localized(), caller)
|
||||
result.themeTextColor = .textPrimary
|
||||
result.textAlignment = .natural
|
||||
result.lineBreakMode = .byWordWrapping
|
||||
result.numberOfLines = 0
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var contentStackView: UIStackView = {
|
||||
let result = UIStackView(arrangedSubviews: [ tipsIconContainerView, titleLabel, messageLabel ])
|
||||
result.axis = .vertical
|
||||
result.spacing = Values.smallSpacing
|
||||
result.isLayoutMarginsRelativeArrangement = true
|
||||
result.layoutMargins = UIEdgeInsets(
|
||||
top: Values.largeSpacing,
|
||||
leading: Values.largeSpacing,
|
||||
bottom: Values.verySmallSpacing,
|
||||
trailing: Values.largeSpacing
|
||||
)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var mainStackView: UIStackView = {
|
||||
let result = UIStackView(arrangedSubviews: [ contentStackView, cancelButton ])
|
||||
result.axis = .vertical
|
||||
result.spacing = Values.largeSpacing - Values.smallFontSize / 2
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(caller: String) {
|
||||
self.caller = caller
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
||||
super.init()
|
||||
|
||||
self.modalPresentationStyle = .overFullScreen
|
||||
self.modalTransitionStyle = .crossDissolve
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init(onCallEnabled:) instead.")
|
||||
}
|
||||
|
||||
override init(nibName: String?, bundle: Bundle?) {
|
||||
preconditionFailure("Use init(onCallEnabled:) instead.")
|
||||
preconditionFailure("Use init(caller:) instead.")
|
||||
}
|
||||
|
||||
override func populateContentView() {
|
||||
// Tips icon
|
||||
let tipsIconImageView = UIImageView(image: UIImage(named: "Tips")?.withTint(Colors.text))
|
||||
tipsIconImageView.set(.width, to: 19)
|
||||
tipsIconImageView.set(.height, to: 28)
|
||||
cancelButton.setTitle("BUTTON_OK".localized(), for: .normal)
|
||||
|
||||
// Tips icon container view
|
||||
let tipsIconContainerView = UIView()
|
||||
contentView.addSubview(mainStackView)
|
||||
tipsIconContainerView.addSubview(tipsIconImageView)
|
||||
|
||||
mainStackView.pin(to: contentView)
|
||||
|
||||
tipsIconImageView.pin(.top, to: .top, of: tipsIconContainerView)
|
||||
tipsIconImageView.pin(.bottom, to: .bottom, of: tipsIconContainerView)
|
||||
tipsIconImageView.center(in: tipsIconContainerView)
|
||||
|
||||
// Title
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.textColor = Colors.text
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
titleLabel.text = "modal_call_missed_tips_title".localized()
|
||||
titleLabel.textAlignment = .center
|
||||
|
||||
// Message
|
||||
let messageLabel = UILabel()
|
||||
messageLabel.textColor = Colors.text
|
||||
messageLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
messageLabel.text = String(format: "modal_call_missed_tips_explanation".localized(), caller)
|
||||
messageLabel.numberOfLines = 0
|
||||
messageLabel.lineBreakMode = .byWordWrapping
|
||||
messageLabel.textAlignment = .natural
|
||||
|
||||
// Cancel Button
|
||||
cancelButton.setTitle("BUTTON_OK".localized(), for: .normal)
|
||||
|
||||
// Main stack view
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ tipsIconContainerView, titleLabel, messageLabel, cancelButton ])
|
||||
mainStackView.axis = .vertical
|
||||
mainStackView.alignment = .fill
|
||||
mainStackView.spacing = Values.largeSpacing
|
||||
contentView.addSubview(mainStackView)
|
||||
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
|
||||
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,10 +10,19 @@ final class IncomingCallBanner: UIView, UIGestureRecognizerDelegate {
|
|||
private var previousY: CGFloat = 0
|
||||
let call: SessionCall
|
||||
|
||||
// MARK: UI Components
|
||||
// MARK: - UI Components
|
||||
|
||||
private lazy var backgroundView: UIView = {
|
||||
let result: UIView = UIView()
|
||||
result.themeBackgroundColor = .black
|
||||
result.alpha = 0.8
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var profilePictureView: ProfilePictureView = {
|
||||
let result = ProfilePictureView()
|
||||
let size = CGFloat(60)
|
||||
let size: CGFloat = 60
|
||||
result.size = size
|
||||
result.set(.width, to: size)
|
||||
result.set(.height, to: size)
|
||||
|
@ -22,53 +31,72 @@ final class IncomingCallBanner: UIView, UIGestureRecognizerDelegate {
|
|||
|
||||
private lazy var displayNameLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
result.textColor = UIColor.white
|
||||
result.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
result.themeTextColor = .white
|
||||
result.lineBreakMode = .byTruncatingTail
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var answerButton: UIButton = {
|
||||
let result = UIButton(type: .custom)
|
||||
let image = UIImage(named: "AnswerCall")!.withTint(.white)?.resizedImage(to: CGSize(width: 24.8, height: 24.8))
|
||||
result.setImage(image, for: UIControl.State.normal)
|
||||
result.setImage(
|
||||
UIImage(named: "AnswerCall")?
|
||||
.resizedImage(to: CGSize(width: 24.8, height: 24.8))?
|
||||
.withRenderingMode(.alwaysTemplate),
|
||||
for: .normal
|
||||
)
|
||||
result.themeTintColor = .white
|
||||
result.themeBackgroundColor = .callAccept_background
|
||||
result.layer.cornerRadius = 24
|
||||
result.addTarget(self, action: #selector(answerCall), for: .touchUpInside)
|
||||
result.set(.width, to: 48)
|
||||
result.set(.height, to: 48)
|
||||
result.backgroundColor = Colors.accent
|
||||
result.layer.cornerRadius = 24
|
||||
result.addTarget(self, action: #selector(answerCall), for: UIControl.Event.touchUpInside)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var hangUpButton: UIButton = {
|
||||
let result = UIButton(type: .custom)
|
||||
let image = UIImage(named: "EndCall")!.withTint(.white)?.resizedImage(to: CGSize(width: 29.6, height: 11.2))
|
||||
result.setImage(image, for: UIControl.State.normal)
|
||||
result.setImage(
|
||||
UIImage(named: "EndCall")?
|
||||
.resizedImage(to: CGSize(width: 29.6, height: 11.2))?
|
||||
.withRenderingMode(.alwaysTemplate),
|
||||
for: .normal
|
||||
)
|
||||
result.themeTintColor = .white
|
||||
result.themeBackgroundColor = .callDecline_background
|
||||
result.layer.cornerRadius = 24
|
||||
result.addTarget(self, action: #selector(endCall), for: .touchUpInside)
|
||||
result.set(.width, to: 48)
|
||||
result.set(.height, to: 48)
|
||||
result.backgroundColor = Colors.destructive
|
||||
result.layer.cornerRadius = 24
|
||||
result.addTarget(self, action: #selector(endCall), for: UIControl.Event.touchUpInside)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var panGestureRecognizer: UIPanGestureRecognizer = {
|
||||
let result = UIPanGestureRecognizer(target: self, action: #selector(handlePan))
|
||||
result.delegate = self
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Initialization
|
||||
// MARK: - Initialization
|
||||
|
||||
public static var current: IncomingCallBanner?
|
||||
|
||||
init(for call: SessionCall) {
|
||||
self.call = call
|
||||
|
||||
super.init(frame: CGRect.zero)
|
||||
|
||||
setUpViewHierarchy()
|
||||
setUpGestureRecognizers()
|
||||
|
||||
if let incomingCallBanner = IncomingCallBanner.current {
|
||||
incomingCallBanner.dismiss()
|
||||
}
|
||||
|
||||
IncomingCallBanner.current = self
|
||||
}
|
||||
|
||||
|
@ -81,22 +109,26 @@ final class IncomingCallBanner: UIView, UIGestureRecognizerDelegate {
|
|||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
self.backgroundColor = UIColor(hex: 0x000000).withAlphaComponent(0.8)
|
||||
self.clipsToBounds = true
|
||||
self.layer.cornerRadius = Values.largeSpacing
|
||||
self.layer.masksToBounds = true
|
||||
self.set(.height, to: 100)
|
||||
|
||||
addSubview(backgroundView)
|
||||
backgroundView.pin(to: self)
|
||||
|
||||
profilePictureView.update(
|
||||
publicKey: call.sessionId,
|
||||
profile: Profile.fetchOrCreate(id: call.sessionId),
|
||||
threadVariant: .contact
|
||||
)
|
||||
displayNameLabel.text = call.contactName
|
||||
|
||||
let stackView = UIStackView(arrangedSubviews: [profilePictureView, displayNameLabel, hangUpButton, answerButton])
|
||||
stackView.axis = .horizontal
|
||||
stackView.alignment = .center
|
||||
stackView.spacing = Values.largeSpacing
|
||||
self.addSubview(stackView)
|
||||
|
||||
stackView.center(.vertical, in: self)
|
||||
stackView.autoPinWidthToSuperview(withMargin: Values.mediumSpacing)
|
||||
}
|
||||
|
@ -108,14 +140,16 @@ final class IncomingCallBanner: UIView, UIGestureRecognizerDelegate {
|
|||
addGestureRecognizer(panGestureRecognizer)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
// MARK: - Interaction
|
||||
|
||||
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
if gestureRecognizer == panGestureRecognizer {
|
||||
let v = panGestureRecognizer.velocity(in: self)
|
||||
|
||||
return abs(v.y) > abs(v.x) // It has to be more vertical than horizontal
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) {
|
||||
|
@ -125,20 +159,27 @@ final class IncomingCallBanner: UIView, UIGestureRecognizerDelegate {
|
|||
@objc private func handlePan(_ gestureRecognizer: UIPanGestureRecognizer) {
|
||||
let translationY = gestureRecognizer.translation(in: self).y
|
||||
switch gestureRecognizer.state {
|
||||
case .changed:
|
||||
self.transform = CGAffineTransform(translationX: 0, y: min(translationY, IncomingCallBanner.swipeToOperateThreshold))
|
||||
if abs(translationY) > IncomingCallBanner.swipeToOperateThreshold && abs(previousY) < IncomingCallBanner.swipeToOperateThreshold {
|
||||
UIImpactFeedbackGenerator(style: .heavy).impactOccurred() // Let the user know when they've hit the swipe to reply threshold
|
||||
}
|
||||
previousY = translationY
|
||||
case .ended, .cancelled:
|
||||
if abs(translationY) > IncomingCallBanner.swipeToOperateThreshold {
|
||||
if translationY > 0 { showCallVC(answer: false) }
|
||||
else { endCall() } // TODO: Or just put the call on hold?
|
||||
} else {
|
||||
self.transform = .identity
|
||||
}
|
||||
default: break
|
||||
case .changed:
|
||||
self.transform = CGAffineTransform(translationX: 0, y: min(translationY, IncomingCallBanner.swipeToOperateThreshold))
|
||||
if abs(translationY) > IncomingCallBanner.swipeToOperateThreshold && abs(previousY) < IncomingCallBanner.swipeToOperateThreshold {
|
||||
UIImpactFeedbackGenerator(style: .heavy).impactOccurred() // Let the user know when they've hit the swipe to reply threshold
|
||||
}
|
||||
previousY = translationY
|
||||
|
||||
case .ended, .cancelled:
|
||||
if abs(translationY) > IncomingCallBanner.swipeToOperateThreshold {
|
||||
if translationY > 0 {
|
||||
showCallVC(answer: false)
|
||||
}
|
||||
else {
|
||||
endCall() // TODO: Or just put the call on hold?
|
||||
}
|
||||
}
|
||||
else {
|
||||
self.transform = .identity
|
||||
}
|
||||
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -152,6 +193,7 @@ final class IncomingCallBanner: UIView, UIGestureRecognizerDelegate {
|
|||
self.call.endSessionCall()
|
||||
AppEnvironment.shared.callManager.reportCurrentCallEnded(reason: nil)
|
||||
}
|
||||
|
||||
self.dismiss()
|
||||
}
|
||||
}
|
||||
|
@ -159,14 +201,18 @@ final class IncomingCallBanner: UIView, UIGestureRecognizerDelegate {
|
|||
public func showCallVC(answer: Bool) {
|
||||
dismiss()
|
||||
guard let presentingVC = CurrentAppContext().frontmostViewController() else { preconditionFailure() } // FIXME: Handle more gracefully
|
||||
|
||||
let callVC = CallVC(for: self.call)
|
||||
if let conversationVC = presentingVC as? ConversationVC {
|
||||
callVC.conversationVC = conversationVC
|
||||
conversationVC.inputAccessoryView?.isHidden = true
|
||||
conversationVC.inputAccessoryView?.alpha = 0
|
||||
}
|
||||
presentingVC.present(callVC, animated: true) {
|
||||
if answer { self.call.answerSessionCall() }
|
||||
|
||||
presentingVC.present(callVC, animated: true) { [weak self] in
|
||||
guard answer else { return }
|
||||
|
||||
self?.call.answerSessionCall()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -174,12 +220,15 @@ final class IncomingCallBanner: UIView, UIGestureRecognizerDelegate {
|
|||
self.alpha = 0.0
|
||||
let window = CurrentAppContext().mainWindow!
|
||||
window.addSubview(self)
|
||||
|
||||
let topMargin = window.safeAreaInsets.top - Values.smallSpacing
|
||||
self.autoPinWidthToSuperview(withMargin: Values.smallSpacing)
|
||||
self.autoPinEdge(toSuperviewEdge: .top, withInset: topMargin)
|
||||
|
||||
UIView.animate(withDuration: 0.5, delay: 0, options: [], animations: {
|
||||
self.alpha = 1.0
|
||||
}, completion: nil)
|
||||
|
||||
CallRingTonePlayer.shared.startVibration()
|
||||
CallRingTonePlayer.shared.startPlayingRingTone()
|
||||
}
|
||||
|
@ -187,6 +236,7 @@ final class IncomingCallBanner: UIView, UIGestureRecognizerDelegate {
|
|||
public func dismiss() {
|
||||
CallRingTonePlayer.shared.stopVibrationIfPossible()
|
||||
CallRingTonePlayer.shared.stopPlayingRingTone()
|
||||
|
||||
UIView.animate(withDuration: 0.5, delay: 0, options: [], animations: {
|
||||
self.alpha = 0.0
|
||||
}, completion: { _ in
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import WebRTC
|
||||
import SessionUIKit
|
||||
|
||||
final class MiniCallView: UIView, RTCVideoViewDelegate {
|
||||
var callVC: CallVC
|
||||
|
@ -7,8 +10,8 @@ final class MiniCallView: UIView, RTCVideoViewDelegate {
|
|||
// MARK: UI
|
||||
private static let defaultSize: CGFloat = UIDevice.current.isIPad ? 200 : 100
|
||||
private static let defaultVideoSize: CGFloat = UIDevice.current.isIPad ? 320 : 160
|
||||
private let topMargin = UIApplication.shared.keyWindow!.safeAreaInsets.top + Values.veryLargeSpacing
|
||||
private let bottomMargin = UIApplication.shared.keyWindow!.safeAreaInsets.bottom
|
||||
private let topMargin = (UIApplication.shared.keyWindow?.safeAreaInsets.top ?? 0) + Values.veryLargeSpacing
|
||||
private let bottomMargin = (UIApplication.shared.keyWindow?.safeAreaInsets.bottom ?? 0)
|
||||
|
||||
private var width: NSLayoutConstraint?
|
||||
private var height: NSLayoutConstraint?
|
||||
|
@ -17,40 +20,59 @@ final class MiniCallView: UIView, RTCVideoViewDelegate {
|
|||
private var top: NSLayoutConstraint?
|
||||
private var bottom: NSLayoutConstraint?
|
||||
|
||||
private let backgroundView: UIView = {
|
||||
let result: UIView = UIView()
|
||||
result.themeBackgroundColor = .textPrimary
|
||||
result.alpha = 0.8
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
#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
|
||||
result.alpha = self.callVC.call.isRemoteVideoEnabled ? 1 : 0
|
||||
result.backgroundColor = .black
|
||||
result.themeBackgroundColor = .backgroundSecondary
|
||||
result.alpha = (self.callVC.call.isRemoteVideoEnabled ? 1 : 0)
|
||||
|
||||
return result
|
||||
}()
|
||||
#else
|
||||
private lazy var remoteVideoView: RTCMTLVideoView = {
|
||||
let result = RTCMTLVideoView()
|
||||
result.delegate = self
|
||||
result.alpha = self.callVC.call.isRemoteVideoEnabled ? 1 : 0
|
||||
result.videoContentMode = .scaleAspectFit
|
||||
result.backgroundColor = .black
|
||||
result.themeBackgroundColor = .backgroundSecondary
|
||||
result.alpha = (self.callVC.call.isRemoteVideoEnabled ? 1 : 0)
|
||||
|
||||
return result
|
||||
}()
|
||||
#endif
|
||||
|
||||
// MARK: Initialization
|
||||
// MARK: - Initialization
|
||||
|
||||
public static var current: MiniCallView?
|
||||
|
||||
init(from callVC: CallVC) {
|
||||
self.callVC = callVC
|
||||
|
||||
super.init(frame: CGRect.zero)
|
||||
self.backgroundColor = UIColor.init(white: 0, alpha: 0.8)
|
||||
|
||||
setUpViewHierarchy()
|
||||
setUpGestureRecognizers()
|
||||
MiniCallView.current = self
|
||||
|
||||
self.callVC.call.remoteVideoStateDidChange = { isEnabled in
|
||||
DispatchQueue.main.async {
|
||||
UIView.animate(withDuration: 0.25) {
|
||||
self.remoteVideoView.alpha = isEnabled ? 1 : 0
|
||||
|
||||
if !isEnabled {
|
||||
self.width?.constant = MiniCallView.defaultSize
|
||||
self.height?.constant = MiniCallView.defaultSize
|
||||
|
@ -58,6 +80,13 @@ final class MiniCallView: UIView, RTCVideoViewDelegate {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(windowSubviewsChanged),
|
||||
name: .windowSubviewsChanged,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
|
@ -68,15 +97,21 @@ final class MiniCallView: UIView, RTCVideoViewDelegate {
|
|||
preconditionFailure("Use init(coder:) instead.")
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
self.clipsToBounds = true
|
||||
self.layer.cornerRadius = UIDevice.current.isIPad ? 20 : 10
|
||||
self.width = self.set(.width, to: MiniCallView.defaultSize)
|
||||
self.height = self.set(.height, to: MiniCallView.defaultSize)
|
||||
self.layer.cornerRadius = UIDevice.current.isIPad ? 20 : 10
|
||||
self.layer.masksToBounds = true
|
||||
|
||||
// Background
|
||||
let background = getBackgroudView()
|
||||
self.addSubview(background)
|
||||
background.pin(to: self)
|
||||
|
||||
// Remote video view
|
||||
callVC.call.attachRemoteVideoRenderer(remoteVideoView)
|
||||
self.addSubview(remoteVideoView)
|
||||
|
@ -85,17 +120,25 @@ final class MiniCallView: UIView, RTCVideoViewDelegate {
|
|||
}
|
||||
|
||||
private func getBackgroudView() -> UIView {
|
||||
let background = UIView()
|
||||
let result: UIView = UIView()
|
||||
|
||||
let background: UIView = UIView()
|
||||
background.themeBackgroundColor = .textPrimary
|
||||
background.alpha = 0.8
|
||||
result.addSubview(background)
|
||||
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
|
||||
background.addSubview(imageView)
|
||||
result.addSubview(imageView)
|
||||
imageView.set(.width, to: 64)
|
||||
imageView.set(.height, to: 64)
|
||||
imageView.center(in: background)
|
||||
return background
|
||||
imageView.center(in: result)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private func setUpGestureRecognizers() {
|
||||
|
@ -105,7 +148,8 @@ final class MiniCallView: UIView, RTCVideoViewDelegate {
|
|||
makeViewDraggable()
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
// MARK: - Interaction
|
||||
|
||||
@objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) {
|
||||
dismiss()
|
||||
guard let presentingVC = CurrentAppContext().frontmostViewController() else { preconditionFailure() } // FIXME: Handle more gracefully
|
||||
|
@ -114,14 +158,16 @@ final class MiniCallView: UIView, RTCVideoViewDelegate {
|
|||
|
||||
public func show() {
|
||||
self.alpha = 0.0
|
||||
let window = CurrentAppContext().mainWindow!
|
||||
guard let window: UIWindow = CurrentAppContext().mainWindow else { return }
|
||||
|
||||
window.addSubview(self)
|
||||
left = self.autoPinEdge(toSuperviewEdge: .left)
|
||||
left?.isActive = false
|
||||
right = self.autoPinEdge(toSuperviewEdge: .right)
|
||||
right = self.autoPinEdge(toSuperviewEdge: .right, withInset: Values.smallSpacing)
|
||||
top = self.autoPinEdge(toSuperviewEdge: .top, withInset: topMargin)
|
||||
bottom = self.autoPinEdge(toSuperviewEdge: .bottom, withInset: bottomMargin)
|
||||
bottom?.isActive = false
|
||||
|
||||
UIView.animate(withDuration: 0.5, delay: 0, options: [], animations: {
|
||||
self.alpha = 1.0
|
||||
}, completion: nil)
|
||||
|
@ -130,15 +176,19 @@ final class MiniCallView: UIView, RTCVideoViewDelegate {
|
|||
public func dismiss() {
|
||||
UIView.animate(withDuration: 0.5, delay: 0, options: [], animations: {
|
||||
self.alpha = 0.0
|
||||
}, completion: { _ in
|
||||
self.callVC.call.removeRemoteVideoRenderer(self.remoteVideoView)
|
||||
self.callVC.setupStateChangeCallbacks()
|
||||
}, completion: { [weak self] _ in
|
||||
if let remoteVideoView: RTCVideoRenderer = self?.remoteVideoView {
|
||||
self?.callVC.call.removeRemoteVideoRenderer(remoteVideoView)
|
||||
}
|
||||
|
||||
self?.callVC.setupStateChangeCallbacks()
|
||||
MiniCallView.current = nil
|
||||
self.removeFromSuperview()
|
||||
self?.removeFromSuperview()
|
||||
})
|
||||
}
|
||||
|
||||
// MARK: RTCVideoViewDelegate
|
||||
// MARK: - RTCVideoViewDelegate
|
||||
|
||||
func videoView(_ videoView: RTCVideoRenderer, didChangeVideoSize size: CGSize) {
|
||||
let newSize = CGSize(
|
||||
width: min(Self.defaultVideoSize, Self.defaultVideoSize * size.width / size.height),
|
||||
|
@ -152,25 +202,49 @@ final class MiniCallView: UIView, RTCVideoViewDelegate {
|
|||
func persistCurrentPosition(newSize: CGSize) {
|
||||
let currentCenter = self.center
|
||||
|
||||
if currentCenter.x < self.superview!.width() / 2 {
|
||||
if currentCenter.x < ((self.superview?.width() ?? 0) / 2) {
|
||||
left?.isActive = true
|
||||
right?.isActive = false
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
left?.isActive = false
|
||||
right?.isActive = true
|
||||
}
|
||||
|
||||
let willTouchTop = currentCenter.y < newSize.height / 2 + topMargin
|
||||
let willTouchBottom = currentCenter.y + newSize.height / 2 >= self.superview!.height()
|
||||
let willTouchTop: Bool = (currentCenter.y < ((newSize.height / 2) + topMargin))
|
||||
let willTouchBottom: Bool = ((currentCenter.y + (newSize.height / 2)) >= (self.superview?.height() ?? 0))
|
||||
|
||||
if willTouchBottom {
|
||||
top?.isActive = false
|
||||
bottom?.isActive = true
|
||||
} else {
|
||||
let constant = willTouchTop ? topMargin : currentCenter.y - newSize.height / 2
|
||||
}
|
||||
else {
|
||||
let constant = (willTouchTop ? topMargin : (currentCenter.y - (newSize.height / 2)))
|
||||
top?.constant = constant
|
||||
top?.isActive = true
|
||||
bottom?.isActive = false
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func windowSubviewsChanged() {
|
||||
// Ensure the MiniCallView always stays in front when presenting screens (need to update the
|
||||
// constraints to match the current values so when the re-layout occurs it doesn't move)
|
||||
if self.top?.isActive == true {
|
||||
self.top?.constant = self.frame.minY
|
||||
}
|
||||
|
||||
if self.left?.isActive == true {
|
||||
self.left?.constant = self.frame.minX
|
||||
}
|
||||
|
||||
if self.right?.isActive == true {
|
||||
self.right?.constant = (self.frame.maxX - (self.superview?.width() ?? 0))
|
||||
}
|
||||
|
||||
if self.bottom?.isActive == true {
|
||||
self.bottom?.constant = (self.frame.maxY - (self.superview?.height() ?? 0))
|
||||
}
|
||||
|
||||
self.window?.bringSubviewToFront(self)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
// Copyright © 2021 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import AVFoundation
|
||||
import CoreMedia
|
||||
|
||||
class RenderView: UIView {
|
||||
|
|
|
@ -2,14 +2,14 @@
|
|||
|
||||
import UIKit
|
||||
import GRDB
|
||||
import DifferenceKit
|
||||
import PromiseKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
@objc(SNEditClosedGroupVC)
|
||||
final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate {
|
||||
private struct GroupMemberDisplayInfo: FetchableRecord, Decodable {
|
||||
private struct GroupMemberDisplayInfo: FetchableRecord, Equatable, Hashable, Decodable, Differentiable {
|
||||
let profileId: String
|
||||
let role: GroupMember.Role
|
||||
let profile: Profile?
|
||||
|
@ -30,8 +30,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
|
||||
|
||||
|
@ -39,14 +39,17 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
|
|||
}()
|
||||
|
||||
private lazy var groupNameTextField: TextField = {
|
||||
let result: TextField = TextField(placeholder: "Enter a group name", usesDefaultHeight: false)
|
||||
let result: TextField = TextField(
|
||||
placeholder: "vc_create_closed_group_text_field_hint".localized(),
|
||||
usesDefaultHeight: false
|
||||
)
|
||||
result.textAlignment = .center
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var addMembersButton: Button = {
|
||||
let result: Button = Button(style: .prominentOutline, size: .large)
|
||||
private lazy var addMembersButton: SessionButton = {
|
||||
let result: SessionButton = SessionButton(style: .bordered, size: .medium)
|
||||
result.setTitle("Add Members", for: UIControl.State.normal)
|
||||
result.addTarget(self, action: #selector(addMembers), for: UIControl.Event.touchUpInside)
|
||||
result.contentEdgeInsets = UIEdgeInsets(top: 0, leading: Values.mediumSpacing, bottom: 0, trailing: Values.mediumSpacing)
|
||||
|
@ -59,17 +62,16 @@ 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)
|
||||
result.register(view: SessionCell.self)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
@objc(initWithThreadId:)
|
||||
init(with threadId: String) {
|
||||
init(threadId: String) {
|
||||
self.threadId = threadId
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
@ -82,18 +84,13 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
|
|||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
setUpGradientBackground()
|
||||
setUpNavBarStyle()
|
||||
setNavBarTitle("Edit Group")
|
||||
|
||||
let backButton = UIBarButtonItem(title: "Back", style: .plain, target: nil, action: nil)
|
||||
backButton.tintColor = Colors.text
|
||||
navigationItem.backBarButtonItem = backButton
|
||||
|
||||
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)
|
||||
|
@ -125,7 +122,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()
|
||||
|
@ -155,17 +157,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 ])
|
||||
|
@ -201,21 +197,33 @@ 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
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell: UserCell = tableView.dequeue(type: UserCell.self, for: indexPath)
|
||||
let cell: SessionCell = tableView.dequeue(type: SessionCell.self, for: indexPath)
|
||||
let displayInfo: GroupMemberDisplayInfo = membersAndZombies[indexPath.row]
|
||||
cell.update(
|
||||
with: membersAndZombies[indexPath.row].profileId,
|
||||
profile: membersAndZombies[indexPath.row].profile,
|
||||
isZombie: (membersAndZombies[indexPath.row].role == .zombie),
|
||||
accessory: (adminIds.contains(userPublicKey) ?
|
||||
.none :
|
||||
.lock
|
||||
)
|
||||
with: SessionCell.Info(
|
||||
id: displayInfo,
|
||||
leftAccessory: .profile(displayInfo.profileId, displayInfo.profile),
|
||||
title: (
|
||||
displayInfo.profile?.displayName() ??
|
||||
Profile.truncated(id: displayInfo.profileId, threadVariant: .contact)
|
||||
),
|
||||
rightAccessory: (adminIds.contains(userPublicKey) ? nil :
|
||||
.icon(
|
||||
UIImage(named: "ic_lock_outline")?
|
||||
.withRenderingMode(.alwaysTemplate),
|
||||
customTint: .textSecondary
|
||||
)
|
||||
)
|
||||
),
|
||||
style: .edgeToEdge,
|
||||
position: Position.with(indexPath.row, count: membersAndZombies.count)
|
||||
)
|
||||
|
||||
return cell
|
||||
|
@ -224,18 +232,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
|
||||
|
@ -243,7 +256,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 {
|
||||
|
@ -251,7 +264,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
|
||||
}
|
||||
|
||||
|
@ -307,14 +320,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
|
||||
|
@ -363,16 +377,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()
|
||||
}
|
||||
|
||||
|
@ -444,8 +457,15 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
|
|||
// MARK: - Convenience
|
||||
|
||||
private func showError(title: String, message: String = "") {
|
||||
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "BUTTON_OK".localized(), style: .default, handler: nil))
|
||||
presentAlert(alert)
|
||||
let modal: ConfirmationModal = ConfirmationModal(
|
||||
targetView: self.view,
|
||||
info: ConfirmationModal.Info(
|
||||
title: title,
|
||||
explanation: message,
|
||||
cancelTitle: "BUTTON_OK".localized(),
|
||||
cancelStyle: .alert_text
|
||||
)
|
||||
)
|
||||
self.present(modal, animated: true)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,9 +2,11 @@
|
|||
|
||||
import UIKit
|
||||
import GRDB
|
||||
import DifferenceKit
|
||||
import PromiseKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
private protocol TableViewTouchDelegate {
|
||||
func tableViewWasTouched(_ tableView: TableView)
|
||||
|
@ -20,10 +22,14 @@ private final class TableView: UITableView {
|
|||
}
|
||||
|
||||
final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate, TableViewTouchDelegate, UITextFieldDelegate, UIScrollViewDelegate {
|
||||
private let contactProfiles: [Profile] = Profile.fetchAllContactProfiles(excludeCurrentUser: true)
|
||||
private var searchResults: [Profile] {
|
||||
return searchText.isEmpty ? contactProfiles : contactProfiles.filter { $0.displayName().range(of: searchText, options: [.caseInsensitive]) != nil }
|
||||
private enum Section: Int, Differentiable, Equatable, Hashable {
|
||||
case contacts
|
||||
}
|
||||
|
||||
private let contactProfiles: [Profile] = Profile.fetchAllContactProfiles(excludeCurrentUser: true)
|
||||
private lazy var data: [ArraySection<Section, Profile>] = [
|
||||
ArraySection(model: .contacts, elements: contactProfiles)
|
||||
]
|
||||
private var selectedContacts: Set<String> = []
|
||||
private var searchText: String = ""
|
||||
|
||||
|
@ -33,62 +39,106 @@ final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate
|
|||
|
||||
// MARK: - Components
|
||||
|
||||
private static let textFieldHeight: CGFloat = 50
|
||||
private static let searchBarHeight: CGFloat = (36 + (Values.mediumSpacing * 2))
|
||||
|
||||
private lazy var nameTextField: TextField = {
|
||||
let result = TextField(
|
||||
placeholder: "vc_create_closed_group_text_field_hint".localized(),
|
||||
usesDefaultHeight: false,
|
||||
customHeight: 50
|
||||
customHeight: NewClosedGroupVC.textFieldHeight
|
||||
)
|
||||
result.set(.height, to: 50)
|
||||
result.layer.borderColor = Colors.border.withAlphaComponent(0.5).cgColor
|
||||
result.set(.height, to: NewClosedGroupVC.textFieldHeight)
|
||||
result.themeBorderColor = .borderSeparator
|
||||
result.layer.cornerRadius = 13
|
||||
result.delegate = self
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var searchBar: ContactsSearchBar = {
|
||||
let result = ContactsSearchBar()
|
||||
result.tintColor = Colors.text
|
||||
result.backgroundColor = .clear
|
||||
result.themeTintColor = .textPrimary
|
||||
result.themeBackgroundColor = .clear
|
||||
result.delegate = self
|
||||
result.set(.height, to: NewClosedGroupVC.searchBarHeight)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var headerView: UIView = {
|
||||
let result: UIView = UIView(
|
||||
frame: CGRect(
|
||||
x: 0, y: 0,
|
||||
width: UIScreen.main.bounds.width,
|
||||
height: (
|
||||
Values.mediumSpacing +
|
||||
NewClosedGroupVC.textFieldHeight +
|
||||
NewClosedGroupVC.searchBarHeight
|
||||
)
|
||||
)
|
||||
)
|
||||
result.addSubview(nameTextField)
|
||||
result.addSubview(searchBar)
|
||||
|
||||
nameTextField.pin(.top, to: .top, of: result, withInset: Values.mediumSpacing)
|
||||
nameTextField.pin(.leading, to: .leading, of: result, withInset: Values.largeSpacing)
|
||||
nameTextField.pin(.trailing, to: .trailing, of: result, withInset: -Values.largeSpacing)
|
||||
|
||||
// Note: The top & bottom padding is built into the search bar
|
||||
searchBar.pin(.top, to: .bottom, of: nameTextField)
|
||||
searchBar.pin(.leading, to: .leading, of: result, withInset: Values.largeSpacing)
|
||||
searchBar.pin(.trailing, to: .trailing, of: result, withInset: -Values.largeSpacing)
|
||||
searchBar.pin(.bottom, to: .bottom, of: result)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var tableView: TableView = {
|
||||
let result: TableView = TableView()
|
||||
result.separatorStyle = .none
|
||||
result.themeBackgroundColor = .clear
|
||||
result.showsVerticalScrollIndicator = false
|
||||
result.tableHeaderView = headerView
|
||||
result.contentInset = UIEdgeInsets(
|
||||
top: 0,
|
||||
leading: 0,
|
||||
bottom: Values.footerGradientHeight(window: UIApplication.shared.keyWindow),
|
||||
trailing: 0
|
||||
)
|
||||
result.register(view: SessionCell.self)
|
||||
result.touchDelegate = self
|
||||
result.dataSource = self
|
||||
result.delegate = self
|
||||
result.touchDelegate = self
|
||||
result.separatorStyle = .none
|
||||
result.backgroundColor = .clear
|
||||
result.isScrollEnabled = false
|
||||
result.register(view: UserCell.self)
|
||||
|
||||
if #available(iOS 15.0, *) {
|
||||
result.sectionHeaderTopPadding = 0
|
||||
}
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var createGroupButton: Button = {
|
||||
let result = Button(style: .prominentOutline, size: .large)
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.setTitle(NSLocalizedString("CREATE_GROUP_BUTTON_TITLE", comment: ""), for: .normal)
|
||||
result.addTarget(self, action: #selector(createClosedGroup), for: .touchUpInside)
|
||||
result.set(.width, to: 160)
|
||||
private lazy var fadeView: GradientView = {
|
||||
let result: GradientView = GradientView()
|
||||
result.themeBackgroundGradient = [
|
||||
.value(.newConversation_background, alpha: 0), // Want this to take up 20% (~25pt)
|
||||
.newConversation_background,
|
||||
.newConversation_background,
|
||||
.newConversation_background,
|
||||
.newConversation_background
|
||||
]
|
||||
result.set(.height, to: Values.footerGradientHeight(window: UIApplication.shared.keyWindow))
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var fadeView: UIView = {
|
||||
let result = UIView()
|
||||
let gradient = Gradients.newClosedGroupVCFade
|
||||
result.setHalfWayGradient(
|
||||
gradient,
|
||||
frame: .init(
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: UIScreen.main.bounds.width,
|
||||
height: 150
|
||||
)
|
||||
)
|
||||
result.isUserInteractionEnabled = false
|
||||
result.set(.height, to: 150)
|
||||
private lazy var createGroupButton: SessionButton = {
|
||||
let result = SessionButton(style: .bordered, size: .large)
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.setTitle("CREATE_GROUP_BUTTON_TITLE".localized(), for: .normal)
|
||||
result.addTarget(self, action: #selector(createClosedGroup), for: .touchUpInside)
|
||||
result.set(.width, to: 160)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
|
@ -96,14 +146,14 @@ final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate
|
|||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = Colors.navigationBarBackground
|
||||
setUpNavBarStyle()
|
||||
|
||||
view.themeBackgroundColor = .newConversation_background
|
||||
|
||||
let customTitleFontSize = Values.largeFontSize
|
||||
setNavBarTitle("vc_create_closed_group_title".localized(), customFontSize: customTitleFontSize)
|
||||
|
||||
let closeButton = UIBarButtonItem(image: #imageLiteral(resourceName: "X"), style: .plain, target: self, action: #selector(close))
|
||||
closeButton.tintColor = Colors.text
|
||||
closeButton.themeTintColor = .textPrimary
|
||||
navigationItem.rightBarButtonItem = closeButton
|
||||
|
||||
// Set up content
|
||||
|
@ -118,16 +168,16 @@ final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate
|
|||
private func setUpViewHierarchy() {
|
||||
guard !contactProfiles.isEmpty else {
|
||||
let explanationLabel: UILabel = UILabel()
|
||||
explanationLabel.textColor = Colors.text
|
||||
explanationLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
explanationLabel.numberOfLines = 0
|
||||
explanationLabel.lineBreakMode = .byWordWrapping
|
||||
explanationLabel.text = "vc_create_closed_group_empty_state_message".localized()
|
||||
explanationLabel.themeTextColor = .textPrimary
|
||||
explanationLabel.textAlignment = .center
|
||||
explanationLabel.text = NSLocalizedString("vc_create_closed_group_empty_state_message", comment: "")
|
||||
explanationLabel.lineBreakMode = .byWordWrapping
|
||||
explanationLabel.numberOfLines = 0
|
||||
|
||||
let createNewPrivateChatButton: Button = Button(style: .prominentOutline, size: .large)
|
||||
createNewPrivateChatButton.setTitle(NSLocalizedString("vc_create_closed_group_empty_state_button_title", comment: ""), for: UIControl.State.normal)
|
||||
createNewPrivateChatButton.addTarget(self, action: #selector(createNewDM), for: UIControl.Event.touchUpInside)
|
||||
let createNewPrivateChatButton: SessionButton = SessionButton(style: .bordered, size: .medium)
|
||||
createNewPrivateChatButton.setTitle("vc_create_closed_group_empty_state_button_title".localized(), for: .normal)
|
||||
createNewPrivateChatButton.addTarget(self, action: #selector(createNewDM), for: .touchUpInside)
|
||||
createNewPrivateChatButton.set(.width, to: 196)
|
||||
|
||||
let stackView: UIStackView = UIStackView(arrangedSubviews: [ explanationLabel, createNewPrivateChatButton ])
|
||||
|
@ -142,39 +192,11 @@ final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate
|
|||
return
|
||||
}
|
||||
|
||||
let mainStackView: UIStackView = UIStackView()
|
||||
mainStackView.axis = .vertical
|
||||
nameTextField.delegate = self
|
||||
|
||||
let nameTextFieldContainer: UIView = UIView()
|
||||
nameTextFieldContainer.addSubview(nameTextField)
|
||||
nameTextField.pin(.leading, to: .leading, of: nameTextFieldContainer, withInset: Values.mediumSpacing)
|
||||
nameTextField.pin(.top, to: .top, of: nameTextFieldContainer, withInset: Values.mediumSpacing)
|
||||
nameTextFieldContainer.pin(.trailing, to: .trailing, of: nameTextField, withInset: Values.mediumSpacing)
|
||||
nameTextFieldContainer.pin(.bottom, to: .bottom, of: nameTextField)
|
||||
mainStackView.addArrangedSubview(nameTextFieldContainer)
|
||||
|
||||
let searchBarContainer: UIView = UIView()
|
||||
searchBarContainer.addSubview(searchBar)
|
||||
searchBar.pin(.leading, to: .leading, of: searchBarContainer, withInset: Values.smallSpacing)
|
||||
searchBarContainer.pin(.trailing, to: .trailing, of: searchBar, withInset: Values.smallSpacing)
|
||||
searchBar.pin([ UIView.VerticalEdge.top, UIView.VerticalEdge.bottom ], to: searchBarContainer)
|
||||
mainStackView.addArrangedSubview(searchBarContainer)
|
||||
|
||||
let separator: UIView = UIView()
|
||||
separator.backgroundColor = Colors.separator
|
||||
separator.set(.height, to: Values.separatorThickness)
|
||||
mainStackView.addArrangedSubview(separator)
|
||||
|
||||
tableView.set(.height, to: CGFloat(contactProfiles.count * 65 + 100)) // A cell is exactly 65 points high
|
||||
tableViewWidth = tableView.set(.width, to: UIScreen.main.bounds.width)
|
||||
mainStackView.addArrangedSubview(tableView)
|
||||
|
||||
let scrollView: UIScrollView = UIScrollView(wrapping: mainStackView, withInsets: UIEdgeInsets.zero)
|
||||
scrollView.showsVerticalScrollIndicator = false
|
||||
scrollView.delegate = self
|
||||
view.addSubview(scrollView)
|
||||
scrollView.pin(to: view)
|
||||
view.addSubview(tableView)
|
||||
tableView.pin(.top, to: .top, of: view)
|
||||
tableView.pin(.leading, to: .leading, of: view)
|
||||
tableView.pin(.trailing, to: .trailing, of: view)
|
||||
tableView.pin(.bottom, to: .bottom, of: view)
|
||||
|
||||
view.addSubview(fadeView)
|
||||
fadeView.pin(.leading, to: .leading, of: view)
|
||||
|
@ -183,70 +205,94 @@ final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate
|
|||
|
||||
view.addSubview(createGroupButton)
|
||||
createGroupButton.center(.horizontal, in: view)
|
||||
createGroupButton.pin(.bottom, to: .bottom, of: view, withInset: -Values.veryLargeSpacing)
|
||||
}
|
||||
|
||||
@objc override internal func handleAppModeChangedNotification(_ notification: Notification) {
|
||||
super.handleAppModeChangedNotification(notification)
|
||||
|
||||
let gradient = Gradients.newClosedGroupVCFade
|
||||
fadeView.setHalfWayGradient(
|
||||
gradient,
|
||||
frame: .init(
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: UIScreen.main.bounds.width,
|
||||
height: 150
|
||||
)
|
||||
) // Re-do the gradient
|
||||
createGroupButton.pin(.bottom, to: .bottom, of: view.safeAreaLayoutGuide, withInset: -Values.smallSpacing)
|
||||
}
|
||||
|
||||
// MARK: - Table View Data Source
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
return searchResults.count
|
||||
return data[section].elements.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell: UserCell = tableView.dequeue(type: UserCell.self, for: indexPath)
|
||||
let cell: SessionCell = tableView.dequeue(type: SessionCell.self, for: indexPath)
|
||||
let profile: Profile = data[indexPath.section].elements[indexPath.row]
|
||||
cell.update(
|
||||
with: searchResults[indexPath.row].id,
|
||||
profile: searchResults[indexPath.row],
|
||||
isZombie: false,
|
||||
accessory: .radio(isSelected: selectedContacts.contains(searchResults[indexPath.row].id))
|
||||
with: SessionCell.Info(
|
||||
id: profile,
|
||||
leftAccessory: .profile(profile.id, profile),
|
||||
title: profile.displayName(),
|
||||
rightAccessory: .radio(isSelected: { [weak self] in
|
||||
self?.selectedContacts.contains(profile.id) == true
|
||||
})
|
||||
),
|
||||
style: .edgeToEdge,
|
||||
position: Position.with(indexPath.row, count: data[indexPath.section].elements.count)
|
||||
)
|
||||
|
||||
return cell
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDelegate
|
||||
|
||||
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
return UITableView.automaticDimension
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
return UITableView.automaticDimension
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
let profileId: String = data[indexPath.section].elements[indexPath.row].id
|
||||
|
||||
if !selectedContacts.contains(profileId) {
|
||||
selectedContacts.insert(profileId)
|
||||
}
|
||||
else {
|
||||
selectedContacts.remove(profileId)
|
||||
}
|
||||
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
tableView.reloadRows(at: [indexPath], with: .none)
|
||||
}
|
||||
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
let nameTextFieldCenterY = nameTextField.convert(nameTextField.bounds.center, to: scrollView).y
|
||||
let shouldShowGroupNameInTitle: Bool = (scrollView.contentOffset.y > nameTextFieldCenterY)
|
||||
let groupNameLabelVisible: Bool = (crossfadeLabel.alpha >= 1)
|
||||
|
||||
switch (shouldShowGroupNameInTitle, groupNameLabelVisible) {
|
||||
case (true, false):
|
||||
UIView.animate(withDuration: 0.2) {
|
||||
self.navBarTitleLabel.alpha = 0
|
||||
self.crossfadeLabel.alpha = 1
|
||||
}
|
||||
|
||||
case (false, true):
|
||||
UIView.animate(withDuration: 0.2) {
|
||||
self.navBarTitleLabel.alpha = 1
|
||||
self.crossfadeLabel.alpha = 0
|
||||
}
|
||||
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Interaction
|
||||
|
||||
func textFieldDidEndEditing(_ textField: UITextField) {
|
||||
crossfadeLabel.text = (textField.text?.isEmpty == true ?
|
||||
"vc_create_closed_group_title".localized() :
|
||||
textField.text
|
||||
)
|
||||
}
|
||||
|
||||
fileprivate func tableViewWasTouched(_ tableView: TableView) {
|
||||
if nameTextField.isFirstResponder {
|
||||
nameTextField.resignFirstResponder()
|
||||
}
|
||||
}
|
||||
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
let nameTextFieldCenterY = nameTextField.convert(nameTextField.bounds.center, to: scrollView).y
|
||||
let tableViewOriginY = tableView.convert(tableView.bounds.origin, to: scrollView).y
|
||||
let titleLabelAlpha = 1 - (scrollView.contentOffset.y - nameTextFieldCenterY) / (tableViewOriginY - nameTextFieldCenterY)
|
||||
let crossfadeLabelAlpha = 1 - titleLabelAlpha
|
||||
navBarTitleLabel.alpha = titleLabelAlpha
|
||||
crossfadeLabel.alpha = crossfadeLabelAlpha
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
if !selectedContacts.contains(searchResults[indexPath.row].id) {
|
||||
selectedContacts.insert(searchResults[indexPath.row].id)
|
||||
}
|
||||
else {
|
||||
selectedContacts.remove(searchResults[indexPath.row].id)
|
||||
}
|
||||
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
tableView.reloadRows(at: [indexPath], with: .none)
|
||||
}
|
||||
|
||||
@objc private func close() {
|
||||
dismiss(animated: true, completion: nil)
|
||||
|
@ -254,21 +300,30 @@ final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate
|
|||
|
||||
@objc private func createClosedGroup() {
|
||||
func showError(title: String, message: String = "") {
|
||||
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: nil))
|
||||
presentAlert(alert)
|
||||
let modal: ConfirmationModal = ConfirmationModal(
|
||||
info: ConfirmationModal.Info(
|
||||
title: title,
|
||||
explanation: message,
|
||||
cancelTitle: "BUTTON_OK".localized(),
|
||||
cancelStyle: .alert_text
|
||||
)
|
||||
)
|
||||
present(modal, animated: true)
|
||||
}
|
||||
guard let name = nameTextField.text?.trimmingCharacters(in: .whitespacesAndNewlines), name.count > 0 else {
|
||||
return showError(title: NSLocalizedString("vc_create_closed_group_group_name_missing_error", comment: ""))
|
||||
guard
|
||||
let name: String = nameTextField.text?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
name.count > 0
|
||||
else {
|
||||
return showError(title: "vc_create_closed_group_group_name_missing_error".localized())
|
||||
}
|
||||
guard name.count < 30 else {
|
||||
return showError(title: NSLocalizedString("vc_create_closed_group_group_name_too_long_error", comment: ""))
|
||||
return showError(title: "vc_create_closed_group_group_name_too_long_error".localized())
|
||||
}
|
||||
guard selectedContacts.count >= 1 else {
|
||||
return showError(title: "Please pick at least 1 group member")
|
||||
}
|
||||
guard selectedContacts.count < 100 else { // Minus one because we're going to include self later
|
||||
return showError(title: NSLocalizedString("vc_create_closed_group_too_many_group_members_error", comment: ""))
|
||||
return showError(title: "vc_create_closed_group_too_many_group_members_error".localized())
|
||||
}
|
||||
let selectedContacts = self.selectedContacts
|
||||
let message: String? = (selectedContacts.count > 20) ? "Please wait while the group is created..." : nil
|
||||
|
@ -288,11 +343,16 @@ final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate
|
|||
.catch(on: DispatchQueue.main) { [weak self] _ in
|
||||
self?.dismiss(animated: true, completion: nil) // Dismiss the loader
|
||||
|
||||
let title = "Couldn't Create Group"
|
||||
let message = "Please check your internet connection and try again."
|
||||
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: nil))
|
||||
self?.presentAlert(alert)
|
||||
let modal: ConfirmationModal = ConfirmationModal(
|
||||
targetView: self?.view,
|
||||
info: ConfirmationModal.Info(
|
||||
title: "Couldn't Create Group",
|
||||
explanation: "Please check your internet connection and try again.",
|
||||
cancelTitle: "BUTTON_OK".localized(),
|
||||
cancelStyle: .alert_text
|
||||
)
|
||||
)
|
||||
self?.present(modal, animated: true)
|
||||
}
|
||||
.retainUntilComplete()
|
||||
}
|
||||
|
@ -308,16 +368,42 @@ final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate
|
|||
extension NewClosedGroupVC: UISearchBarDelegate {
|
||||
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
|
||||
self.searchText = searchText
|
||||
self.tableView.reloadData()
|
||||
|
||||
let changeset: StagedChangeset<[ArraySection<Section, Profile>]> = StagedChangeset(
|
||||
source: data,
|
||||
target: [
|
||||
ArraySection(
|
||||
model: .contacts,
|
||||
elements: (searchText.isEmpty ?
|
||||
contactProfiles :
|
||||
contactProfiles
|
||||
.filter { $0.displayName().range(of: searchText, options: [.caseInsensitive]) != nil }
|
||||
)
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
self.tableView.reload(
|
||||
using: changeset,
|
||||
deleteSectionsAnimation: .none,
|
||||
insertSectionsAnimation: .none,
|
||||
reloadSectionsAnimation: .none,
|
||||
deleteRowsAnimation: .none,
|
||||
insertRowsAnimation: .none,
|
||||
reloadRowsAnimation: .none,
|
||||
interrupt: { $0.changeCount > 100 }
|
||||
) { [weak self] updatedData in
|
||||
self?.data = updatedData
|
||||
}
|
||||
}
|
||||
|
||||
func searchBarShouldBeginEditing(_ searchBar: UISearchBar) -> Bool {
|
||||
searchBar.showsCancelButton = true
|
||||
searchBar.setShowsCancelButton(true, animated: true)
|
||||
return true
|
||||
}
|
||||
|
||||
func searchBarShouldEndEditing(_ searchBar: UISearchBar) -> Bool {
|
||||
searchBar.showsCancelButton = false
|
||||
searchBar.setShowsCancelButton(false, animated: true)
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
|
@ -109,6 +109,9 @@ extension ContextMenuVC {
|
|||
delegate: ContextMenuActionDelegate?
|
||||
) -> [Action]? {
|
||||
// No context items for info messages
|
||||
guard cellViewModel.variant != .standardIncomingDeleted else {
|
||||
return [ Action.delete(cellViewModel, delegate) ]
|
||||
}
|
||||
guard cellViewModel.variant == .standardOutgoing || cellViewModel.variant == .standardIncoming else {
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -11,6 +11,27 @@ extension ContextMenuVC {
|
|||
|
||||
private let action: Action
|
||||
private let dismiss: () -> Void
|
||||
private var didTouchDownInside: Bool = false
|
||||
|
||||
// MARK: - UI
|
||||
|
||||
private let iconImageView: UIImageView = {
|
||||
let result: UIImageView = UIImageView()
|
||||
result.contentMode = .center
|
||||
result.themeTintColor = .textPrimary
|
||||
result.set(.width, to: ActionView.iconImageViewSize)
|
||||
result.set(.height, to: ActionView.iconImageViewSize)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private let titleLabel: UILabel = {
|
||||
let result: UILabel = UILabel()
|
||||
result.font = .systemFont(ofSize: Values.mediumFontSize)
|
||||
result.themeTextColor = .textPrimary
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
|
@ -32,23 +53,12 @@ extension ContextMenuVC {
|
|||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
// Icon
|
||||
let iconSize = ActionView.iconSize
|
||||
let iconImageView: UIImageView = UIImageView(
|
||||
image: action.icon?
|
||||
.resizedImage(to: CGSize(width: iconSize, height: iconSize))?
|
||||
.withRenderingMode(.alwaysTemplate)
|
||||
)
|
||||
iconImageView.set(.width, to: ActionView.iconImageViewSize)
|
||||
iconImageView.set(.height, to: ActionView.iconImageViewSize)
|
||||
iconImageView.contentMode = .center
|
||||
iconImageView.tintColor = Colors.text
|
||||
themeBackgroundColor = .clear
|
||||
|
||||
// Title
|
||||
let titleLabel = UILabel()
|
||||
iconImageView.image = action.icon?
|
||||
.resizedImage(to: CGSize(width: ActionView.iconSize, height: ActionView.iconSize))?
|
||||
.withRenderingMode(.alwaysTemplate)
|
||||
titleLabel.text = action.title
|
||||
titleLabel.textColor = Colors.text
|
||||
titleLabel.font = .systemFont(ofSize: Values.mediumFontSize)
|
||||
|
||||
// Stack view
|
||||
let stackView: UIStackView = UIStackView(arrangedSubviews: [ iconImageView, titleLabel ])
|
||||
|
@ -78,5 +88,58 @@ extension ContextMenuVC {
|
|||
action.work()
|
||||
dismiss()
|
||||
}
|
||||
|
||||
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
guard
|
||||
isUserInteractionEnabled,
|
||||
let location: CGPoint = touches.first?.location(in: self),
|
||||
bounds.contains(location)
|
||||
else { return }
|
||||
|
||||
didTouchDownInside = true
|
||||
themeBackgroundColor = .contextMenu_highlight
|
||||
iconImageView.themeTintColor = .contextMenu_textHighlight
|
||||
titleLabel.themeTextColor = .contextMenu_textHighlight
|
||||
}
|
||||
|
||||
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
guard
|
||||
isUserInteractionEnabled,
|
||||
let location: CGPoint = touches.first?.location(in: self),
|
||||
bounds.contains(location),
|
||||
didTouchDownInside
|
||||
else {
|
||||
if didTouchDownInside {
|
||||
themeBackgroundColor = .clear
|
||||
iconImageView.themeTintColor = .contextMenu_text
|
||||
titleLabel.themeTextColor = .contextMenu_text
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
themeBackgroundColor = .contextMenu_highlight
|
||||
iconImageView.themeTintColor = .contextMenu_textHighlight
|
||||
titleLabel.themeTextColor = .contextMenu_textHighlight
|
||||
}
|
||||
|
||||
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
if didTouchDownInside {
|
||||
themeBackgroundColor = .clear
|
||||
iconImageView.themeTintColor = .contextMenu_text
|
||||
titleLabel.themeTextColor = .contextMenu_text
|
||||
}
|
||||
|
||||
didTouchDownInside = false
|
||||
}
|
||||
|
||||
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
if didTouchDownInside {
|
||||
themeBackgroundColor = .clear
|
||||
iconImageView.themeTintColor = .contextMenu_text
|
||||
titleLabel.themeTextColor = .contextMenu_text
|
||||
}
|
||||
|
||||
didTouchDownInside = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -33,9 +33,9 @@ extension ContextMenuVC {
|
|||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
let emojiLabel = UILabel()
|
||||
emojiLabel.text = self.action.title
|
||||
let emojiLabel: UILabel = UILabel()
|
||||
emojiLabel.font = .systemFont(ofSize: Values.veryLargeFontSize)
|
||||
emojiLabel.text = self.action.title
|
||||
emojiLabel.set(.height, to: ContextMenuVC.EmojiReactsView.size)
|
||||
addSubview(emojiLabel)
|
||||
emojiLabel.pin(to: self)
|
||||
|
@ -84,7 +84,7 @@ extension ContextMenuVC {
|
|||
private func setUpViewHierarchy() {
|
||||
// Icon image
|
||||
let iconImageView = UIImageView(image: #imageLiteral(resourceName: "ic_plus_24").withRenderingMode(.alwaysTemplate))
|
||||
iconImageView.tintColor = Colors.text
|
||||
iconImageView.themeTintColor = .textPrimary
|
||||
iconImageView.set(.width, to: iconSize)
|
||||
iconImageView.set(.height, to: iconSize)
|
||||
iconImageView.contentMode = .scaleAspectFit
|
||||
|
@ -93,7 +93,7 @@ extension ContextMenuVC {
|
|||
|
||||
// Background
|
||||
isUserInteractionEnabled = true
|
||||
backgroundColor = Colors.sessionEmojiPlusButtonBackground
|
||||
themeBackgroundColor = .reactions_contextMoreBackground
|
||||
|
||||
// Tap gesture recognizer
|
||||
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
|
||||
|
|
|
@ -10,44 +10,51 @@ final class ContextMenuVC: UIViewController {
|
|||
|
||||
private let snapshot: UIView
|
||||
private let frame: CGRect
|
||||
private var targetFrame: CGRect = .zero
|
||||
private let cellViewModel: MessageViewModel
|
||||
private let actions: [Action]
|
||||
private let dismiss: () -> Void
|
||||
|
||||
// MARK: - UI
|
||||
|
||||
private lazy var blurView: UIVisualEffectView = UIVisualEffectView(effect: nil)
|
||||
public override var preferredStatusBarStyle: UIStatusBarStyle {
|
||||
return ThemeManager.currentTheme.statusBarStyle
|
||||
}
|
||||
|
||||
private lazy var blurView: UIVisualEffectView = UIVisualEffectView()
|
||||
|
||||
private lazy var emojiBar: UIView = {
|
||||
let result = UIView()
|
||||
result.layer.shadowColor = UIColor.black.cgColor
|
||||
let result: UIView = UIView()
|
||||
result.themeShadowColor = .black
|
||||
result.layer.shadowOffset = CGSize.zero
|
||||
result.layer.shadowOpacity = 0.4
|
||||
result.layer.shadowRadius = 4
|
||||
result.set(.height, to: ContextMenuVC.actionViewHeight)
|
||||
result.alpha = 0
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var emojiPlusButton: EmojiPlusButton = {
|
||||
let result = EmojiPlusButton(
|
||||
let result: EmojiPlusButton = EmojiPlusButton(
|
||||
action: self.actions.first(where: { $0.isEmojiPlus }),
|
||||
dismiss: snDismiss
|
||||
)
|
||||
result.clipsToBounds = true
|
||||
result.set(.width, to: EmojiPlusButton.size)
|
||||
result.set(.height, to: EmojiPlusButton.size)
|
||||
result.layer.cornerRadius = EmojiPlusButton.size / 2
|
||||
result.layer.masksToBounds = true
|
||||
result.layer.cornerRadius = (EmojiPlusButton.size / 2)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var menuView: UIView = {
|
||||
let result: UIView = UIView()
|
||||
result.layer.shadowColor = UIColor.black.cgColor
|
||||
result.themeShadowColor = .black
|
||||
result.layer.shadowOffset = CGSize.zero
|
||||
result.layer.shadowOpacity = 0.4
|
||||
result.layer.shadowRadius = 4
|
||||
result.alpha = 0
|
||||
|
||||
return result
|
||||
}()
|
||||
|
@ -55,11 +62,19 @@ final class ContextMenuVC: UIViewController {
|
|||
private lazy var timestampLabel: UILabel = {
|
||||
let result: UILabel = UILabel()
|
||||
result.font = .systemFont(ofSize: Values.verySmallFontSize)
|
||||
result.textColor = (isLightMode ? .black : .white)
|
||||
result.text = cellViewModel.dateForUI.formattedForDisplay
|
||||
result.themeTextColor = .textPrimary
|
||||
result.alpha = 0
|
||||
|
||||
if let dateForUI: Date = cellViewModel.dateForUI {
|
||||
result.text = dateForUI.formattedForDisplay
|
||||
}
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var fallbackTimestampLabel: UILabel = {
|
||||
let result: UILabel = UILabel()
|
||||
result.font = .systemFont(ofSize: Values.verySmallFontSize)
|
||||
result.text = cellViewModel.dateForUI.formattedForDisplay
|
||||
result.themeTextColor = .textPrimary
|
||||
result.alpha = 0
|
||||
|
||||
return result
|
||||
}()
|
||||
|
@ -96,35 +111,24 @@ final class ContextMenuVC: UIViewController {
|
|||
super.viewDidLoad()
|
||||
|
||||
// Background color
|
||||
view.backgroundColor = .clear
|
||||
view.themeBackgroundColor = .clear
|
||||
|
||||
// Blur
|
||||
view.addSubview(blurView)
|
||||
blurView.pin(to: view)
|
||||
|
||||
// Snapshot
|
||||
snapshot.layer.shadowColor = UIColor.black.cgColor
|
||||
snapshot.themeShadowColor = .black
|
||||
snapshot.layer.shadowOffset = CGSize.zero
|
||||
snapshot.layer.shadowOpacity = 0.4
|
||||
snapshot.layer.shadowRadius = 4
|
||||
view.addSubview(snapshot)
|
||||
|
||||
// Timestamp
|
||||
view.addSubview(timestampLabel)
|
||||
timestampLabel.center(.vertical, in: snapshot)
|
||||
|
||||
if cellViewModel.variant == .standardOutgoing {
|
||||
timestampLabel.pin(.right, to: .left, of: snapshot, withInset: -Values.smallSpacing)
|
||||
}
|
||||
else {
|
||||
timestampLabel.pin(.left, to: .right, of: snapshot, withInset: Values.smallSpacing)
|
||||
}
|
||||
|
||||
// Emoji reacts
|
||||
let emojiBarBackgroundView = UIView()
|
||||
emojiBarBackgroundView.backgroundColor = Colors.receivedMessageBackground
|
||||
emojiBarBackgroundView.layer.cornerRadius = ContextMenuVC.actionViewHeight / 2
|
||||
emojiBarBackgroundView.layer.masksToBounds = true
|
||||
let emojiBarBackgroundView: UIView = UIView()
|
||||
emojiBarBackgroundView.clipsToBounds = true
|
||||
emojiBarBackgroundView.themeBackgroundColor = .reactions_contextBackground
|
||||
emojiBarBackgroundView.layer.cornerRadius = (ContextMenuVC.actionViewHeight / 2)
|
||||
emojiBar.addSubview(emojiBarBackgroundView)
|
||||
emojiBarBackgroundView.pin(to: emojiBar)
|
||||
|
||||
|
@ -150,10 +154,10 @@ final class ContextMenuVC: UIViewController {
|
|||
view.addSubview(emojiBar)
|
||||
|
||||
// Menu
|
||||
let menuBackgroundView = UIView()
|
||||
menuBackgroundView.backgroundColor = Colors.receivedMessageBackground
|
||||
let menuBackgroundView: UIView = UIView()
|
||||
menuBackgroundView.clipsToBounds = true
|
||||
menuBackgroundView.themeBackgroundColor = .contextMenu_background
|
||||
menuBackgroundView.layer.cornerRadius = ContextMenuVC.menuCornerRadius
|
||||
menuBackgroundView.layer.masksToBounds = true
|
||||
menuView.addSubview(menuBackgroundView)
|
||||
menuBackgroundView.pin(to: menuView)
|
||||
|
||||
|
@ -163,30 +167,63 @@ final class ContextMenuVC: UIViewController {
|
|||
.map { action -> ActionView in ActionView(for: action, dismiss: snDismiss) }
|
||||
)
|
||||
menuStackView.axis = .vertical
|
||||
menuView.addSubview(menuStackView)
|
||||
menuStackView.pin(to: menuView)
|
||||
menuBackgroundView.addSubview(menuStackView)
|
||||
menuStackView.pin(to: menuBackgroundView)
|
||||
view.addSubview(menuView)
|
||||
|
||||
// Timestamp
|
||||
view.addSubview(timestampLabel)
|
||||
timestampLabel.center(.vertical, in: snapshot)
|
||||
|
||||
if cellViewModel.variant == .standardOutgoing {
|
||||
timestampLabel.pin(.right, to: .left, of: snapshot, withInset: -Values.smallSpacing)
|
||||
}
|
||||
else {
|
||||
timestampLabel.pin(.left, to: .right, of: snapshot, withInset: Values.smallSpacing)
|
||||
}
|
||||
|
||||
view.addSubview(fallbackTimestampLabel)
|
||||
fallbackTimestampLabel.pin(.top, to: .top, of: menuView)
|
||||
fallbackTimestampLabel.set(.height, to: ContextMenuVC.actionViewHeight)
|
||||
|
||||
if cellViewModel.variant == .standardOutgoing {
|
||||
fallbackTimestampLabel.pin(.right, to: .left, of: menuView, withInset: -Values.mediumSpacing)
|
||||
}
|
||||
else {
|
||||
fallbackTimestampLabel.pin(.left, to: .right, of: menuView, withInset: Values.mediumSpacing)
|
||||
}
|
||||
|
||||
// Constrains
|
||||
let timestampSize: CGSize = timestampLabel.sizeThatFits(UIScreen.main.bounds.size)
|
||||
let menuHeight: CGFloat = CGFloat(menuStackView.arrangedSubviews.count) * ContextMenuVC.actionViewHeight
|
||||
let spacing: CGFloat = Values.smallSpacing
|
||||
let targetFrame: CGRect = calculateFrame(menuHeight: menuHeight, spacing: spacing)
|
||||
self.targetFrame = calculateFrame(menuHeight: menuHeight, spacing: spacing)
|
||||
|
||||
snapshot.pin(.left, to: .left, of: view, withInset: targetFrame.origin.x)
|
||||
snapshot.pin(.top, to: .top, of: view, withInset: targetFrame.origin.y)
|
||||
snapshot.set(.width, to: targetFrame.width)
|
||||
snapshot.set(.height, to: targetFrame.height)
|
||||
emojiBar.pin(.bottom, to: .top, of: snapshot, withInset: -spacing)
|
||||
menuView.pin(.top, to: .bottom, of: snapshot, withInset: spacing)
|
||||
// Decide which timestamp label should be used based on whether it'll go off screen
|
||||
self.timestampLabel.isHidden = {
|
||||
switch cellViewModel.variant {
|
||||
case .standardOutgoing:
|
||||
return ((self.targetFrame.minX - timestampSize.width - Values.mediumSpacing) < 0)
|
||||
|
||||
default:
|
||||
return ((self.targetFrame.maxX + timestampSize.width + Values.mediumSpacing) > UIScreen.main.bounds.width)
|
||||
}
|
||||
}()
|
||||
self.fallbackTimestampLabel.isHidden = !self.timestampLabel.isHidden
|
||||
|
||||
// Position the snapshot view in it's original message position
|
||||
snapshot.frame = self.frame
|
||||
emojiBar.pin(.bottom, to: .top, of: view, withInset: targetFrame.minY - spacing)
|
||||
menuView.pin(.top, to: .top, of: view, withInset: targetFrame.maxY + spacing)
|
||||
|
||||
switch cellViewModel.variant {
|
||||
case .standardOutgoing:
|
||||
menuView.pin(.right, to: .right, of: snapshot)
|
||||
emojiBar.pin(.right, to: .right, of: snapshot)
|
||||
|
||||
case .standardIncoming:
|
||||
menuView.pin(.left, to: .left, of: snapshot)
|
||||
emojiBar.pin(.left, to: .left, of: snapshot)
|
||||
menuView.pin(.right, to: .right, of: view, withInset: -(UIScreen.main.bounds.width - targetFrame.maxX))
|
||||
emojiBar.pin(.right, to: .right, of: view, withInset: -(UIScreen.main.bounds.width - targetFrame.maxX))
|
||||
|
||||
case .standardIncoming, .standardIncomingDeleted:
|
||||
menuView.pin(.left, to: .left, of: view, withInset: targetFrame.minX)
|
||||
emojiBar.pin(.left, to: .left, of: view, withInset: targetFrame.minX)
|
||||
|
||||
default: break // Should never occur
|
||||
}
|
||||
|
@ -199,9 +236,50 @@ final class ContextMenuVC: UIViewController {
|
|||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
UIView.animate(withDuration: 0.25) {
|
||||
self.blurView.effect = UIBlurEffect(style: .regular)
|
||||
self.menuView.alpha = 1
|
||||
// Fade the menus in and animate the snapshot from it's starting position to where it
|
||||
// needs to be on screen in order to fit the menu
|
||||
let view: UIView = self.view
|
||||
let targetFrame: CGRect = self.targetFrame
|
||||
|
||||
UIView.animate(withDuration: 0.3) { [weak self] in
|
||||
self?.blurView.effect = UIBlurEffect(
|
||||
style: (ThemeManager.currentTheme.interfaceStyle == .light ?
|
||||
.light :
|
||||
.dark
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
UIView.animate(withDuration: 0.2) { [weak self] in
|
||||
self?.emojiBar.alpha = 1
|
||||
self?.menuView.alpha = 1
|
||||
self?.timestampLabel.alpha = 1
|
||||
self?.fallbackTimestampLabel.alpha = 1
|
||||
}
|
||||
|
||||
UIView.animate(
|
||||
withDuration: 0.3,
|
||||
delay: 0,
|
||||
usingSpringWithDamping: 0.8,
|
||||
initialSpringVelocity: 0.6,
|
||||
options: .curveEaseInOut,
|
||||
animations: { [weak self] in
|
||||
self?.snapshot.pin(.left, to: .left, of: view, withInset: targetFrame.origin.x)
|
||||
self?.snapshot.pin(.top, to: .top, of: view, withInset: targetFrame.origin.y)
|
||||
self?.snapshot.set(.width, to: targetFrame.width)
|
||||
self?.snapshot.set(.height, to: targetFrame.height)
|
||||
self?.snapshot.superview?.setNeedsLayout()
|
||||
self?.snapshot.superview?.layoutIfNeeded()
|
||||
},
|
||||
completion: nil
|
||||
)
|
||||
|
||||
// Change the blur effect on theme change
|
||||
ThemeManager.onThemeChange(observer: blurView) { [weak self] theme, _ in
|
||||
switch theme.interfaceStyle {
|
||||
case .light: self?.blurView.effect = UIBlurEffect(style: .light)
|
||||
default: self?.blurView.effect = UIBlurEffect(style: .dark)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -210,8 +288,8 @@ final class ContextMenuVC: UIViewController {
|
|||
let ratio: CGFloat = (frame.width / frame.height)
|
||||
|
||||
// FIXME: Need to update this when an appropriate replacement is added (see https://teng.pub/technical/2021/11/9/uiapplication-key-window-replacement)
|
||||
let topMargin = max(UIApplication.shared.keyWindow!.safeAreaInsets.top, Values.mediumSpacing)
|
||||
let bottomMargin = max(UIApplication.shared.keyWindow!.safeAreaInsets.bottom, Values.mediumSpacing)
|
||||
let topMargin = max((UIApplication.shared.keyWindow?.safeAreaInsets.top ?? 0), Values.mediumSpacing)
|
||||
let bottomMargin = max((UIApplication.shared.keyWindow?.safeAreaInsets.bottom ?? 0), Values.mediumSpacing)
|
||||
let diffY = finalFrame.height + menuHeight + Self.actionViewHeight + 2 * spacing + topMargin + bottomMargin - UIScreen.main.bounds.height
|
||||
|
||||
if diffY > 0 {
|
||||
|
@ -261,14 +339,56 @@ final class ContextMenuVC: UIViewController {
|
|||
}
|
||||
|
||||
func snDismiss() {
|
||||
let currentFrame: CGRect = self.snapshot.frame
|
||||
let currentLabelFrame: CGRect = self.timestampLabel.frame
|
||||
let originalFrame: CGRect = self.frame
|
||||
let frameDiff: CGRect = CGRect(
|
||||
x: (currentFrame.minX - originalFrame.minX),
|
||||
y: (currentFrame.minY - originalFrame.minY),
|
||||
width: (currentFrame.width - originalFrame.width),
|
||||
height: (currentFrame.height - originalFrame.height)
|
||||
)
|
||||
let endLabelFrame: CGRect = CGRect(
|
||||
x: (currentLabelFrame.minX - (frameDiff.origin.x + frameDiff.width)),
|
||||
y: (currentLabelFrame.minY - (frameDiff.origin.y + frameDiff.height)),
|
||||
width: currentLabelFrame.width,
|
||||
height: currentLabelFrame.height
|
||||
)
|
||||
|
||||
// Remove the snapshot view and it's timestampLabel from the view hierarchy to remove its
|
||||
// constaints (and prevent them from causing animation bugs - also need to turn
|
||||
// 'translatesAutoresizingMaskIntoConstraints' back on so autod layout doesn't mess with
|
||||
// the frame manipulation)
|
||||
let oldSuperview: UIView? = self.snapshot.superview
|
||||
self.snapshot.removeFromSuperview()
|
||||
self.timestampLabel.removeFromSuperview()
|
||||
oldSuperview?.insertSubview(self.snapshot, aboveSubview: self.blurView)
|
||||
oldSuperview?.insertSubview(self.timestampLabel, aboveSubview: self.blurView)
|
||||
|
||||
self.snapshot.translatesAutoresizingMaskIntoConstraints = true
|
||||
self.timestampLabel.translatesAutoresizingMaskIntoConstraints = true
|
||||
self.snapshot.frame = currentFrame
|
||||
self.timestampLabel.frame = currentLabelFrame
|
||||
|
||||
UIView.animate(
|
||||
withDuration: 0.15,
|
||||
delay: 0,
|
||||
options: .curveEaseOut,
|
||||
animations: { [weak self] in
|
||||
self?.snapshot.frame = originalFrame
|
||||
self?.timestampLabel.frame = endLabelFrame
|
||||
},
|
||||
completion: nil
|
||||
)
|
||||
|
||||
UIView.animate(
|
||||
withDuration: 0.25,
|
||||
animations: { [weak self] in
|
||||
self?.blurView.effect = nil
|
||||
self?.menuView.alpha = 0
|
||||
self?.emojiBar.alpha = 0
|
||||
self?.snapshot.alpha = 0
|
||||
self?.timestampLabel.alpha = 0
|
||||
self?.fallbackTimestampLabel.alpha = 0
|
||||
},
|
||||
completion: { [weak self] _ in
|
||||
self?.dismiss()
|
||||
|
|
|
@ -1,14 +1,38 @@
|
|||
// 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
|
||||
}
|
||||
|
||||
let stubbableSearchBar: StubbableSearchBar = StubbableSearchBar()
|
||||
override public var searchBar: UISearchBar {
|
||||
get { stubbableSearchBar }
|
||||
}
|
||||
}
|
||||
|
||||
public class StubbableSearchBar: UISearchBar {
|
||||
weak var stubbedNextResponder: UIResponder?
|
||||
|
||||
public override var next: UIResponder? {
|
||||
if let stubbedNextResponder = self.stubbedNextResponder {
|
||||
return stubbedNextResponder
|
||||
}
|
||||
|
||||
return super.next
|
||||
}
|
||||
}
|
||||
|
||||
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: StyledSearchController = StyledSearchController(searchResultsController: nil)
|
||||
public let resultsBar: SearchResultsBar = SearchResultsBar()
|
||||
|
||||
private var lastSearchText: String?
|
||||
|
@ -57,17 +81,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 +132,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?
|
||||
|
||||
|
@ -103,43 +143,49 @@ public final class SearchResultsBar: UIView {
|
|||
private lazy var label: UILabel = {
|
||||
let result = UILabel()
|
||||
result.font = .boldSystemFont(ofSize: Values.smallFontSize)
|
||||
result.textColor = Colors.text
|
||||
result.themeTextColor = .textPrimary
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var upButton: UIButton = {
|
||||
let icon = #imageLiteral(resourceName: "ic_chevron_up").withRenderingMode(.alwaysTemplate)
|
||||
let result = UIButton()
|
||||
let result: UIButton = UIButton()
|
||||
result.setImage(icon, for: UIControl.State.normal)
|
||||
result.tintColor = Colors.accent
|
||||
result.themeTintColor = .primary
|
||||
result.addTarget(self, action: #selector(handleUpButtonTapped), for: UIControl.Event.touchUpInside)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var downButton: UIButton = {
|
||||
let icon = #imageLiteral(resourceName: "ic_chevron_down").withRenderingMode(.alwaysTemplate)
|
||||
let result = UIButton()
|
||||
let result: UIButton = UIButton()
|
||||
result.setImage(icon, for: UIControl.State.normal)
|
||||
result.tintColor = Colors.accent
|
||||
result.themeTintColor = .primary
|
||||
result.addTarget(self, action: #selector(handleDownButtonTapped), for: UIControl.Event.touchUpInside)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var loadingIndicator: UIActivityIndicatorView = {
|
||||
let result = UIActivityIndicatorView(style: .medium)
|
||||
result.tintColor = Colors.text
|
||||
result.themeTintColor = .textPrimary
|
||||
result.alpha = 0.5
|
||||
result.hidesWhenStopped = true
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
|
@ -148,18 +194,26 @@ public final class SearchResultsBar: UIView {
|
|||
|
||||
// Background & blur
|
||||
let backgroundView = UIView()
|
||||
backgroundView.backgroundColor = isLightMode ? .white : .black
|
||||
backgroundView.themeBackgroundColor = .backgroundSecondary
|
||||
backgroundView.alpha = Values.lowOpacity
|
||||
addSubview(backgroundView)
|
||||
backgroundView.pin(to: self)
|
||||
let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .regular))
|
||||
|
||||
let blurView = UIVisualEffectView()
|
||||
addSubview(blurView)
|
||||
blurView.pin(to: self)
|
||||
|
||||
ThemeManager.onThemeChange(observer: blurView) { [weak blurView] theme, _ in
|
||||
switch theme.interfaceStyle {
|
||||
case .light: blurView?.effect = UIBlurEffect(style: .light)
|
||||
default: blurView?.effect = UIBlurEffect(style: .dark)
|
||||
}
|
||||
}
|
||||
|
||||
// Separator
|
||||
let separator = UIView()
|
||||
separator.backgroundColor = Colors.text.withAlphaComponent(0.2)
|
||||
separator.set(.height, to: 1 / UIScreen.main.scale)
|
||||
separator.themeBackgroundColor = .borderSeparator
|
||||
separator.set(.height, to: Values.separatorThickness)
|
||||
addSubview(separator)
|
||||
separator.pin([ UIView.HorizontalEdge.leading, UIView.VerticalEdge.top, UIView.HorizontalEdge.trailing ], to: self)
|
||||
|
||||
|
@ -191,11 +245,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 +258,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 +268,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 +301,8 @@ public final class SearchResultsBar: UIView {
|
|||
return 0
|
||||
}()
|
||||
|
||||
self.results = results
|
||||
self.readConnection.mutate { $0 = nil }
|
||||
self.results.mutate { $0 = results }
|
||||
|
||||
updateBarItems()
|
||||
|
||||
|
@ -238,7 +312,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: SessionTableViewController = SessionTableViewController(
|
||||
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
|
||||
|
@ -55,12 +64,32 @@ extension ConversationVC:
|
|||
@objc func startCall(_ sender: Any?) {
|
||||
guard SessionCall.isEnabled else { return }
|
||||
guard Storage.shared[.areCallsEnabled] else {
|
||||
let callPermissionRequestModal = CallPermissionRequestModal()
|
||||
self.navigationController?.present(callPermissionRequestModal, animated: true, completion: nil)
|
||||
let confirmationModal: ConfirmationModal = ConfirmationModal(
|
||||
info: ConfirmationModal.Info(
|
||||
title: "modal_call_permission_request_title".localized(),
|
||||
explanation: "modal_call_permission_request_explanation".localized(),
|
||||
confirmTitle: "vc_settings_title".localized(),
|
||||
dismissOnConfirm: false // Custom dismissal logic
|
||||
) { [weak self] _ in
|
||||
self?.dismiss(animated: true) {
|
||||
let navController: UINavigationController = StyledNavigationController(
|
||||
rootViewController: SessionTableViewController(
|
||||
viewModel: PrivacySettingsViewModel(
|
||||
shouldShowCloseButton: true
|
||||
)
|
||||
)
|
||||
)
|
||||
navController.modalPresentationStyle = .fullScreen
|
||||
self?.present(navController, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
self.navigationController?.present(confirmationModal, animated: true, completion: nil)
|
||||
return
|
||||
}
|
||||
|
||||
requestMicrophonePermissionIfNeeded { }
|
||||
Permissions.requestMicrophonePermissionIfNeeded()
|
||||
|
||||
let threadId: String = self.viewModel.threadData.threadId
|
||||
|
||||
|
@ -85,19 +114,41 @@ extension ConversationVC:
|
|||
}
|
||||
|
||||
@discardableResult func showBlockedModalIfNeeded() -> Bool {
|
||||
guard self.viewModel.threadData.threadIsBlocked == true else { return false }
|
||||
guard
|
||||
self.viewModel.threadData.threadVariant == .contact &&
|
||||
self.viewModel.threadData.threadIsBlocked == true
|
||||
else { return false }
|
||||
|
||||
let blockedModal = BlockedModal(publicKey: viewModel.threadData.threadId)
|
||||
blockedModal.modalPresentationStyle = .overFullScreen
|
||||
blockedModal.modalTransitionStyle = .crossDissolve
|
||||
present(blockedModal, animated: true, completion: nil)
|
||||
let message = String(
|
||||
format: "modal_blocked_explanation".localized(),
|
||||
self.viewModel.threadData.displayName
|
||||
)
|
||||
let confirmationModal: ConfirmationModal = ConfirmationModal(
|
||||
info: ConfirmationModal.Info(
|
||||
title: String(
|
||||
format: "modal_blocked_title".localized(),
|
||||
self.viewModel.threadData.displayName
|
||||
),
|
||||
attributedExplanation: NSAttributedString(string: message)
|
||||
.adding(
|
||||
attributes: [ .font: UIFont.boldSystemFont(ofSize: Values.smallFontSize) ],
|
||||
range: (message as NSString).range(of: self.viewModel.threadData.displayName)
|
||||
),
|
||||
confirmTitle: "modal_blocked_button_title".localized(),
|
||||
dismissOnConfirm: false // Custom dismissal logic
|
||||
) { [weak self] _ in
|
||||
self?.viewModel.unblockContact()
|
||||
self?.dismiss(animated: true, completion: nil)
|
||||
}
|
||||
)
|
||||
present(confirmationModal, animated: true, completion: nil)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - SendMediaNavDelegate
|
||||
|
||||
func sendMediaNavDidCancel(_ sendMediaNavigationController: SendMediaNavigationController) {
|
||||
func sendMediaNavDidCancel(_ sendMediaNavigationController: SendMediaNavigationController?) {
|
||||
dismiss(animated: true, completion: nil)
|
||||
}
|
||||
|
||||
|
@ -148,7 +199,7 @@ extension ConversationVC:
|
|||
let gifVC = GifPickerViewController()
|
||||
gifVC.delegate = self
|
||||
|
||||
let navController = OWSNavigationController(rootViewController: gifVC)
|
||||
let navController = StyledNavigationController(rootViewController: gifVC)
|
||||
navController.modalPresentationStyle = .fullScreen
|
||||
present(navController, animated: true) { }
|
||||
}
|
||||
|
@ -159,14 +210,14 @@ extension ConversationVC:
|
|||
let documentPickerVC = UIDocumentPickerViewController(documentTypes: [ kUTTypeItem as String ], in: UIDocumentPickerMode.import)
|
||||
documentPickerVC.delegate = self
|
||||
documentPickerVC.modalPresentationStyle = .fullScreen
|
||||
SNAppearance.switchToDocumentPickerAppearance()
|
||||
|
||||
present(documentPickerVC, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
func handleLibraryButtonTapped() {
|
||||
let threadId: String = self.viewModel.threadData.threadId
|
||||
|
||||
requestLibraryPermissionIfNeeded { [weak self] in
|
||||
Permissions.requestLibraryPermissionIfNeeded { [weak self] in
|
||||
DispatchQueue.main.async {
|
||||
let sendMediaNavController = SendMediaNavigationController.showingMediaLibraryFirst(
|
||||
threadId: threadId
|
||||
|
@ -179,9 +230,9 @@ extension ConversationVC:
|
|||
}
|
||||
|
||||
func handleCameraButtonTapped() {
|
||||
guard requestCameraPermissionIfNeeded() else { return }
|
||||
guard Permissions.requestCameraPermissionIfNeeded(presentingViewController: self) else { return }
|
||||
|
||||
requestMicrophonePermissionIfNeeded { }
|
||||
Permissions.requestMicrophonePermissionIfNeeded()
|
||||
|
||||
if AVAudioSession.sharedInstance().recordPermission != .granted {
|
||||
SNLog("Proceeding without microphone access. Any recorded video will be silent.")
|
||||
|
@ -201,13 +252,8 @@ extension ConversationVC:
|
|||
}
|
||||
|
||||
// MARK: - UIDocumentPickerDelegate
|
||||
|
||||
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
|
||||
SNAppearance.switchToSessionAppearance() // Switch back to the correct appearance
|
||||
}
|
||||
|
||||
|
||||
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
||||
SNAppearance.switchToSessionAppearance()
|
||||
guard let url = urls.first else { return } // TODO: Handle multiple?
|
||||
|
||||
let urlResourceValues: URLResourceValues
|
||||
|
@ -216,29 +262,49 @@ extension ConversationVC:
|
|||
}
|
||||
catch {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
let alert = UIAlertController(title: "Session", message: "An error occurred.", preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil))
|
||||
|
||||
self?.present(alert, animated: true, completion: nil)
|
||||
let modal: ConfirmationModal = ConfirmationModal(
|
||||
targetView: self?.view,
|
||||
info: ConfirmationModal.Info(
|
||||
title: "Session",
|
||||
explanation: "An error occurred.",
|
||||
cancelTitle: "BUTTON_OK".localized(),
|
||||
cancelStyle: .alert_text
|
||||
)
|
||||
)
|
||||
self?.present(modal, animated: true)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let type = urlResourceValues.typeIdentifier ?? (kUTTypeData as String)
|
||||
guard urlResourceValues.isDirectory != true else {
|
||||
DispatchQueue.main.async {
|
||||
OWSAlerts.showAlert(
|
||||
title: "ATTACHMENT_PICKER_DOCUMENTS_PICKED_DIRECTORY_FAILED_ALERT_TITLE".localized(),
|
||||
message: "ATTACHMENT_PICKER_DOCUMENTS_PICKED_DIRECTORY_FAILED_ALERT_BODY".localized()
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
let modal: ConfirmationModal = ConfirmationModal(
|
||||
targetView: self?.view,
|
||||
info: ConfirmationModal.Info(
|
||||
title: "ATTACHMENT_PICKER_DOCUMENTS_PICKED_DIRECTORY_FAILED_ALERT_TITLE".localized(),
|
||||
explanation: "ATTACHMENT_PICKER_DOCUMENTS_PICKED_DIRECTORY_FAILED_ALERT_BODY".localized(),
|
||||
cancelTitle: "BUTTON_OK".localized(),
|
||||
cancelStyle: .alert_text
|
||||
)
|
||||
)
|
||||
self?.present(modal, animated: true)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let fileName = urlResourceValues.name ?? NSLocalizedString("ATTACHMENT_DEFAULT_FILENAME", comment: "")
|
||||
guard let dataSource = DataSourcePath.dataSource(with: url, shouldDeleteOnDeallocation: false) else {
|
||||
DispatchQueue.main.async {
|
||||
OWSAlerts.showAlert(title: "ATTACHMENT_PICKER_DOCUMENTS_FAILED_ALERT_TITLE".localized())
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
let modal: ConfirmationModal = ConfirmationModal(
|
||||
targetView: self?.view,
|
||||
info: ConfirmationModal.Info(
|
||||
title: "ATTACHMENT_PICKER_DOCUMENTS_FAILED_ALERT_TITLE".localized(),
|
||||
cancelTitle: "BUTTON_OK".localized(),
|
||||
cancelStyle: .alert_text
|
||||
)
|
||||
)
|
||||
self?.present(modal, animated: true)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
@ -312,10 +378,17 @@ extension ConversationVC:
|
|||
|
||||
if text.contains(mnemonic) && !viewModel.threadData.threadIsNoteToSelf && !hasPermissionToSendSeed {
|
||||
// Warn the user if they're about to send their seed to someone
|
||||
let modal = SendSeedModal()
|
||||
modal.modalPresentationStyle = .overFullScreen
|
||||
modal.modalTransitionStyle = .crossDissolve
|
||||
modal.proceed = { [weak self] in self?.sendMessage(hasPermissionToSendSeed: true) }
|
||||
let modal: ConfirmationModal = ConfirmationModal(
|
||||
info: ConfirmationModal.Info(
|
||||
title: "modal_send_seed_title".localized(),
|
||||
explanation: "modal_send_seed_explanation".localized(),
|
||||
confirmTitle: "modal_send_seed_send_button_title".localized(),
|
||||
confirmStyle: .danger,
|
||||
cancelStyle: .alert_text,
|
||||
onConfirm: { [weak self] _ in self?.sendMessage(hasPermissionToSendSeed: true) }
|
||||
)
|
||||
)
|
||||
|
||||
return present(modal, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
|
@ -428,12 +501,19 @@ extension ConversationVC:
|
|||
|
||||
if text.contains(mnemonic) && !viewModel.threadData.threadIsNoteToSelf && !hasPermissionToSendSeed {
|
||||
// Warn the user if they're about to send their seed to someone
|
||||
let modal = SendSeedModal()
|
||||
modal.modalPresentationStyle = .overFullScreen
|
||||
modal.modalTransitionStyle = .crossDissolve
|
||||
modal.proceed = { [weak self] in
|
||||
self?.sendAttachments(attachments, with: text, hasPermissionToSendSeed: true, onComplete: onComplete)
|
||||
}
|
||||
let modal: ConfirmationModal = ConfirmationModal(
|
||||
info: ConfirmationModal.Info(
|
||||
title: "modal_send_seed_title".localized(),
|
||||
explanation: "modal_send_seed_explanation".localized(),
|
||||
confirmTitle: "modal_send_seed_send_button_title".localized(),
|
||||
confirmStyle: .danger,
|
||||
cancelStyle: .alert_text,
|
||||
onConfirm: { [weak self] _ in
|
||||
self?.sendAttachments(attachments, with: text, hasPermissionToSendSeed: true, onComplete: onComplete)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
return present(modal, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
|
@ -527,12 +607,21 @@ extension ConversationVC:
|
|||
}
|
||||
|
||||
func showLinkPreviewSuggestionModal() {
|
||||
let linkPreviewModel = LinkPreviewModal() { [weak self] in
|
||||
self?.snInputView.autoGenerateLinkPreview()
|
||||
}
|
||||
linkPreviewModel.modalPresentationStyle = .overFullScreen
|
||||
linkPreviewModel.modalTransitionStyle = .crossDissolve
|
||||
present(linkPreviewModel, animated: true, completion: nil)
|
||||
let linkPreviewModal: ConfirmationModal = ConfirmationModal(
|
||||
info: ConfirmationModal.Info(
|
||||
title: "modal_link_previews_title".localized(),
|
||||
explanation: "modal_link_previews_explanation".localized(),
|
||||
confirmTitle: "modal_link_previews_button_title".localized()
|
||||
) { [weak self] _ in
|
||||
Storage.shared.writeAsync { db in
|
||||
db[.areLinkPreviewsEnabled] = true
|
||||
}
|
||||
|
||||
self?.snInputView.autoGenerateLinkPreview()
|
||||
}
|
||||
)
|
||||
|
||||
present(linkPreviewModal, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
func inputTextViewDidChangeContent(_ inputTextView: InputTextView) {
|
||||
|
@ -580,7 +669,7 @@ extension ConversationVC:
|
|||
|
||||
// MARK: --Mentions
|
||||
|
||||
func handleMentionSelected(_ mentionInfo: ConversationViewModel.MentionInfo, from view: MentionSelectionView) {
|
||||
func handleMentionSelected(_ mentionInfo: MentionInfo, from view: MentionSelectionView) {
|
||||
guard let currentMentionStartIndex = currentMentionStartIndex else { return }
|
||||
|
||||
mentions.append(mentionInfo)
|
||||
|
@ -693,6 +782,9 @@ extension ConversationVC:
|
|||
)
|
||||
else { return }
|
||||
|
||||
/// Lock the contentOffset of the tableView so the transition doesn't look buggy
|
||||
self.tableView.lockContentOffset = true
|
||||
|
||||
UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
|
||||
self.contextMenuWindow = ContextMenuWindow()
|
||||
self.contextMenuVC = ContextMenuVC(
|
||||
|
@ -706,15 +798,26 @@ extension ConversationVC:
|
|||
self?.contextMenuWindow = nil
|
||||
self?.scrollButton.alpha = 0
|
||||
|
||||
UIView.animate(withDuration: 0.25) {
|
||||
self?.scrollButton.alpha = (self?.getScrollButtonOpacity() ?? 0)
|
||||
self?.unreadCountView.alpha = (self?.scrollButton.alpha ?? 0)
|
||||
}
|
||||
UIView.animate(
|
||||
withDuration: 0.25,
|
||||
animations: {
|
||||
self?.scrollButton.alpha = (self?.getScrollButtonOpacity() ?? 0)
|
||||
self?.unreadCountView.alpha = (self?.scrollButton.alpha ?? 0)
|
||||
},
|
||||
completion: { _ in
|
||||
guard let contentOffset: CGPoint = self?.tableView.contentOffset else { return }
|
||||
|
||||
// Unlock the contentOffset so everything will be in the right
|
||||
// place when we return
|
||||
self?.tableView.lockContentOffset = false
|
||||
self?.tableView.setContentOffset(contentOffset, animated: false)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
self.contextMenuWindow?.backgroundColor = .clear
|
||||
self.contextMenuWindow?.themeBackgroundColor = .clear
|
||||
self.contextMenuWindow?.rootViewController = self.contextMenuVC
|
||||
self.contextMenuWindow?.overrideUserInterfaceStyle = (isDarkMode ? .dark : .light)
|
||||
self.contextMenuWindow?.overrideUserInterfaceStyle = ThemeManager.currentTheme.interfaceStyle
|
||||
self.contextMenuWindow?.makeKeyAndVisible()
|
||||
}
|
||||
|
||||
|
@ -734,11 +837,30 @@ extension ConversationVC:
|
|||
|
||||
// If it's an incoming media message and the thread isn't trusted then show the placeholder view
|
||||
if cellViewModel.cellType != .textOnlyMessage && cellViewModel.variant == .standardIncoming && !cellViewModel.threadIsTrusted {
|
||||
let modal = DownloadAttachmentModal(profile: cellViewModel.profile)
|
||||
modal.modalPresentationStyle = .overFullScreen
|
||||
modal.modalTransitionStyle = .crossDissolve
|
||||
let message: String = String(
|
||||
format: "modal_download_attachment_explanation".localized(),
|
||||
cellViewModel.authorName
|
||||
)
|
||||
let confirmationModal: ConfirmationModal = ConfirmationModal(
|
||||
info: ConfirmationModal.Info(
|
||||
title: String(
|
||||
format: "modal_download_attachment_title".localized(),
|
||||
cellViewModel.authorName
|
||||
),
|
||||
attributedExplanation: NSAttributedString(string: message)
|
||||
.adding(
|
||||
attributes: [ .font: UIFont.boldSystemFont(ofSize: Values.smallFontSize) ],
|
||||
range: (message as NSString).range(of: cellViewModel.authorName)
|
||||
),
|
||||
confirmTitle: "modal_download_button_title".localized(),
|
||||
dismissOnConfirm: false // Custom dismissal logic
|
||||
) { [weak self] _ in
|
||||
self?.viewModel.trustContact()
|
||||
self?.dismiss(animated: true, completion: nil)
|
||||
}
|
||||
)
|
||||
|
||||
present(modal, animated: true, completion: nil)
|
||||
present(confirmationModal, animated: true, completion: nil)
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -898,38 +1020,31 @@ extension ConversationVC:
|
|||
guard let url: URL = URL(string: urlString) else { return }
|
||||
|
||||
// URLs can be unsafe, so always ask the user whether they want to open one
|
||||
let alertVC = UIAlertController.init(
|
||||
let actionSheet: UIAlertController = UIAlertController(
|
||||
title: "modal_open_url_title".localized(),
|
||||
message: String(format: "modal_open_url_explanation".localized(), url.absoluteString),
|
||||
preferredStyle: .actionSheet
|
||||
)
|
||||
alertVC.addAction(UIAlertAction.init(title: "modal_open_url_button_title".localized(), style: .default) { [weak self] _ in
|
||||
actionSheet.addAction(UIAlertAction(title: "modal_open_url_button_title".localized(), style: .default) { [weak self] _ in
|
||||
UIApplication.shared.open(url, options: [:], completionHandler: nil)
|
||||
self?.showInputAccessoryView()
|
||||
})
|
||||
alertVC.addAction(UIAlertAction.init(title: "modal_copy_url_button_title".localized(), style: .default) { [weak self] _ in
|
||||
actionSheet.addAction(UIAlertAction(title: "modal_copy_url_button_title".localized(), style: .default) { [weak self] _ in
|
||||
UIPasteboard.general.string = url.absoluteString
|
||||
self?.showInputAccessoryView()
|
||||
})
|
||||
alertVC.addAction(UIAlertAction.init(title: "cancel".localized(), style: .cancel) { [weak self] _ in
|
||||
actionSheet.addAction(UIAlertAction(title: "cancel".localized(), style: .cancel) { [weak self] _ in
|
||||
self?.showInputAccessoryView()
|
||||
})
|
||||
|
||||
self.presentAlert(alertVC)
|
||||
Modal.setupForIPadIfNeeded(actionSheet, targetView: self.view)
|
||||
self.present(actionSheet, animated: true)
|
||||
}
|
||||
|
||||
func handleReplyButtonTapped(for cellViewModel: MessageViewModel) {
|
||||
reply(cellViewModel)
|
||||
}
|
||||
|
||||
func showUserDetails(for profile: Profile) {
|
||||
let userDetailsSheet = UserDetailsSheet(for: profile)
|
||||
userDetailsSheet.modalPresentationStyle = .overFullScreen
|
||||
userDetailsSheet.modalTransitionStyle = .crossDissolve
|
||||
|
||||
present(userDetailsSheet, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
func startThread(with sessionId: String, openGroupServer: String?, openGroupPublicKey: String?) {
|
||||
guard viewModel.threadData.canWrite else { return }
|
||||
guard SessionId.Prefix(from: sessionId) == .blinded else {
|
||||
|
@ -1210,7 +1325,8 @@ extension ConversationVC:
|
|||
|
||||
}
|
||||
.retainUntilComplete()
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
let pendingChange = OpenGroupManager
|
||||
.addPendingReaction(
|
||||
emoji: emoji,
|
||||
|
@ -1219,6 +1335,7 @@ extension ConversationVC:
|
|||
on: openGroup.server,
|
||||
type: .add
|
||||
)
|
||||
|
||||
OpenGroupAPI
|
||||
.reactionAdd(
|
||||
db,
|
||||
|
@ -1244,8 +1361,8 @@ extension ConversationVC:
|
|||
}
|
||||
.retainUntilComplete()
|
||||
}
|
||||
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
// Send the actual message
|
||||
try MessageSender.send(
|
||||
db,
|
||||
|
@ -1303,7 +1420,7 @@ extension ConversationVC:
|
|||
self?.showInputAccessoryView()
|
||||
}
|
||||
)
|
||||
emojiPicker.modalPresentationStyle = .overFullScreen
|
||||
|
||||
present(emojiPicker, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
|
@ -1358,11 +1475,66 @@ extension ConversationVC:
|
|||
|
||||
func joinOpenGroup(name: String?, url: String) {
|
||||
// Open groups can be unsafe, so always ask the user whether they want to join one
|
||||
let joinOpenGroupModal: JoinOpenGroupModal = JoinOpenGroupModal(name: name, url: url)
|
||||
joinOpenGroupModal.modalPresentationStyle = .overFullScreen
|
||||
joinOpenGroupModal.modalTransitionStyle = .crossDissolve
|
||||
let finalName: String = (name ?? "Open Group")
|
||||
let message: String = "Are you sure you want to join the \(finalName) open group?";
|
||||
let modal: ConfirmationModal = ConfirmationModal(
|
||||
info: ConfirmationModal.Info(
|
||||
title: "Join \(finalName)?",
|
||||
attributedExplanation: NSMutableAttributedString(string: message)
|
||||
.adding(
|
||||
attributes: [ .font: UIFont.boldSystemFont(ofSize: Values.smallFontSize) ],
|
||||
range: (message as NSString).range(of: finalName)
|
||||
),
|
||||
confirmTitle: "JOIN_COMMUNITY_BUTTON_TITLE".localized(),
|
||||
onConfirm: { modal in
|
||||
guard let presentingViewController: UIViewController = modal.presentingViewController else {
|
||||
return
|
||||
}
|
||||
guard let (room, server, publicKey) = OpenGroupManager.parseOpenGroup(from: url) else {
|
||||
let errorModal: ConfirmationModal = ConfirmationModal(
|
||||
info: ConfirmationModal.Info(
|
||||
title: "Couldn't Join",
|
||||
cancelTitle: "BUTTON_OK".localized(),
|
||||
cancelStyle: .alert_text
|
||||
)
|
||||
)
|
||||
|
||||
return presentingViewController.present(errorModal, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
Storage.shared
|
||||
.writeAsync { db in
|
||||
OpenGroupManager.shared.add(
|
||||
db,
|
||||
roomToken: room,
|
||||
server: server,
|
||||
publicKey: publicKey,
|
||||
isConfigMessage: false
|
||||
)
|
||||
}
|
||||
.done(on: DispatchQueue.main) { _ in
|
||||
Storage.shared.writeAsync { db in
|
||||
try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() // FIXME: It's probably cleaner to do this inside addOpenGroup(...)
|
||||
}
|
||||
}
|
||||
.catch(on: DispatchQueue.main) { error in
|
||||
let errorModal: ConfirmationModal = ConfirmationModal(
|
||||
info: ConfirmationModal.Info(
|
||||
title: "Couldn't Join",
|
||||
explanation: error.localizedDescription,
|
||||
cancelTitle: "BUTTON_OK".localized(),
|
||||
cancelStyle: .alert_text
|
||||
)
|
||||
)
|
||||
|
||||
presentingViewController.present(errorModal, animated: true, completion: nil)
|
||||
}
|
||||
.retainUntilComplete()
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
present(joinOpenGroupModal, animated: true, completion: nil)
|
||||
present(modal, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
// MARK: - ContextMenuActionDelegate
|
||||
|
@ -1422,6 +1594,14 @@ extension ConversationVC:
|
|||
|
||||
func delete(_ cellViewModel: MessageViewModel) {
|
||||
// Only allow deletion on incoming and outgoing messages
|
||||
guard cellViewModel.variant != .standardIncomingDeleted else {
|
||||
Storage.shared.writeAsync { db in
|
||||
_ = try Interaction
|
||||
.filter(id: cellViewModel.id)
|
||||
.deleteAll(db)
|
||||
}
|
||||
return
|
||||
}
|
||||
guard cellViewModel.variant == .standardIncoming || cellViewModel.variant == .standardOutgoing else {
|
||||
return
|
||||
}
|
||||
|
@ -1489,9 +1669,7 @@ extension ConversationVC:
|
|||
)
|
||||
else {
|
||||
// If the message hasn't been sent yet then just delete locally
|
||||
guard cellViewModel.state == .sending || cellViewModel.state == .failed else {
|
||||
return
|
||||
}
|
||||
guard cellViewModel.state == .sending || cellViewModel.state == .failed else { return }
|
||||
|
||||
// Retrieve any message send jobs for this interaction
|
||||
let jobs: [Job] = Storage.shared
|
||||
|
@ -1597,8 +1775,8 @@ extension ConversationVC:
|
|||
return
|
||||
}
|
||||
|
||||
let alertVC = UIAlertController.init(title: nil, message: nil, preferredStyle: .actionSheet)
|
||||
alertVC.addAction(UIAlertAction(title: "delete_message_for_me".localized(), style: .destructive) { [weak self] _ in
|
||||
let actionSheet: UIAlertController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
|
||||
actionSheet.addAction(UIAlertAction(title: "delete_message_for_me".localized(), style: .destructive) { [weak self] _ in
|
||||
Storage.shared.writeAsync { db in
|
||||
_ = try Interaction
|
||||
.filter(id: cellViewModel.id)
|
||||
|
@ -1616,7 +1794,7 @@ extension ConversationVC:
|
|||
self?.showInputAccessoryView()
|
||||
})
|
||||
|
||||
alertVC.addAction(UIAlertAction(
|
||||
actionSheet.addAction(UIAlertAction(
|
||||
title: (cellViewModel.threadVariant == .closedGroup ?
|
||||
"delete_message_for_everyone".localized() :
|
||||
String(format: "delete_message_for_me_and_recipient".localized(), threadName)
|
||||
|
@ -1650,13 +1828,14 @@ extension ConversationVC:
|
|||
}
|
||||
})
|
||||
|
||||
alertVC.addAction(UIAlertAction.init(title: "TXT_CANCEL_TITLE".localized(), style: .cancel) { [weak self] _ in
|
||||
actionSheet.addAction(UIAlertAction.init(title: "TXT_CANCEL_TITLE".localized(), style: .cancel) { [weak self] _ in
|
||||
self?.showInputAccessoryView()
|
||||
})
|
||||
|
||||
self.inputAccessoryView?.isHidden = true
|
||||
self.inputAccessoryView?.alpha = 0
|
||||
self.presentAlert(alertVC)
|
||||
Modal.setupForIPadIfNeeded(actionSheet, targetView: self.view)
|
||||
self.present(actionSheet, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1722,85 +1901,105 @@ extension ConversationVC:
|
|||
guard cellViewModel.threadVariant == .openGroup else { return }
|
||||
|
||||
let threadId: String = self.viewModel.threadData.threadId
|
||||
let alert: UIAlertController = UIAlertController(
|
||||
title: "Session",
|
||||
message: "This will ban the selected user from this room. It won't ban them from other rooms.",
|
||||
preferredStyle: .alert
|
||||
)
|
||||
alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { [weak self] _ in
|
||||
Storage.shared
|
||||
.read { db -> Promise<Void> in
|
||||
guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: threadId) else {
|
||||
return Promise(error: StorageError.objectNotFound)
|
||||
}
|
||||
let modal: ConfirmationModal = ConfirmationModal(
|
||||
targetView: self.view,
|
||||
info: ConfirmationModal.Info(
|
||||
title: "Session",
|
||||
explanation: "This will ban the selected user from this room. It won't ban them from other rooms.",
|
||||
confirmTitle: "BUTTON_OK".localized(),
|
||||
cancelStyle: .alert_text,
|
||||
onConfirm: { [weak self] _ in
|
||||
Storage.shared
|
||||
.read { db -> Promise<Void> in
|
||||
guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: threadId) else {
|
||||
return Promise(error: StorageError.objectNotFound)
|
||||
}
|
||||
|
||||
return OpenGroupAPI
|
||||
.userBan(
|
||||
db,
|
||||
sessionId: cellViewModel.authorId,
|
||||
from: [openGroup.roomToken],
|
||||
on: openGroup.server
|
||||
)
|
||||
.map { _ in () }
|
||||
}
|
||||
.catch(on: DispatchQueue.main) { _ in
|
||||
let modal: ConfirmationModal = ConfirmationModal(
|
||||
targetView: self?.view,
|
||||
info: ConfirmationModal.Info(
|
||||
title: CommonStrings.errorAlertTitle,
|
||||
explanation: "context_menu_ban_user_error_alert_message".localized(),
|
||||
cancelTitle: "BUTTON_OK".localized(),
|
||||
cancelStyle: .alert_text
|
||||
)
|
||||
)
|
||||
self?.present(modal, animated: true)
|
||||
}
|
||||
.retainUntilComplete()
|
||||
|
||||
return OpenGroupAPI
|
||||
.userBan(
|
||||
db,
|
||||
sessionId: cellViewModel.authorId,
|
||||
from: [openGroup.roomToken],
|
||||
on: openGroup.server
|
||||
)
|
||||
.map { _ in () }
|
||||
}
|
||||
.catch(on: DispatchQueue.main) { _ in
|
||||
OWSAlerts.showErrorAlert(message: "context_menu_ban_user_error_alert_message".localized())
|
||||
}
|
||||
.retainUntilComplete()
|
||||
|
||||
self?.becomeFirstResponder()
|
||||
}))
|
||||
alert.addAction(UIAlertAction(title: "Cancel", style: .default, handler: { [weak self] _ in
|
||||
self?.becomeFirstResponder()
|
||||
}))
|
||||
|
||||
present(alert, animated: true, completion: nil)
|
||||
self?.becomeFirstResponder()
|
||||
},
|
||||
afterClosed: { [weak self] in self?.becomeFirstResponder() }
|
||||
)
|
||||
)
|
||||
self.present(modal, animated: true)
|
||||
}
|
||||
|
||||
func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel) {
|
||||
guard cellViewModel.threadVariant == .openGroup else { return }
|
||||
|
||||
let threadId: String = self.viewModel.threadData.threadId
|
||||
let alert: UIAlertController = UIAlertController(
|
||||
title: "Session",
|
||||
message: "This will ban the selected user from this room and delete all messages sent by them. It won't ban them from other rooms or delete the messages they sent there.",
|
||||
preferredStyle: .alert
|
||||
let modal: ConfirmationModal = ConfirmationModal(
|
||||
targetView: self.view,
|
||||
info: ConfirmationModal.Info(
|
||||
title: "Session",
|
||||
explanation: "This will ban the selected user from this room and delete all messages sent by them. It won't ban them from other rooms or delete the messages they sent there.",
|
||||
confirmTitle: "BUTTON_OK".localized(),
|
||||
cancelStyle: .alert_text,
|
||||
onConfirm: { [weak self] _ in
|
||||
Storage.shared
|
||||
.read { db -> Promise<Void> in
|
||||
guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: threadId) else {
|
||||
return Promise(error: StorageError.objectNotFound)
|
||||
}
|
||||
|
||||
return OpenGroupAPI
|
||||
.userBanAndDeleteAllMessages(
|
||||
db,
|
||||
sessionId: cellViewModel.authorId,
|
||||
in: openGroup.roomToken,
|
||||
on: openGroup.server
|
||||
)
|
||||
.map { _ in () }
|
||||
}
|
||||
.catch(on: DispatchQueue.main) { _ in
|
||||
let modal: ConfirmationModal = ConfirmationModal(
|
||||
targetView: self?.view,
|
||||
info: ConfirmationModal.Info(
|
||||
title: CommonStrings.errorAlertTitle,
|
||||
explanation: "context_menu_ban_user_error_alert_message".localized(),
|
||||
cancelTitle: "BUTTON_OK".localized(),
|
||||
cancelStyle: .alert_text
|
||||
)
|
||||
)
|
||||
self?.present(modal, animated: true)
|
||||
}
|
||||
.retainUntilComplete()
|
||||
|
||||
self?.becomeFirstResponder()
|
||||
},
|
||||
afterClosed: { [weak self] in self?.becomeFirstResponder() }
|
||||
)
|
||||
)
|
||||
alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { [weak self] _ in
|
||||
Storage.shared
|
||||
.read { db -> Promise<Void> in
|
||||
guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: threadId) else {
|
||||
return Promise(error: StorageError.objectNotFound)
|
||||
}
|
||||
|
||||
return OpenGroupAPI
|
||||
.userBanAndDeleteAllMessages(
|
||||
db,
|
||||
sessionId: cellViewModel.authorId,
|
||||
in: openGroup.roomToken,
|
||||
on: openGroup.server
|
||||
)
|
||||
.map { _ in () }
|
||||
}
|
||||
.catch(on: DispatchQueue.main) { _ in
|
||||
OWSAlerts.showErrorAlert(message: "context_menu_ban_user_error_alert_message".localized())
|
||||
}
|
||||
.retainUntilComplete()
|
||||
|
||||
self?.becomeFirstResponder()
|
||||
}))
|
||||
alert.addAction(UIAlertAction(title: "Cancel", style: .default, handler: { [weak self] _ in
|
||||
self?.becomeFirstResponder()
|
||||
}))
|
||||
|
||||
present(alert, animated: true, completion: nil)
|
||||
self.present(modal, animated: true)
|
||||
}
|
||||
|
||||
// MARK: - VoiceMessageRecordingViewDelegate
|
||||
|
||||
func startVoiceMessageRecording() {
|
||||
// Request permission if needed
|
||||
requestMicrophonePermissionIfNeeded() { [weak self] in
|
||||
Permissions.requestMicrophonePermissionIfNeeded() { [weak self] in
|
||||
self?.cancelVoiceMessageRecording()
|
||||
}
|
||||
|
||||
|
@ -1883,10 +2082,16 @@ extension ConversationVC:
|
|||
guard duration > 1 else {
|
||||
self.audioRecorder = nil
|
||||
|
||||
OWSAlerts.showAlert(
|
||||
title: "VOICE_MESSAGE_TOO_SHORT_ALERT_TITLE".localized(),
|
||||
message: "VOICE_MESSAGE_TOO_SHORT_ALERT_MESSAGE".localized()
|
||||
let modal: ConfirmationModal = ConfirmationModal(
|
||||
targetView: self.view,
|
||||
info: ConfirmationModal.Info(
|
||||
title: "VOICE_MESSAGE_TOO_SHORT_ALERT_TITLE".localized(),
|
||||
explanation: "VOICE_MESSAGE_TOO_SHORT_ALERT_MESSAGE".localized(),
|
||||
cancelTitle: "BUTTON_OK".localized(),
|
||||
cancelStyle: .alert_text
|
||||
)
|
||||
)
|
||||
self.present(modal, animated: true)
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -1922,110 +2127,42 @@ extension ConversationVC:
|
|||
Environment.shared?.audioSession.endAudioActivity(recordVoiceMessageActivity)
|
||||
}
|
||||
|
||||
// MARK: - Permissions
|
||||
// MARK: - Data Extraction Notifications
|
||||
|
||||
func requestCameraPermissionIfNeeded() -> Bool {
|
||||
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
||||
case .authorized: return true
|
||||
case .denied, .restricted:
|
||||
let modal = PermissionMissingModal(permission: "camera") { }
|
||||
modal.modalPresentationStyle = .overFullScreen
|
||||
modal.modalTransitionStyle = .crossDissolve
|
||||
present(modal, animated: true, completion: nil)
|
||||
return false
|
||||
|
||||
case .notDetermined:
|
||||
AVCaptureDevice.requestAccess(for: .video, completionHandler: { _ in })
|
||||
return false
|
||||
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
func requestMicrophonePermissionIfNeeded(onNotGranted: @escaping () -> Void) {
|
||||
switch AVAudioSession.sharedInstance().recordPermission {
|
||||
case .granted: break
|
||||
case .denied:
|
||||
onNotGranted()
|
||||
let modal = PermissionMissingModal(permission: "microphone") {
|
||||
onNotGranted()
|
||||
}
|
||||
modal.modalPresentationStyle = .overFullScreen
|
||||
modal.modalTransitionStyle = .crossDissolve
|
||||
present(modal, animated: true, completion: nil)
|
||||
|
||||
case .undetermined:
|
||||
onNotGranted()
|
||||
AVAudioSession.sharedInstance().requestRecordPermission { _ in }
|
||||
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
func requestLibraryPermissionIfNeeded(onAuthorized: @escaping () -> Void) {
|
||||
let authorizationStatus: PHAuthorizationStatus
|
||||
if #available(iOS 14, *) {
|
||||
authorizationStatus = PHPhotoLibrary.authorizationStatus(for: .readWrite)
|
||||
if authorizationStatus == .notDetermined {
|
||||
// When the user chooses to select photos (which is the .limit status),
|
||||
// the PHPhotoUI will present the picker view on the top of the front view.
|
||||
// Since we have the ScreenLockUI showing when we request premissions,
|
||||
// the picker view will be presented on the top of the ScreenLockUI.
|
||||
// However, the ScreenLockUI will dismiss with the permission request alert view, so
|
||||
// the picker view then will dismiss, too. The selection process cannot be finished
|
||||
// this way. So we add a flag (isRequestingPermission) to prevent the ScreenLockUI
|
||||
// from showing when we request the photo library permission.
|
||||
Environment.shared?.isRequestingPermission = true
|
||||
let appMode = AppModeManager.shared.currentAppMode
|
||||
// FIXME: Rather than setting the app mode to light and then to dark again once we're done,
|
||||
// it'd be better to just customize the appearance of the image picker. There doesn't currently
|
||||
// appear to be a good way to do so though...
|
||||
AppModeManager.shared.setCurrentAppMode(to: .light)
|
||||
PHPhotoLibrary.requestAuthorization(for: .readWrite) { status in
|
||||
DispatchQueue.main.async {
|
||||
AppModeManager.shared.setCurrentAppMode(to: appMode)
|
||||
}
|
||||
Environment.shared?.isRequestingPermission = false
|
||||
if [ PHAuthorizationStatus.authorized, PHAuthorizationStatus.limited ].contains(status) {
|
||||
onAuthorized()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
authorizationStatus = PHPhotoLibrary.authorizationStatus()
|
||||
if authorizationStatus == .notDetermined {
|
||||
PHPhotoLibrary.requestAuthorization { status in
|
||||
if status == .authorized {
|
||||
onAuthorized()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@objc func sendScreenshotNotification() {
|
||||
// Only send screenshot notifications to one-to-one conversations
|
||||
guard self.viewModel.threadData.threadVariant == .contact else { return }
|
||||
|
||||
switch authorizationStatus {
|
||||
case .authorized, .limited:
|
||||
onAuthorized()
|
||||
|
||||
case .denied, .restricted:
|
||||
let modal = PermissionMissingModal(permission: "library") { }
|
||||
modal.modalPresentationStyle = .overFullScreen
|
||||
modal.modalTransitionStyle = .crossDissolve
|
||||
present(modal, animated: true, completion: nil)
|
||||
|
||||
default: return
|
||||
let threadId: String = self.viewModel.threadData.threadId
|
||||
|
||||
Storage.shared.writeAsync { db in
|
||||
guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else { return }
|
||||
|
||||
try MessageSender.send(
|
||||
db,
|
||||
message: DataExtractionNotification(
|
||||
kind: .screenshot
|
||||
),
|
||||
interactionId: nil,
|
||||
in: thread
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Convenience
|
||||
|
||||
func showErrorAlert(for attachment: SignalAttachment, onDismiss: (() -> ())?) {
|
||||
OWSAlerts.showAlert(
|
||||
title: "ATTACHMENT_ERROR_ALERT_TITLE".localized(),
|
||||
message: (attachment.localizedErrorDescription ?? SignalAttachment.missingDataErrorMessage),
|
||||
buttonTitle: nil
|
||||
) { _ in
|
||||
onDismiss?()
|
||||
}
|
||||
let modal: ConfirmationModal = ConfirmationModal(
|
||||
targetView: self.view,
|
||||
info: ConfirmationModal.Info(
|
||||
title: "ATTACHMENT_ERROR_ALERT_TITLE".localized(),
|
||||
explanation: (attachment.localizedErrorDescription ?? SignalAttachment.missingDataErrorMessage),
|
||||
cancelTitle: "BUTTON_OK".localized(),
|
||||
cancelStyle: .alert_text,
|
||||
afterClosed: onDismiss
|
||||
)
|
||||
)
|
||||
self.present(modal, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -8,9 +8,8 @@ import SessionMessagingKit
|
|||
import SessionUtilitiesKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, ConversationSearchControllerDelegate, UITableViewDataSource, UITableViewDelegate {
|
||||
private static let loadingHeaderHeight: CGFloat = 20
|
||||
private static let messageRequestButtonHeight: CGFloat = 34
|
||||
final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITableViewDataSource, UITableViewDelegate {
|
||||
private static let loadingHeaderHeight: CGFloat = 40
|
||||
|
||||
internal let viewModel: ConversationViewModel
|
||||
private var dataChangeObservable: DatabaseCancellable?
|
||||
|
@ -47,7 +46,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
|
||||
// Mentions
|
||||
var currentMentionStartIndex: String.Index?
|
||||
var mentions: [ConversationViewModel.MentionInfo] = []
|
||||
var mentions: [MentionInfo] = []
|
||||
|
||||
// Scrolling & paging
|
||||
var isUserScrolling = false
|
||||
|
@ -69,7 +68,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
// Need to return false during the swap between threads to prevent keyboard dismissal
|
||||
!isReplacingThread
|
||||
}
|
||||
|
||||
|
||||
override var inputAccessoryView: UIView? {
|
||||
guard viewModel.threadData.canWrite else { return nil }
|
||||
|
||||
|
@ -137,15 +136,17 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
lazy var tableView: InsetLockableTableView = {
|
||||
let result: InsetLockableTableView = InsetLockableTableView()
|
||||
result.separatorStyle = .none
|
||||
result.backgroundColor = .clear
|
||||
result.themeBackgroundColor = .clear
|
||||
result.showsVerticalScrollIndicator = false
|
||||
result.contentInsetAdjustmentBehavior = .never
|
||||
result.keyboardDismissMode = .interactive
|
||||
let bottomInset: CGFloat = viewModel.threadData.canWrite ? Values.mediumSpacing : Values.mediumSpacing + UIApplication.shared.keyWindow!.safeAreaInsets.bottom
|
||||
result.contentInset = UIEdgeInsets(
|
||||
top: 0,
|
||||
leading: 0,
|
||||
bottom: bottomInset,
|
||||
bottom: (viewModel.threadData.canWrite ?
|
||||
Values.mediumSpacing :
|
||||
(Values.mediumSpacing + (UIApplication.shared.keyWindow?.safeAreaInsets.bottom ?? 0))
|
||||
),
|
||||
trailing: 0
|
||||
)
|
||||
result.registerHeaderFooterView(view: UITableViewHeaderFooterView.self)
|
||||
|
@ -166,11 +167,11 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
|
||||
lazy var unreadCountView: UIView = {
|
||||
let result: UIView = UIView()
|
||||
result.backgroundColor = Colors.text.withAlphaComponent(Values.veryLowOpacity)
|
||||
result.set(.width, greaterThanOrEqualTo: ConversationVC.unreadCountViewSize)
|
||||
result.set(.height, to: ConversationVC.unreadCountViewSize)
|
||||
result.themeBackgroundColor = .backgroundSecondary
|
||||
result.layer.masksToBounds = true
|
||||
result.layer.cornerRadius = (ConversationVC.unreadCountViewSize / 2)
|
||||
result.set(.width, greaterThanOrEqualTo: ConversationVC.unreadCountViewSize)
|
||||
result.set(.height, to: ConversationVC.unreadCountViewSize)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
@ -178,7 +179,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
lazy var unreadCountLabel: UILabel = {
|
||||
let result: UILabel = UILabel()
|
||||
result.font = .boldSystemFont(ofSize: Values.verySmallFontSize)
|
||||
result.textColor = Colors.text
|
||||
result.themeTextColor = .textPrimary
|
||||
result.textAlignment = .center
|
||||
|
||||
return result
|
||||
|
@ -187,7 +188,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
lazy var blockedBanner: InfoBanner = {
|
||||
let result: InfoBanner = InfoBanner(
|
||||
message: self.viewModel.blockedBannerMessage,
|
||||
backgroundColor: Colors.destructive
|
||||
backgroundColor: .danger
|
||||
)
|
||||
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(unblock))
|
||||
result.addGestureRecognizer(tapGestureRecognizer)
|
||||
|
@ -213,11 +214,11 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
lazy var messageRequestView: UIView = {
|
||||
let result: UIView = UIView()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.themeBackgroundColor = .backgroundPrimary
|
||||
result.isHidden = (
|
||||
self.viewModel.threadData.threadIsMessageRequest == false ||
|
||||
self.viewModel.threadData.threadRequiresApproval == true
|
||||
)
|
||||
result.setGradient(Gradients.defaultBackground)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
@ -226,8 +227,8 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
let result: UILabel = UILabel()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.font = UIFont.systemFont(ofSize: 12)
|
||||
result.text = NSLocalizedString("MESSAGE_REQUESTS_INFO", comment: "")
|
||||
result.textColor = Colors.sessionMessageRequestsInfoText
|
||||
result.text = "MESSAGE_REQUESTS_INFO".localized()
|
||||
result.themeTextColor = .textSecondary
|
||||
result.textAlignment = .center
|
||||
result.numberOfLines = 2
|
||||
|
||||
|
@ -235,50 +236,18 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
}()
|
||||
|
||||
private lazy var messageRequestAcceptButton: UIButton = {
|
||||
let result: UIButton = UIButton()
|
||||
let result: SessionButton = SessionButton(style: .bordered, size: .medium)
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.clipsToBounds = true
|
||||
result.titleLabel?.font = UIFont.boldSystemFont(ofSize: 18)
|
||||
result.setTitle(NSLocalizedString("TXT_DELETE_ACCEPT", comment: ""), for: .normal)
|
||||
result.setTitleColor(Colors.sessionHeading, for: .normal)
|
||||
result.setBackgroundImage(
|
||||
Colors.sessionHeading
|
||||
.withAlphaComponent(isDarkMode ? 0.2 : 0.06)
|
||||
.toImage(isDarkMode: isDarkMode),
|
||||
for: .highlighted
|
||||
)
|
||||
result.layer.cornerRadius = (ConversationVC.messageRequestButtonHeight / 2)
|
||||
result.layer.borderColor = Colors.sessionHeading
|
||||
.resolvedColor(
|
||||
// Note: This is needed for '.cgColor' to support dark mode
|
||||
with: UITraitCollection(userInterfaceStyle: isDarkMode ? .dark : .light)
|
||||
).cgColor
|
||||
result.layer.borderWidth = 1
|
||||
result.setTitle("TXT_DELETE_ACCEPT".localized(), for: .normal)
|
||||
result.addTarget(self, action: #selector(acceptMessageRequest), for: .touchUpInside)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var messageRequestDeleteButton: UIButton = {
|
||||
let result: UIButton = UIButton()
|
||||
let result: SessionButton = SessionButton(style: .destructive, size: .medium)
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.clipsToBounds = true
|
||||
result.titleLabel?.font = UIFont.boldSystemFont(ofSize: 18)
|
||||
result.setTitle(NSLocalizedString("TXT_DECLINE_TITLE", comment: ""), for: .normal)
|
||||
result.setTitleColor(Colors.destructive, for: .normal)
|
||||
result.setBackgroundImage(
|
||||
Colors.destructive
|
||||
.withAlphaComponent(isDarkMode ? 0.2 : 0.06)
|
||||
.toImage(isDarkMode: isDarkMode),
|
||||
for: .highlighted
|
||||
)
|
||||
result.layer.cornerRadius = (ConversationVC.messageRequestButtonHeight / 2)
|
||||
result.layer.borderColor = Colors.destructive
|
||||
.resolvedColor(
|
||||
// Note: This is needed for '.cgColor' to support dark mode
|
||||
with: UITraitCollection(userInterfaceStyle: isDarkMode ? .dark : .light)
|
||||
).cgColor
|
||||
result.layer.borderWidth = 1
|
||||
result.setTitle("TXT_DECLINE_TITLE".localized(), for: .normal)
|
||||
result.addTarget(self, action: #selector(deleteMessageRequest), for: .touchUpInside)
|
||||
|
||||
return result
|
||||
|
@ -289,8 +258,8 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.clipsToBounds = true
|
||||
result.titleLabel?.font = UIFont.boldSystemFont(ofSize: 16)
|
||||
result.setTitle(NSLocalizedString("TXT_BLOCK_USER_TITLE", comment: ""), for: .normal)
|
||||
result.setTitleColor(Colors.destructive, for: .normal)
|
||||
result.setTitle("TXT_BLOCK_USER_TITLE".localized(), for: .normal)
|
||||
result.setThemeTitleColor(.danger, for: .normal)
|
||||
result.addTarget(self, action: #selector(block), for: .touchUpInside)
|
||||
|
||||
return result
|
||||
|
@ -333,11 +302,6 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
// Gradient
|
||||
setUpGradientBackground()
|
||||
|
||||
// Nav bar
|
||||
setUpNavBarStyle()
|
||||
navigationItem.titleView = titleView
|
||||
|
||||
// Note: We need to update the nav bar buttons here (with invalid data) because if we don't the
|
||||
|
@ -378,14 +342,12 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
messageRequestAcceptButton.pin(.top, to: .bottom, of: messageRequestDescriptionLabel, withInset: 20)
|
||||
messageRequestAcceptButton.pin(.left, to: .left, of: messageRequestView, withInset: 20)
|
||||
messageRequestAcceptButton.pin(.bottom, to: .bottom, of: messageRequestView)
|
||||
messageRequestAcceptButton.set(.height, to: ConversationVC.messageRequestButtonHeight)
|
||||
|
||||
messageRequestDeleteButton.pin(.top, to: .bottom, of: messageRequestDescriptionLabel, withInset: 20)
|
||||
messageRequestDeleteButton.pin(.left, to: .right, of: messageRequestAcceptButton, withInset: UIDevice.current.isIPad ? Values.iPadButtonSpacing : 20)
|
||||
messageRequestDeleteButton.pin(.right, to: .right, of: messageRequestView, withInset: -20)
|
||||
messageRequestDeleteButton.pin(.bottom, to: .bottom, of: messageRequestView)
|
||||
messageRequestDeleteButton.set(.width, to: .width, of: messageRequestAcceptButton)
|
||||
messageRequestDeleteButton.set(.height, to: ConversationVC.messageRequestButtonHeight)
|
||||
|
||||
// Unread count view
|
||||
view.addSubview(unreadCountView)
|
||||
|
@ -421,6 +383,12 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
name: UIResponder.keyboardWillHideNotification,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(sendScreenshotNotification),
|
||||
name: UIApplication.userDidTakeScreenshotNotification,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
|
@ -472,6 +440,15 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
@objc func applicationDidBecomeActive(_ notification: Notification) {
|
||||
startObservingChanges(didReturnFromBackground: true)
|
||||
recoverInputView()
|
||||
|
||||
if !isShowingSearchUI {
|
||||
if !self.isFirstResponder {
|
||||
self.becomeFirstResponder()
|
||||
}
|
||||
else {
|
||||
self.reloadInputViews()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func applicationDidResignActive(_ notification: Notification) {
|
||||
|
@ -637,11 +614,8 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
snInputView.text = draft
|
||||
}
|
||||
|
||||
// Now we have done all the needed diffs, update the viewModel with the latest data and mark
|
||||
// all messages as read (we do it in here as the 'threadData' actually contains the last
|
||||
// 'interactionId' for the thread)
|
||||
// Now we have done all the needed diffs update the viewModel with the latest data
|
||||
self.viewModel.updateThreadData(updatedThreadData)
|
||||
self.viewModel.markAllAsRead()
|
||||
|
||||
/// **Note:** This needs to happen **after** we have update the viewModel's thread data
|
||||
if initialLoad || viewModel.threadData.currentUserIsClosedGroupMember != updatedThreadData.currentUserIsClosedGroupMember {
|
||||
|
@ -715,7 +689,8 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
source: viewModel.interactionData,
|
||||
target: updatedData
|
||||
)
|
||||
let isInsert: Bool = (changeset.map({ $0.elementInserted.count }).reduce(0, +) > 0)
|
||||
let numItemsInserted: Int = changeset.map { $0.elementInserted.count }.reduce(0, +)
|
||||
let isInsert: Bool = (numItemsInserted > 0)
|
||||
let wasLoadingMore: Bool = self.isLoadingMore
|
||||
let wasOffsetCloseToBottom: Bool = self.isCloseToBottom
|
||||
let numItemsInUpdatedData: [Int] = updatedData.map { $0.elements.count }
|
||||
|
@ -791,10 +766,12 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
}
|
||||
}
|
||||
}
|
||||
else if wasOffsetCloseToBottom && !wasLoadingMore {
|
||||
// Scroll to the bottom if an interaction was just inserted and we either
|
||||
// just sent a message or are close enough to the bottom (wait a tiny fraction
|
||||
// to avoid buggy animation behaviour)
|
||||
else if wasOffsetCloseToBottom && !wasLoadingMore && numItemsInserted < 5 {
|
||||
/// Scroll to the bottom if an interaction was just inserted and we either just sent a message or are close enough to the
|
||||
/// bottom (wait a tiny fraction to avoid buggy animation behaviour)
|
||||
///
|
||||
/// **Note:** We won't automatically scroll to the bottom if 5 or more messages were inserted (to avoid endlessly
|
||||
/// auto-scrolling to the bottom when fetching new pages of data within open groups
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { [weak self] in
|
||||
self?.scrollToBottom(isAnimated: true)
|
||||
}
|
||||
|
@ -804,6 +781,11 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
self.isLoadingMore = false
|
||||
self.autoLoadNextPageIfNeeded()
|
||||
}
|
||||
else {
|
||||
// Need to update the scroll button alpha in case new messages were added but we didn't scroll
|
||||
self.scrollButton.alpha = self.getScrollButtonOpacity()
|
||||
self.unreadCountView.alpha = self.scrollButton.alpha
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -830,25 +812,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 {
|
||||
|
@ -1083,7 +1071,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
self.view.layoutIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let keyboardTop = (UIScreen.main.bounds.height - keyboardRect.minY)
|
||||
let messageRequestsOffset: CGFloat = (messageRequestView.isHidden ? 0 : messageRequestView.bounds.height + 16)
|
||||
let oldContentInset: UIEdgeInsets = tableView.contentInset
|
||||
|
@ -1208,9 +1196,18 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
with: cellViewModel,
|
||||
mediaCache: mediaCache,
|
||||
playbackInfo: viewModel.playbackInfo(for: cellViewModel) { updatedInfo, error in
|
||||
DispatchQueue.main.async {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard error == nil else {
|
||||
OWSAlerts.showErrorAlert(message: "INVALID_AUDIO_FILE_ALERT_ERROR_MESSAGE".localized())
|
||||
let modal: ConfirmationModal = ConfirmationModal(
|
||||
targetView: self?.view,
|
||||
info: ConfirmationModal.Info(
|
||||
title: CommonStrings.errorAlertTitle,
|
||||
explanation: "INVALID_AUDIO_FILE_ALERT_ERROR_MESSAGE".localized(),
|
||||
cancelTitle: "BUTTON_OK".localized(),
|
||||
cancelStyle: .alert_text
|
||||
)
|
||||
)
|
||||
self?.present(modal, animated: true)
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -1235,7 +1232,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
switch section.model {
|
||||
case .loadOlder, .loadNewer:
|
||||
let loadingIndicator: UIActivityIndicatorView = UIActivityIndicatorView(style: .medium)
|
||||
loadingIndicator.tintColor = Colors.text
|
||||
loadingIndicator.themeTintColor = .textPrimary
|
||||
loadingIndicator.alpha = 0.5
|
||||
loadingIndicator.startAnimating()
|
||||
|
||||
|
@ -1329,6 +1326,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
)
|
||||
|
||||
self.handleInitialOffsetBounceBug(targetIndexPath: targetIndexPath, at: .bottom)
|
||||
self.viewModel.markAsRead(beforeInclusive: nil)
|
||||
}
|
||||
|
||||
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
|
||||
|
@ -1340,8 +1338,41 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
}
|
||||
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
scrollButton.alpha = getScrollButtonOpacity()
|
||||
unreadCountView.alpha = scrollButton.alpha
|
||||
self.scrollButton.alpha = self.getScrollButtonOpacity()
|
||||
self.unreadCountView.alpha = self.scrollButton.alpha
|
||||
|
||||
// We want to mark messages as read while we scroll, so grab the newest message and mark
|
||||
// everything older as read
|
||||
//
|
||||
// Note: For the 'tableVisualBottom' we remove the 'Values.mediumSpacing' as that is the distance
|
||||
// the table content appears above the input view
|
||||
let tableVisualBottom: CGFloat = (tableView.frame.maxY - (tableView.contentInset.bottom - Values.mediumSpacing))
|
||||
|
||||
if
|
||||
let visibleIndexPaths: [IndexPath] = self.tableView.indexPathsForVisibleRows,
|
||||
let messagesSection: Int = visibleIndexPaths
|
||||
.first(where: { self.viewModel.interactionData[$0.section].model == .messages })?
|
||||
.section,
|
||||
let newestCellViewModel: MessageViewModel = visibleIndexPaths
|
||||
.sorted()
|
||||
.filter({ $0.section == messagesSection })
|
||||
.compactMap({ indexPath -> (frame: CGRect, cellViewModel: MessageViewModel)? in
|
||||
guard let frame: CGRect = tableView.cellForRow(at: indexPath)?.frame else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return (
|
||||
view.convert(frame, from: tableView),
|
||||
self.viewModel.interactionData[indexPath.section].elements[indexPath.row]
|
||||
)
|
||||
})
|
||||
// Exclude messages that are partially off the bottom of the screen
|
||||
.filter({ $0.frame.maxY <= tableVisualBottom })
|
||||
.last?
|
||||
.cellViewModel
|
||||
{
|
||||
self.viewModel.markAsRead(beforeInclusive: newestCellViewModel.id)
|
||||
}
|
||||
}
|
||||
|
||||
func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
|
||||
|
@ -1376,19 +1407,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
|
||||
|
||||
|
@ -1409,15 +1440,16 @@ 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)
|
||||
ipadCancelButton.setThemeTitleColor(.textPrimary, for: .normal)
|
||||
searchBarContainer.addSubview(ipadCancelButton)
|
||||
ipadCancelButton.pin(.trailing, to: .trailing, of: searchBarContainer)
|
||||
ipadCancelButton.autoVCenterInSuperview()
|
||||
searchBar.autoPinEdgesToSuperviewEdges(with: UIEdgeInsets.zero, excludingEdge: .trailing)
|
||||
searchBar.pin(.trailing, to: .leading, of: ipadCancelButton, withInset: -Values.smallSpacing)
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
searchBar.autoPinEdgesToSuperviewMargins()
|
||||
}
|
||||
|
||||
|
@ -1451,8 +1483,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
// So here we stub the next responder on the navBar so that when the searchBar resigns
|
||||
// first responder, the ConversationVC will be in it's responder chain - keeeping the
|
||||
// ResultsBar on the bottom of the screen after dismissing the keyboard.
|
||||
let navBar = navigationController!.navigationBar as! OWSNavigationBar
|
||||
navBar.stubbedNextResponder = self
|
||||
searchController.uiSearchController.stubbableSearchBar.stubbedNextResponder = self
|
||||
}
|
||||
|
||||
@objc func hideSearchUI() {
|
||||
|
@ -1460,8 +1491,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
navigationItem.titleView = titleView
|
||||
updateNavBarButtons(threadData: self.viewModel.threadData, initialVariant: viewModel.initialThreadVariant)
|
||||
|
||||
let navBar: OWSNavigationBar? = navigationController?.navigationBar as? OWSNavigationBar
|
||||
navBar?.stubbedNextResponder = nil
|
||||
searchController.uiSearchController.stubbableSearchBar.stubbedNextResponder = nil
|
||||
becomeFirstResponder()
|
||||
reloadInputViews()
|
||||
}
|
||||
|
@ -1469,7 +1499,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)
|
||||
|
@ -1489,6 +1519,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
// Store the info incase we need to load more data (call will be re-triggered)
|
||||
self.focusedInteractionId = interactionId
|
||||
self.shouldHighlightNextScrollToInteraction = highlight
|
||||
self.viewModel.markAsRead(beforeInclusive: interactionId)
|
||||
|
||||
// Ensure the target interaction has been loaded
|
||||
guard
|
||||
|
|
|
@ -149,6 +149,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
|
||||
// MARK: - Interaction Data
|
||||
|
||||
private var lastInteractionIdMarkedAsRead: Int64?
|
||||
public private(set) var unobservedInteractionDataChanges: [SectionModel]?
|
||||
public private(set) var interactionData: [SectionModel] = []
|
||||
public private(set) var reactionExpandedInteractionIds: Set<Int64> = []
|
||||
|
@ -198,6 +199,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
}()
|
||||
)
|
||||
],
|
||||
joinSQL: MessageViewModel.optimisedJoinSQL,
|
||||
filterSQL: MessageViewModel.filterSQL(threadId: threadId),
|
||||
groupSQL: MessageViewModel.groupSQL,
|
||||
orderSQL: MessageViewModel.orderSQL,
|
||||
|
@ -320,134 +322,39 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
|
||||
// MARK: - Mentions
|
||||
|
||||
public struct MentionInfo: FetchableRecord, Decodable {
|
||||
fileprivate static let threadVariantKey = CodingKeys.threadVariant.stringValue
|
||||
fileprivate static let openGroupServerKey = CodingKeys.openGroupServer.stringValue
|
||||
fileprivate static let openGroupRoomTokenKey = CodingKeys.openGroupRoomToken.stringValue
|
||||
|
||||
let profile: Profile
|
||||
let threadVariant: SessionThread.Variant
|
||||
let openGroupServer: String?
|
||||
let openGroupRoomToken: String?
|
||||
}
|
||||
|
||||
public func mentions(for query: String = "") -> [MentionInfo] {
|
||||
let threadData: SessionThreadViewModel = self.threadData
|
||||
|
||||
let results: [MentionInfo] = Storage.shared
|
||||
return Storage.shared
|
||||
.read { db -> [MentionInfo] in
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||
let pattern: FTS5Pattern? = try? SessionThreadViewModel.pattern(db, searchTerm: query, forTable: Profile.self)
|
||||
let capabilities: Set<Capability.Variant> = (threadData.threadVariant != .openGroup ?
|
||||
nil :
|
||||
try? Capability
|
||||
.select(.variant)
|
||||
.filter(Capability.Columns.openGroupServer == threadData.openGroupServer)
|
||||
.asRequest(of: Capability.Variant.self)
|
||||
.fetchSet(db)
|
||||
)
|
||||
.defaulting(to: [])
|
||||
let targetPrefix: SessionId.Prefix = (capabilities.contains(.blind) ?
|
||||
.blinded :
|
||||
.standard
|
||||
)
|
||||
|
||||
switch threadData.threadVariant {
|
||||
case .contact:
|
||||
guard userPublicKey != threadData.threadId else { return [] }
|
||||
|
||||
return [Profile.fetchOrCreate(db, id: threadData.threadId)]
|
||||
.map { profile in
|
||||
MentionInfo(
|
||||
profile: profile,
|
||||
threadVariant: threadData.threadVariant,
|
||||
openGroupServer: nil,
|
||||
openGroupRoomToken: nil
|
||||
)
|
||||
}
|
||||
.filter {
|
||||
query.count < 2 ||
|
||||
$0.profile.displayName(for: $0.threadVariant).contains(query)
|
||||
}
|
||||
|
||||
case .closedGroup:
|
||||
let profile: TypedTableAlias<Profile> = TypedTableAlias()
|
||||
|
||||
return try GroupMember
|
||||
.select(
|
||||
profile.allColumns(),
|
||||
SQL("\(threadData.threadVariant)").forKey(MentionInfo.threadVariantKey)
|
||||
)
|
||||
.filter(GroupMember.Columns.groupId == threadData.threadId)
|
||||
.filter(GroupMember.Columns.profileId != userPublicKey)
|
||||
.filter(GroupMember.Columns.role == GroupMember.Role.standard)
|
||||
.joining(
|
||||
required: GroupMember.profile
|
||||
.aliased(profile)
|
||||
// Note: LIKE is case-insensitive in SQLite
|
||||
.filter(
|
||||
query.count < 2 || (
|
||||
profile[.nickname] != nil &&
|
||||
profile[.nickname].like("%\(query)%")
|
||||
) || (
|
||||
profile[.nickname] == nil &&
|
||||
profile[.name].like("%\(query)%")
|
||||
)
|
||||
)
|
||||
)
|
||||
.asRequest(of: MentionInfo.self)
|
||||
.fetchAll(db)
|
||||
|
||||
case .openGroup:
|
||||
let profile: TypedTableAlias<Profile> = TypedTableAlias()
|
||||
let capabilities: Set<Capability.Variant> = (try? Capability
|
||||
.select(.variant)
|
||||
.filter(Capability.Columns.openGroupServer == threadData.openGroupServer)
|
||||
.asRequest(of: Capability.Variant.self)
|
||||
.fetchSet(db))
|
||||
.defaulting(to: [])
|
||||
let targetPrefix: SessionId.Prefix = (capabilities.contains(.blind) ?
|
||||
.blinded :
|
||||
.standard
|
||||
)
|
||||
|
||||
return try Interaction
|
||||
.select(
|
||||
profile.allColumns(),
|
||||
SQL("\(threadData.threadVariant)").forKey(MentionInfo.threadVariantKey),
|
||||
SQL("\(threadData.openGroupServer)").forKey(MentionInfo.openGroupServerKey),
|
||||
SQL("\(threadData.openGroupRoomToken)").forKey(MentionInfo.openGroupRoomTokenKey)
|
||||
)
|
||||
.distinct()
|
||||
.group(Interaction.Columns.authorId)
|
||||
.filter(Interaction.Columns.threadId == threadData.threadId)
|
||||
.filter(Interaction.Columns.authorId != userPublicKey)
|
||||
.joining(
|
||||
required: Interaction.profile
|
||||
.aliased(profile)
|
||||
.filter(Profile.Columns.id.like("\(targetPrefix.rawValue)%"))
|
||||
// Note: LIKE is case-insensitive in SQLite
|
||||
.filter(
|
||||
query.count < 2 || (
|
||||
profile[.nickname] != nil &&
|
||||
profile[.nickname].like("%\(query)%")
|
||||
) || (
|
||||
profile[.nickname] == nil &&
|
||||
profile[.name].like("%\(query)%")
|
||||
)
|
||||
)
|
||||
)
|
||||
.order(Interaction.Columns.timestampMs.desc)
|
||||
.limit(20)
|
||||
.asRequest(of: MentionInfo.self)
|
||||
.fetchAll(db)
|
||||
}
|
||||
return (try MentionInfo
|
||||
.query(
|
||||
userPublicKey: userPublicKey,
|
||||
threadId: threadData.threadId,
|
||||
threadVariant: threadData.threadVariant,
|
||||
targetPrefix: targetPrefix,
|
||||
pattern: pattern
|
||||
)?
|
||||
.fetchAll(db))
|
||||
.defaulting(to: [])
|
||||
}
|
||||
.defaulting(to: [])
|
||||
|
||||
guard query.count >= 2 else {
|
||||
return results.sorted { lhs, rhs -> Bool in
|
||||
lhs.profile.displayName(for: lhs.threadVariant) < rhs.profile.displayName(for: rhs.threadVariant)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
.sorted { lhs, rhs -> Bool in
|
||||
let maybeLhsRange = lhs.profile.displayName(for: lhs.threadVariant).lowercased().range(of: query.lowercased())
|
||||
let maybeRhsRange = rhs.profile.displayName(for: rhs.threadVariant).lowercased().range(of: query.lowercased())
|
||||
|
||||
guard let lhsRange: Range<String.Index> = maybeLhsRange, let rhsRange: Range<String.Index> = maybeRhsRange else {
|
||||
return true
|
||||
}
|
||||
|
||||
return (lhsRange.lowerBound < rhsRange.lowerBound)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Functions
|
||||
|
@ -474,21 +381,30 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
public func markAllAsRead() {
|
||||
// Don't bother marking anything as read if there are no unread interactions (we can rely
|
||||
// on the 'threadData.threadUnreadCount' to always be accurate)
|
||||
/// This method will mark all interactions as read before the specified interaction id, if no id is provided then all interactions for
|
||||
/// the thread will be marked as read
|
||||
public func markAsRead(beforeInclusive interactionId: Int64?) {
|
||||
/// Since this method now gets triggered when scrolling we want to try to optimise it and avoid busying the database
|
||||
/// write queue when it isn't needed, in order to do this we:
|
||||
///
|
||||
/// - Don't bother marking anything as read if there are no unread interactions (we can rely on the
|
||||
/// `threadData.threadUnreadCount` to always be accurate)
|
||||
/// - Don't bother marking anything as read if this was called with the same `interactionId` that we
|
||||
/// previously marked as read (ie. when scrolling and the last message hasn't changed)
|
||||
guard
|
||||
(self.threadData.threadUnreadCount ?? 0) > 0,
|
||||
let lastInteractionId: Int64 = self.threadData.interactionId
|
||||
let targetInteractionId: Int64 = (interactionId ?? self.threadData.interactionId),
|
||||
self.lastInteractionIdMarkedAsRead != targetInteractionId
|
||||
else { return }
|
||||
|
||||
let threadId: String = self.threadData.threadId
|
||||
let trySendReadReceipt: Bool = (self.threadData.threadIsMessageRequest == false)
|
||||
self.lastInteractionIdMarkedAsRead = targetInteractionId
|
||||
|
||||
Storage.shared.writeAsync { db in
|
||||
try Interaction.markAsRead(
|
||||
db,
|
||||
interactionId: lastInteractionId,
|
||||
interactionId: targetInteractionId,
|
||||
threadId: threadId,
|
||||
includingOlder: true,
|
||||
trySendReadReceipt: trySendReadReceipt
|
||||
|
@ -519,6 +435,53 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
public func trustContact() {
|
||||
guard self.threadData.threadVariant == .contact else { return }
|
||||
|
||||
let threadId: String = self.threadId
|
||||
|
||||
Storage.shared.writeAsync { db in
|
||||
try Contact
|
||||
.filter(id: threadId)
|
||||
.updateAll(db, Contact.Columns.isTrusted.set(to: true))
|
||||
|
||||
// Start downloading any pending attachments for this contact (UI will automatically be
|
||||
// updated due to the database observation)
|
||||
try Attachment
|
||||
.stateInfo(authorId: threadId, state: .pendingDownload)
|
||||
.fetchAll(db)
|
||||
.forEach { attachmentDownloadInfo in
|
||||
JobRunner.add(
|
||||
db,
|
||||
job: Job(
|
||||
variant: .attachmentDownload,
|
||||
threadId: threadId,
|
||||
interactionId: attachmentDownloadInfo.interactionId,
|
||||
details: AttachmentDownloadJob.Details(
|
||||
attachmentId: attachmentDownloadInfo.attachmentId
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func unblockContact() {
|
||||
guard self.threadData.threadVariant == .contact else { return }
|
||||
|
||||
let threadId: String = self.threadId
|
||||
|
||||
Storage.shared.writeAsync { db in
|
||||
try Contact
|
||||
.filter(id: threadId)
|
||||
.updateAll(db, Contact.Columns.isBlocked.set(to: false))
|
||||
|
||||
try MessageSender
|
||||
.syncConfiguration(db, forceSyncNow: true)
|
||||
.retainUntilComplete()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Audio Playback
|
||||
|
||||
public struct PlaybackInfo {
|
||||
|
@ -725,7 +688,8 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
let currentIndex: Int = messageSection.elements
|
||||
.firstIndex(where: { $0.id == interactionId }),
|
||||
currentIndex < (messageSection.elements.count - 1),
|
||||
messageSection.elements[currentIndex + 1].cellType == .audio
|
||||
messageSection.elements[currentIndex + 1].cellType == .audio,
|
||||
Storage.shared[.shouldAutoPlayConsecutiveAudioMessages] == true
|
||||
else { return }
|
||||
|
||||
let nextItem: MessageViewModel = messageSection.elements[currentIndex + 1]
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
protocol EmojiPickerCollectionViewDelegate: AnyObject {
|
||||
|
@ -63,7 +64,7 @@ class EmojiPickerCollectionView: UICollectionView {
|
|||
withReuseIdentifier: EmojiSectionHeader.reuseIdentifier
|
||||
)
|
||||
|
||||
backgroundColor = .clear
|
||||
themeBackgroundColor = .clear
|
||||
|
||||
let longPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress))
|
||||
panGestureRecognizer.require(toFail: longPressGesture)
|
||||
|
@ -303,7 +304,7 @@ private class EmojiCell: UICollectionViewCell {
|
|||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
||||
backgroundColor = .clear
|
||||
themeBackgroundColor = .clear
|
||||
|
||||
emojiLabel.font = .boldSystemFont(ofSize: 32)
|
||||
contentView.addSubview(emojiLabel)
|
||||
|
@ -341,7 +342,7 @@ private class EmojiSectionHeader: UICollectionReusableView {
|
|||
)
|
||||
|
||||
label.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
label.textColor = Colors.text
|
||||
label.themeTextColor = .textPrimary
|
||||
addSubview(label)
|
||||
label.autoPinEdgesToSuperviewMargins()
|
||||
label.setCompressionResistanceHigh()
|
||||
|
@ -355,6 +356,7 @@ private class EmojiSectionHeader: UICollectionReusableView {
|
|||
var labelSize = label.sizeThatFits(size)
|
||||
labelSize.width += layoutMargins.left + layoutMargins.right
|
||||
labelSize.height += layoutMargins.top + layoutMargins.bottom
|
||||
|
||||
return labelSize
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,19 +1,42 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
|
||||
class EmojiPickerSheet: BaseVC {
|
||||
let completionHandler: (EmojiWithSkinTones?) -> Void
|
||||
let dismissHandler: () -> Void
|
||||
|
||||
// MARK: Components
|
||||
|
||||
private lazy var bottomConstraint: NSLayoutConstraint = contentView.pin(.bottom, to: .bottom, of: view)
|
||||
|
||||
private lazy var contentView: UIView = {
|
||||
let result = UIView()
|
||||
|
||||
let backgroundView = UIView()
|
||||
backgroundView.themeBackgroundColor = .backgroundSecondary
|
||||
backgroundView.alpha = Values.lowOpacity
|
||||
result.addSubview(backgroundView)
|
||||
backgroundView.pin(to: result)
|
||||
|
||||
let blurView: UIVisualEffectView = UIVisualEffectView()
|
||||
result.addSubview(blurView)
|
||||
blurView.pin(to: result)
|
||||
|
||||
ThemeManager.onThemeChange(observer: blurView) { [weak blurView] theme, _ in
|
||||
switch theme.interfaceStyle {
|
||||
case .light: blurView?.effect = UIBlurEffect(style: .light)
|
||||
default: blurView?.effect = UIBlurEffect(style: .dark)
|
||||
}
|
||||
}
|
||||
|
||||
let line = UIView()
|
||||
line.set(.height, to: 0.5)
|
||||
line.backgroundColor = Colors.border.withAlphaComponent(0.5)
|
||||
line.themeBackgroundColor = .borderSeparator
|
||||
result.addSubview(line)
|
||||
line.set(.height, to: Values.separatorThickness)
|
||||
line.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing, UIView.VerticalEdge.top ], to: result)
|
||||
result.backgroundColor = Colors.modalBackground
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
|
@ -21,18 +44,22 @@ class EmojiPickerSheet: BaseVC {
|
|||
|
||||
private lazy var searchBar: SearchBar = {
|
||||
let result = SearchBar()
|
||||
result.tintColor = Colors.text
|
||||
result.backgroundColor = .clear
|
||||
result.themeTintColor = .textPrimary
|
||||
result.themeBackgroundColor = .clear
|
||||
result.delegate = self
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Lifecycle
|
||||
// MARK: - Initialization
|
||||
|
||||
init(completionHandler: @escaping (EmojiWithSkinTones?) -> Void, dismissHandler: @escaping () -> Void) {
|
||||
self.completionHandler = completionHandler
|
||||
self.dismissHandler = dismissHandler
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
||||
self.modalPresentationStyle = .overFullScreen
|
||||
}
|
||||
|
||||
public required init() {
|
||||
|
@ -43,35 +70,55 @@ class EmojiPickerSheet: BaseVC {
|
|||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
override public func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
view.themeBackgroundColor = .clear
|
||||
|
||||
setUpViewHierarchy()
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handleKeyboardWillChangeFrameNotification(_:)),
|
||||
name: UIResponder.keyboardWillChangeFrameNotification,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handleKeyboardWillHideNotification(_:)),
|
||||
name: UIResponder.keyboardWillHideNotification,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
view.addSubview(contentView)
|
||||
contentView.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing, UIView.VerticalEdge.bottom ], to: view)
|
||||
|
||||
contentView.pin(.leading, to: .leading, of: view)
|
||||
contentView.pin(.trailing, to: .trailing, of: view)
|
||||
contentView.set(.height, to: 440)
|
||||
populateContentView()
|
||||
}
|
||||
|
||||
private func populateContentView() {
|
||||
bottomConstraint.isActive = true
|
||||
|
||||
let topStackView = UIStackView()
|
||||
topStackView.axis = .horizontal
|
||||
topStackView.isLayoutMarginsRelativeArrangement = true
|
||||
topStackView.spacing = 8
|
||||
|
||||
contentView.addSubview(topStackView)
|
||||
topStackView.set(.width, to: .width, of: contentView)
|
||||
topStackView.pin(.top, to: .top, of: contentView)
|
||||
|
||||
topStackView.addArrangedSubview(searchBar)
|
||||
|
||||
contentView.addSubview(topStackView)
|
||||
|
||||
topStackView.autoPinWidthToSuperview()
|
||||
topStackView.autoPinEdge(toSuperviewEdge: .top)
|
||||
|
||||
contentView.addSubview(collectionView)
|
||||
collectionView.autoPinEdge(.top, to: .bottom, of: searchBar)
|
||||
collectionView.autoPinEdge(.bottom, to: .bottom, of: contentView)
|
||||
collectionView.autoPinWidthToSuperview()
|
||||
collectionView.pin(.top, to: .bottom, of: searchBar)
|
||||
collectionView.pin(.bottom, to: .bottom, of: contentView)
|
||||
collectionView.set(.width, to: .width, of: contentView)
|
||||
collectionView.pickerDelegate = self
|
||||
collectionView.alwaysBounceVertical = true
|
||||
}
|
||||
|
@ -92,16 +139,73 @@ class EmojiPickerSheet: BaseVC {
|
|||
contentView.layoutIfNeeded()
|
||||
}
|
||||
|
||||
// MARK: - Keyboard Avoidance
|
||||
|
||||
@objc func handleKeyboardWillChangeFrameNotification(_ notification: Notification) {
|
||||
// Please refer to https://github.com/mapbox/mapbox-navigation-ios/issues/1600
|
||||
// and https://stackoverflow.com/a/25260930 to better understand what we are
|
||||
// doing with the UIViewAnimationOptions
|
||||
let userInfo: [AnyHashable: Any] = (notification.userInfo ?? [:])
|
||||
let duration = ((userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval) ?? 0)
|
||||
let curveValue: Int = ((userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? Int) ?? Int(UIView.AnimationOptions.curveEaseInOut.rawValue))
|
||||
let options: UIView.AnimationOptions = UIView.AnimationOptions(rawValue: UInt(curveValue << 16))
|
||||
let keyboardRect: CGRect = ((userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect) ?? CGRect.zero)
|
||||
let keyboardTop = (UIScreen.main.bounds.height - keyboardRect.minY)
|
||||
|
||||
UIView.animate(
|
||||
withDuration: duration,
|
||||
delay: 0,
|
||||
options: options,
|
||||
animations: { [weak self] in
|
||||
// Note: We don't need to completely avoid the keyboard here for this to be useful (and
|
||||
// probably don't want to on smaller screens anyway)
|
||||
self?.bottomConstraint.constant = -(keyboardTop / 2)
|
||||
|
||||
self?.view.setNeedsLayout()
|
||||
self?.view.layoutIfNeeded()
|
||||
},
|
||||
completion: nil
|
||||
)
|
||||
}
|
||||
|
||||
@objc func handleKeyboardWillHideNotification(_ notification: Notification) {
|
||||
// Please refer to https://github.com/mapbox/mapbox-navigation-ios/issues/1600
|
||||
// and https://stackoverflow.com/a/25260930 to better understand what we are
|
||||
// doing with the UIViewAnimationOptions
|
||||
let userInfo: [AnyHashable: Any] = (notification.userInfo ?? [:])
|
||||
let duration = ((userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval) ?? 0)
|
||||
let curveValue: Int = ((userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? Int) ?? Int(UIView.AnimationOptions.curveEaseInOut.rawValue))
|
||||
let options: UIView.AnimationOptions = UIView.AnimationOptions(rawValue: UInt(curveValue << 16))
|
||||
|
||||
let keyboardRect: CGRect = ((userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect) ?? CGRect.zero)
|
||||
let keyboardTop = (UIScreen.main.bounds.height - keyboardRect.minY)
|
||||
|
||||
UIView.animate(
|
||||
withDuration: duration,
|
||||
delay: 0,
|
||||
options: options,
|
||||
animations: { [weak self] in
|
||||
self?.bottomConstraint.constant = 0
|
||||
|
||||
self?.view.setNeedsLayout()
|
||||
self?.view.layoutIfNeeded()
|
||||
},
|
||||
completion: nil
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
|
||||
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
let touch = touches.first!
|
||||
let location = touch.location(in: view)
|
||||
if contentView.frame.contains(location) {
|
||||
super.touchesBegan(touches, with: event)
|
||||
} else {
|
||||
guard
|
||||
let touch: UITouch = touches.first,
|
||||
contentView.frame.contains(touch.location(in: view))
|
||||
else {
|
||||
close()
|
||||
return
|
||||
}
|
||||
|
||||
super.touchesBegan(touches, with: event)
|
||||
}
|
||||
|
||||
@objc func close() {
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
|
||||
class EmojiSkinTonePicker: UIView {
|
||||
let emoji: Emoji
|
||||
|
@ -116,12 +118,12 @@ class EmojiSkinTonePicker: UIView {
|
|||
layer.shadowOpacity = 0.25
|
||||
layer.shadowRadius = 4
|
||||
|
||||
referenceOverlay.backgroundColor = Colors.modalBackground
|
||||
referenceOverlay.themeBackgroundColor = .backgroundSecondary
|
||||
referenceOverlay.layer.cornerRadius = 9
|
||||
addSubview(referenceOverlay)
|
||||
|
||||
containerView.layoutMargins = UIEdgeInsets(top: 9, leading: 16, bottom: 9, trailing: 16)
|
||||
containerView.backgroundColor = Colors.modalBackground
|
||||
containerView.themeBackgroundColor = .backgroundSecondary
|
||||
containerView.layer.cornerRadius = 11
|
||||
addSubview(containerView)
|
||||
containerView.autoPinWidthToSuperview()
|
||||
|
@ -129,7 +131,8 @@ class EmojiSkinTonePicker: UIView {
|
|||
|
||||
if emoji.baseEmoji!.allowsMultipleSkinTones {
|
||||
prepareForMultipleSkinTones()
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
prepareForSingleSkinTone()
|
||||
}
|
||||
}
|
||||
|
@ -159,7 +162,7 @@ class EmojiSkinTonePicker: UIView {
|
|||
|
||||
let divider = UIView()
|
||||
divider.autoSetDimension(.width, toSize: 1)
|
||||
divider.backgroundColor = isDarkMode ? .ows_gray75 : .ows_gray05
|
||||
divider.themeBackgroundColor = .borderSeparator
|
||||
hStack.addArrangedSubview(divider)
|
||||
|
||||
hStack.addArrangedSubview(.spacer(withWidth: 2))
|
||||
|
@ -266,7 +269,7 @@ class EmojiSkinTonePicker: UIView {
|
|||
|
||||
let divider = UIView()
|
||||
divider.autoSetDimension(.height, toSize: 1)
|
||||
divider.backgroundColor = isDarkMode ? .ows_gray75 : .ows_gray05
|
||||
divider.themeBackgroundColor = .borderSeparator
|
||||
vStack.addArrangedSubview(divider)
|
||||
|
||||
let leftSpacer = UIView.hStretchingSpacer()
|
||||
|
@ -296,7 +299,7 @@ class EmojiSkinTonePicker: UIView {
|
|||
let button = OWSButton { handler(emoji) }
|
||||
button.titleLabel?.font = .boldSystemFont(ofSize: 32)
|
||||
button.setTitle(emoji.rawValue, for: .normal)
|
||||
button.setBackgroundImage(UIImage(color: isDarkMode ? .ows_gray60 : .ows_gray25), for: .selected)
|
||||
button.setThemeBackgroundColor(.backgroundPrimary, for: .selected)
|
||||
button.layer.cornerRadius = 6
|
||||
button.clipsToBounds = true
|
||||
button.autoSetDimensions(to: CGSize(width: 38, height: 38))
|
||||
|
|
|
@ -1,5 +1,9 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
final class ExpandingAttachmentsButton : UIView, InputViewButtonDelegate {
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
|
||||
final class ExpandingAttachmentsButton: UIView, InputViewButtonDelegate {
|
||||
private weak var delegate: ExpandingAttachmentsButtonDelegate?
|
||||
private var isExpanded = false { didSet { expandOrCollapse() } }
|
||||
|
||||
|
@ -22,31 +26,36 @@ final class ExpandingAttachmentsButton : UIView, InputViewButtonDelegate {
|
|||
// MARK: UI Components
|
||||
lazy var gifButton: InputViewButton = {
|
||||
let result = InputViewButton(icon: #imageLiteral(resourceName: "actionsheet_gif_black"), delegate: self, hasOpaqueBackground: true)
|
||||
result.accessibilityLabel = NSLocalizedString("accessibility_gif_button", comment: "")
|
||||
result.accessibilityLabel = "accessibility_gif_button".localized()
|
||||
|
||||
return result
|
||||
}()
|
||||
lazy var gifButtonContainer = container(for: gifButton)
|
||||
lazy var documentButton: InputViewButton = {
|
||||
let result = InputViewButton(icon: #imageLiteral(resourceName: "actionsheet_document_black"), delegate: self, hasOpaqueBackground: true)
|
||||
result.accessibilityLabel = NSLocalizedString("accessibility_document_button", comment: "")
|
||||
result.accessibilityLabel = "accessibility_document_button".localized()
|
||||
|
||||
return result
|
||||
}()
|
||||
lazy var documentButtonContainer = container(for: documentButton)
|
||||
lazy var libraryButton: InputViewButton = {
|
||||
let result = InputViewButton(icon: #imageLiteral(resourceName: "actionsheet_camera_roll_black"), delegate: self, hasOpaqueBackground: true)
|
||||
result.accessibilityLabel = NSLocalizedString("accessibility_library_button", comment: "")
|
||||
result.accessibilityLabel = "accessibility_library_button".localized()
|
||||
|
||||
return result
|
||||
}()
|
||||
lazy var libraryButtonContainer = container(for: libraryButton)
|
||||
lazy var cameraButton: InputViewButton = {
|
||||
let result = InputViewButton(icon: #imageLiteral(resourceName: "actionsheet_camera_black"), delegate: self, hasOpaqueBackground: true)
|
||||
result.accessibilityLabel = NSLocalizedString("accessibility_camera_button", comment: "")
|
||||
result.accessibilityLabel = "accessibility_camera_button".localized()
|
||||
|
||||
return result
|
||||
}()
|
||||
lazy var cameraButtonContainer = container(for: cameraButton)
|
||||
lazy var mainButton: InputViewButton = {
|
||||
let result = InputViewButton(icon: #imageLiteral(resourceName: "ic_plus_24"), delegate: self)
|
||||
result.accessibilityLabel = NSLocalizedString("accessibility_expanding_attachments_button", comment: "")
|
||||
result.accessibilityLabel = "accessibility_expanding_attachments_button".localized()
|
||||
|
||||
return result
|
||||
}()
|
||||
lazy var mainButtonContainer = container(for: mainButton)
|
||||
|
|
|
@ -1,33 +1,43 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
public final class InputTextView : UITextView, UITextViewDelegate {
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
|
||||
public final class InputTextView: UITextView, UITextViewDelegate {
|
||||
private weak var snDelegate: InputTextViewDelegate?
|
||||
private let maxWidth: CGFloat
|
||||
private lazy var heightConstraint = self.set(.height, to: minHeight)
|
||||
|
||||
public override var text: String? { didSet { handleTextChanged() } }
|
||||
|
||||
// MARK: UI Components
|
||||
// MARK: - UI Components
|
||||
|
||||
private lazy var placeholderLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
result.text = NSLocalizedString("vc_conversation_input_prompt", comment: "")
|
||||
result.font = .systemFont(ofSize: Values.mediumFontSize)
|
||||
result.textColor = Colors.text.withAlphaComponent(Values.mediumOpacity)
|
||||
result.text = "vc_conversation_input_prompt".localized()
|
||||
result.themeTextColor = .textSecondary
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Settings
|
||||
// MARK: - Settings
|
||||
|
||||
private let minHeight: CGFloat = 22
|
||||
private let maxHeight: CGFloat = 80
|
||||
|
||||
// MARK: Lifecycle
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(delegate: InputTextViewDelegate, maxWidth: CGFloat) {
|
||||
snDelegate = delegate
|
||||
self.maxWidth = maxWidth
|
||||
|
||||
super.init(frame: CGRect.zero, textContainer: nil)
|
||||
|
||||
setUpViewHierarchy()
|
||||
self.delegate = self
|
||||
self.isAccessibilityElement = true
|
||||
self.accessibilityLabel = NSLocalizedString("vc_conversation_input_prompt", comment: "")
|
||||
self.accessibilityLabel = "vc_conversation_input_prompt".localized()
|
||||
}
|
||||
|
||||
public override init(frame: CGRect, textContainer: NSTextContainer?) {
|
||||
|
@ -57,11 +67,12 @@ public final class InputTextView : UITextView, UITextViewDelegate {
|
|||
private func setUpViewHierarchy() {
|
||||
showsHorizontalScrollIndicator = false
|
||||
showsVerticalScrollIndicator = false
|
||||
backgroundColor = .clear
|
||||
textColor = Colors.text
|
||||
|
||||
font = .systemFont(ofSize: Values.mediumFontSize)
|
||||
tintColor = Colors.accent
|
||||
keyboardAppearance = isLightMode ? .light : .dark
|
||||
themeBackgroundColor = .clear
|
||||
themeTextColor = .textPrimary
|
||||
themeTintColor = .primary
|
||||
|
||||
heightConstraint.isActive = true
|
||||
let horizontalInset: CGFloat = 2
|
||||
textContainerInset = UIEdgeInsets(top: 0, left: horizontalInset, bottom: 0, right: horizontalInset)
|
||||
|
@ -70,9 +81,17 @@ public final class InputTextView : UITextView, UITextViewDelegate {
|
|||
placeholderLabel.pin(.top, to: .top, of: self)
|
||||
pin(.trailing, to: .trailing, of: placeholderLabel, withInset: horizontalInset)
|
||||
pin(.bottom, to: .bottom, of: placeholderLabel)
|
||||
|
||||
ThemeManager.onThemeChange(observer: self) { [weak self] theme, _ in
|
||||
switch theme.interfaceStyle {
|
||||
case .light: self?.keyboardAppearance = .light
|
||||
default: self?.keyboardAppearance = .dark
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Updating
|
||||
// MARK: - Updating
|
||||
|
||||
public func textViewDidChange(_ textView: UITextView) {
|
||||
handleTextChanged()
|
||||
}
|
||||
|
|
|
@ -54,15 +54,17 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
|
|||
|
||||
private lazy var voiceMessageButton: InputViewButton = {
|
||||
let result = InputViewButton(icon: #imageLiteral(resourceName: "Microphone"), delegate: self)
|
||||
result.accessibilityLabel = NSLocalizedString("VOICE_MESSAGE_TOO_SHORT_ALERT_TITLE", comment: "")
|
||||
result.accessibilityHint = NSLocalizedString("VOICE_MESSAGE_TOO_SHORT_ALERT_MESSAGE", comment: "")
|
||||
result.accessibilityLabel = "VOICE_MESSAGE_TOO_SHORT_ALERT_TITLE".localized()
|
||||
result.accessibilityHint = "VOICE_MESSAGE_TOO_SHORT_ALERT_MESSAGE".localized()
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var sendButton: InputViewButton = {
|
||||
let result = InputViewButton(icon: #imageLiteral(resourceName: "ArrowUp"), isSendButton: true, delegate: self)
|
||||
result.isHidden = true
|
||||
result.accessibilityLabel = NSLocalizedString("ATTACHMENT_APPROVAL_SEND_BUTTON", comment: "")
|
||||
result.accessibilityLabel = "ATTACHMENT_APPROVAL_SEND_BUTTON".localized()
|
||||
|
||||
return result
|
||||
}()
|
||||
private lazy var voiceMessageButtonContainer = container(for: voiceMessageButton)
|
||||
|
@ -76,16 +78,24 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
|
|||
|
||||
private lazy var mentionsViewContainer: UIView = {
|
||||
let result: UIView = UIView()
|
||||
result.alpha = 0
|
||||
|
||||
let backgroundView = UIView()
|
||||
backgroundView.backgroundColor = (isLightMode ? .white : .black)
|
||||
backgroundView.themeBackgroundColor = .backgroundSecondary
|
||||
backgroundView.alpha = Values.lowOpacity
|
||||
result.addSubview(backgroundView)
|
||||
backgroundView.pin(to: result)
|
||||
|
||||
let blurView: UIVisualEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .regular))
|
||||
let blurView: UIVisualEffectView = UIVisualEffectView()
|
||||
result.addSubview(blurView)
|
||||
blurView.pin(to: result)
|
||||
result.alpha = 0
|
||||
|
||||
ThemeManager.onThemeChange(observer: blurView) { [weak blurView] theme, _ in
|
||||
switch theme.interfaceStyle {
|
||||
case .light: blurView?.effect = UIBlurEffect(style: .light)
|
||||
default: blurView?.effect = UIBlurEffect(style: .dark)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}()
|
||||
|
@ -103,8 +113,8 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
|
|||
private lazy var disabledInputLabel: UILabel = {
|
||||
let label: UILabel = UILabel()
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
label.font = UIFont.systemFont(ofSize: Values.smallFontSize)
|
||||
label.textColor = Colors.text.withAlphaComponent(Values.mediumOpacity)
|
||||
label.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
label.themeTextColor = .textPrimary
|
||||
label.textAlignment = .center
|
||||
label.alpha = 0
|
||||
|
||||
|
@ -137,19 +147,26 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
|
|||
|
||||
// Background & blur
|
||||
let backgroundView = UIView()
|
||||
backgroundView.backgroundColor = isLightMode ? .white : .black
|
||||
backgroundView.themeBackgroundColor = .backgroundSecondary
|
||||
backgroundView.alpha = Values.lowOpacity
|
||||
addSubview(backgroundView)
|
||||
backgroundView.pin(to: self)
|
||||
|
||||
let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .regular))
|
||||
let blurView = UIVisualEffectView()
|
||||
addSubview(blurView)
|
||||
blurView.pin(to: self)
|
||||
|
||||
ThemeManager.onThemeChange(observer: blurView) { [weak blurView] theme, _ in
|
||||
switch theme.interfaceStyle {
|
||||
case .light: blurView?.effect = UIBlurEffect(style: .light)
|
||||
default: blurView?.effect = UIBlurEffect(style: .dark)
|
||||
}
|
||||
}
|
||||
|
||||
// Separator
|
||||
let separator = UIView()
|
||||
separator.backgroundColor = Colors.text.withAlphaComponent(0.2)
|
||||
separator.set(.height, to: 1 / UIScreen.main.scale)
|
||||
separator.themeBackgroundColor = .borderSeparator
|
||||
separator.set(.height, to: Values.separatorThickness)
|
||||
addSubview(separator)
|
||||
separator.pin([ UIView.HorizontalEdge.leading, UIView.VerticalEdge.top, UIView.HorizontalEdge.trailing ], to: self)
|
||||
|
||||
|
@ -330,7 +347,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
|
|||
1 :
|
||||
(messageTypes == .textOnly ? 0.4 : 0)
|
||||
)
|
||||
self?.disabledInputLabel.alpha = (messageTypes != .none ? 0 : 1)
|
||||
self?.disabledInputLabel.alpha = (messageTypes != .none ? 0 : Values.mediumOpacity)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -371,21 +388,30 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
|
|||
if inputViewButton == sendButton { delegate?.handleSendButtonTapped() }
|
||||
}
|
||||
|
||||
func handleInputViewButtonLongPressBegan(_ inputViewButton: InputViewButton) {
|
||||
func handleInputViewButtonLongPressBegan(_ inputViewButton: InputViewButton?) {
|
||||
guard inputViewButton == voiceMessageButton else { return }
|
||||
|
||||
delegate?.startVoiceMessageRecording()
|
||||
showVoiceMessageUI()
|
||||
}
|
||||
|
||||
func handleInputViewButtonLongPressMoved(_ inputViewButton: InputViewButton, with touch: UITouch) {
|
||||
guard let voiceMessageRecordingView = voiceMessageRecordingView, inputViewButton == voiceMessageButton else { return }
|
||||
let location = touch.location(in: voiceMessageRecordingView)
|
||||
func handleInputViewButtonLongPressMoved(_ inputViewButton: InputViewButton, with touch: UITouch?) {
|
||||
guard
|
||||
let voiceMessageRecordingView: VoiceMessageRecordingView = voiceMessageRecordingView,
|
||||
inputViewButton == voiceMessageButton,
|
||||
let location = touch?.location(in: voiceMessageRecordingView)
|
||||
else { return }
|
||||
|
||||
voiceMessageRecordingView.handleLongPressMoved(to: location)
|
||||
}
|
||||
|
||||
func handleInputViewButtonLongPressEnded(_ inputViewButton: InputViewButton, with touch: UITouch) {
|
||||
guard let voiceMessageRecordingView = voiceMessageRecordingView, inputViewButton == voiceMessageButton else { return }
|
||||
let location = touch.location(in: voiceMessageRecordingView)
|
||||
func handleInputViewButtonLongPressEnded(_ inputViewButton: InputViewButton, with touch: UITouch?) {
|
||||
guard
|
||||
let voiceMessageRecordingView: VoiceMessageRecordingView = voiceMessageRecordingView,
|
||||
inputViewButton == voiceMessageButton,
|
||||
let location = touch?.location(in: voiceMessageRecordingView)
|
||||
else { return }
|
||||
|
||||
voiceMessageRecordingView.handleLongPressEnded(at: location)
|
||||
}
|
||||
|
||||
|
@ -440,7 +466,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
|
|||
)
|
||||
}
|
||||
|
||||
func showMentionsUI(for candidates: [ConversationViewModel.MentionInfo]) {
|
||||
func showMentionsUI(for candidates: [MentionInfo]) {
|
||||
mentionsView.candidates = candidates
|
||||
|
||||
let mentionCellHeight = (Values.smallProfilePictureSize + 2 * Values.smallSpacing)
|
||||
|
@ -452,7 +478,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
|
|||
}
|
||||
}
|
||||
|
||||
func handleMentionSelected(_ mentionInfo: ConversationViewModel.MentionInfo, from view: MentionSelectionView) {
|
||||
func handleMentionSelected(_ mentionInfo: MentionInfo, from view: MentionSelectionView) {
|
||||
delegate?.handleMentionSelected(mentionInfo, from: view)
|
||||
}
|
||||
|
||||
|
@ -479,6 +505,6 @@ protocol InputViewDelegate: ExpandingAttachmentsButtonDelegate, VoiceMessageReco
|
|||
func showLinkPreviewSuggestionModal()
|
||||
func handleSendButtonTapped()
|
||||
func inputTextViewDidChangeContent(_ inputTextView: InputTextView)
|
||||
func handleMentionSelected(_ mentionInfo: ConversationViewModel.MentionInfo, from view: MentionSelectionView)
|
||||
func handleMentionSelected(_ mentionInfo: MentionInfo, from view: MentionSelectionView)
|
||||
func didPasteImageFromPasteboard(_ image: UIImage)
|
||||
}
|
||||
|
|
|
@ -1,29 +1,41 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
final class InputViewButton : UIView {
|
||||
private let icon: UIImage
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
|
||||
final class InputViewButton: UIView {
|
||||
private let icon: UIImage?
|
||||
private let isSendButton: Bool
|
||||
private weak var delegate: InputViewButtonDelegate?
|
||||
private let hasOpaqueBackground: Bool
|
||||
private let onTap: (() -> Void)?
|
||||
private lazy var widthConstraint = set(.width, to: InputViewButton.size)
|
||||
private lazy var heightConstraint = set(.height, to: InputViewButton.size)
|
||||
private var longPressTimer: Timer?
|
||||
private var isLongPress = false
|
||||
|
||||
// MARK: UI Components
|
||||
private lazy var backgroundView = UIView()
|
||||
// MARK: - UI Components
|
||||
|
||||
// MARK: Settings
|
||||
static let size = CGFloat(40)
|
||||
static let expandedSize = CGFloat(48)
|
||||
private lazy var backgroundView: UIView = UIView()
|
||||
private lazy var iconImageView: UIImageView = UIImageView()
|
||||
|
||||
// MARK: - Settings
|
||||
|
||||
static let size: CGFloat = 40
|
||||
static let expandedSize: CGFloat = 48
|
||||
static let iconSize: CGFloat = 20
|
||||
|
||||
// MARK: Lifecycle
|
||||
init(icon: UIImage, isSendButton: Bool = false, delegate: InputViewButtonDelegate, hasOpaqueBackground: Bool = false) {
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(icon: UIImage?, isSendButton: Bool = false, delegate: InputViewButtonDelegate? = nil, hasOpaqueBackground: Bool = false, onTap: (() -> Void)? = nil) {
|
||||
self.icon = icon
|
||||
self.isSendButton = isSendButton
|
||||
self.delegate = delegate
|
||||
self.hasOpaqueBackground = hasOpaqueBackground
|
||||
self.onTap = onTap
|
||||
|
||||
super.init(frame: CGRect.zero)
|
||||
|
||||
setUpViewHierarchy()
|
||||
self.isAccessibilityElement = true
|
||||
}
|
||||
|
@ -37,63 +49,91 @@ final class InputViewButton : UIView {
|
|||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
backgroundColor = .clear
|
||||
themeBackgroundColor = .clear
|
||||
|
||||
if hasOpaqueBackground {
|
||||
let backgroundView = UIView()
|
||||
backgroundView.backgroundColor = isLightMode ? .white : .black
|
||||
let backgroundView: UIView = UIView()
|
||||
backgroundView.themeBackgroundColor = .inputButton_background
|
||||
backgroundView.alpha = Values.lowOpacity
|
||||
addSubview(backgroundView)
|
||||
backgroundView.pin(to: self)
|
||||
let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .regular))
|
||||
|
||||
let blurView: UIVisualEffectView = UIVisualEffectView()
|
||||
addSubview(blurView)
|
||||
blurView.pin(to: self)
|
||||
|
||||
ThemeManager.onThemeChange(observer: blurView) { [weak blurView] theme, _ in
|
||||
switch theme.interfaceStyle {
|
||||
case .light: blurView?.effect = UIBlurEffect(style: .light)
|
||||
default: blurView?.effect = UIBlurEffect(style: .dark)
|
||||
}
|
||||
}
|
||||
|
||||
themeBorderColor = .borderSeparator
|
||||
layer.borderWidth = Values.separatorThickness
|
||||
let borderColor = (isLightMode ? UIColor.black : UIColor.white).withAlphaComponent(Values.veryLowOpacity)
|
||||
layer.borderColor = borderColor.cgColor
|
||||
}
|
||||
backgroundView.backgroundColor = isSendButton ? Colors.accent : Colors.text.withAlphaComponent(0.05)
|
||||
|
||||
backgroundView.themeBackgroundColor = (isSendButton ? .primary : .inputButton_background)
|
||||
backgroundView.alpha = (isSendButton ? 1 : Values.lowOpacity)
|
||||
addSubview(backgroundView)
|
||||
backgroundView.pin(to: self)
|
||||
layer.cornerRadius = InputViewButton.size / 2
|
||||
|
||||
layer.cornerRadius = (InputViewButton.size / 2)
|
||||
layer.masksToBounds = true
|
||||
isUserInteractionEnabled = true
|
||||
widthConstraint.isActive = true
|
||||
heightConstraint.isActive = true
|
||||
let iconImageView = UIImageView(image: icon.withRenderingMode(.alwaysTemplate))
|
||||
iconImageView.tintColor = (isSendButton ? UIColor.black : Colors.text)
|
||||
|
||||
iconImageView.image = icon?.withRenderingMode(.alwaysTemplate)
|
||||
iconImageView.themeTintColor = (isSendButton ? .black : .textPrimary)
|
||||
iconImageView.contentMode = .scaleAspectFit
|
||||
let iconSize = InputViewButton.iconSize
|
||||
iconImageView.set(.width, to: iconSize)
|
||||
iconImageView.set(.height, to: iconSize)
|
||||
addSubview(iconImageView)
|
||||
iconImageView.center(in: self)
|
||||
iconImageView.set(.width, to: InputViewButton.iconSize)
|
||||
iconImageView.set(.height, to: InputViewButton.iconSize)
|
||||
}
|
||||
|
||||
// MARK: Animation
|
||||
private func animate(to size: CGFloat, glowColor: UIColor, backgroundColor: UIColor) {
|
||||
// MARK: - Animation
|
||||
|
||||
private func animate(
|
||||
to size: CGFloat,
|
||||
themeBackgroundColor: ThemeValue,
|
||||
themeTintColor: ThemeValue,
|
||||
alpha: CGFloat
|
||||
) {
|
||||
let frame = CGRect(center: center, size: CGSize(width: size, height: size))
|
||||
widthConstraint.constant = size
|
||||
heightConstraint.constant = size
|
||||
|
||||
UIView.animate(withDuration: 0.25) {
|
||||
self.layoutIfNeeded()
|
||||
self.frame = frame
|
||||
self.layer.cornerRadius = size / 2
|
||||
let glowConfiguration = UIView.CircularGlowConfiguration(size: size, color: glowColor, isAnimated: true, radius: isLightMode ? 4 : 6)
|
||||
self.setCircularGlow(with: glowConfiguration)
|
||||
self.backgroundView.backgroundColor = backgroundColor
|
||||
self.layer.cornerRadius = (size / 2)
|
||||
self.iconImageView.themeTintColor = themeTintColor
|
||||
self.backgroundView.themeBackgroundColor = themeBackgroundColor
|
||||
self.backgroundView.alpha = alpha
|
||||
}
|
||||
}
|
||||
|
||||
private func expand() {
|
||||
animate(to: InputViewButton.expandedSize, glowColor: Colors.expandedButtonGlowColor, backgroundColor: Colors.accent)
|
||||
animate(
|
||||
to: InputViewButton.expandedSize,
|
||||
themeBackgroundColor: .primary,
|
||||
themeTintColor: .black,
|
||||
alpha: 1
|
||||
)
|
||||
}
|
||||
|
||||
private func collapse() {
|
||||
let backgroundColor = isSendButton ? Colors.accent : Colors.text.withAlphaComponent(0.05)
|
||||
animate(to: InputViewButton.size, glowColor: .clear, backgroundColor: backgroundColor)
|
||||
animate(
|
||||
to: InputViewButton.size,
|
||||
themeBackgroundColor: (isSendButton ? .primary : .inputButton_background),
|
||||
themeTintColor: (isSendButton ? .black : .textPrimary),
|
||||
alpha: (isSendButton ? 1 : Values.lowOpacity)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
// MARK: - Interaction
|
||||
|
||||
// We want to detect both taps and long presses
|
||||
|
||||
|
@ -104,9 +144,8 @@ final class InputViewButton : UIView {
|
|||
expand()
|
||||
invalidateLongPressIfNeeded()
|
||||
longPressTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false, block: { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
self.isLongPress = true
|
||||
self.delegate?.handleInputViewButtonLongPressBegan(self)
|
||||
self?.isLongPress = true
|
||||
self?.delegate?.handleInputViewButtonLongPressBegan(self)
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -114,7 +153,7 @@ final class InputViewButton : UIView {
|
|||
guard isUserInteractionEnabled else { return }
|
||||
|
||||
if isLongPress {
|
||||
delegate?.handleInputViewButtonLongPressMoved(self, with: touches.first!)
|
||||
delegate?.handleInputViewButtonLongPressMoved(self, with: touches.first)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -124,8 +163,9 @@ final class InputViewButton : UIView {
|
|||
collapse()
|
||||
if !isLongPress {
|
||||
delegate?.handleInputViewButtonTapped(self)
|
||||
onTap?()
|
||||
} else {
|
||||
delegate?.handleInputViewButtonLongPressEnded(self, with: touches.first!)
|
||||
delegate?.handleInputViewButtonLongPressEnded(self, with: touches.first)
|
||||
}
|
||||
invalidateLongPressIfNeeded()
|
||||
}
|
||||
|
@ -145,13 +185,13 @@ final class InputViewButton : UIView {
|
|||
|
||||
protocol InputViewButtonDelegate: AnyObject {
|
||||
func handleInputViewButtonTapped(_ inputViewButton: InputViewButton)
|
||||
func handleInputViewButtonLongPressBegan(_ inputViewButton: InputViewButton)
|
||||
func handleInputViewButtonLongPressMoved(_ inputViewButton: InputViewButton, with touch: UITouch)
|
||||
func handleInputViewButtonLongPressEnded(_ inputViewButton: InputViewButton, with touch: UITouch)
|
||||
func handleInputViewButtonLongPressBegan(_ inputViewButton: InputViewButton?)
|
||||
func handleInputViewButtonLongPressMoved(_ inputViewButton: InputViewButton, with touch: UITouch?)
|
||||
func handleInputViewButtonLongPressEnded(_ inputViewButton: InputViewButton, with touch: UITouch?)
|
||||
}
|
||||
|
||||
extension InputViewButtonDelegate {
|
||||
func handleInputViewButtonLongPressBegan(_ inputViewButton: InputViewButton) { }
|
||||
func handleInputViewButtonLongPressMoved(_ inputViewButton: InputViewButton, with touch: UITouch) { }
|
||||
func handleInputViewButtonLongPressEnded(_ inputViewButton: InputViewButton, with touch: UITouch) { }
|
||||
func handleInputViewButtonLongPressBegan(_ inputViewButton: InputViewButton?) { }
|
||||
func handleInputViewButtonLongPressMoved(_ inputViewButton: InputViewButton, with touch: UITouch?) { }
|
||||
func handleInputViewButtonLongPressEnded(_ inputViewButton: InputViewButton, with touch: UITouch?) { }
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@ import SessionUtilitiesKit
|
|||
import SignalUtilitiesKit
|
||||
|
||||
final class MentionSelectionView: UIView, UITableViewDataSource, UITableViewDelegate {
|
||||
var candidates: [ConversationViewModel.MentionInfo] = [] {
|
||||
var candidates: [MentionInfo] = [] {
|
||||
didSet {
|
||||
tableView.isScrollEnabled = (candidates.count > 4)
|
||||
tableView.reloadData()
|
||||
|
@ -27,7 +27,7 @@ final class MentionSelectionView: UIView, UITableViewDataSource, UITableViewDele
|
|||
result.dataSource = self
|
||||
result.delegate = self
|
||||
result.separatorStyle = .none
|
||||
result.backgroundColor = .clear
|
||||
result.themeBackgroundColor = .clear
|
||||
result.showsVerticalScrollIndicator = false
|
||||
result.register(view: Cell.self)
|
||||
|
||||
|
@ -55,7 +55,7 @@ final class MentionSelectionView: UIView, UITableViewDataSource, UITableViewDele
|
|||
|
||||
// Top separator
|
||||
let topSeparator: UIView = UIView()
|
||||
topSeparator.backgroundColor = Colors.separator
|
||||
topSeparator.themeBackgroundColor = .borderSeparator
|
||||
topSeparator.set(.height, to: Values.separatorThickness)
|
||||
addSubview(topSeparator)
|
||||
topSeparator.pin(.leading, to: .leading, of: self)
|
||||
|
@ -64,7 +64,7 @@ final class MentionSelectionView: UIView, UITableViewDataSource, UITableViewDele
|
|||
|
||||
// Bottom separator
|
||||
let bottomSeparator: UIView = UIView()
|
||||
bottomSeparator.backgroundColor = Colors.separator
|
||||
bottomSeparator.themeBackgroundColor = .borderSeparator
|
||||
bottomSeparator.set(.height, to: Values.separatorThickness)
|
||||
addSubview(bottomSeparator)
|
||||
|
||||
|
@ -116,8 +116,8 @@ private extension MentionSelectionView {
|
|||
|
||||
private lazy var displayNameLabel: UILabel = {
|
||||
let result: UILabel = UILabel()
|
||||
result.textColor = Colors.text
|
||||
result.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
result.themeTextColor = .textPrimary
|
||||
result.lineBreakMode = .byTruncatingTail
|
||||
|
||||
return result
|
||||
|
@ -125,7 +125,7 @@ private extension MentionSelectionView {
|
|||
|
||||
lazy var separator: UIView = {
|
||||
let result: UIView = UIView()
|
||||
result.backgroundColor = Colors.separator
|
||||
result.themeBackgroundColor = .borderSeparator
|
||||
result.set(.height, to: Values.separatorThickness)
|
||||
|
||||
return result
|
||||
|
@ -147,11 +147,11 @@ private extension MentionSelectionView {
|
|||
|
||||
private func setUpViewHierarchy() {
|
||||
// Cell background color
|
||||
backgroundColor = .clear
|
||||
themeBackgroundColor = .settings_tabBackground
|
||||
|
||||
// Highlight color
|
||||
let selectedBackgroundView = UIView()
|
||||
selectedBackgroundView.backgroundColor = .clear
|
||||
selectedBackgroundView.themeBackgroundColor = .settings_tabHighlight
|
||||
self.selectedBackgroundView = selectedBackgroundView
|
||||
|
||||
// Profile picture image view
|
||||
|
@ -210,5 +210,5 @@ private extension MentionSelectionView {
|
|||
// MARK: - Delegate
|
||||
|
||||
protocol MentionSelectionViewDelegate: AnyObject {
|
||||
func handleMentionSelected(_ mention: ConversationViewModel.MentionInfo, from view: MentionSelectionView)
|
||||
func handleMentionSelected(_ mention: MentionInfo, from view: MentionSelectionView)
|
||||
}
|
||||
|
|
|
@ -1,5 +1,10 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
final class VoiceMessageRecordingView : UIView {
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
final class VoiceMessageRecordingView: UIView {
|
||||
private let voiceMessageButtonFrame: CGRect
|
||||
private weak var delegate: VoiceMessageRecordingViewDelegate?
|
||||
private lazy var slideToCancelStackViewRightConstraint = slideToCancelStackView.pin(.right, to: .right, of: self)
|
||||
|
@ -10,103 +15,118 @@ final class VoiceMessageRecordingView : UIView {
|
|||
private let recordingStartDate = Date()
|
||||
private var recordingTimer: Timer?
|
||||
|
||||
// MARK: UI Components
|
||||
// MARK: - UI Components
|
||||
|
||||
private lazy var iconImageView: UIImageView = {
|
||||
let result = UIImageView()
|
||||
result.image = UIImage(named: "Microphone")!.withTint(.white)
|
||||
let result: UIImageView = UIImageView()
|
||||
result.image = UIImage(named: "Microphone")?
|
||||
.withRenderingMode(.alwaysTemplate)
|
||||
result.themeTintColor = .white
|
||||
result.contentMode = .scaleAspectFit
|
||||
let size = VoiceMessageRecordingView.iconSize
|
||||
result.set(.width, to: size)
|
||||
result.set(.height, to: size)
|
||||
result.set(.width, to: VoiceMessageRecordingView.iconSize)
|
||||
result.set(.height, to: VoiceMessageRecordingView.iconSize)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var circleView: UIView = {
|
||||
let result = UIView()
|
||||
result.backgroundColor = Colors.destructive
|
||||
let size = VoiceMessageRecordingView.circleSize
|
||||
result.set(.width, to: size)
|
||||
result.set(.height, to: size)
|
||||
result.layer.cornerRadius = size / 2
|
||||
result.layer.masksToBounds = true
|
||||
let result: UIView = UIView()
|
||||
result.clipsToBounds = true
|
||||
result.themeBackgroundColor = .danger
|
||||
result.set(.width, to: VoiceMessageRecordingView.circleSize)
|
||||
result.set(.height, to: VoiceMessageRecordingView.circleSize)
|
||||
result.layer.cornerRadius = (VoiceMessageRecordingView.circleSize / 2)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var pulseView: UIView = {
|
||||
let result = UIView()
|
||||
result.backgroundColor = Colors.destructive
|
||||
result.layer.cornerRadius = VoiceMessageRecordingView.circleSize / 2
|
||||
let result: UIView = UIView()
|
||||
result.themeBackgroundColor = .danger
|
||||
result.layer.cornerRadius = (VoiceMessageRecordingView.circleSize / 2)
|
||||
result.layer.masksToBounds = true
|
||||
result.alpha = 0.5
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var slideToCancelStackView: UIStackView = {
|
||||
let result = UIStackView()
|
||||
let result: UIStackView = UIStackView()
|
||||
result.axis = .horizontal
|
||||
result.spacing = Values.smallSpacing
|
||||
result.alignment = .center
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var chevronImageView: UIImageView = {
|
||||
let chevronSize = VoiceMessageRecordingView.chevronSize
|
||||
let chevronColor = (isLightMode ? UIColor.black : UIColor.white).withAlphaComponent(Values.mediumOpacity)
|
||||
let result = UIImageView(image: UIImage(named: "small_chevron_left")!.withTint(chevronColor))
|
||||
let result: UIImageView = UIImageView(
|
||||
image: UIImage(named: "small_chevron_left")?
|
||||
.withRenderingMode(.alwaysTemplate)
|
||||
)
|
||||
result.themeTintColor = .textPrimary
|
||||
result.contentMode = .scaleAspectFit
|
||||
result.set(.width, to: chevronSize)
|
||||
result.set(.height, to: chevronSize)
|
||||
result.alpha = Values.mediumOpacity
|
||||
result.set(.width, to: VoiceMessageRecordingView.chevronSize)
|
||||
result.set(.height, to: VoiceMessageRecordingView.chevronSize)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var slideToCancelLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
result.text = NSLocalizedString("vc_conversation_voice_message_cancel_message", comment: "")
|
||||
let result: UILabel = UILabel()
|
||||
result.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
result.textColor = Colors.text.withAlphaComponent(Values.mediumOpacity)
|
||||
result.text = "vc_conversation_voice_message_cancel_message".localized()
|
||||
result.themeTextColor = .textPrimary
|
||||
result.alpha = Values.mediumOpacity
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var cancelButton: UIButton = {
|
||||
let result = UIButton()
|
||||
result.setTitle("Cancel", for: UIControl.State.normal)
|
||||
result.titleLabel!.font = .boldSystemFont(ofSize: Values.smallFontSize)
|
||||
result.setTitleColor(Colors.text, for: UIControl.State.normal)
|
||||
let result: UIButton = UIButton()
|
||||
result.setTitle("cancel".localized(), for: .normal)
|
||||
result.titleLabel?.font = .boldSystemFont(ofSize: Values.smallFontSize)
|
||||
result.setThemeTitleColor(.textPrimary, for: .normal)
|
||||
result.addTarget(self, action: #selector(handleCancelButtonTapped), for: UIControl.Event.touchUpInside)
|
||||
result.alpha = 0
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var durationStackView: UIStackView = {
|
||||
let result = UIStackView()
|
||||
let result: UIStackView = UIStackView()
|
||||
result.axis = .horizontal
|
||||
result.spacing = Values.smallSpacing
|
||||
result.alignment = .center
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var dotView: UIView = {
|
||||
let result = UIView()
|
||||
result.backgroundColor = Colors.destructive
|
||||
let dotSize = VoiceMessageRecordingView.dotSize
|
||||
result.set(.width, to: dotSize)
|
||||
result.set(.height, to: dotSize)
|
||||
result.layer.cornerRadius = dotSize / 2
|
||||
result.layer.masksToBounds = true
|
||||
let result: UIView = UIView()
|
||||
result.clipsToBounds = true
|
||||
result.themeBackgroundColor = .danger
|
||||
result.set(.width, to: VoiceMessageRecordingView.dotSize)
|
||||
result.set(.height, to: VoiceMessageRecordingView.dotSize)
|
||||
result.layer.cornerRadius = (VoiceMessageRecordingView.dotSize / 2)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var durationLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
result.textColor = Colors.text
|
||||
let result: UILabel = UILabel()
|
||||
result.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
result.themeTextColor = .textPrimary
|
||||
result.text = "0:00"
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var lockView = LockView()
|
||||
|
||||
// MARK: Settings
|
||||
// MARK: - Settings
|
||||
|
||||
private static let circleSize: CGFloat = 96
|
||||
private static let pulseSize: CGFloat = 24
|
||||
private static let iconSize: CGFloat = 28
|
||||
|
@ -114,11 +134,14 @@ final class VoiceMessageRecordingView : UIView {
|
|||
private static let dotSize: CGFloat = 16
|
||||
private static let lockViewHitMargin: CGFloat = 40
|
||||
|
||||
// MARK: Lifecycle
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(voiceMessageButtonFrame: CGRect, delegate: VoiceMessageRecordingViewDelegate?) {
|
||||
self.voiceMessageButtonFrame = voiceMessageButtonFrame
|
||||
self.delegate = delegate
|
||||
|
||||
super.init(frame: CGRect.zero)
|
||||
|
||||
setUpViewHierarchy()
|
||||
recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
|
||||
self?.updateDurationLabel()
|
||||
|
@ -141,57 +164,67 @@ final class VoiceMessageRecordingView : UIView {
|
|||
// Icon
|
||||
let iconSize = VoiceMessageRecordingView.iconSize
|
||||
addSubview(iconImageView)
|
||||
|
||||
let voiceMessageButtonCenter = voiceMessageButtonFrame.center
|
||||
iconImageView.pin(.left, to: .left, of: self, withInset: voiceMessageButtonCenter.x - iconSize / 2)
|
||||
iconImageView.pin(.top, to: .top, of: self, withInset: voiceMessageButtonCenter.y - iconSize / 2)
|
||||
iconImageView.pin(.left, to: .left, of: self, withInset: (voiceMessageButtonCenter.x - (iconSize / 2)))
|
||||
iconImageView.pin(.top, to: .top, of: self, withInset: (voiceMessageButtonCenter.y - (iconSize / 2)))
|
||||
|
||||
// Circle
|
||||
insertSubview(circleView, at: 0)
|
||||
circleView.center(in: iconImageView)
|
||||
|
||||
// Pulse
|
||||
insertSubview(pulseView, at: 0)
|
||||
pulseView.center(in: circleView)
|
||||
|
||||
// Slide to cancel stack view
|
||||
slideToCancelStackView.addArrangedSubview(chevronImageView)
|
||||
slideToCancelStackView.addArrangedSubview(slideToCancelLabel)
|
||||
addSubview(slideToCancelStackView)
|
||||
slideToCancelStackViewRightConstraint.isActive = true
|
||||
slideToCancelStackView.center(.vertical, in: iconImageView)
|
||||
|
||||
// Cancel button
|
||||
addSubview(cancelButton)
|
||||
cancelButton.center(.horizontal, in: self)
|
||||
cancelButton.center(.vertical, in: iconImageView)
|
||||
|
||||
// Duration stack view
|
||||
durationStackView.addArrangedSubview(dotView)
|
||||
durationStackView.addArrangedSubview(durationLabel)
|
||||
addSubview(durationStackView)
|
||||
|
||||
durationStackView.pin(.left, to: .left, of: self, withInset: Values.largeSpacing)
|
||||
durationStackView.center(.vertical, in: iconImageView)
|
||||
|
||||
// Lock view
|
||||
addSubview(lockView)
|
||||
lockView.centerXAnchor.constraint(equalTo: iconImageView.centerXAnchor, constant: 2).isActive = true
|
||||
lockViewBottomConstraint.isActive = true
|
||||
}
|
||||
|
||||
// MARK: Updating
|
||||
// MARK: - Updating
|
||||
|
||||
@objc private func updateDurationLabel() {
|
||||
let interval = Date().timeIntervalSince(recordingStartDate)
|
||||
durationLabel.text = OWSFormat.formatDurationSeconds(Int(interval))
|
||||
}
|
||||
|
||||
// MARK: Animation
|
||||
// MARK: - Animation
|
||||
|
||||
func animate() {
|
||||
layoutIfNeeded()
|
||||
|
||||
slideToCancelStackViewRightConstraint.isActive = false
|
||||
slideToCancelLabelCenterHorizontalConstraint.isActive = true
|
||||
lockViewBottomConstraint.constant = -Values.mediumSpacing
|
||||
|
||||
UIView.animate(withDuration: 0.25, animations: { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.alpha = 1
|
||||
self.layoutIfNeeded()
|
||||
self?.alpha = 1
|
||||
self?.layoutIfNeeded()
|
||||
}, completion: { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
self.fadeOutDotView()
|
||||
self.pulse()
|
||||
self?.fadeOutDotView()
|
||||
self?.pulse()
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -218,24 +251,24 @@ final class VoiceMessageRecordingView : UIView {
|
|||
let expandedFrame = CGRect(center: pulseView.center, size: CGSize(width: expandedSize, height: expandedSize))
|
||||
pulseViewWidthConstraint.constant = expandedSize
|
||||
pulseViewHeightConstraint.constant = expandedSize
|
||||
|
||||
UIView.animate(withDuration: 1, animations: { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.layoutIfNeeded()
|
||||
self.pulseView.frame = expandedFrame
|
||||
self.pulseView.layer.cornerRadius = expandedSize / 2
|
||||
self.pulseView.alpha = 0
|
||||
self?.layoutIfNeeded()
|
||||
self?.pulseView.frame = expandedFrame
|
||||
self?.pulseView.layer.cornerRadius = (expandedSize / 2)
|
||||
self?.pulseView.alpha = 0
|
||||
}, completion: { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
self.pulseViewWidthConstraint.constant = collapsedSize
|
||||
self.pulseViewHeightConstraint.constant = collapsedSize
|
||||
self.pulseView.frame = collapsedFrame
|
||||
self.pulseView.layer.cornerRadius = collapsedSize / 2
|
||||
self.pulseView.alpha = 0.5
|
||||
self.pulse()
|
||||
self?.pulseViewWidthConstraint.constant = collapsedSize
|
||||
self?.pulseViewHeightConstraint.constant = collapsedSize
|
||||
self?.pulseView.frame = collapsedFrame
|
||||
self?.pulseView.layer.cornerRadius = (collapsedSize / 2)
|
||||
self?.pulseView.alpha = 0.5
|
||||
self?.pulse()
|
||||
})
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
// MARK: - Interaction
|
||||
|
||||
func handleLongPressMoved(to location: CGPoint) {
|
||||
if location.x < bounds.center.x {
|
||||
let translationX = location.x - bounds.center.x
|
||||
|
@ -244,12 +277,15 @@ final class VoiceMessageRecordingView : UIView {
|
|||
let labelDamping: CGFloat = 3
|
||||
let chevronX = (chevronDamping * (sqrt(abs(translationX)) / sqrt(chevronDamping))) * sign
|
||||
let labelX = (labelDamping * (sqrt(abs(translationX)) / sqrt(labelDamping))) * sign
|
||||
|
||||
chevronImageView.transform = CGAffineTransform(translationX: chevronX, y: 0)
|
||||
slideToCancelLabel.transform = CGAffineTransform(translationX: labelX, y: 0)
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
chevronImageView.transform = .identity
|
||||
slideToCancelLabel.transform = .identity
|
||||
}
|
||||
|
||||
if isValidLockViewLocation(location) {
|
||||
if !lockView.isExpanded {
|
||||
UIView.animate(withDuration: 0.25) {
|
||||
|
@ -257,7 +293,8 @@ final class VoiceMessageRecordingView : UIView {
|
|||
}
|
||||
}
|
||||
lockView.expandIfNeeded()
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
if lockView.isExpanded {
|
||||
UIView.animate(withDuration: 0.25) {
|
||||
self.lockViewBottomConstraint.constant = -Values.mediumSpacing
|
||||
|
@ -270,18 +307,21 @@ final class VoiceMessageRecordingView : UIView {
|
|||
func handleLongPressEnded(at location: CGPoint) {
|
||||
if pulseView.frame.contains(location) {
|
||||
delegate?.endVoiceMessageRecording()
|
||||
} else if isValidLockViewLocation(location) {
|
||||
}
|
||||
else if isValidLockViewLocation(location) {
|
||||
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleCircleViewTap))
|
||||
circleView.addGestureRecognizer(tapGestureRecognizer)
|
||||
|
||||
UIView.animate(withDuration: 0.25, delay: 0, options: .transitionCrossDissolve, animations: {
|
||||
self.lockView.alpha = 0
|
||||
self.iconImageView.image = UIImage(named: "ArrowUp")!.withTint(.white)
|
||||
self.iconImageView.image = UIImage(named: "ArrowUp")?.withRenderingMode(.alwaysTemplate)
|
||||
self.slideToCancelStackView.alpha = 0
|
||||
self.cancelButton.alpha = 1
|
||||
}, completion: { _ in
|
||||
// Do nothing
|
||||
})
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
delegate?.cancelVoiceMessageRecording()
|
||||
}
|
||||
}
|
||||
|
@ -294,27 +334,30 @@ final class VoiceMessageRecordingView : UIView {
|
|||
delegate?.cancelVoiceMessageRecording()
|
||||
}
|
||||
|
||||
// MARK: Convenience
|
||||
// MARK: - Convenience
|
||||
|
||||
private func isValidLockViewLocation(_ location: CGPoint) -> Bool {
|
||||
let lockViewHitMargin = VoiceMessageRecordingView.lockViewHitMargin
|
||||
|
||||
return location.y < 0 && location.x > (lockView.frame.minX - lockViewHitMargin) && location.x < (lockView.frame.maxX + lockViewHitMargin)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Lock View
|
||||
extension VoiceMessageRecordingView {
|
||||
// MARK: - Lock View
|
||||
|
||||
fileprivate final class LockView : UIView {
|
||||
extension VoiceMessageRecordingView {
|
||||
fileprivate final class LockView: UIView {
|
||||
private lazy var widthConstraint = set(.width, to: LockView.width)
|
||||
private(set) var isExpanded = false
|
||||
|
||||
private lazy var stackView: UIStackView = {
|
||||
let result = UIStackView()
|
||||
let result: UIStackView = UIStackView()
|
||||
result.axis = .vertical
|
||||
result.spacing = Values.smallSpacing
|
||||
result.alignment = .center
|
||||
result.isLayoutMarginsRelativeArrangement = true
|
||||
result.layoutMargins = UIEdgeInsets(top: 12, leading: 0, bottom: 8, trailing: 0)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
|
@ -325,45 +368,64 @@ extension VoiceMessageRecordingView {
|
|||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
let iconTint: UIColor = isLightMode ? .black : .white
|
||||
// Background & blur
|
||||
let backgroundView = UIView()
|
||||
backgroundView.backgroundColor = isLightMode ? .white : .black
|
||||
let backgroundView: UIView = UIView()
|
||||
backgroundView.themeBackgroundColor = .backgroundSecondary
|
||||
backgroundView.alpha = Values.lowOpacity
|
||||
addSubview(backgroundView)
|
||||
backgroundView.pin(to: self)
|
||||
let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .regular))
|
||||
|
||||
let blurView = UIVisualEffectView()
|
||||
addSubview(blurView)
|
||||
blurView.pin(to: self)
|
||||
|
||||
ThemeManager.onThemeChange(observer: blurView) { [weak blurView] theme, _ in
|
||||
switch theme.interfaceStyle {
|
||||
case .light: blurView?.effect = UIBlurEffect(style: .light)
|
||||
default: blurView?.effect = UIBlurEffect(style: .dark)
|
||||
}
|
||||
}
|
||||
|
||||
// Size & shape
|
||||
widthConstraint.isActive = true
|
||||
layer.cornerRadius = LockView.width / 2
|
||||
layer.cornerRadius = (LockView.width / 2)
|
||||
layer.masksToBounds = true
|
||||
|
||||
// Border
|
||||
themeBorderColor = .borderSeparator
|
||||
layer.borderWidth = 1
|
||||
let borderColor = (isLightMode ? UIColor.black : UIColor.white).withAlphaComponent(Values.veryLowOpacity)
|
||||
layer.borderColor = borderColor.cgColor
|
||||
|
||||
// Lock icon
|
||||
let lockIconImageView = UIImageView(image: UIImage(named: "ic_lock_outline")!.withTint(iconTint))
|
||||
let lockIconSize = LockView.lockIconSize
|
||||
lockIconImageView.set(.width, to: lockIconSize)
|
||||
lockIconImageView.set(.height, to: lockIconSize)
|
||||
let lockIconImageView: UIImageView = UIImageView(
|
||||
image: UIImage(named: "ic_lock_outline")?
|
||||
.withRenderingMode(.alwaysTemplate)
|
||||
)
|
||||
lockIconImageView.themeTintColor = .textPrimary
|
||||
lockIconImageView.set(.width, to: LockView.lockIconSize)
|
||||
lockIconImageView.set(.height, to: LockView.lockIconSize)
|
||||
stackView.addArrangedSubview(lockIconImageView)
|
||||
|
||||
// Chevron icon
|
||||
let chevronIconImageView = UIImageView(image: UIImage(named: "ic_chevron_up")!.withTint(iconTint))
|
||||
let chevronIconSize = LockView.chevronIconSize
|
||||
chevronIconImageView.set(.width, to: chevronIconSize)
|
||||
chevronIconImageView.set(.height, to: chevronIconSize)
|
||||
let chevronIconImageView: UIImageView = UIImageView(
|
||||
image: UIImage(named: "ic_chevron_up")?
|
||||
.withRenderingMode(.alwaysTemplate)
|
||||
)
|
||||
chevronIconImageView.themeTintColor = .textPrimary
|
||||
chevronIconImageView.set(.width, to: LockView.chevronIconSize)
|
||||
chevronIconImageView.set(.height, to: LockView.chevronIconSize)
|
||||
stackView.addArrangedSubview(chevronIconImageView)
|
||||
|
||||
// Stack view
|
||||
addSubview(stackView)
|
||||
stackView.pin(to: self)
|
||||
|
@ -371,10 +433,14 @@ extension VoiceMessageRecordingView {
|
|||
|
||||
func expandIfNeeded() {
|
||||
guard !isExpanded else { return }
|
||||
|
||||
isExpanded = true
|
||||
|
||||
let expansionMargin = LockView.expansionMargin
|
||||
let newWidth = LockView.width + 2 * expansionMargin
|
||||
|
||||
widthConstraint.constant = newWidth
|
||||
|
||||
UIView.animate(withDuration: 0.25) {
|
||||
self.layer.cornerRadius = newWidth / 2
|
||||
self.stackView.layoutMargins = UIEdgeInsets(top: 12 + expansionMargin, leading: 0, bottom: 8 + expansionMargin, trailing: 0)
|
||||
|
@ -384,9 +450,12 @@ extension VoiceMessageRecordingView {
|
|||
|
||||
func collapseIfNeeded() {
|
||||
guard isExpanded else { return }
|
||||
|
||||
isExpanded = false
|
||||
|
||||
let newWidth = LockView.width
|
||||
widthConstraint.constant = newWidth
|
||||
|
||||
UIView.animate(withDuration: 0.25) {
|
||||
self.layer.cornerRadius = newWidth / 2
|
||||
self.stackView.layoutMargins = UIEdgeInsets(top: 12, leading: 0, bottom: 8, trailing: 0)
|
||||
|
|
|
@ -19,8 +19,11 @@ final class CallMessageCell: MessageCell {
|
|||
|
||||
private lazy var iconImageView: UIImageView = UIImageView()
|
||||
private lazy var infoImageView: UIImageView = {
|
||||
let result: UIImageView = UIImageView(image: UIImage(named: "ic_info")?.withRenderingMode(.alwaysTemplate))
|
||||
result.tintColor = Colors.text
|
||||
let result: UIImageView = UIImageView(
|
||||
image: UIImage(named: "ic_info")?
|
||||
.withRenderingMode(.alwaysTemplate)
|
||||
)
|
||||
result.themeTintColor = .textPrimary
|
||||
|
||||
return result
|
||||
}()
|
||||
|
@ -28,7 +31,7 @@ final class CallMessageCell: MessageCell {
|
|||
private lazy var timestampLabel: UILabel = {
|
||||
let result: UILabel = UILabel()
|
||||
result.font = .boldSystemFont(ofSize: Values.verySmallFontSize)
|
||||
result.textColor = Colors.text
|
||||
result.themeTextColor = .textPrimary
|
||||
result.textAlignment = .center
|
||||
|
||||
return result
|
||||
|
@ -36,19 +39,19 @@ final class CallMessageCell: MessageCell {
|
|||
|
||||
private lazy var label: UILabel = {
|
||||
let result: UILabel = UILabel()
|
||||
result.numberOfLines = 0
|
||||
result.lineBreakMode = .byWordWrapping
|
||||
result.font = .boldSystemFont(ofSize: Values.smallFontSize)
|
||||
result.textColor = Colors.text
|
||||
result.themeTextColor = .textPrimary
|
||||
result.textAlignment = .center
|
||||
result.lineBreakMode = .byWordWrapping
|
||||
result.numberOfLines = 0
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var container: UIView = {
|
||||
let result: UIView = UIView()
|
||||
result.themeBackgroundColor = .backgroundSecondary
|
||||
result.layer.cornerRadius = 18
|
||||
result.backgroundColor = Colors.callMessageBackground
|
||||
result.addSubview(label)
|
||||
|
||||
label.pin(.top, to: .top, of: result, withInset: CallMessageCell.inset)
|
||||
|
@ -136,10 +139,10 @@ final class CallMessageCell: MessageCell {
|
|||
default: return nil
|
||||
}
|
||||
}()
|
||||
iconImageView.tintColor = {
|
||||
iconImageView.themeTintColor = {
|
||||
switch messageInfo.state {
|
||||
case .outgoing, .incoming: return Colors.text
|
||||
case .missed, .permissionDenied: return Colors.destructive
|
||||
case .outgoing, .incoming: return .textPrimary
|
||||
case .missed, .permissionDenied: return .danger
|
||||
default: return nil
|
||||
}
|
||||
}()
|
||||
|
@ -154,7 +157,7 @@ final class CallMessageCell: MessageCell {
|
|||
infoImageViewHeightConstraint.constant = (shouldShowInfoIcon ? CallMessageCell.iconSize : 0)
|
||||
|
||||
label.text = cellViewModel.body
|
||||
timestampLabel.text = cellViewModel.dateForUI?.formattedForDisplay
|
||||
timestampLabel.text = cellViewModel.dateForUI.formattedForDisplay
|
||||
}
|
||||
|
||||
override func dynamicUpdate(with cellViewModel: MessageViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) {
|
||||
|
|
|
@ -1,57 +0,0 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
final class CallMessageView: UIView {
|
||||
private static let iconSize: CGFloat = 24
|
||||
private static let iconImageViewSize: CGFloat = 40
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(cellViewModel: MessageViewModel, textColor: UIColor) {
|
||||
super.init(frame: CGRect.zero)
|
||||
|
||||
setUpViewHierarchy(cellViewModel: cellViewModel, textColor: textColor)
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
preconditionFailure("Use init(viewItem:textColor:) instead.")
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init(viewItem:textColor:) instead.")
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy(cellViewModel: MessageViewModel, textColor: UIColor) {
|
||||
// Image view
|
||||
let imageView: UIImageView = UIImageView(
|
||||
image: UIImage(named: "Phone")?
|
||||
.resizedImage(to: CGSize(width: CallMessageView.iconSize, height: CallMessageView.iconSize))?
|
||||
.withRenderingMode(.alwaysTemplate)
|
||||
)
|
||||
imageView.tintColor = textColor
|
||||
imageView.contentMode = .center
|
||||
|
||||
let iconImageViewSize = CallMessageView.iconImageViewSize
|
||||
imageView.set(.width, to: iconImageViewSize)
|
||||
imageView.set(.height, to: iconImageViewSize)
|
||||
|
||||
// Body label
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.lineBreakMode = .byTruncatingTail
|
||||
titleLabel.text = cellViewModel.body
|
||||
titleLabel.textColor = textColor
|
||||
titleLabel.font = .systemFont(ofSize: Values.mediumFontSize)
|
||||
|
||||
// Stack view
|
||||
let stackView = UIStackView(arrangedSubviews: [ imageView, titleLabel ])
|
||||
stackView.axis = .horizontal
|
||||
stackView.alignment = .center
|
||||
stackView.isLayoutMarginsRelativeArrangement = true
|
||||
stackView.layoutMargins = UIEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 12)
|
||||
addSubview(stackView)
|
||||
stackView.pin(to: self, withInset: Values.smallSpacing)
|
||||
}
|
||||
}
|
|
@ -3,6 +3,7 @@
|
|||
import UIKit
|
||||
import SignalUtilitiesKit
|
||||
import SessionUtilitiesKit
|
||||
import SessionUIKit
|
||||
|
||||
final class DeletedMessageView: UIView {
|
||||
private static let iconSize: CGFloat = 18
|
||||
|
@ -10,7 +11,7 @@ final class DeletedMessageView: UIView {
|
|||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(textColor: UIColor) {
|
||||
init(textColor: ThemeValue) {
|
||||
super.init(frame: CGRect.zero)
|
||||
|
||||
setUpViewHierarchy(textColor: textColor)
|
||||
|
@ -24,7 +25,7 @@ final class DeletedMessageView: UIView {
|
|||
preconditionFailure("Use init(textColor:) instead.")
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy(textColor: UIColor) {
|
||||
private func setUpViewHierarchy(textColor: ThemeValue) {
|
||||
// Image view
|
||||
let icon = UIImage(named: "ic_trash")?
|
||||
.resizedImage(to: CGSize(
|
||||
|
@ -34,17 +35,17 @@ final class DeletedMessageView: UIView {
|
|||
.withRenderingMode(.alwaysTemplate)
|
||||
|
||||
let imageView = UIImageView(image: icon)
|
||||
imageView.tintColor = textColor
|
||||
imageView.themeTintColor = textColor
|
||||
imageView.contentMode = .center
|
||||
imageView.set(.width, to: DeletedMessageView.iconImageViewSize)
|
||||
imageView.set(.height, to: DeletedMessageView.iconImageViewSize)
|
||||
|
||||
// Body label
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.lineBreakMode = .byTruncatingTail
|
||||
titleLabel.text = "message_deleted".localized()
|
||||
titleLabel.textColor = textColor
|
||||
titleLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
titleLabel.text = "message_deleted".localized()
|
||||
titleLabel.themeTextColor = textColor
|
||||
titleLabel.lineBreakMode = .byTruncatingTail
|
||||
|
||||
// Stack view
|
||||
let stackView = UIStackView(arrangedSubviews: [ imageView, titleLabel ])
|
||||
|
|
|
@ -5,11 +5,9 @@ import SessionUIKit
|
|||
import SessionMessagingKit
|
||||
|
||||
final class DocumentView: UIView {
|
||||
private static let iconImageViewSize: CGSize = CGSize(width: 31, height: 40)
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(attachment: Attachment, textColor: UIColor) {
|
||||
init(attachment: Attachment, textColor: ThemeValue) {
|
||||
super.init(frame: CGRect.zero)
|
||||
|
||||
setUpViewHierarchy(attachment: attachment, textColor: textColor)
|
||||
|
@ -23,40 +21,74 @@ final class DocumentView: UIView {
|
|||
preconditionFailure("Use init(viewItem:textColor:) instead.")
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy(attachment: Attachment, textColor: UIColor) {
|
||||
// Image view
|
||||
let imageView = UIImageView(image: UIImage(named: "File")?.withRenderingMode(.alwaysTemplate))
|
||||
imageView.tintColor = textColor
|
||||
imageView.contentMode = .center
|
||||
private func setUpViewHierarchy(attachment: Attachment, textColor: ThemeValue) {
|
||||
let imageBackgroundView: UIView = UIView()
|
||||
imageBackgroundView.themeBackgroundColor = .messageBubble_overlay
|
||||
addSubview(imageBackgroundView)
|
||||
|
||||
let iconImageViewSize = DocumentView.iconImageViewSize
|
||||
imageView.set(.width, to: iconImageViewSize.width)
|
||||
imageView.set(.height, to: iconImageViewSize.height)
|
||||
// Image view
|
||||
let imageView = UIImageView(
|
||||
image: UIImage(systemName: "doc")?
|
||||
.withRenderingMode(.alwaysTemplate)
|
||||
)
|
||||
imageView.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||
imageView.setContentHuggingPriority(.required, for: .horizontal)
|
||||
imageView.themeTintColor = textColor
|
||||
imageView.set(.height, to: 22)
|
||||
|
||||
// Body label
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.lineBreakMode = .byTruncatingTail
|
||||
titleLabel.font = .systemFont(ofSize: Values.mediumFontSize)
|
||||
titleLabel.text = (attachment.sourceFilename ?? "File")
|
||||
titleLabel.textColor = textColor
|
||||
titleLabel.font = .systemFont(ofSize: Values.smallFontSize, weight: .light)
|
||||
titleLabel.themeTextColor = textColor
|
||||
titleLabel.lineBreakMode = .byTruncatingTail
|
||||
|
||||
// Size label
|
||||
let sizeLabel = UILabel()
|
||||
sizeLabel.lineBreakMode = .byTruncatingTail
|
||||
sizeLabel.text = OWSFormat.formatFileSize(UInt(attachment.byteCount))
|
||||
sizeLabel.textColor = textColor
|
||||
sizeLabel.font = .systemFont(ofSize: Values.verySmallFontSize)
|
||||
sizeLabel.text = OWSFormat.formatFileSize(UInt(attachment.byteCount))
|
||||
sizeLabel.themeTextColor = textColor
|
||||
sizeLabel.lineBreakMode = .byTruncatingTail
|
||||
|
||||
// Label stack view
|
||||
let labelStackView = UIStackView(arrangedSubviews: [ titleLabel, sizeLabel ])
|
||||
labelStackView.axis = .vertical
|
||||
|
||||
// Download image view
|
||||
let downloadImageView = UIImageView(
|
||||
image: UIImage(systemName: "arrow.down")?
|
||||
.withRenderingMode(.alwaysTemplate)
|
||||
)
|
||||
downloadImageView.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||
downloadImageView.setContentHuggingPriority(.required, for: .horizontal)
|
||||
downloadImageView.themeTintColor = textColor
|
||||
downloadImageView.set(.height, to: 16)
|
||||
|
||||
// Stack view
|
||||
let stackView = UIStackView(arrangedSubviews: [ imageView, labelStackView ])
|
||||
let stackView = UIStackView(
|
||||
arrangedSubviews: [
|
||||
imageView,
|
||||
UIView.spacer(withWidth: 0),
|
||||
labelStackView,
|
||||
downloadImageView
|
||||
]
|
||||
)
|
||||
stackView.axis = .horizontal
|
||||
stackView.spacing = Values.verySmallSpacing
|
||||
stackView.spacing = Values.mediumSpacing
|
||||
stackView.alignment = .center
|
||||
stackView.layoutMargins = UIEdgeInsets(
|
||||
top: Values.smallSpacing,
|
||||
leading: Values.mediumSpacing,
|
||||
bottom: Values.smallSpacing,
|
||||
trailing: Values.mediumSpacing
|
||||
)
|
||||
stackView.isLayoutMarginsRelativeArrangement = true
|
||||
addSubview(stackView)
|
||||
stackView.pin(to: self)
|
||||
|
||||
imageBackgroundView.pin(.top, to: .top, of: self)
|
||||
imageBackgroundView.pin(.leading, to: .leading, of: self)
|
||||
imageBackgroundView.pin(.trailing, to: .trailing, of: imageView, withInset: Values.mediumSpacing)
|
||||
imageBackgroundView.pin(.bottom, to: .bottom, of: self)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -33,11 +33,21 @@ final class LinkPreviewView: UIView {
|
|||
return result
|
||||
}()
|
||||
|
||||
private lazy var loader: NVActivityIndicatorView = {
|
||||
// FIXME: This will have issues with theme transitions
|
||||
let color: UIColor = (isLightMode ? .black : .white)
|
||||
private let loader: NVActivityIndicatorView = {
|
||||
let result: NVActivityIndicatorView = NVActivityIndicatorView(
|
||||
frame: CGRect.zero,
|
||||
type: .circleStrokeSpin,
|
||||
color: .black,
|
||||
padding: nil
|
||||
)
|
||||
|
||||
return NVActivityIndicatorView(frame: CGRect.zero, type: .circleStrokeSpin, color: color, padding: nil)
|
||||
ThemeManager.onThemeChange(observer: result) { [weak result] theme, _ in
|
||||
guard let textPrimary: UIColor = theme.color(for: .textPrimary) else { return }
|
||||
|
||||
result?.color = textPrimary
|
||||
}
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var titleLabel: UILabel = {
|
||||
|
@ -55,10 +65,13 @@ final class LinkPreviewView: UIView {
|
|||
private lazy var hStackView: UIStackView = UIStackView()
|
||||
|
||||
private lazy var cancelButton: UIButton = {
|
||||
// FIXME: This will have issues with theme transitions
|
||||
let result: UIButton = UIButton(type: .custom)
|
||||
result.setImage(UIImage(named: "X")?.withRenderingMode(.alwaysTemplate), for: UIControl.State.normal)
|
||||
result.tintColor = (isLightMode ? .black : .white)
|
||||
result.setImage(
|
||||
UIImage(named: "X")?
|
||||
.withRenderingMode(.alwaysTemplate),
|
||||
for: .normal
|
||||
)
|
||||
result.themeTintColor = .textPrimary
|
||||
|
||||
let cancelButtonSize = LinkPreviewView.cancelButtonSize
|
||||
result.set(.width, to: cancelButtonSize)
|
||||
|
@ -131,7 +144,7 @@ final class LinkPreviewView: UIView {
|
|||
isOutgoing: Bool,
|
||||
delegate: TappableLabelDelegate? = nil,
|
||||
cellViewModel: MessageViewModel? = nil,
|
||||
bodyLabelTextColor: UIColor? = nil,
|
||||
bodyLabelTextColor: ThemeValue? = nil,
|
||||
lastSearchText: String? = nil
|
||||
) {
|
||||
cancelButton.removeFromSuperview()
|
||||
|
@ -139,7 +152,7 @@ final class LinkPreviewView: UIView {
|
|||
var image: UIImage? = state.image
|
||||
let stateHasImage: Bool = (image != nil)
|
||||
if image == nil && (state is LinkPreview.DraftState || state is LinkPreview.SentState) {
|
||||
image = UIImage(named: "Link")?.withTint(isLightMode ? .black : .white)
|
||||
image = UIImage(named: "Link")?.withRenderingMode(.alwaysTemplate)
|
||||
}
|
||||
|
||||
// Image view
|
||||
|
@ -148,14 +161,11 @@ final class LinkPreviewView: UIView {
|
|||
imageViewContainerHeightConstraint.constant = imageViewContainerSize
|
||||
imageViewContainer.layer.cornerRadius = (state is LinkPreview.SentState ? 0 : 8)
|
||||
|
||||
if state is LinkPreview.LoadingState {
|
||||
imageViewContainer.backgroundColor = .clear
|
||||
}
|
||||
else {
|
||||
imageViewContainer.backgroundColor = isDarkMode ? .black : UIColor.black.withAlphaComponent(0.06)
|
||||
}
|
||||
|
||||
imageView.image = image
|
||||
imageView.themeTintColor = (isOutgoing ?
|
||||
.messageBubble_outgoingText :
|
||||
.messageBubble_incomingText
|
||||
)
|
||||
imageView.contentMode = (stateHasImage ? .scaleAspectFill : .center)
|
||||
|
||||
// Loader
|
||||
|
@ -163,24 +173,25 @@ final class LinkPreviewView: UIView {
|
|||
if image != nil { loader.stopAnimating() } else { loader.startAnimating() }
|
||||
|
||||
// Title
|
||||
let sentLinkPreviewTextColor: UIColor = {
|
||||
switch (isOutgoing, AppModeManager.shared.currentAppMode) {
|
||||
case (false, .light): return .black
|
||||
case (true, .light): return Colors.grey
|
||||
default: return .white
|
||||
}
|
||||
}()
|
||||
titleLabel.textColor = sentLinkPreviewTextColor
|
||||
titleLabel.text = state.title
|
||||
titleLabel.themeTextColor = (isOutgoing ?
|
||||
.messageBubble_outgoingText :
|
||||
.messageBubble_incomingText
|
||||
)
|
||||
|
||||
// Horizontal stack view
|
||||
switch state {
|
||||
case is LinkPreview.LoadingState:
|
||||
imageViewContainer.themeBackgroundColor = .clear
|
||||
hStackViewContainer.themeBackgroundColor = nil
|
||||
|
||||
case is LinkPreview.SentState:
|
||||
// FIXME: This will have issues with theme transitions
|
||||
hStackViewContainer.backgroundColor = (isDarkMode ? .black : UIColor.black.withAlphaComponent(0.06))
|
||||
imageViewContainer.themeBackgroundColor = .messageBubble_overlay
|
||||
hStackViewContainer.themeBackgroundColor = .messageBubble_overlay
|
||||
|
||||
default:
|
||||
hStackViewContainer.backgroundColor = nil
|
||||
imageViewContainer.themeBackgroundColor = .messageBubble_overlay
|
||||
hStackViewContainer.themeBackgroundColor = nil
|
||||
}
|
||||
|
||||
// Body text view
|
||||
|
@ -190,7 +201,7 @@ final class LinkPreviewView: UIView {
|
|||
let bodyTappableLabel = VisibleMessageCell.getBodyTappableLabel(
|
||||
for: cellViewModel,
|
||||
with: maxWidth,
|
||||
textColor: (bodyLabelTextColor ?? sentLinkPreviewTextColor),
|
||||
textColor: (bodyLabelTextColor ?? .textPrimary),
|
||||
searchText: lastSearchText,
|
||||
delegate: delegate
|
||||
)
|
||||
|
|
|
@ -39,6 +39,14 @@ public class MediaAlbumView: UIStackView {
|
|||
}
|
||||
|
||||
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.")
|
||||
|
||||
|
@ -97,7 +105,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()
|
||||
|
||||
|
@ -108,11 +116,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
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import UIKit
|
||||
import SessionMessagingKit
|
||||
import SessionUIKit
|
||||
|
||||
final class MediaPlaceholderView: UIView {
|
||||
private static let iconSize: CGFloat = 24
|
||||
|
@ -9,7 +10,7 @@ final class MediaPlaceholderView: UIView {
|
|||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(cellViewModel: MessageViewModel, textColor: UIColor) {
|
||||
init(cellViewModel: MessageViewModel, textColor: ThemeValue) {
|
||||
super.init(frame: CGRect.zero)
|
||||
|
||||
setUpViewHierarchy(cellViewModel: cellViewModel, textColor: textColor)
|
||||
|
@ -25,7 +26,7 @@ final class MediaPlaceholderView: UIView {
|
|||
|
||||
private func setUpViewHierarchy(
|
||||
cellViewModel: MessageViewModel,
|
||||
textColor: UIColor
|
||||
textColor: ThemeValue
|
||||
) {
|
||||
let (iconName, attachmentDescription): (String, String) = {
|
||||
guard
|
||||
|
@ -52,17 +53,17 @@ final class MediaPlaceholderView: UIView {
|
|||
)?
|
||||
.withRenderingMode(.alwaysTemplate)
|
||||
)
|
||||
imageView.tintColor = textColor
|
||||
imageView.themeTintColor = textColor
|
||||
imageView.contentMode = .center
|
||||
imageView.set(.width, to: MediaPlaceholderView.iconImageViewSize)
|
||||
imageView.set(.height, to: MediaPlaceholderView.iconImageViewSize)
|
||||
|
||||
// Body label
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.lineBreakMode = .byTruncatingTail
|
||||
titleLabel.text = "Tap to download \(attachmentDescription)"
|
||||
titleLabel.textColor = textColor
|
||||
titleLabel.font = .systemFont(ofSize: Values.mediumFontSize)
|
||||
titleLabel.text = "Tap to download \(attachmentDescription)"
|
||||
titleLabel.themeTextColor = textColor
|
||||
titleLabel.lineBreakMode = .byTruncatingTail
|
||||
|
||||
// Stack view
|
||||
let stackView = UIStackView(arrangedSubviews: [ imageView, titleLabel ])
|
||||
|
|
|
@ -58,7 +58,7 @@ public class MediaView: UIView {
|
|||
|
||||
super.init(frame: .zero)
|
||||
|
||||
backgroundColor = Colors.unimportant
|
||||
themeBackgroundColor = .backgroundSecondary
|
||||
clipsToBounds = true
|
||||
layer.masksToBounds = true
|
||||
layer.cornerRadius = VisibleMessageCell.largeCornerRadius
|
||||
|
@ -119,7 +119,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)
|
||||
|
@ -152,7 +152,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()
|
||||
|
@ -209,7 +209,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()
|
||||
|
@ -268,7 +268,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)
|
||||
|
@ -358,20 +358,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()
|
||||
}
|
||||
|
|
|
@ -10,7 +10,7 @@ final class OpenGroupInvitationView: UIView {
|
|||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(name: String, url: String, textColor: UIColor, isOutgoing: Bool) {
|
||||
init(name: String, url: String, textColor: ThemeValue, isOutgoing: Bool) {
|
||||
super.init(frame: CGRect.zero)
|
||||
|
||||
setUpViewHierarchy(
|
||||
|
@ -29,24 +29,24 @@ final class OpenGroupInvitationView: UIView {
|
|||
preconditionFailure("Use init(name:url:textColor:) instead.")
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy(name: String, rawUrl: String, textColor: UIColor, isOutgoing: Bool) {
|
||||
private func setUpViewHierarchy(name: String, rawUrl: String, textColor: ThemeValue, isOutgoing: Bool) {
|
||||
// Title
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.lineBreakMode = .byTruncatingTail
|
||||
titleLabel.text = name
|
||||
titleLabel.textColor = textColor
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize)
|
||||
titleLabel.text = name
|
||||
titleLabel.themeTextColor = textColor
|
||||
titleLabel.lineBreakMode = .byTruncatingTail
|
||||
|
||||
// Subtitle
|
||||
let subtitleLabel = UILabel()
|
||||
subtitleLabel.lineBreakMode = .byTruncatingTail
|
||||
subtitleLabel.text = NSLocalizedString("view_open_group_invitation_description", comment: "")
|
||||
subtitleLabel.textColor = textColor
|
||||
subtitleLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
subtitleLabel.text = "view_open_group_invitation_description".localized()
|
||||
subtitleLabel.themeTextColor = textColor
|
||||
subtitleLabel.lineBreakMode = .byTruncatingTail
|
||||
|
||||
// URL
|
||||
let urlLabel = UILabel()
|
||||
urlLabel.lineBreakMode = .byCharWrapping
|
||||
urlLabel.font = .systemFont(ofSize: Values.verySmallFontSize)
|
||||
urlLabel.text = {
|
||||
if let range = rawUrl.range(of: "?public_key=") {
|
||||
return String(rawUrl[..<range.lowerBound])
|
||||
|
@ -54,9 +54,9 @@ final class OpenGroupInvitationView: UIView {
|
|||
|
||||
return rawUrl
|
||||
}()
|
||||
urlLabel.textColor = textColor
|
||||
urlLabel.themeTextColor = textColor
|
||||
urlLabel.lineBreakMode = .byCharWrapping
|
||||
urlLabel.numberOfLines = 0
|
||||
urlLabel.font = .systemFont(ofSize: Values.verySmallFontSize)
|
||||
|
||||
// Label stack
|
||||
let labelStackView = UIStackView(arrangedSubviews: [ titleLabel, UIView.vSpacer(2), subtitleLabel, UIView.vSpacer(4), urlLabel ])
|
||||
|
@ -71,11 +71,11 @@ final class OpenGroupInvitationView: UIView {
|
|||
.resizedImage(to: CGSize(width: iconSize, height: iconSize))?
|
||||
.withRenderingMode(.alwaysTemplate)
|
||||
)
|
||||
iconImageView.tintColor = .white
|
||||
iconImageView.themeTintColor = (isOutgoing ? .messageBubble_outgoingText : .textPrimary)
|
||||
iconImageView.contentMode = .center
|
||||
iconImageView.layer.cornerRadius = iconImageViewSize / 2
|
||||
iconImageView.layer.masksToBounds = true
|
||||
iconImageView.backgroundColor = Colors.accent
|
||||
iconImageView.themeBackgroundColor = (isOutgoing ? .messageBubble_overlay : .primary)
|
||||
iconImageView.set(.width, to: iconImageViewSize)
|
||||
iconImageView.set(.height, to: iconImageViewSize)
|
||||
|
||||
|
|
|
@ -119,15 +119,14 @@ final class QuoteView: UIView {
|
|||
contentView.rightAnchor.constraint(lessThanOrEqualTo: self.rightAnchor).isActive = true
|
||||
|
||||
// Line view
|
||||
let lineColor: UIColor = {
|
||||
switch (mode, AppModeManager.shared.currentAppMode) {
|
||||
case (.regular, .light), (.draft, .light): return .black
|
||||
case (.regular, .dark): return (direction == .outgoing) ? .black : Colors.accent
|
||||
case (.draft, .dark): return Colors.accent
|
||||
let lineColor: ThemeValue = {
|
||||
switch mode {
|
||||
case .regular: return (direction == .outgoing ? .messageBubble_outgoingText : .primary)
|
||||
case .draft: return .primary
|
||||
}
|
||||
}()
|
||||
let lineView = UIView()
|
||||
lineView.backgroundColor = lineColor
|
||||
lineView.themeBackgroundColor = lineColor
|
||||
lineView.set(.width, to: Values.accentLineThickness)
|
||||
|
||||
if let attachment: Attachment = attachment {
|
||||
|
@ -139,9 +138,17 @@ final class QuoteView: UIView {
|
|||
.withRenderingMode(.alwaysTemplate)
|
||||
)
|
||||
|
||||
imageView.tintColor = .white
|
||||
imageView.themeTintColor = {
|
||||
switch mode {
|
||||
case .regular: return (direction == .outgoing ?
|
||||
.messageBubble_outgoingText :
|
||||
.messageBubble_incomingText
|
||||
)
|
||||
case .draft: return .messageBubble_outgoingText
|
||||
}
|
||||
}()
|
||||
imageView.contentMode = .center
|
||||
imageView.backgroundColor = lineColor
|
||||
imageView.themeBackgroundColor = .messageBubble_overlay
|
||||
imageView.layer.cornerRadius = VisibleMessageCell.smallCornerRadius
|
||||
imageView.layer.masksToBounds = true
|
||||
imageView.set(.width, to: thumbnailSize)
|
||||
|
@ -180,85 +187,78 @@ final class QuoteView: UIView {
|
|||
}
|
||||
|
||||
// Body label
|
||||
let textColor: UIColor = {
|
||||
guard mode != .draft else { return Colors.text }
|
||||
|
||||
switch (direction, AppModeManager.shared.currentAppMode) {
|
||||
case (.outgoing, .dark), (.incoming, .light): return .black
|
||||
default: return .white
|
||||
}
|
||||
}()
|
||||
let bodyLabel = UILabel()
|
||||
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)
|
||||
bodyLabel.attributedText = body
|
||||
.map {
|
||||
MentionUtilities.highlightMentions(
|
||||
in: $0,
|
||||
threadVariant: threadVariant,
|
||||
currentUserPublicKey: currentUserPublicKey,
|
||||
currentUserBlindedPublicKey: currentUserBlindedPublicKey,
|
||||
isOutgoingMessage: isOutgoing,
|
||||
attributes: [:]
|
||||
)
|
||||
}
|
||||
.defaulting(
|
||||
to: attachment.map {
|
||||
NSAttributedString(string: MIMETypeUtil.isAudio($0.contentType) ? "Audio" : "Document")
|
||||
}
|
||||
)
|
||||
.defaulting(to: NSAttributedString(string: "Document"))
|
||||
bodyLabel.textColor = textColor
|
||||
|
||||
let bodyLabelSize = bodyLabel.systemLayoutSizeFitting(availableSpace)
|
||||
ThemeManager.onThemeChange(observer: bodyLabel) { [weak bodyLabel] theme, primaryColor in
|
||||
guard let textColor: UIColor = theme.color(for: targetThemeColor) else { return }
|
||||
|
||||
bodyLabel?.attributedText = body
|
||||
.map {
|
||||
MentionUtilities.highlightMentions(
|
||||
in: $0,
|
||||
threadVariant: threadVariant,
|
||||
currentUserPublicKey: currentUserPublicKey,
|
||||
currentUserBlindedPublicKey: currentUserBlindedPublicKey,
|
||||
isOutgoingMessage: (direction == .outgoing),
|
||||
textColor: textColor,
|
||||
theme: theme,
|
||||
primaryColor: primaryColor,
|
||||
attributes: [
|
||||
.foregroundColor: textColor
|
||||
]
|
||||
)
|
||||
}
|
||||
.defaulting(
|
||||
to: attachment.map {
|
||||
NSAttributedString(string: MIMETypeUtil.isAudio($0.contentType) ? "Audio" : "Document")
|
||||
}
|
||||
)
|
||||
.defaulting(to: NSAttributedString(string: "Document"))
|
||||
}
|
||||
|
||||
// Label stack view
|
||||
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.lineBreakMode = .byTruncatingTail
|
||||
authorLabel.text = (isCurrentUser ?
|
||||
"MEDIA_GALLERY_SENDER_NAME_YOU".localized() :
|
||||
Profile.displayName(
|
||||
id: authorId,
|
||||
threadVariant: threadVariant
|
||||
)
|
||||
)
|
||||
authorLabel.textColor = textColor
|
||||
authorLabel.font = .boldSystemFont(ofSize: Values.smallFontSize)
|
||||
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)
|
||||
}
|
||||
|
||||
// Cancel button
|
||||
let cancelButton = UIButton(type: .custom)
|
||||
cancelButton.setImage(UIImage(named: "X")?.withRenderingMode(.alwaysTemplate), for: UIControl.State.normal)
|
||||
cancelButton.tintColor = (isLightMode ? .black : .white)
|
||||
cancelButton.set(.width, to: cancelButtonSize)
|
||||
cancelButton.set(.height, to: cancelButtonSize)
|
||||
cancelButton.addTarget(self, action: #selector(cancel), for: UIControl.Event.touchUpInside)
|
||||
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 = 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)
|
||||
|
@ -288,6 +288,14 @@ final class QuoteView: UIView {
|
|||
lineView.set(.height, to: contentViewHeight - 8) // Add a small amount of spacing above and below the line
|
||||
|
||||
if mode == .draft {
|
||||
// Cancel button
|
||||
let cancelButton = UIButton(type: .custom)
|
||||
cancelButton.setImage(UIImage(named: "X")?.withRenderingMode(.alwaysTemplate), for: .normal)
|
||||
cancelButton.themeTintColor = .textPrimary
|
||||
cancelButton.set(.width, to: cancelButtonSize)
|
||||
cancelButton.set(.height, to: cancelButtonSize)
|
||||
cancelButton.addTarget(self, action: #selector(cancel), for: UIControl.Event.touchUpInside)
|
||||
|
||||
addSubview(cancelButton)
|
||||
cancelButton.center(.vertical, in: self)
|
||||
cancelButton.pin(.right, to: .right, of: self)
|
||||
|
|
|
@ -2,11 +2,13 @@
|
|||
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
final class ReactionContainerView: UIView {
|
||||
var showingAllReactions = false
|
||||
private var showNumbers = true
|
||||
private var maxEmojisPerLine = isIPhone6OrSmaller ? 5 : 6
|
||||
private var oldSize: CGSize = .zero
|
||||
|
||||
var reactions: [ReactionViewModel] = []
|
||||
var reactionViews: [ReactionButton] = []
|
||||
|
@ -14,35 +16,52 @@ final class ReactionContainerView: UIView {
|
|||
// MARK: - UI
|
||||
|
||||
private lazy var mainStackView: UIStackView = {
|
||||
let result = UIStackView(arrangedSubviews: [ reactionContainerView ])
|
||||
let result: UIStackView = UIStackView(arrangedSubviews: [ reactionContainerView, collapseButton ])
|
||||
result.axis = .vertical
|
||||
result.spacing = Values.smallSpacing
|
||||
result.alignment = .center
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var reactionContainerView: UIStackView = {
|
||||
let result = UIStackView()
|
||||
let result: UIStackView = UIStackView()
|
||||
result.axis = .vertical
|
||||
result.spacing = Values.smallSpacing
|
||||
result.alignment = .leading
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
var expandButton: ExpandingReactionButton?
|
||||
|
||||
var collapseButton: UIStackView = {
|
||||
let arrow = UIImageView(image: UIImage(named: "ic_chevron_up")?.resizedImage(to: CGSize(width: 15, height: 13))?.withRenderingMode(.alwaysTemplate))
|
||||
arrow.tintColor = Colors.text
|
||||
let arrow = UIImageView(
|
||||
image: UIImage(named: "ic_chevron_up")?
|
||||
.resizedImage(to: CGSize(width: 15, height: 13))?
|
||||
.withRenderingMode(.alwaysTemplate)
|
||||
)
|
||||
arrow.themeTintColor = .textPrimary
|
||||
|
||||
let textLabel = UILabel()
|
||||
textLabel.text = "EMOJI_REACTS_SHOW_LESS".localized()
|
||||
let textLabel: UILabel = UILabel()
|
||||
textLabel.font = .systemFont(ofSize: Values.verySmallFontSize)
|
||||
textLabel.textColor = Colors.text
|
||||
textLabel.text = "EMOJI_REACTS_SHOW_LESS".localized()
|
||||
textLabel.themeTextColor = .textPrimary
|
||||
|
||||
let result = UIStackView(arrangedSubviews: [ UIView.hStretchingSpacer(), arrow, textLabel, UIView.hStretchingSpacer() ])
|
||||
let leftSpacer: UIView = UIView.hStretchingSpacer()
|
||||
let rightSpacer: UIView = UIView.hStretchingSpacer()
|
||||
let result: UIStackView = UIStackView(arrangedSubviews: [
|
||||
leftSpacer,
|
||||
arrow,
|
||||
textLabel,
|
||||
rightSpacer
|
||||
])
|
||||
result.isLayoutMarginsRelativeArrangement = true
|
||||
result.spacing = Values.verySmallSpacing
|
||||
result.alignment = .center
|
||||
result.isHidden = true
|
||||
rightSpacer.set(.width, to: .width, of: leftSpacer)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
|
@ -65,6 +84,23 @@ final class ReactionContainerView: UIView {
|
|||
addSubview(mainStackView)
|
||||
|
||||
mainStackView.pin(to: self)
|
||||
collapseButton.set(.width, to: .width, of: mainStackView)
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
// Note: We update the 'collapseButton.layoutMargins' to try to make the "show less"
|
||||
// button appear horizontally centered (if we don't do this it gets offset to one side)
|
||||
guard frame != CGRect.zero, frame.size != oldSize else { return }
|
||||
|
||||
collapseButton.layoutMargins = UIEdgeInsets(
|
||||
top: 0,
|
||||
leading: -frame.minX,
|
||||
bottom: 0,
|
||||
trailing: -((superview?.frame.width ?? 0) - frame.maxX)
|
||||
)
|
||||
oldSize = frame.size
|
||||
}
|
||||
|
||||
public func update(_ reactions: [ReactionViewModel], showNumbers: Bool) {
|
||||
|
@ -135,7 +171,7 @@ final class ReactionContainerView: UIView {
|
|||
}
|
||||
|
||||
if numberOfLines > 1 {
|
||||
mainStackView.addArrangedSubview(collapseButton)
|
||||
collapseButton.isHidden = false
|
||||
}
|
||||
else {
|
||||
showingAllReactions = false
|
||||
|
@ -148,8 +184,7 @@ final class ReactionContainerView: UIView {
|
|||
subview.removeFromSuperview()
|
||||
}
|
||||
|
||||
mainStackView.removeArrangedSubview(collapseButton)
|
||||
collapseButton.removeFromSuperview()
|
||||
collapseButton.isHidden = true
|
||||
reactionViews = []
|
||||
}
|
||||
|
||||
|
|
|
@ -39,11 +39,11 @@ final class ReactionButton: UIView {
|
|||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
let emojiLabel = UILabel()
|
||||
emojiLabel.text = viewModel.emoji.rawValue
|
||||
let emojiLabel: UILabel = UILabel()
|
||||
emojiLabel.font = .systemFont(ofSize: fontSize)
|
||||
emojiLabel.text = viewModel.emoji.rawValue
|
||||
|
||||
let stackView = UIStackView(arrangedSubviews: [ emojiLabel ])
|
||||
let stackView: UIStackView = UIStackView(arrangedSubviews: [ emojiLabel ])
|
||||
stackView.axis = .horizontal
|
||||
stackView.spacing = spacing
|
||||
stackView.alignment = .center
|
||||
|
@ -52,19 +52,20 @@ final class ReactionButton: UIView {
|
|||
addSubview(stackView)
|
||||
stackView.pin(to: self)
|
||||
|
||||
themeBorderColor = (viewModel.showBorder ? .primary : .clear)
|
||||
themeBackgroundColor = .messageBubble_incomingBackground
|
||||
layer.cornerRadius = (self.height / 2)
|
||||
layer.borderWidth = 1 // Intentionally 1pt (instead of 'Values.separatorThickness')
|
||||
set(.height, to: self.height)
|
||||
backgroundColor = Colors.receivedMessageBackground
|
||||
layer.cornerRadius = self.height / 2
|
||||
|
||||
if viewModel.showBorder {
|
||||
self.addBorder(with: Colors.accent)
|
||||
}
|
||||
|
||||
if showNumber || viewModel.number > 1 {
|
||||
let numberLabel = UILabel()
|
||||
numberLabel.text = viewModel.number < 1000 ? "\(viewModel.number)" : String(format: "%.1f", Float(viewModel.number) / 1000) + "k"
|
||||
numberLabel.font = .systemFont(ofSize: fontSize)
|
||||
numberLabel.textColor = Colors.text
|
||||
numberLabel.text = (viewModel.number < 1000 ?
|
||||
"\(viewModel.number)" :
|
||||
String(format: "%.1f", Float(viewModel.number) / 1000) + "k"
|
||||
)
|
||||
numberLabel.themeTextColor = .textPrimary
|
||||
stackView.addArrangedSubview(numberLabel)
|
||||
}
|
||||
}
|
||||
|
@ -100,18 +101,17 @@ final class ExpandingReactionButton: UIView {
|
|||
var rightMargin: CGFloat = 0
|
||||
|
||||
for emoji in self.emojis.reversed() {
|
||||
let container = UIView()
|
||||
let container: UIView = UIView()
|
||||
container.set(.width, to: size)
|
||||
container.set(.height, to: size)
|
||||
container.backgroundColor = Colors.receivedMessageBackground
|
||||
container.themeBorderColor = .backgroundPrimary
|
||||
container.themeBackgroundColor = .messageBubble_incomingBackground
|
||||
container.layer.cornerRadius = size / 2
|
||||
container.layer.borderWidth = 1
|
||||
// FIXME: This is going to have issues when swapping between light/dark mode
|
||||
container.layer.borderColor = (isDarkMode ? UIColor.black.cgColor : UIColor.white.cgColor)
|
||||
container.layer.borderWidth = 1 // Intentionally 1pt (instead of 'Values.separatorThickness')
|
||||
|
||||
let emojiLabel = UILabel()
|
||||
emojiLabel.text = emoji.rawValue
|
||||
let emojiLabel: UILabel = UILabel()
|
||||
emojiLabel.font = .systemFont(ofSize: Values.verySmallFontSize)
|
||||
emojiLabel.text = emoji.rawValue
|
||||
|
||||
container.addSubview(emojiLabel)
|
||||
emojiLabel.center(in: container)
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
|
||||
@objc class TypingIndicatorView: UIStackView {
|
||||
// This represents the spacing between the dots
|
||||
|
@ -122,12 +123,18 @@
|
|||
autoSetDimension(.height, toSize: kMaxRadiusPt)
|
||||
|
||||
layer.addSublayer(shapeLayer)
|
||||
|
||||
ThemeManager.onThemeChange(observer: self) { [weak self] _, _ in
|
||||
guard self?.shapeLayer.animationKeys()?.isEmpty == false else { return }
|
||||
|
||||
self?.startAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func startAnimation() {
|
||||
stopAnimation()
|
||||
|
||||
let baseColor = Colors.text
|
||||
let baseColor: UIColor = (ThemeManager.currentTheme.color(for: .messageBubble_incomingText) ?? .white)
|
||||
let timeIncrement: CFTimeInterval = 0.15
|
||||
var colorValues = [CGColor]()
|
||||
var pathValues = [CGPath]()
|
||||
|
|
|
@ -16,37 +16,57 @@ public final class VoiceMessageView: UIView {
|
|||
|
||||
private lazy var progressView: UIView = {
|
||||
let result: UIView = UIView()
|
||||
result.backgroundColor = UIColor.black.withAlphaComponent(0.2)
|
||||
result.themeBackgroundColor = .messageBubble_overlay
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var toggleContainer: UIView = {
|
||||
let result: UIView = UIView()
|
||||
result.themeBackgroundColor = .backgroundSecondary
|
||||
result.set(.width, to: VoiceMessageView.toggleContainerSize)
|
||||
result.set(.height, to: VoiceMessageView.toggleContainerSize)
|
||||
result.layer.masksToBounds = true
|
||||
result.layer.cornerRadius = (VoiceMessageView.toggleContainerSize / 2)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var toggleImageView: UIImageView = {
|
||||
let result: UIImageView = UIImageView(image: UIImage(named: "Play"))
|
||||
let result: UIImageView = UIImageView(
|
||||
image: UIImage(named: "Play")?.withRenderingMode(.alwaysTemplate)
|
||||
)
|
||||
result.contentMode = .scaleAspectFit
|
||||
result.themeTintColor = .textPrimary
|
||||
result.set(.width, to: 8)
|
||||
result.set(.height, to: 8)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var loader: NVActivityIndicatorView = {
|
||||
private let loader: NVActivityIndicatorView = {
|
||||
let result: NVActivityIndicatorView = NVActivityIndicatorView(
|
||||
frame: .zero,
|
||||
type: .circleStrokeSpin,
|
||||
color: Colors.text,
|
||||
color: .black,
|
||||
padding: nil
|
||||
)
|
||||
result.set(.width, to: VoiceMessageView.toggleContainerSize + 2)
|
||||
result.set(.height, to: VoiceMessageView.toggleContainerSize + 2)
|
||||
|
||||
ThemeManager.onThemeChange(observer: result) { [weak result] theme, _ in
|
||||
guard let textPrimary: UIColor = theme.color(for: .textPrimary) else { return }
|
||||
|
||||
result?.color = textPrimary
|
||||
}
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var countdownLabelContainer: UIView = {
|
||||
let result: UIView = UIView()
|
||||
result.backgroundColor = .white
|
||||
result.layer.masksToBounds = true
|
||||
result.clipsToBounds = true
|
||||
result.themeBackgroundColor = .backgroundSecondary
|
||||
result.set(.height, to: VoiceMessageView.toggleContainerSize)
|
||||
result.set(.width, to: 44)
|
||||
|
||||
|
@ -55,20 +75,20 @@ public final class VoiceMessageView: UIView {
|
|||
|
||||
private lazy var countdownLabel: UILabel = {
|
||||
let result: UILabel = UILabel()
|
||||
result.textColor = .black
|
||||
result.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
result.text = "0:00"
|
||||
result.themeTextColor = .textPrimary
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var speedUpLabel: UILabel = {
|
||||
let result: UILabel = UILabel()
|
||||
result.textColor = .black
|
||||
result.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
result.alpha = 0
|
||||
result.text = "1.5x"
|
||||
result.themeTextColor = .textPrimary
|
||||
result.textAlignment = .center
|
||||
result.alpha = 0
|
||||
|
||||
return result
|
||||
}()
|
||||
|
@ -97,18 +117,12 @@ public final class VoiceMessageView: UIView {
|
|||
set(.width, to: VoiceMessageView.width)
|
||||
|
||||
// Toggle
|
||||
let toggleContainer: UIView = UIView()
|
||||
toggleContainer.backgroundColor = .white
|
||||
toggleContainer.set(.width, to: toggleContainerSize)
|
||||
toggleContainer.set(.height, to: toggleContainerSize)
|
||||
toggleContainer.addSubview(toggleImageView)
|
||||
toggleImageView.center(in: toggleContainer)
|
||||
toggleContainer.layer.cornerRadius = (toggleContainerSize / 2)
|
||||
toggleContainer.layer.masksToBounds = true
|
||||
|
||||
// Line
|
||||
let lineView = UIView()
|
||||
lineView.backgroundColor = .white
|
||||
lineView.themeBackgroundColor = .backgroundSecondary
|
||||
lineView.set(.height, to: 1)
|
||||
|
||||
// Countdown label
|
||||
|
@ -164,7 +178,8 @@ public final class VoiceMessageView: UIView {
|
|||
loader.isHidden = true
|
||||
loader.stopAnimating()
|
||||
|
||||
toggleImageView.image = (isPlaying ? UIImage(named: "Pause") : UIImage(named: "Play"))
|
||||
toggleImageView.image = (isPlaying ? UIImage(named: "Pause") : UIImage(named: "Play"))?
|
||||
.withRenderingMode(.alwaysTemplate)
|
||||
countdownLabel.text = OWSFormat.formatDurationSeconds(max(0, Int(floor(attachment.duration.defaulting(to: 0) - progress))))
|
||||
|
||||
guard let duration: TimeInterval = attachment.duration, duration > 0, progress > 0 else {
|
||||
|
|
|
@ -17,11 +17,11 @@ final class InfoMessageCell: MessageCell {
|
|||
|
||||
private lazy var label: UILabel = {
|
||||
let result: UILabel = UILabel()
|
||||
result.numberOfLines = 0
|
||||
result.lineBreakMode = .byWordWrapping
|
||||
result.font = .boldSystemFont(ofSize: Values.verySmallFontSize)
|
||||
result.textColor = Colors.text
|
||||
result.themeTextColor = .textPrimary
|
||||
result.textAlignment = .center
|
||||
result.lineBreakMode = .byWordWrapping
|
||||
result.numberOfLines = 0
|
||||
|
||||
return result
|
||||
}()
|
||||
|
@ -79,7 +79,7 @@ final class InfoMessageCell: MessageCell {
|
|||
|
||||
if let icon = icon {
|
||||
iconImageView.image = icon.withRenderingMode(.alwaysTemplate)
|
||||
iconImageView.tintColor = Colors.text
|
||||
iconImageView.themeTintColor = .textPrimary
|
||||
}
|
||||
|
||||
iconImageViewWidthConstraint.constant = (icon != nil) ? InfoMessageCell.iconSize : 0
|
||||
|
|
|
@ -30,10 +30,10 @@ public class MessageCell: UITableViewCell {
|
|||
}
|
||||
|
||||
func setUpViewHierarchy() {
|
||||
backgroundColor = .clear
|
||||
themeBackgroundColor = .clear
|
||||
|
||||
let selectedBackgroundView = UIView()
|
||||
selectedBackgroundView.backgroundColor = .clear
|
||||
selectedBackgroundView.themeBackgroundColor = .clear
|
||||
self.selectedBackgroundView = selectedBackgroundView
|
||||
}
|
||||
|
||||
|
@ -88,7 +88,6 @@ protocol MessageCellDelegate: ReactionDelegate {
|
|||
func handleItemSwiped(_ cellViewModel: MessageViewModel, state: SwipeState)
|
||||
func openUrl(_ urlString: String)
|
||||
func handleReplyButtonTapped(for cellViewModel: MessageViewModel)
|
||||
func showUserDetails(for profile: Profile)
|
||||
func startThread(with sessionId: String, openGroupServer: String?, openGroupPublicKey: String?)
|
||||
func showReactionList(_ cellViewModel: MessageViewModel, selectedReaction: EmojiWithSkinTones?)
|
||||
func needsLayout(for cellViewModel: MessageViewModel, expandingReactions: Bool)
|
||||
|
|
|
@ -13,14 +13,14 @@ final class TypingIndicatorCell: MessageCell {
|
|||
private lazy var bubbleView: UIView = {
|
||||
let result: UIView = UIView()
|
||||
result.layer.cornerRadius = VisibleMessageCell.smallCornerRadius
|
||||
result.backgroundColor = Colors.receivedMessageBackground
|
||||
result.themeBackgroundColor = .messageBubble_incomingBackground
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private let bubbleViewMaskLayer: CAShapeLayer = CAShapeLayer()
|
||||
|
||||
private lazy var typingIndicatorView: TypingIndicatorView = TypingIndicatorView()
|
||||
public lazy var typingIndicatorView: TypingIndicatorView = TypingIndicatorView()
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
|
@ -94,7 +94,7 @@ final class TypingIndicatorCell: MessageCell {
|
|||
case .top: return [ .topLeft, .topRight, .bottomRight ]
|
||||
case .middle: return [ .topRight, .bottomRight ]
|
||||
case .bottom: return [ .topRight, .bottomRight, .bottomLeft ]
|
||||
case .none: return .allCorners
|
||||
case .none, .individual: return .allCorners
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -109,11 +109,12 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
|||
let size = VisibleMessageCell.replyButtonSize + 8
|
||||
result.set(.width, to: size)
|
||||
result.set(.height, to: size)
|
||||
result.themeBorderColor = .textPrimary
|
||||
result.layer.borderWidth = 1
|
||||
result.layer.borderColor = Colors.text.cgColor
|
||||
result.layer.cornerRadius = size / 2
|
||||
result.layer.cornerRadius = (size / 2)
|
||||
result.layer.masksToBounds = true
|
||||
result.alpha = 0
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
|
@ -123,7 +124,8 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
|||
result.set(.width, to: size)
|
||||
result.set(.height, to: size)
|
||||
result.image = UIImage(named: "ic_reply")?.withRenderingMode(.alwaysTemplate)
|
||||
result.tintColor = Colors.text
|
||||
result.themeTintColor = .textPrimary
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
|
@ -284,11 +286,13 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
|||
cellViewModel.variant == .standardIncoming ||
|
||||
cellViewModel.variant == .standardIncomingDeleted
|
||||
)
|
||||
bubbleView.backgroundColor = ((
|
||||
|
||||
let bubbleBackgroundColor: ThemeValue = ((
|
||||
cellViewModel.variant == .standardIncoming ||
|
||||
cellViewModel.variant == .standardIncomingDeleted
|
||||
) ? Colors.receivedMessageBackground : Colors.sentMessageBackground)
|
||||
bubbleBackgroundView.backgroundColor = bubbleView.backgroundColor
|
||||
) ? .messageBubble_incomingBackground : .messageBubble_outgoingBackground)
|
||||
bubbleView.themeBackgroundColor = bubbleBackgroundColor
|
||||
bubbleBackgroundView.themeBackgroundColor = bubbleBackgroundColor
|
||||
updateBubbleViewCorners()
|
||||
|
||||
// Content view
|
||||
|
@ -311,9 +315,9 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
|||
populateHeader(for: cellViewModel, shouldInsetHeader: shouldInsetHeader)
|
||||
|
||||
// Author label
|
||||
authorLabel.textColor = Colors.text
|
||||
authorLabel.isHidden = (cellViewModel.senderName == nil)
|
||||
authorLabel.text = cellViewModel.senderName
|
||||
authorLabel.themeTextColor = .textPrimary
|
||||
|
||||
let authorLabelAvailableWidth: CGFloat = (VisibleMessageCell.getMaxWidth(for: cellViewModel) - 2 * VisibleMessageCell.authorLabelInset)
|
||||
let authorLabelAvailableSpace = CGSize(width: authorLabelAvailableWidth, height: .greatestFiniteMagnitude)
|
||||
|
@ -321,10 +325,12 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
|||
authorLabelHeightConstraint.constant = (cellViewModel.senderName != nil ? authorLabelSize.height : 0)
|
||||
|
||||
// Message status image view
|
||||
let (image, tintColor, backgroundColor) = getMessageStatusImage(for: cellViewModel)
|
||||
let (image, tintColor) = cellViewModel.state.statusIconInfo(
|
||||
variant: cellViewModel.variant,
|
||||
hasAtLeastOneReadReceipt: cellViewModel.hasAtLeastOneReadReceipt
|
||||
)
|
||||
messageStatusImageView.image = image
|
||||
messageStatusImageView.tintColor = tintColor
|
||||
messageStatusImageView.backgroundColor = backgroundColor
|
||||
messageStatusImageView.themeTintColor = tintColor
|
||||
messageStatusImageView.isHidden = (
|
||||
cellViewModel.variant != .standardOutgoing ||
|
||||
cellViewModel.variant == .infoCall ||
|
||||
|
@ -348,9 +354,9 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
|||
|
||||
timerView.configure(
|
||||
withExpirationTimestamp: UInt64(floor(expirationTimestampMs)),
|
||||
initialDurationSeconds: UInt32(floor(expiresInSeconds)),
|
||||
tintColor: Colors.text
|
||||
initialDurationSeconds: UInt32(floor(expiresInSeconds))
|
||||
)
|
||||
timerView.themeTintColor = .textPrimary
|
||||
timerView.isHidden = false
|
||||
}
|
||||
else {
|
||||
|
@ -373,13 +379,13 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
|||
}
|
||||
|
||||
private func populateHeader(for cellViewModel: MessageViewModel, shouldInsetHeader: Bool) {
|
||||
guard let date: Date = cellViewModel.dateForUI else { return }
|
||||
guard cellViewModel.shouldShowDateHeader else { return }
|
||||
|
||||
let dateBreakLabel: UILabel = UILabel()
|
||||
dateBreakLabel.font = .boldSystemFont(ofSize: Values.verySmallFontSize)
|
||||
dateBreakLabel.textColor = Colors.text
|
||||
dateBreakLabel.text = cellViewModel.dateForUI.formattedForDisplay
|
||||
dateBreakLabel.themeTextColor = .textPrimary
|
||||
dateBreakLabel.textAlignment = .center
|
||||
dateBreakLabel.text = date.formattedForDisplay
|
||||
headerView.addSubview(dateBreakLabel)
|
||||
dateBreakLabel.pin(.top, to: .top, of: headerView, withInset: Values.smallSpacing)
|
||||
|
||||
|
@ -399,16 +405,15 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
|||
playbackInfo: ConversationViewModel.PlaybackInfo?,
|
||||
lastSearchText: String?
|
||||
) {
|
||||
let direction: Direction = cellViewModel.variant == .standardOutgoing ? .outgoing : .incoming
|
||||
let bodyLabelTextColor: UIColor = {
|
||||
switch (direction, AppModeManager.shared.currentAppMode) {
|
||||
case (.outgoing, .dark), (.incoming, .light): return .black
|
||||
case (.outgoing, .light): return Colors.grey
|
||||
default: return .white
|
||||
}
|
||||
}()
|
||||
let bodyLabelTextColor: ThemeValue = (cellViewModel.variant == .standardOutgoing ?
|
||||
.messageBubble_outgoingText :
|
||||
.messageBubble_incomingText
|
||||
)
|
||||
|
||||
snContentView.alignment = direction == .incoming ? .leading : .trailing
|
||||
snContentView.alignment = (cellViewModel.variant == .standardOutgoing ?
|
||||
.trailing :
|
||||
.leading
|
||||
)
|
||||
|
||||
for subview in snContentView.arrangedSubviews {
|
||||
snContentView.removeArrangedSubview(subview)
|
||||
|
@ -584,7 +589,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
|||
|
||||
let inset: CGFloat = 12
|
||||
let maxWidth = (VisibleMessageCell.getMaxWidth(for: cellViewModel) - 2 * inset)
|
||||
|
||||
|
||||
// Stack view
|
||||
let stackView = UIStackView(arrangedSubviews: [])
|
||||
stackView.axis = .vertical
|
||||
|
@ -596,6 +601,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
|||
|
||||
// Body text view
|
||||
if let body: String = cellViewModel.body, !body.isEmpty { // delegate should always be set at this point
|
||||
let bodyContainerView: UIView = UIView()
|
||||
let bodyTappableLabel = VisibleMessageCell.getBodyTappableLabel(
|
||||
for: cellViewModel,
|
||||
with: maxWidth,
|
||||
|
@ -605,11 +611,16 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
|||
)
|
||||
|
||||
self.bodyTappableLabel = bodyTappableLabel
|
||||
stackView.addArrangedSubview(bodyTappableLabel)
|
||||
bodyContainerView.addSubview(bodyTappableLabel)
|
||||
bodyTappableLabel.pin(.top, to: .top, of: bodyContainerView)
|
||||
bodyTappableLabel.pin(.leading, to: .leading, of: bodyContainerView, withInset: 12)
|
||||
bodyTappableLabel.pin(.trailing, to: .trailing, of: bodyContainerView, withInset: -12)
|
||||
bodyTappableLabel.pin(.bottom, to: .bottom, of: bodyContainerView, withInset: -12)
|
||||
stackView.addArrangedSubview(bodyContainerView)
|
||||
}
|
||||
|
||||
bubbleView.addSubview(stackView)
|
||||
stackView.pin(to: bubbleView, withInset: inset)
|
||||
stackView.pin(to: bubbleView)
|
||||
snContentView.addArrangedSubview(bubbleBackgroundView)
|
||||
}
|
||||
}
|
||||
|
@ -734,21 +745,26 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
|||
}
|
||||
|
||||
func highlight() {
|
||||
// FIXME: This will have issues with themes
|
||||
let shawdowColour = (isLightMode ? UIColor.black.cgColor : Colors.accent.cgColor)
|
||||
let opacity: Float = (isLightMode ? 0.5 : 1)
|
||||
let shadowColor: ThemeValue = (ThemeManager.currentTheme.interfaceStyle == .light ?
|
||||
.black :
|
||||
.primary
|
||||
)
|
||||
let opacity: Float = (ThemeManager.currentTheme.interfaceStyle == .light ?
|
||||
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
|
||||
|
@ -932,34 +948,6 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
private func getMessageStatusImage(for cellViewModel: MessageViewModel) -> (image: UIImage?, tintColor: UIColor?, backgroundColor: UIColor?) {
|
||||
guard cellViewModel.variant == .standardOutgoing else { return (nil, nil, nil) }
|
||||
|
||||
let image: UIImage
|
||||
var tintColor: UIColor? = nil
|
||||
var backgroundColor: UIColor? = nil
|
||||
|
||||
switch (cellViewModel.state, cellViewModel.hasAtLeastOneReadReceipt) {
|
||||
case (.sending, _):
|
||||
image = #imageLiteral(resourceName: "CircleDotDotDot").withRenderingMode(.alwaysTemplate)
|
||||
tintColor = Colors.text
|
||||
|
||||
case (.sent, false), (.skipped, _):
|
||||
image = #imageLiteral(resourceName: "CircleCheck").withRenderingMode(.alwaysTemplate)
|
||||
tintColor = Colors.text
|
||||
|
||||
case (.sent, true):
|
||||
image = isLightMode ? #imageLiteral(resourceName: "FilledCircleCheckLightMode") : #imageLiteral(resourceName: "FilledCircleCheckDarkMode")
|
||||
backgroundColor = isLightMode ? .black : .white
|
||||
|
||||
case (.failed, _):
|
||||
image = #imageLiteral(resourceName: "message_status_failed").withRenderingMode(.alwaysTemplate)
|
||||
tintColor = Colors.destructive
|
||||
}
|
||||
|
||||
return (image, tintColor, backgroundColor)
|
||||
}
|
||||
|
||||
private func getSize(for cellViewModel: MessageViewModel) -> CGSize {
|
||||
guard let mediaAttachments: [Attachment] = cellViewModel.attachments?.filter({ $0.isVisualMedia }) else {
|
||||
preconditionFailure()
|
||||
|
@ -1029,114 +1017,144 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
|||
static func getBodyTappableLabel(
|
||||
for cellViewModel: MessageViewModel,
|
||||
with availableWidth: CGFloat,
|
||||
textColor: UIColor,
|
||||
textColor: ThemeValue,
|
||||
searchText: String?,
|
||||
delegate: TappableLabelDelegate?
|
||||
) -> TappableLabel {
|
||||
let result = TappableLabel()
|
||||
let isOutgoing: Bool = (cellViewModel.variant == .standardOutgoing)
|
||||
|
||||
let attributedText: NSMutableAttributedString = NSMutableAttributedString(
|
||||
attributedString: MentionUtilities.highlightMentions(
|
||||
in: (cellViewModel.body ?? ""),
|
||||
threadVariant: cellViewModel.threadVariant,
|
||||
currentUserPublicKey: cellViewModel.currentUserPublicKey,
|
||||
currentUserBlindedPublicKey: cellViewModel.currentUserBlindedPublicKey,
|
||||
isOutgoingMessage: isOutgoing,
|
||||
attributes: [
|
||||
.foregroundColor : textColor,
|
||||
.font : UIFont.systemFont(ofSize: getFontSize(for: cellViewModel))
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
// Custom handle links
|
||||
let links: [String: NSRange] = {
|
||||
guard
|
||||
let body: String = cellViewModel.body,
|
||||
let detector: NSDataDetector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
||||
else { return [:] }
|
||||
|
||||
var links: [String: NSRange] = [:]
|
||||
let matches = detector.matches(
|
||||
in: body,
|
||||
options: [],
|
||||
range: NSRange(location: 0, length: body.count)
|
||||
)
|
||||
|
||||
for match in matches {
|
||||
guard let matchURL = match.url else { continue }
|
||||
|
||||
/// If the URL entered didn't have a scheme it will default to 'http', we want to catch this and
|
||||
/// set the scheme to 'https' instead as we don't load previews for 'http' so this will result
|
||||
/// in more previews actually getting loaded without forcing the user to enter 'https://' before
|
||||
/// every URL they enter
|
||||
let urlString: String = (matchURL.absoluteString == "http://\(body)" ?
|
||||
"https://\(body)" :
|
||||
matchURL.absoluteString
|
||||
)
|
||||
|
||||
if URL(string: urlString) != nil {
|
||||
links[urlString] = (body as NSString).range(of: urlString)
|
||||
}
|
||||
}
|
||||
|
||||
return links
|
||||
}()
|
||||
|
||||
for (urlString, range) in links {
|
||||
guard let url: URL = URL(string: urlString) else { continue }
|
||||
|
||||
attributedText.addAttributes(
|
||||
[
|
||||
.font: UIFont.systemFont(ofSize: getFontSize(for: cellViewModel)),
|
||||
.foregroundColor: textColor,
|
||||
.underlineColor: textColor,
|
||||
.underlineStyle: NSUnderlineStyle.single.rawValue,
|
||||
.attachment: url
|
||||
],
|
||||
range: range
|
||||
)
|
||||
}
|
||||
|
||||
// If there is a valid search term then highlight each part that matched
|
||||
if let searchText = searchText, searchText.count >= ConversationSearchController.minimumSearchTextLength {
|
||||
let normalizedBody: String = attributedText.string.lowercased()
|
||||
|
||||
SessionThreadViewModel.searchTermParts(searchText)
|
||||
.map { part -> String in
|
||||
guard part.hasPrefix("\"") && part.hasSuffix("\"") else { return part }
|
||||
|
||||
return String(part[part.index(after: part.startIndex)..<part.endIndex])
|
||||
}
|
||||
.forEach { part in
|
||||
// Highlight all ranges of the text (Note: The search logic only finds results that start
|
||||
// with the term so we use the regex below to ensure we only highlight those cases)
|
||||
normalizedBody
|
||||
.ranges(
|
||||
of: (CurrentAppContext().isRTL ?
|
||||
"\(part.lowercased())(^|[ ])" :
|
||||
"(^|[ ])\(part.lowercased())"
|
||||
),
|
||||
options: [.regularExpression]
|
||||
)
|
||||
.forEach { range in
|
||||
let legacyRange: NSRange = NSRange(range, in: normalizedBody)
|
||||
attributedText.addAttribute(.backgroundColor, value: UIColor.white, range: legacyRange)
|
||||
attributedText.addAttribute(.foregroundColor, value: UIColor.black, range: legacyRange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.attributedText = attributedText
|
||||
result.backgroundColor = .clear
|
||||
let result: TappableLabel = TappableLabel()
|
||||
result.themeBackgroundColor = .clear
|
||||
result.isOpaque = false
|
||||
result.isUserInteractionEnabled = true
|
||||
result.delegate = delegate
|
||||
|
||||
let availableSpace = CGSize(width: availableWidth, height: .greatestFiniteMagnitude)
|
||||
let size = result.sizeThatFits(availableSpace)
|
||||
result.set(.height, to: size.height)
|
||||
ThemeManager.onThemeChange(observer: result) { [weak result] theme, primaryColor in
|
||||
guard
|
||||
let actualTextColor: UIColor = theme.color(for: textColor),
|
||||
let backgroundPrimaryColor: UIColor = theme.color(for: .backgroundPrimary),
|
||||
let textPrimaryColor: UIColor = theme.color(for: .textPrimary)
|
||||
else { return }
|
||||
|
||||
let hasPreviousSetText: Bool = ((result?.attributedText?.length ?? 0) > 0)
|
||||
|
||||
let attributedText: NSMutableAttributedString = NSMutableAttributedString(
|
||||
attributedString: MentionUtilities.highlightMentions(
|
||||
in: (cellViewModel.body ?? ""),
|
||||
threadVariant: cellViewModel.threadVariant,
|
||||
currentUserPublicKey: cellViewModel.currentUserPublicKey,
|
||||
currentUserBlindedPublicKey: cellViewModel.currentUserBlindedPublicKey,
|
||||
isOutgoingMessage: isOutgoing,
|
||||
textColor: actualTextColor,
|
||||
theme: theme,
|
||||
primaryColor: primaryColor,
|
||||
attributes: [
|
||||
.foregroundColor: actualTextColor,
|
||||
.font: UIFont.systemFont(ofSize: getFontSize(for: cellViewModel))
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
// Custom handle links
|
||||
let links: [URL: NSRange] = {
|
||||
guard let detector: NSDataDetector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) else {
|
||||
return [:]
|
||||
}
|
||||
|
||||
return detector
|
||||
.matches(
|
||||
in: attributedText.string,
|
||||
options: [],
|
||||
range: NSRange(location: 0, length: attributedText.string.count)
|
||||
)
|
||||
.reduce(into: [:]) { result, match in
|
||||
guard
|
||||
let matchUrl: URL = match.url,
|
||||
let originalRange: Range = Range(match.range, in: attributedText.string)
|
||||
else { return }
|
||||
|
||||
/// If the URL entered didn't have a scheme it will default to 'http', we want to catch this and
|
||||
/// set the scheme to 'https' instead as we don't load previews for 'http' so this will result
|
||||
/// in more previews actually getting loaded without forcing the user to enter 'https://' before
|
||||
/// every URL they enter
|
||||
let originalString: String = String(attributedText.string[originalRange])
|
||||
|
||||
guard matchUrl.absoluteString != "http://\(originalString)" else {
|
||||
guard let httpsUrl: URL = URL(string: "https://\(originalString)") else {
|
||||
return
|
||||
}
|
||||
|
||||
result[httpsUrl] = match.range
|
||||
return
|
||||
}
|
||||
|
||||
result[matchUrl] = match.range
|
||||
}
|
||||
}()
|
||||
|
||||
for (linkUrl, urlRange) in links {
|
||||
attributedText.addAttributes(
|
||||
[
|
||||
.font: UIFont.systemFont(ofSize: getFontSize(for: cellViewModel)),
|
||||
.foregroundColor: actualTextColor,
|
||||
.underlineColor: actualTextColor,
|
||||
.underlineStyle: NSUnderlineStyle.single.rawValue,
|
||||
.attachment: linkUrl
|
||||
],
|
||||
range: urlRange
|
||||
)
|
||||
}
|
||||
|
||||
// If there is a valid search term then highlight each part that matched
|
||||
if let searchText = searchText, searchText.count >= ConversationSearchController.minimumSearchTextLength {
|
||||
let normalizedBody: String = attributedText.string.lowercased()
|
||||
|
||||
SessionThreadViewModel.searchTermParts(searchText)
|
||||
.map { part -> String in
|
||||
guard part.hasPrefix("\"") && part.hasSuffix("\"") else { return part }
|
||||
|
||||
let partRange = (part.index(after: part.startIndex)..<part.index(before: part.endIndex))
|
||||
return String(part[partRange])
|
||||
}
|
||||
.forEach { part in
|
||||
// Highlight all ranges of the text (Note: The search logic only finds
|
||||
// results that start with the term so we use the regex below to ensure
|
||||
// we only highlight those cases)
|
||||
normalizedBody
|
||||
.ranges(
|
||||
of: (CurrentAppContext().isRTL ?
|
||||
"(\(part.lowercased()))(^|[^a-zA-Z0-9])" :
|
||||
"(^|[^a-zA-Z0-9])(\(part.lowercased()))"
|
||||
),
|
||||
options: [.regularExpression]
|
||||
)
|
||||
.forEach { range in
|
||||
let targetRange: Range<String.Index> = {
|
||||
let term: String = String(normalizedBody[range])
|
||||
|
||||
// If the matched term doesn't actually match the "part" value then it means
|
||||
// we've matched a term after a non-alphanumeric character so need to shift
|
||||
// the range over by 1
|
||||
guard term.starts(with: part.lowercased()) else {
|
||||
return (normalizedBody.index(after: range.lowerBound)..<range.upperBound)
|
||||
}
|
||||
|
||||
return range
|
||||
}()
|
||||
|
||||
let legacyRange: NSRange = NSRange(targetRange, in: normalizedBody)
|
||||
attributedText.addThemeAttribute(.background(backgroundPrimaryColor), range: legacyRange)
|
||||
attributedText.addThemeAttribute(.foreground(textPrimaryColor), range: legacyRange)
|
||||
}
|
||||
}
|
||||
}
|
||||
result?.attributedText = attributedText
|
||||
|
||||
if let result: TappableLabel = result, !hasPreviousSetText {
|
||||
let availableSpace = CGSize(width: availableWidth, height: .greatestFiniteMagnitude)
|
||||
let size = result.sizeThatFits(availableSpace)
|
||||
result.set(.height, to: size.height)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
|
|
@ -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,964 +0,0 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
#import "OWSConversationSettingsViewController.h"
|
||||
#import "OWSSoundSettingsViewController.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:^{
|
||||
OWSSoundSettingsViewController *vc = [OWSSoundSettingsViewController new];
|
||||
vc.threadId = weakSelf.threadId;
|
||||
[weakSelf.navigationController pushViewController:vc 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
|
|
@ -8,13 +8,14 @@ NS_ASSUME_NONNULL_BEGIN
|
|||
|
||||
@interface OWSMessageTimerView : UIView
|
||||
|
||||
@property (nonatomic) UIImageView *imageView;
|
||||
|
||||
- (instancetype)init NS_DESIGNATED_INITIALIZER;
|
||||
- (instancetype)initWithFrame:(CGRect)frame NS_UNAVAILABLE;
|
||||
- (nullable instancetype)initWithCoder:(NSCoder *)aDecoder NS_UNAVAILABLE;
|
||||
|
||||
- (void)configureWithExpirationTimestamp:(uint64_t)expirationTimestamp
|
||||
initialDurationSeconds:(uint32_t)initialDurationSeconds
|
||||
tintColor:(UIColor *)tintColor;
|
||||
initialDurationSeconds:(uint32_t)initialDurationSeconds;
|
||||
|
||||
- (void)prepareForReuse;
|
||||
|
||||
|
|
|
@ -18,9 +18,6 @@ const CGFloat kDisappearingMessageIconSize = 12.f;
|
|||
|
||||
@property (nonatomic) uint32_t initialDurationSeconds;
|
||||
@property (nonatomic) uint64_t expirationTimestamp;
|
||||
@property (nonatomic) UIColor *tintColor;
|
||||
|
||||
@property (nonatomic) UIImageView *imageView;
|
||||
|
||||
@property (nonatomic, nullable) NSTimer *animationTimer;
|
||||
|
||||
|
@ -62,11 +59,9 @@ const CGFloat kDisappearingMessageIconSize = 12.f;
|
|||
|
||||
- (void)configureWithExpirationTimestamp:(uint64_t)expirationTimestamp
|
||||
initialDurationSeconds:(uint32_t)initialDurationSeconds
|
||||
tintColor:(UIColor *)tintColor;
|
||||
{
|
||||
self.expirationTimestamp = expirationTimestamp;
|
||||
self.initialDurationSeconds = initialDurationSeconds;
|
||||
self.tintColor = tintColor;
|
||||
|
||||
[self updateProgress12];
|
||||
[self updateIcon];
|
||||
|
@ -107,7 +102,6 @@ const CGFloat kDisappearingMessageIconSize = 12.f;
|
|||
- (void)updateIcon
|
||||
{
|
||||
self.imageView.image = [[self progressIcon] imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate];
|
||||
self.imageView.tintColor = self.tintColor;
|
||||
}
|
||||
|
||||
- (UIImage *)progressIcon
|
||||
|
|
|
@ -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
|
||||
setUpGradientBackground()
|
||||
setUpNavBarStyle()
|
||||
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,192 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import GRDB
|
||||
import DifferenceKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
class ThreadDisappearingMessagesViewModel: SessionTableViewModel<ThreadDisappearingMessagesViewModel.NavButton, ThreadDisappearingMessagesViewModel.Section, ThreadDisappearingMessagesViewModel.Item> {
|
||||
// MARK: - Config
|
||||
|
||||
enum NavButton: Equatable {
|
||||
case cancel
|
||||
case save
|
||||
}
|
||||
|
||||
public enum Section: SessionTableSection {
|
||||
case content
|
||||
}
|
||||
|
||||
public struct Item: Equatable, Hashable, Differentiable {
|
||||
let title: String
|
||||
|
||||
public var differenceIdentifier: String { title }
|
||||
}
|
||||
|
||||
// MARK: - Variables
|
||||
|
||||
private let storage: Storage
|
||||
private let scheduler: ValueObservationScheduler
|
||||
private let threadId: String
|
||||
private let config: DisappearingMessagesConfiguration
|
||||
private var storedSelection: TimeInterval
|
||||
private var currentSelection: CurrentValueSubject<TimeInterval, Never>
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(
|
||||
storage: Storage = Storage.shared,
|
||||
scheduling scheduler: ValueObservationScheduler = Storage.defaultPublisherScheduler,
|
||||
threadId: String,
|
||||
config: DisappearingMessagesConfiguration
|
||||
) {
|
||||
self.storage = storage
|
||||
self.scheduler = scheduler
|
||||
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"
|
||||
) { [weak self] in self?.dismissScreen() }
|
||||
]).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"
|
||||
) { [weak self] in
|
||||
self?.saveChanges()
|
||||
self?.dismissScreen()
|
||||
}
|
||||
]
|
||||
}
|
||||
.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] db -> [SectionModel] in
|
||||
return [
|
||||
SectionModel(
|
||||
model: .content,
|
||||
elements: [
|
||||
SessionCell.Info(
|
||||
id: Item(title: "DISAPPEARING_MESSAGES_OFF".localized()),
|
||||
title: "DISAPPEARING_MESSAGES_OFF".localized(),
|
||||
rightAccessory: .radio(
|
||||
isSelected: { (self?.currentSelection.value == 0) }
|
||||
),
|
||||
onTap: { self?.currentSelection.send(0) }
|
||||
)
|
||||
].appending(
|
||||
contentsOf: DisappearingMessagesConfiguration.validDurationsSeconds
|
||||
.map { duration in
|
||||
let title: String = NSString.formatDurationSeconds(
|
||||
UInt32(duration),
|
||||
useShortFormat: false
|
||||
)
|
||||
|
||||
return SessionCell.Info(
|
||||
id: Item(title: title),
|
||||
title: title,
|
||||
rightAccessory: .radio(
|
||||
isSelected: { (self?.currentSelection.value == duration) }
|
||||
),
|
||||
onTap: { self?.currentSelection.send(duration) }
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
]
|
||||
}
|
||||
.removeDuplicates()
|
||||
.publisher(in: storage, scheduling: scheduler)
|
||||
|
||||
// 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.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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
662
Session/Conversations/Settings/ThreadSettingsViewModel.swift
Normal file
662
Session/Conversations/Settings/ThreadSettingsViewModel.swift
Normal file
|
@ -0,0 +1,662 @@
|
|||
// 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: SessionTableViewModel<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: SessionTableSection {
|
||||
case conversationInfo
|
||||
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: .conversationInfo,
|
||||
elements: [
|
||||
SessionCell.Info(
|
||||
id: .threadInfo,
|
||||
leftAccessory: .threadInfo(
|
||||
threadViewModel: threadViewModel,
|
||||
avatarTapped: { [weak self] in
|
||||
self?.updateProfilePicture(threadViewModel: threadViewModel)
|
||||
},
|
||||
titleTapped: { [weak self] in self?.setIsEditing(true) },
|
||||
titleChanged: { [weak self] text in self?.editedDisplayName = text }
|
||||
),
|
||||
title: threadViewModel.displayName,
|
||||
shouldHaveBackground: false
|
||||
)
|
||||
]
|
||||
),
|
||||
SectionModel(
|
||||
model: .content,
|
||||
elements: [
|
||||
(threadVariant == .closedGroup ? nil :
|
||||
SessionCell.Info(
|
||||
id: .copyThreadId,
|
||||
leftAccessory: .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",
|
||||
onTap: {
|
||||
UIPasteboard.general.string = threadId
|
||||
self?.showToast(
|
||||
text: "copied".localized(),
|
||||
backgroundColor: .backgroundSecondary
|
||||
)
|
||||
}
|
||||
)
|
||||
),
|
||||
|
||||
SessionCell.Info(
|
||||
id: .allMedia,
|
||||
leftAccessory: .icon(
|
||||
UIImage(named: "actionsheet_camera_roll_black")?
|
||||
.withRenderingMode(.alwaysTemplate)
|
||||
),
|
||||
title: MediaStrings.allMedia,
|
||||
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).all_media",
|
||||
onTap: { [weak self] in
|
||||
self?.transitionToScreen(
|
||||
MediaGalleryViewModel.createAllMediaViewController(
|
||||
threadId: threadId,
|
||||
threadVariant: threadVariant,
|
||||
focusedAttachmentId: nil
|
||||
)
|
||||
)
|
||||
}
|
||||
),
|
||||
|
||||
SessionCell.Info(
|
||||
id: .searchConversation,
|
||||
leftAccessory: .icon(
|
||||
UIImage(named: "conversation_settings_search")?
|
||||
.withRenderingMode(.alwaysTemplate)
|
||||
),
|
||||
title: "CONVERSATION_SETTINGS_SEARCH".localized(),
|
||||
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).search",
|
||||
onTap: { [weak self] in
|
||||
self?.didTriggerSearch()
|
||||
}
|
||||
),
|
||||
|
||||
(threadVariant != .openGroup ? nil :
|
||||
SessionCell.Info(
|
||||
id: .addToOpenGroup,
|
||||
leftAccessory: .icon(
|
||||
UIImage(named: "ic_plus_24")?
|
||||
.withRenderingMode(.alwaysTemplate)
|
||||
),
|
||||
title: "vc_conversation_settings_invite_button_title".localized(),
|
||||
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).add_to_open_group",
|
||||
onTap: { [weak self] in
|
||||
self?.transitionToScreen(
|
||||
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 :
|
||||
SessionCell.Info(
|
||||
id: .disappearingMessages,
|
||||
leftAccessory: .icon(
|
||||
UIImage(
|
||||
named: (disappearingMessagesConfig.isEnabled ?
|
||||
"ic_timer" :
|
||||
"ic_timer_disabled"
|
||||
)
|
||||
)?.withRenderingMode(.alwaysTemplate)
|
||||
),
|
||||
title: "DISAPPEARING_MESSAGES".localized(),
|
||||
subtitle: (disappearingMessagesConfig.isEnabled ?
|
||||
String(
|
||||
format: "DISAPPEARING_MESSAGES_SUBTITLE_DISAPPEAR_AFTER".localized(),
|
||||
arguments: [disappearingMessagesConfig.durationString]
|
||||
) :
|
||||
"DISAPPEARING_MESSAGES_SUBTITLE_OFF".localized()
|
||||
),
|
||||
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).disappearing_messages",
|
||||
onTap: { [weak self] in
|
||||
self?.transitionToScreen(
|
||||
SessionTableViewController(
|
||||
viewModel: ThreadDisappearingMessagesViewModel(
|
||||
threadId: threadId,
|
||||
config: disappearingMessagesConfig
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
),
|
||||
|
||||
(!currentUserIsClosedGroupMember ? nil :
|
||||
SessionCell.Info(
|
||||
id: .editGroup,
|
||||
leftAccessory: .icon(
|
||||
UIImage(named: "table_ic_group_edit")?
|
||||
.withRenderingMode(.alwaysTemplate)
|
||||
),
|
||||
title: "EDIT_GROUP_ACTION".localized(),
|
||||
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).edit_group",
|
||||
onTap: { [weak self] in
|
||||
self?.transitionToScreen(EditClosedGroupVC(threadId: threadId))
|
||||
}
|
||||
)
|
||||
),
|
||||
|
||||
(!currentUserIsClosedGroupMember ? nil :
|
||||
SessionCell.Info(
|
||||
id: .leaveGroup,
|
||||
leftAccessory: .icon(
|
||||
UIImage(named: "table_ic_group_leave")?
|
||||
.withRenderingMode(.alwaysTemplate)
|
||||
),
|
||||
title: "LEAVE_GROUP_ACTION".localized(),
|
||||
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).leave_group",
|
||||
confirmationInfo: 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: .alert_text
|
||||
),
|
||||
onTap: { [weak self] in
|
||||
Storage.shared.writeAsync { db in
|
||||
try MessageSender.leave(db, groupPublicKey: threadId)
|
||||
}
|
||||
}
|
||||
)
|
||||
),
|
||||
|
||||
(threadViewModel.threadIsNoteToSelf ? nil :
|
||||
SessionCell.Info(
|
||||
id: .notificationSound,
|
||||
leftAccessory: .icon(
|
||||
UIImage(named: "table_ic_notification_sound")?
|
||||
.withRenderingMode(.alwaysTemplate)
|
||||
),
|
||||
title: "SETTINGS_ITEM_NOTIFICATION_SOUND".localized(),
|
||||
rightAccessory: .dropDown(
|
||||
.dynamicString { notificationSound.displayName }
|
||||
),
|
||||
onTap: { [weak self] in
|
||||
self?.transitionToScreen(
|
||||
SessionTableViewController(
|
||||
viewModel: NotificationSoundViewModel(threadId: threadId)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
),
|
||||
|
||||
(threadVariant == .contact ? nil :
|
||||
SessionCell.Info(
|
||||
id: .notificationMentionsOnly,
|
||||
leftAccessory: .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(),
|
||||
rightAccessory: .toggle(
|
||||
.boolValue(threadViewModel.threadOnlyNotifyForMentions == true)
|
||||
),
|
||||
isEnabled: (
|
||||
threadViewModel.threadVariant != .closedGroup ||
|
||||
currentUserIsClosedGroupMember
|
||||
),
|
||||
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).notify_for_mentions_only",
|
||||
onTap: {
|
||||
let newValue: Bool = !(threadViewModel.threadOnlyNotifyForMentions == true)
|
||||
|
||||
Storage.shared.writeAsync { db in
|
||||
try SessionThread
|
||||
.filter(id: threadId)
|
||||
.updateAll(
|
||||
db,
|
||||
SessionThread.Columns.onlyNotifyForMentions
|
||||
.set(to: newValue)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
),
|
||||
|
||||
(threadViewModel.threadIsNoteToSelf ? nil :
|
||||
SessionCell.Info(
|
||||
id: .notificationMute,
|
||||
leftAccessory: .icon(
|
||||
UIImage(named: "Mute")?
|
||||
.withRenderingMode(.alwaysTemplate)
|
||||
),
|
||||
title: "CONVERSATION_SETTINGS_MUTE_LABEL".localized(),
|
||||
rightAccessory: .toggle(
|
||||
.boolValue(threadViewModel.threadMutedUntilTimestamp != nil)
|
||||
),
|
||||
isEnabled: (
|
||||
threadViewModel.threadVariant != .closedGroup ||
|
||||
currentUserIsClosedGroupMember
|
||||
),
|
||||
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).mute",
|
||||
onTap: {
|
||||
let newValue: Bool = !(threadViewModel.threadMutedUntilTimestamp != nil)
|
||||
|
||||
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 :
|
||||
SessionCell.Info(
|
||||
id: .blockUser,
|
||||
leftAccessory: .icon(
|
||||
UIImage(named: "table_ic_block")?
|
||||
.withRenderingMode(.alwaysTemplate)
|
||||
),
|
||||
title: "CONVERSATION_SETTINGS_BLOCK_THIS_USER".localized(),
|
||||
rightAccessory: .toggle(
|
||||
.boolValue(threadViewModel.threadIsBlocked == true)
|
||||
),
|
||||
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).block",
|
||||
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: .alert_text
|
||||
),
|
||||
onTap: {
|
||||
let isBlocked: Bool = (threadViewModel.threadIsBlocked == true)
|
||||
|
||||
self?.updateBlockedState(
|
||||
from: isBlocked,
|
||||
isBlocked: !isBlocked,
|
||||
threadId: threadId,
|
||||
displayName: threadViewModel.displayName
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
].compactMap { $0 }
|
||||
)
|
||||
]
|
||||
}
|
||||
.removeDuplicates()
|
||||
.publisher(in: Storage.shared)
|
||||
|
||||
// MARK: - Functions
|
||||
|
||||
public override func updateSettings(_ updatedSettings: [SectionModel]) {
|
||||
self._settingsData = updatedSettings
|
||||
}
|
||||
|
||||
private func updateProfilePicture(threadViewModel: SessionThreadViewModel) {
|
||||
guard
|
||||
threadViewModel.threadVariant == .contact,
|
||||
let profile: Profile = threadViewModel.profile,
|
||||
let profileData: Data = ProfileManager.profileAvatar(profile: profile)
|
||||
else { return }
|
||||
|
||||
let format: ImageFormat = profileData.guessedImageFormat
|
||||
let navController: UINavigationController = StyledNavigationController(
|
||||
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
|
||||
|
||||
self.transitionToScreen(navController, transitionType: .present)
|
||||
}
|
||||
|
||||
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
|
||||
) {
|
||||
guard oldBlockedState != isBlocked else { return }
|
||||
|
||||
Storage.shared.writeAsync(
|
||||
updates: { db in
|
||||
try Contact
|
||||
.fetchOrCreate(db, id: threadId)
|
||||
.with(isBlocked: .updateTo(isBlocked))
|
||||
.save(db)
|
||||
},
|
||||
completion: { [weak self] 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: .alert_text
|
||||
)
|
||||
)
|
||||
|
||||
self?.transitionToScreen(modal, transitionType: .present)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
|
@ -1,93 +0,0 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import GRDB
|
||||
import SessionUIKit
|
||||
import SessionUtilitiesKit
|
||||
import SessionMessagingKit
|
||||
|
||||
final class BlockedModal: Modal {
|
||||
private let publicKey: String
|
||||
|
||||
// MARK: Lifecycle
|
||||
init(publicKey: String) {
|
||||
self.publicKey = publicKey
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
override init(nibName: String?, bundle: Bundle?) {
|
||||
preconditionFailure("Use init(publicKey:) instead.")
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init(publicKey:) instead.")
|
||||
}
|
||||
|
||||
override func populateContentView() {
|
||||
// Name
|
||||
let name = Profile.displayName(id: publicKey)
|
||||
// Title
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.textColor = Colors.text
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
titleLabel.text = String(format: NSLocalizedString("modal_blocked_title", comment: ""), name)
|
||||
titleLabel.textAlignment = .center
|
||||
// Message
|
||||
let messageLabel = UILabel()
|
||||
messageLabel.textColor = Colors.text
|
||||
messageLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
let message = String(format: NSLocalizedString("modal_blocked_explanation", comment: ""), name)
|
||||
let attributedMessage = NSMutableAttributedString(string: message)
|
||||
attributedMessage.addAttributes([ .font : UIFont.boldSystemFont(ofSize: Values.smallFontSize) ], range: (message as NSString).range(of: name))
|
||||
messageLabel.attributedText = attributedMessage
|
||||
messageLabel.numberOfLines = 0
|
||||
messageLabel.lineBreakMode = .byWordWrapping
|
||||
messageLabel.textAlignment = .center
|
||||
// Unblock button
|
||||
let unblockButton = UIButton()
|
||||
unblockButton.set(.height, to: Values.mediumButtonHeight)
|
||||
unblockButton.layer.cornerRadius = Modal.buttonCornerRadius
|
||||
unblockButton.backgroundColor = Colors.buttonBackground
|
||||
unblockButton.titleLabel!.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
unblockButton.setTitleColor(Colors.text, for: UIControl.State.normal)
|
||||
unblockButton.setTitle(NSLocalizedString("modal_blocked_button_title", comment: ""), for: UIControl.State.normal)
|
||||
unblockButton.addTarget(self, action: #selector(unblock), for: UIControl.Event.touchUpInside)
|
||||
// Button stack view
|
||||
let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, unblockButton ])
|
||||
buttonStackView.axis = .horizontal
|
||||
buttonStackView.spacing = Values.mediumSpacing
|
||||
buttonStackView.distribution = .fillEqually
|
||||
// Content stack view
|
||||
let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ])
|
||||
contentStackView.axis = .vertical
|
||||
contentStackView.spacing = Values.largeSpacing
|
||||
// Main stack view
|
||||
let spacing = Values.largeSpacing - Values.smallFontSize / 2
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
|
||||
mainStackView.axis = .vertical
|
||||
mainStackView.spacing = spacing
|
||||
contentView.addSubview(mainStackView)
|
||||
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
|
||||
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
|
||||
}
|
||||
|
||||
// MARK: - Interaction
|
||||
|
||||
@objc private func unblock() {
|
||||
let publicKey: String = self.publicKey
|
||||
|
||||
Storage.shared.writeAsync { db in
|
||||
try Contact
|
||||
.filter(id: publicKey)
|
||||
.updateAll(db, Contact.Columns.isBlocked.set(to: false))
|
||||
|
||||
try MessageSender
|
||||
.syncConfiguration(db, forceSyncNow: true)
|
||||
.retainUntilComplete()
|
||||
}
|
||||
|
||||
presentingViewController?.dismiss(animated: true, completion: nil)
|
||||
}
|
||||
}
|
|
@ -1,83 +0,0 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
@objc
|
||||
final class CallModal: Modal {
|
||||
private let onCallEnabled: () -> Void
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
@objc
|
||||
init(onCallEnabled: @escaping () -> Void) {
|
||||
self.onCallEnabled = onCallEnabled
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
||||
self.modalPresentationStyle = .overFullScreen
|
||||
self.modalTransitionStyle = .crossDissolve
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init(onCallEnabled:) instead.")
|
||||
}
|
||||
|
||||
override init(nibName: String?, bundle: Bundle?) {
|
||||
preconditionFailure("Use init(onCallEnabled:) instead.")
|
||||
}
|
||||
|
||||
override func populateContentView() {
|
||||
// Title
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.textColor = Colors.text
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize)
|
||||
titleLabel.text = NSLocalizedString("modal_call_title", comment: "")
|
||||
titleLabel.textAlignment = .center
|
||||
|
||||
// Message
|
||||
let messageLabel = UILabel()
|
||||
messageLabel.textColor = Colors.text
|
||||
messageLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
messageLabel.text = "modal_call_explanation".localized()
|
||||
messageLabel.numberOfLines = 0
|
||||
messageLabel.lineBreakMode = .byWordWrapping
|
||||
messageLabel.textAlignment = .center
|
||||
|
||||
// Enable button
|
||||
let enableButton = UIButton()
|
||||
enableButton.set(.height, to: Values.mediumButtonHeight)
|
||||
enableButton.layer.cornerRadius = Modal.buttonCornerRadius
|
||||
enableButton.backgroundColor = Colors.buttonBackground
|
||||
enableButton.titleLabel!.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
enableButton.setTitleColor(Colors.text, for: UIControl.State.normal)
|
||||
enableButton.setTitle(NSLocalizedString("modal_link_previews_button_title", comment: ""), for: UIControl.State.normal)
|
||||
enableButton.addTarget(self, action: #selector(enable), for: UIControl.Event.touchUpInside)
|
||||
|
||||
// Button stack view
|
||||
let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, enableButton ])
|
||||
buttonStackView.axis = .horizontal
|
||||
buttonStackView.spacing = Values.mediumSpacing
|
||||
buttonStackView.distribution = .fillEqually
|
||||
|
||||
// Main stack view
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel, buttonStackView ])
|
||||
mainStackView.axis = .vertical
|
||||
mainStackView.spacing = Values.largeSpacing
|
||||
contentView.addSubview(mainStackView)
|
||||
|
||||
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
|
||||
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing)
|
||||
}
|
||||
|
||||
// MARK: - Interaction
|
||||
|
||||
@objc private func enable() {
|
||||
Storage.shared.writeAsync { db in db[.areCallsEnabled] = true }
|
||||
presentingViewController?.dismiss(animated: true, completion: nil)
|
||||
onCallEnabled()
|
||||
}
|
||||
}
|
|
@ -1,79 +0,0 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
@objc
|
||||
final class CallPermissionRequestModal : Modal {
|
||||
|
||||
// MARK: Lifecycle
|
||||
@objc
|
||||
init() {
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
self.modalPresentationStyle = .overFullScreen
|
||||
self.modalTransitionStyle = .crossDissolve
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init(onCallEnabled:) instead.")
|
||||
}
|
||||
|
||||
override init(nibName: String?, bundle: Bundle?) {
|
||||
preconditionFailure("Use init(onCallEnabled:) instead.")
|
||||
}
|
||||
|
||||
override func populateContentView() {
|
||||
// Title
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.textColor = Colors.text
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
titleLabel.text = NSLocalizedString("modal_call_permission_request_title", comment: "")
|
||||
titleLabel.textAlignment = .center
|
||||
// Message
|
||||
let messageLabel = UILabel()
|
||||
messageLabel.textColor = Colors.text
|
||||
messageLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
let message = NSLocalizedString("modal_call_permission_request_explanation", comment: "")
|
||||
messageLabel.text = message
|
||||
messageLabel.numberOfLines = 0
|
||||
messageLabel.lineBreakMode = .byWordWrapping
|
||||
messageLabel.textAlignment = .center
|
||||
// Enable button
|
||||
let goToSettingsButton = UIButton()
|
||||
goToSettingsButton.set(.height, to: Values.mediumButtonHeight)
|
||||
goToSettingsButton.layer.cornerRadius = Modal.buttonCornerRadius
|
||||
goToSettingsButton.backgroundColor = Colors.buttonBackground
|
||||
goToSettingsButton.titleLabel!.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
goToSettingsButton.setTitleColor(Colors.text, for: UIControl.State.normal)
|
||||
goToSettingsButton.setTitle(NSLocalizedString("vc_settings_title", comment: ""), for: UIControl.State.normal)
|
||||
goToSettingsButton.addTarget(self, action: #selector(goToSettings), for: UIControl.Event.touchUpInside)
|
||||
// Content stack view
|
||||
let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ])
|
||||
contentStackView.axis = .vertical
|
||||
contentStackView.spacing = Values.largeSpacing
|
||||
// Button stack view
|
||||
let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, goToSettingsButton ])
|
||||
buttonStackView.axis = .horizontal
|
||||
buttonStackView.distribution = .fillEqually
|
||||
// Main stack view
|
||||
let spacing = Values.largeSpacing - Values.smallFontSize / 2
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
|
||||
mainStackView.axis = .vertical
|
||||
mainStackView.spacing = spacing
|
||||
contentView.addSubview(mainStackView)
|
||||
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
|
||||
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
@objc func goToSettings(_ sender: Any) {
|
||||
dismiss(animated: true, completion: {
|
||||
if let vc = CurrentAppContext().frontmostViewController() {
|
||||
let privacySettingsVC = PrivacySettingsTableViewController()
|
||||
privacySettingsVC.shouldShowCloseButton = true
|
||||
let nav = OWSNavigationController(rootViewController: privacySettingsVC)
|
||||
nav.modalPresentationStyle = .fullScreen
|
||||
vc.present(nav, animated: true, completion: nil)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -17,8 +17,8 @@ final class ConversationTitleView: UIView {
|
|||
|
||||
private lazy var titleLabel: UILabel = {
|
||||
let result: UILabel = UILabel()
|
||||
result.textColor = Colors.text
|
||||
result.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
result.themeTextColor = .textPrimary
|
||||
result.lineBreakMode = .byTruncatingTail
|
||||
|
||||
return result
|
||||
|
@ -26,8 +26,8 @@ final class ConversationTitleView: UIView {
|
|||
|
||||
private lazy var subtitleLabel: UILabel = {
|
||||
let result: UILabel = UILabel()
|
||||
result.textColor = Colors.text
|
||||
result.font = .systemFont(ofSize: 13)
|
||||
result.themeTextColor = .textPrimary
|
||||
result.lineBreakMode = .byTruncatingTail
|
||||
|
||||
return result
|
||||
|
@ -95,23 +95,37 @@ final class ConversationTitleView: UIView {
|
|||
return
|
||||
}
|
||||
|
||||
// Generate the subtitle
|
||||
let subtitle: NSAttributedString? = {
|
||||
let shouldHaveSubtitle: Bool = (
|
||||
Date().timeIntervalSince1970 <= (mutedUntilTimestamp ?? 0) ||
|
||||
onlyNotifyForMentions ||
|
||||
userCount != nil
|
||||
)
|
||||
|
||||
self.titleLabel.text = name
|
||||
self.titleLabel.font = .boldSystemFont(
|
||||
ofSize: (shouldHaveSubtitle ?
|
||||
Values.mediumFontSize :
|
||||
Values.veryLargeFontSize
|
||||
)
|
||||
)
|
||||
|
||||
ThemeManager.onThemeChange(observer: self.subtitleLabel) { [weak subtitleLabel] theme, _ in
|
||||
guard let textPrimary: UIColor = theme.color(for: .textPrimary) else { return }
|
||||
//subtitleLabel?.attributedText = subtitle
|
||||
guard Date().timeIntervalSince1970 > (mutedUntilTimestamp ?? 0) else {
|
||||
return NSAttributedString(
|
||||
subtitleLabel?.attributedText = NSAttributedString(
|
||||
string: "\u{e067} ",
|
||||
attributes: [
|
||||
.font: UIFont.ows_elegantIconsFont(10),
|
||||
.foregroundColor: Colors.text
|
||||
.foregroundColor: textPrimary
|
||||
]
|
||||
)
|
||||
.appending(string: "Muted")
|
||||
return
|
||||
}
|
||||
guard !onlyNotifyForMentions else {
|
||||
// FIXME: This is going to have issues when swapping between light/dark mode
|
||||
let imageAttachment = NSTextAttachment()
|
||||
let color: UIColor = (isDarkMode ? .white : .black)
|
||||
imageAttachment.image = UIImage(named: "NotifyMentions.png")?.asTintedImage(color: color)
|
||||
imageAttachment.image = UIImage(named: "NotifyMentions.png")?.withTint(textPrimary)
|
||||
imageAttachment.bounds = CGRect(
|
||||
x: 0,
|
||||
y: -2,
|
||||
|
@ -119,23 +133,17 @@ final class ConversationTitleView: UIView {
|
|||
height: Values.smallFontSize
|
||||
)
|
||||
|
||||
return NSAttributedString(attachment: imageAttachment)
|
||||
subtitleLabel?.attributedText = NSAttributedString(attachment: imageAttachment)
|
||||
.appending(string: " ")
|
||||
.appending(string: "view_conversation_title_notify_for_mentions_only".localized())
|
||||
return
|
||||
}
|
||||
guard let userCount: Int = userCount else { return nil }
|
||||
guard let userCount: Int = userCount else { return }
|
||||
|
||||
return NSAttributedString(string: "\(userCount) member\(userCount == 1 ? "" : "s")")
|
||||
}()
|
||||
|
||||
self.titleLabel.text = name
|
||||
self.titleLabel.font = .boldSystemFont(
|
||||
ofSize: (subtitle != nil ?
|
||||
Values.mediumFontSize :
|
||||
Values.veryLargeFontSize
|
||||
subtitleLabel?.attributedText = NSAttributedString(
|
||||
string: "\(userCount) member\(userCount == 1 ? "" : "s")"
|
||||
)
|
||||
)
|
||||
self.subtitleLabel.attributedText = subtitle
|
||||
}
|
||||
|
||||
// Contact threads also have the call button to compensate for
|
||||
let shouldShowCallButton: Bool = (
|
||||
|
|
|
@ -1,120 +0,0 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import GRDB
|
||||
import SessionUIKit
|
||||
import SessionUtilitiesKit
|
||||
import SessionMessagingKit
|
||||
|
||||
final class DownloadAttachmentModal: Modal {
|
||||
private let profile: Profile?
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(profile: Profile?) {
|
||||
self.profile = profile
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
override init(nibName: String?, bundle: Bundle?) {
|
||||
preconditionFailure("Use init(viewItem:) instead.")
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init(viewItem:) instead.")
|
||||
}
|
||||
|
||||
override func populateContentView() {
|
||||
guard let profile: Profile = profile else { return }
|
||||
|
||||
// Name
|
||||
let name: String = profile.displayName()
|
||||
|
||||
// Title
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.textColor = Colors.text
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
titleLabel.text = String(format: NSLocalizedString("modal_download_attachment_title", comment: ""), name)
|
||||
titleLabel.textAlignment = .center
|
||||
|
||||
// Message
|
||||
let messageLabel = UILabel()
|
||||
messageLabel.textColor = Colors.text
|
||||
messageLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
let message = String(format: NSLocalizedString("modal_download_attachment_explanation", comment: ""), name)
|
||||
let attributedMessage = NSMutableAttributedString(string: message)
|
||||
attributedMessage.addAttributes(
|
||||
[.font: UIFont.boldSystemFont(ofSize: Values.smallFontSize) ],
|
||||
range: (message as NSString).range(of: name)
|
||||
)
|
||||
messageLabel.attributedText = attributedMessage
|
||||
messageLabel.numberOfLines = 0
|
||||
messageLabel.lineBreakMode = .byWordWrapping
|
||||
messageLabel.textAlignment = .center
|
||||
|
||||
// Download button
|
||||
let downloadButton = UIButton()
|
||||
downloadButton.set(.height, to: Values.mediumButtonHeight)
|
||||
downloadButton.layer.cornerRadius = Modal.buttonCornerRadius
|
||||
downloadButton.titleLabel!.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
downloadButton.setTitleColor(Colors.text, for: UIControl.State.normal)
|
||||
downloadButton.setTitle(NSLocalizedString("modal_download_button_title", comment: ""), for: UIControl.State.normal)
|
||||
downloadButton.addTarget(self, action: #selector(trust), for: UIControl.Event.touchUpInside)
|
||||
|
||||
// Button stack view
|
||||
let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, downloadButton ])
|
||||
buttonStackView.axis = .horizontal
|
||||
buttonStackView.spacing = Values.mediumSpacing
|
||||
buttonStackView.distribution = .fillEqually
|
||||
|
||||
// Content stack view
|
||||
let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ])
|
||||
contentStackView.axis = .vertical
|
||||
contentStackView.spacing = Values.largeSpacing
|
||||
|
||||
// Main stack view
|
||||
let spacing = Values.largeSpacing - Values.smallFontSize / 2
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
|
||||
mainStackView.axis = .vertical
|
||||
mainStackView.spacing = spacing
|
||||
contentView.addSubview(mainStackView)
|
||||
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
|
||||
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
|
||||
}
|
||||
|
||||
// MARK: - Interaction
|
||||
|
||||
@objc private func trust() {
|
||||
guard let profileId: String = profile?.id else { return }
|
||||
|
||||
Storage.shared.writeAsync { db in
|
||||
try Contact
|
||||
.filter(id: profileId)
|
||||
.updateAll(db, Contact.Columns.isTrusted.set(to: true))
|
||||
|
||||
// Start downloading any pending attachments for this contact (UI will automatically be
|
||||
// updated due to the database observation)
|
||||
try Attachment
|
||||
.stateInfo(authorId: profileId, state: .pendingDownload)
|
||||
.fetchAll(db)
|
||||
.forEach { attachmentDownloadInfo in
|
||||
JobRunner.add(
|
||||
db,
|
||||
job: Job(
|
||||
variant: .attachmentDownload,
|
||||
threadId: profileId,
|
||||
interactionId: attachmentDownloadInfo.interactionId,
|
||||
details: AttachmentDownloadJob.Details(
|
||||
attachmentId: attachmentDownloadInfo.attachmentId
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
presentingViewController?.dismiss(animated: true, completion: nil)
|
||||
}
|
||||
}
|
|
@ -1,13 +1,13 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
final class InfoBanner : UIView {
|
||||
private let message: String
|
||||
private let snBackgroundColor: UIColor
|
||||
|
||||
init(message: String, backgroundColor: UIColor) {
|
||||
self.message = message
|
||||
self.snBackgroundColor = backgroundColor
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
|
||||
final class InfoBanner: UIView {
|
||||
init(message: String, backgroundColor: ThemeValue) {
|
||||
super.init(frame: CGRect.zero)
|
||||
setUpViewHierarchy()
|
||||
|
||||
setUpViewHierarchy(message: message, backgroundColor: backgroundColor)
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
|
@ -18,16 +18,18 @@ final class InfoBanner : UIView {
|
|||
preconditionFailure("Use init(coder:) instead.")
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
backgroundColor = snBackgroundColor
|
||||
let label = UILabel()
|
||||
label.text = message
|
||||
private func setUpViewHierarchy(message: String, backgroundColor: ThemeValue) {
|
||||
themeBackgroundColor = backgroundColor
|
||||
|
||||
let label: UILabel = UILabel()
|
||||
label.font = .boldSystemFont(ofSize: Values.smallFontSize)
|
||||
label.textColor = .white
|
||||
label.numberOfLines = 0
|
||||
label.text = message
|
||||
label.themeTextColor = .textPrimary
|
||||
label.textAlignment = .center
|
||||
label.lineBreakMode = .byWordWrapping
|
||||
label.numberOfLines = 0
|
||||
addSubview(label)
|
||||
|
||||
label.pin(to: self, withInset: Values.mediumSpacing)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,117 +0,0 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import GRDB
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
final class JoinOpenGroupModal: Modal {
|
||||
private let name: String
|
||||
private let url: String
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(name: String?, url: String) {
|
||||
self.name = (name ?? "Open Group")
|
||||
self.url = url
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
override init(nibName: String?, bundle: Bundle?) {
|
||||
preconditionFailure("Use init(name:url:) instead.")
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init(name:url:) instead.")
|
||||
}
|
||||
|
||||
override func populateContentView() {
|
||||
// Title
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.textColor = Colors.text
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
titleLabel.text = "Join \(name)?"
|
||||
titleLabel.textAlignment = .center
|
||||
|
||||
// Message
|
||||
let messageLabel = UILabel()
|
||||
messageLabel.textColor = Colors.text
|
||||
messageLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
let message = "Are you sure you want to join the \(name) open group?";
|
||||
let attributedMessage = NSMutableAttributedString(string: message)
|
||||
attributedMessage.addAttributes([ .font : UIFont.boldSystemFont(ofSize: Values.smallFontSize) ], range: (message as NSString).range(of: name))
|
||||
messageLabel.attributedText = attributedMessage
|
||||
messageLabel.numberOfLines = 0
|
||||
messageLabel.lineBreakMode = .byWordWrapping
|
||||
messageLabel.textAlignment = .center
|
||||
|
||||
// Join button
|
||||
let joinButton = UIButton()
|
||||
joinButton.set(.height, to: Values.mediumButtonHeight)
|
||||
joinButton.layer.cornerRadius = Modal.buttonCornerRadius
|
||||
joinButton.backgroundColor = Colors.buttonBackground
|
||||
joinButton.titleLabel!.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
joinButton.setTitleColor(Colors.text, for: UIControl.State.normal)
|
||||
joinButton.setTitle("JOIN_COMMUNITY_BUTTON_TITLE".localized(), for: UIControl.State.normal)
|
||||
joinButton.addTarget(self, action: #selector(joinOpenGroup), for: UIControl.Event.touchUpInside)
|
||||
|
||||
// Button stack view
|
||||
let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, joinButton ])
|
||||
buttonStackView.axis = .horizontal
|
||||
buttonStackView.spacing = Values.mediumSpacing
|
||||
buttonStackView.distribution = .fillEqually
|
||||
|
||||
// Content stack view
|
||||
let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ])
|
||||
contentStackView.axis = .vertical
|
||||
contentStackView.spacing = Values.largeSpacing
|
||||
|
||||
// Main stack view
|
||||
let spacing = Values.largeSpacing - Values.smallFontSize / 2
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
|
||||
mainStackView.axis = .vertical
|
||||
mainStackView.spacing = spacing
|
||||
contentView.addSubview(mainStackView)
|
||||
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
|
||||
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
|
||||
}
|
||||
|
||||
// MARK: - Interaction
|
||||
|
||||
@objc private func joinOpenGroup() {
|
||||
guard let presentingViewController: UIViewController = self.presentingViewController else { return }
|
||||
guard let (room, server, publicKey) = OpenGroupManager.parseOpenGroup(from: url) else {
|
||||
let alert = UIAlertController(title: "Couldn't Join", message: nil, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "BUTTON_OK".localized(), style: .default, handler: nil))
|
||||
|
||||
return presentingViewController.present(alert, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
presentingViewController.dismiss(animated: true, completion: nil)
|
||||
|
||||
Storage.shared
|
||||
.writeAsync { db in
|
||||
OpenGroupManager.shared.add(
|
||||
db,
|
||||
roomToken: room,
|
||||
server: server,
|
||||
publicKey: publicKey,
|
||||
isConfigMessage: false
|
||||
)
|
||||
}
|
||||
.done(on: DispatchQueue.main) { _ in
|
||||
Storage.shared.writeAsync { db in
|
||||
try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() // FIXME: It's probably cleaner to do this inside addOpenGroup(...)
|
||||
}
|
||||
}
|
||||
.catch(on: DispatchQueue.main) { error in
|
||||
let alert = UIAlertController(title: "Couldn't Join", message: error.localizedDescription, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "BUTTON_OK".localized(), style: .default, handler: nil))
|
||||
presentingViewController.present(alert, animated: true, completion: nil)
|
||||
}
|
||||
.retainUntilComplete()
|
||||
}
|
||||
}
|
|
@ -1,87 +0,0 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import GRDB
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
final class LinkPreviewModal: Modal {
|
||||
private let onLinkPreviewsEnabled: () -> Void
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(onLinkPreviewsEnabled: @escaping () -> Void) {
|
||||
self.onLinkPreviewsEnabled = onLinkPreviewsEnabled
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init(onLinkPreviewsEnabled:) instead.")
|
||||
}
|
||||
|
||||
override init(nibName: String?, bundle: Bundle?) {
|
||||
preconditionFailure("Use init(onLinkPreviewsEnabled:) instead.")
|
||||
}
|
||||
|
||||
override func populateContentView() {
|
||||
// Title
|
||||
let titleLabel: UILabel = UILabel()
|
||||
titleLabel.textColor = Colors.text
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
titleLabel.text = "modal_link_previews_title".localized()
|
||||
titleLabel.textAlignment = .center
|
||||
|
||||
// Message
|
||||
let messageLabel: UILabel = UILabel()
|
||||
messageLabel.textColor = Colors.text
|
||||
messageLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
messageLabel.text = "modal_link_previews_explanation".localized()
|
||||
messageLabel.numberOfLines = 0
|
||||
messageLabel.lineBreakMode = .byWordWrapping
|
||||
messageLabel.textAlignment = .center
|
||||
|
||||
// Enable button
|
||||
let enableButton: UIButton = UIButton()
|
||||
enableButton.set(.height, to: Values.mediumButtonHeight)
|
||||
enableButton.layer.cornerRadius = Modal.buttonCornerRadius
|
||||
enableButton.backgroundColor = Colors.buttonBackground
|
||||
enableButton.titleLabel!.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
enableButton.setTitleColor(Colors.text, for: UIControl.State.normal)
|
||||
enableButton.setTitle(NSLocalizedString("modal_link_previews_button_title", comment: ""), for: UIControl.State.normal)
|
||||
enableButton.addTarget(self, action: #selector(enable), for: UIControl.Event.touchUpInside)
|
||||
|
||||
// Button stack view
|
||||
let buttonStackView: UIStackView = UIStackView(arrangedSubviews: [ cancelButton, enableButton ])
|
||||
buttonStackView.axis = .horizontal
|
||||
buttonStackView.spacing = Values.mediumSpacing
|
||||
buttonStackView.distribution = .fillEqually
|
||||
|
||||
// Content stack view
|
||||
let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ])
|
||||
contentStackView.axis = .vertical
|
||||
contentStackView.spacing = Values.largeSpacing
|
||||
|
||||
// Main stack view
|
||||
let spacing = Values.largeSpacing - Values.smallFontSize / 2
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
|
||||
|
||||
mainStackView.axis = .vertical
|
||||
mainStackView.spacing = spacing
|
||||
contentView.addSubview(mainStackView)
|
||||
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
|
||||
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
|
||||
}
|
||||
|
||||
// MARK: - Interaction
|
||||
|
||||
@objc private func enable() {
|
||||
Storage.shared.writeAsync { db in
|
||||
db[.areLinkPreviewsEnabled] = true
|
||||
}
|
||||
|
||||
presentingViewController?.dismiss(animated: true, completion: nil)
|
||||
onLinkPreviewsEnabled()
|
||||
}
|
||||
}
|
|
@ -1,80 +0,0 @@
|
|||
|
||||
final class PermissionMissingModal : Modal {
|
||||
private let permission: String
|
||||
private let onCancel: () -> Void
|
||||
|
||||
// MARK: Lifecycle
|
||||
init(permission: String, onCancel: @escaping () -> Void) {
|
||||
self.permission = permission
|
||||
self.onCancel = onCancel
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
override init(nibName: String?, bundle: Bundle?) {
|
||||
preconditionFailure("Use init(permission:) instead.")
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init(permission:) instead.")
|
||||
}
|
||||
|
||||
override func populateContentView() {
|
||||
// Title
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.textColor = Colors.text
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
titleLabel.text = "Session"
|
||||
titleLabel.textAlignment = .center
|
||||
// Message
|
||||
let messageLabel = UILabel()
|
||||
messageLabel.textColor = Colors.text
|
||||
messageLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
let message = "Session needs \(permission) access to continue. You can enable access in the iOS settings."
|
||||
let attributedMessage = NSMutableAttributedString(string: message)
|
||||
attributedMessage.addAttributes([ .font : UIFont.boldSystemFont(ofSize: Values.smallFontSize) ], range: (message as NSString).range(of: permission))
|
||||
messageLabel.attributedText = attributedMessage
|
||||
messageLabel.numberOfLines = 0
|
||||
messageLabel.lineBreakMode = .byWordWrapping
|
||||
messageLabel.textAlignment = .center
|
||||
// Settings button
|
||||
let settingsButton = UIButton()
|
||||
settingsButton.set(.height, to: Values.mediumButtonHeight)
|
||||
settingsButton.layer.cornerRadius = Modal.buttonCornerRadius
|
||||
settingsButton.backgroundColor = Colors.buttonBackground
|
||||
settingsButton.titleLabel!.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
settingsButton.setTitleColor(Colors.text, for: UIControl.State.normal)
|
||||
settingsButton.setTitle("Settings", for: UIControl.State.normal)
|
||||
settingsButton.addTarget(self, action: #selector(goToSettings), for: UIControl.Event.touchUpInside)
|
||||
// Button stack view
|
||||
let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, settingsButton ])
|
||||
buttonStackView.axis = .horizontal
|
||||
buttonStackView.spacing = Values.mediumSpacing
|
||||
buttonStackView.distribution = .fillEqually
|
||||
// Content stack view
|
||||
let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ])
|
||||
contentStackView.axis = .vertical
|
||||
contentStackView.spacing = Values.largeSpacing
|
||||
// Main stack view
|
||||
let spacing = Values.largeSpacing - Values.smallFontSize / 2
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
|
||||
mainStackView.axis = .vertical
|
||||
mainStackView.spacing = spacing
|
||||
contentView.addSubview(mainStackView)
|
||||
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
|
||||
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
@objc private func goToSettings() {
|
||||
presentingViewController?.dismiss(animated: true, completion: {
|
||||
UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!)
|
||||
})
|
||||
}
|
||||
|
||||
override func close() {
|
||||
super.close()
|
||||
onCancel()
|
||||
}
|
||||
}
|
|
@ -29,13 +29,13 @@ final class ReactionListSheet: BaseVC {
|
|||
|
||||
private lazy var contentView: UIView = {
|
||||
let result: UIView = UIView()
|
||||
result.backgroundColor = Colors.modalBackground
|
||||
result.themeBackgroundColor = .backgroundSecondary
|
||||
|
||||
let line: UIView = UIView()
|
||||
line.backgroundColor = Colors.border.withAlphaComponent(0.5)
|
||||
line.themeBackgroundColor = .borderSeparator
|
||||
result.addSubview(line)
|
||||
|
||||
line.set(.height, to: 0.5)
|
||||
line.set(.height, to: Values.separatorThickness)
|
||||
line.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing, UIView.VerticalEdge.top ], to: result)
|
||||
|
||||
return result
|
||||
|
@ -61,7 +61,7 @@ final class ReactionListSheet: BaseVC {
|
|||
let result: UICollectionView = UICollectionView(frame: CGRect.zero, collectionViewLayout: layout)
|
||||
result.register(view: Cell.self)
|
||||
result.set(.height, to: 48)
|
||||
result.backgroundColor = .clear
|
||||
result.themeBackgroundColor = .clear
|
||||
result.isScrollEnabled = true
|
||||
result.showsHorizontalScrollIndicator = false
|
||||
result.dataSource = self
|
||||
|
@ -73,18 +73,17 @@ final class ReactionListSheet: BaseVC {
|
|||
private lazy var detailInfoLabel: UILabel = {
|
||||
let result: UILabel = UILabel()
|
||||
result.font = .systemFont(ofSize: Values.mediumFontSize)
|
||||
result.textColor = Colors.grey.withAlphaComponent(0.8)
|
||||
result.themeTextColor = .textSecondary
|
||||
result.set(.height, to: 32)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var clearAllButton: Button = {
|
||||
let result: Button = Button(style: .destructiveOutline, size: .small)
|
||||
private lazy var clearAllButton: SessionButton = {
|
||||
let result: SessionButton = SessionButton(style: .destructiveBorderless, size: .small)
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.setTitle("MESSAGE_REQUESTS_CLEAR_ALL".localized(), for: .normal)
|
||||
result.addTarget(self, action: #selector(clearAllTapped), for: .touchUpInside)
|
||||
result.layer.borderWidth = 0
|
||||
result.isHidden = true
|
||||
|
||||
return result
|
||||
|
@ -94,10 +93,10 @@ final class ReactionListSheet: BaseVC {
|
|||
let result: UITableView = UITableView()
|
||||
result.dataSource = self
|
||||
result.delegate = self
|
||||
result.register(view: UserCell.self)
|
||||
result.register(view: SessionCell.self)
|
||||
result.register(view: FooterCell.self)
|
||||
result.separatorStyle = .none
|
||||
result.backgroundColor = .clear
|
||||
result.themeBackgroundColor = .clear
|
||||
result.showsVerticalScrollIndicator = false
|
||||
|
||||
return result
|
||||
|
@ -123,7 +122,7 @@ final class ReactionListSheet: BaseVC {
|
|||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
view.backgroundColor = .clear
|
||||
view.themeBackgroundColor = .clear
|
||||
|
||||
let swipeGestureRecognizer = UISwipeGestureRecognizer(target: self, action: #selector(close))
|
||||
swipeGestureRecognizer.direction = .down
|
||||
|
@ -134,6 +133,7 @@ final class ReactionListSheet: BaseVC {
|
|||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
|
||||
reactionContainer.scrollToItem(
|
||||
at: IndexPath(item: lastSelectedReactionIndex, section: 0),
|
||||
at: .centeredHorizontally,
|
||||
|
@ -151,7 +151,7 @@ final class ReactionListSheet: BaseVC {
|
|||
view.addSubview(contentView)
|
||||
contentView.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing, UIView.VerticalEdge.bottom ], to: view)
|
||||
// Emoji collectionView height + seleted emoji detail height + 5 × user cell height + footer cell height + bottom safe area inset
|
||||
let contentViewHeight: CGFloat = 100 + 5 * 65 + 45 + UIApplication.shared.keyWindow!.safeAreaInsets.bottom
|
||||
let contentViewHeight: CGFloat = 100 + 5 * 65 + 45 + (UIApplication.shared.keyWindow?.safeAreaInsets.bottom ?? 0)
|
||||
contentView.set(.height, to: contentViewHeight)
|
||||
populateContentView()
|
||||
}
|
||||
|
@ -164,7 +164,7 @@ final class ReactionListSheet: BaseVC {
|
|||
|
||||
// Seperator
|
||||
let seperator = UIView()
|
||||
seperator.backgroundColor = Colors.border.withAlphaComponent(0.1)
|
||||
seperator.themeBackgroundColor = .borderSeparator
|
||||
seperator.set(.height, to: 0.5)
|
||||
contentView.addSubview(seperator)
|
||||
seperator.pin(.leading, to: .leading, of: contentView, withInset: Values.smallSpacing)
|
||||
|
@ -181,7 +181,7 @@ final class ReactionListSheet: BaseVC {
|
|||
// Line
|
||||
let line = UIView()
|
||||
line.set(.height, to: 0.5)
|
||||
line.backgroundColor = Colors.border.withAlphaComponent(0.5)
|
||||
line.themeBackgroundColor = .borderSeparator
|
||||
contentView.addSubview(line)
|
||||
line.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing ], to: contentView)
|
||||
line.pin(.top, to: .bottom, of: stackView, withInset: Values.smallSpacing)
|
||||
|
@ -425,17 +425,31 @@ extension ReactionListSheet: UITableViewDelegate, UITableViewDataSource {
|
|||
return footerCell
|
||||
}
|
||||
|
||||
let cell: UserCell = tableView.dequeue(type: UserCell.self, for: indexPath)
|
||||
let cell: SessionCell = tableView.dequeue(type: SessionCell.self, for: indexPath)
|
||||
let cellViewModel: MessageViewModel.ReactionInfo = self.selectedReactionUserList[indexPath.row]
|
||||
let authorId: String = cellViewModel.reaction.authorId
|
||||
cell.update(
|
||||
with: cellViewModel.reaction.authorId,
|
||||
profile: cellViewModel.profile,
|
||||
isZombie: false,
|
||||
mediumFont: true,
|
||||
accessory: (cellViewModel.reaction.authorId == self.messageViewModel.currentUserPublicKey ?
|
||||
.x :
|
||||
.none
|
||||
)
|
||||
with: SessionCell.Info(
|
||||
id: cellViewModel,
|
||||
leftAccessory: .profile(authorId, cellViewModel.profile),
|
||||
title: (
|
||||
cellViewModel.profile?.displayName() ??
|
||||
Profile.truncated(
|
||||
id: authorId,
|
||||
threadVariant: self.messageViewModel.threadVariant
|
||||
)
|
||||
),
|
||||
rightAccessory: (authorId != self.messageViewModel.currentUserPublicKey ? nil :
|
||||
.icon(
|
||||
UIImage(named: "X")?
|
||||
.withRenderingMode(.alwaysTemplate),
|
||||
size: .fit
|
||||
)
|
||||
),
|
||||
isEnabled: (authorId == self.messageViewModel.currentUserPublicKey)
|
||||
),
|
||||
style: .edgeToEdge,
|
||||
position: Position.with(indexPath.row, count: self.selectedReactionUserList.count)
|
||||
)
|
||||
|
||||
return cell
|
||||
|
@ -471,22 +485,25 @@ extension ReactionListSheet {
|
|||
|
||||
private lazy var snContentView: UIView = {
|
||||
let result = UIView()
|
||||
result.backgroundColor = Colors.receivedMessageBackground
|
||||
result.set(.height, to: Cell.contentViewHeight)
|
||||
result.themeBackgroundColor = .messageBubble_incomingBackground
|
||||
result.layer.cornerRadius = Cell.contentViewCornerRadius
|
||||
result.layer.borderWidth = 1 // Intentionally 1pt (instead of 'Values.separatorThickness')
|
||||
result.set(.height, to: Cell.contentViewHeight)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var emojiLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
let result: UILabel = UILabel()
|
||||
result.font = .systemFont(ofSize: Values.mediumFontSize)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var numberLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
result.textColor = Colors.text
|
||||
let result: UILabel = UILabel()
|
||||
result.font = .systemFont(ofSize: Values.mediumFontSize)
|
||||
result.themeTextColor = .textPrimary
|
||||
|
||||
return result
|
||||
}()
|
||||
|
@ -528,25 +545,22 @@ extension ReactionListSheet {
|
|||
count: Int,
|
||||
isCurrentSelection: Bool
|
||||
) {
|
||||
snContentView.addBorder(
|
||||
with: (isCurrentSelection == true ? Colors.accent : .clear)
|
||||
)
|
||||
|
||||
emojiLabel.text = emoji
|
||||
numberLabel.text = (count < 1000 ?
|
||||
"\(count)" :
|
||||
String(format: "%.1fk", Float(count) / 1000)
|
||||
)
|
||||
snContentView.themeBorderColor = (isCurrentSelection ? .primary : .clear)
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate final class FooterCell: UITableViewCell {
|
||||
|
||||
private lazy var label: UILabel = {
|
||||
let result = UILabel()
|
||||
result.textAlignment = .center
|
||||
let result: UILabel = UILabel()
|
||||
result.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
result.textColor = Colors.grey.withAlphaComponent(0.8)
|
||||
result.themeTextColor = .textSecondary
|
||||
result.textAlignment = .center
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
|
@ -554,17 +568,19 @@ extension ReactionListSheet {
|
|||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
// Background color
|
||||
backgroundColor = Colors.cellBackground
|
||||
themeBackgroundColor = .backgroundSecondary
|
||||
|
||||
contentView.addSubview(label)
|
||||
label.pin(to: contentView)
|
||||
|
@ -572,9 +588,10 @@ extension ReactionListSheet {
|
|||
}
|
||||
|
||||
func update(moreReactorCount: Int, emoji: String) {
|
||||
label.text = (moreReactorCount == 1) ?
|
||||
label.text = (moreReactorCount == 1 ?
|
||||
String(format: "EMOJI_REACTS_MORE_REACTORS_ONE".localized(), "\(emoji)") :
|
||||
String(format: "EMOJI_REACTS_MORE_REACTORS_MUTIPLE".localized(), "\(moreReactorCount)" ,"\(emoji)")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
|
||||
final class ScrollToBottomButton: UIView {
|
||||
private weak var delegate: ScrollToBottomButtonDelegate?
|
||||
|
@ -14,7 +15,9 @@ final class ScrollToBottomButton: UIView {
|
|||
|
||||
init(delegate: ScrollToBottomButtonDelegate) {
|
||||
self.delegate = delegate
|
||||
|
||||
super.init(frame: CGRect.zero)
|
||||
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
|
@ -29,32 +32,44 @@ final class ScrollToBottomButton: UIView {
|
|||
private func setUpViewHierarchy() {
|
||||
// Background & blur
|
||||
let backgroundView = UIView()
|
||||
backgroundView.backgroundColor = isLightMode ? .white : .black
|
||||
backgroundView.themeBackgroundColor = .backgroundSecondary
|
||||
backgroundView.alpha = Values.lowOpacity
|
||||
addSubview(backgroundView)
|
||||
backgroundView.pin(to: self)
|
||||
let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .regular))
|
||||
|
||||
let blurView = UIVisualEffectView()
|
||||
addSubview(blurView)
|
||||
blurView.pin(to: self)
|
||||
|
||||
ThemeManager.onThemeChange(observer: blurView) { [weak blurView] theme, _ in
|
||||
switch theme.interfaceStyle {
|
||||
case .light: blurView?.effect = UIBlurEffect(style: .light)
|
||||
default: blurView?.effect = UIBlurEffect(style: .dark)
|
||||
}
|
||||
}
|
||||
|
||||
// Size & shape
|
||||
let size = ScrollToBottomButton.size
|
||||
set(.width, to: size)
|
||||
set(.height, to: size)
|
||||
layer.cornerRadius = size / 2
|
||||
set(.width, to: ScrollToBottomButton.size)
|
||||
set(.height, to: ScrollToBottomButton.size)
|
||||
layer.cornerRadius = (ScrollToBottomButton.size / 2)
|
||||
layer.masksToBounds = true
|
||||
|
||||
// Border
|
||||
self.themeBorderColor = .borderSeparator
|
||||
layer.borderWidth = Values.separatorThickness
|
||||
let borderColor = (isLightMode ? UIColor.black : UIColor.white).withAlphaComponent(Values.veryLowOpacity)
|
||||
layer.borderColor = borderColor.cgColor
|
||||
|
||||
// Icon
|
||||
let tint = isLightMode ? UIColor.black : UIColor.white
|
||||
let icon = UIImage(named: "ic_chevron_down")!.withTint(tint)
|
||||
let iconImageView = UIImageView(image: icon)
|
||||
iconImageView.set(.width, to: ScrollToBottomButton.iconSize)
|
||||
iconImageView.set(.height, to: ScrollToBottomButton.iconSize)
|
||||
let iconImageView = UIImageView(
|
||||
image: UIImage(named: "ic_chevron_down")?
|
||||
.withRenderingMode(.alwaysTemplate)
|
||||
)
|
||||
iconImageView.themeTintColor = .textPrimary
|
||||
iconImageView.contentMode = .scaleAspectFit
|
||||
addSubview(iconImageView)
|
||||
iconImageView.center(in: self)
|
||||
iconImageView.set(.width, to: ScrollToBottomButton.iconSize)
|
||||
iconImageView.set(.height, to: ScrollToBottomButton.iconSize)
|
||||
|
||||
// Gesture recognizer
|
||||
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
|
||||
addGestureRecognizer(tapGestureRecognizer)
|
||||
|
|
|
@ -1,75 +0,0 @@
|
|||
|
||||
final class SendSeedModal : Modal {
|
||||
var proceed: (() -> Void)? = nil
|
||||
|
||||
private lazy var titleLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
result.textColor = Colors.text
|
||||
result.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
result.text = NSLocalizedString("modal_send_seed_title", comment: "")
|
||||
result.textAlignment = .center
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var explanationLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
result.textColor = Colors.text
|
||||
result.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
result.text = NSLocalizedString("modal_send_seed_explanation", comment: "")
|
||||
result.numberOfLines = 0
|
||||
result.lineBreakMode = .byWordWrapping
|
||||
result.textAlignment = .center
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var sendSeedButton: UIButton = {
|
||||
let result = UIButton()
|
||||
result.set(.height, to: Values.mediumButtonHeight)
|
||||
result.layer.cornerRadius = Modal.buttonCornerRadius
|
||||
if isDarkMode {
|
||||
result.backgroundColor = Colors.destructive
|
||||
}
|
||||
result.titleLabel!.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
result.setTitleColor(isLightMode ? Colors.destructive : Colors.text, for: UIControl.State.normal)
|
||||
result.setTitle(NSLocalizedString("modal_send_seed_send_button_title", comment: ""), for: UIControl.State.normal)
|
||||
result.addTarget(self, action: #selector(sendSeed), for: UIControl.Event.touchUpInside)
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var buttonStackView: UIStackView = {
|
||||
let result = UIStackView(arrangedSubviews: [ cancelButton, sendSeedButton ])
|
||||
result.axis = .horizontal
|
||||
result.spacing = Values.mediumSpacing
|
||||
result.distribution = .fillEqually
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var contentStackView: UIStackView = {
|
||||
let result = UIStackView(arrangedSubviews: [ titleLabel, explanationLabel ])
|
||||
result.axis = .vertical
|
||||
result.spacing = Values.largeSpacing
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var mainStackView: UIStackView = {
|
||||
let result = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
|
||||
result.axis = .vertical
|
||||
result.spacing = Values.largeSpacing - Values.smallFontSize / 2
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Lifecycle
|
||||
override func populateContentView() {
|
||||
contentView.addSubview(mainStackView)
|
||||
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
|
||||
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: mainStackView.spacing)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
@objc private func sendSeed() {
|
||||
proceed?()
|
||||
presentingViewController?.dismiss(animated: true, completion: nil)
|
||||
}
|
||||
}
|
|
@ -1,86 +0,0 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
|
||||
final class URLModal: Modal {
|
||||
private let url: URL
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(url: URL) {
|
||||
self.url = url
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
override init(nibName: String?, bundle: Bundle?) {
|
||||
preconditionFailure("Use init(url:) instead.")
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init(url:) instead.")
|
||||
}
|
||||
|
||||
override func populateContentView() {
|
||||
// Title
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.textColor = Colors.text
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
titleLabel.text = NSLocalizedString("modal_open_url_title", comment: "")
|
||||
titleLabel.textAlignment = .center
|
||||
|
||||
// Message
|
||||
let messageLabel = UILabel()
|
||||
messageLabel.textColor = Colors.text
|
||||
messageLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
let message = String(format: NSLocalizedString("modal_open_url_explanation", comment: ""), url.absoluteString)
|
||||
let attributedMessage = NSMutableAttributedString(string: message)
|
||||
attributedMessage.addAttributes([ .font : UIFont.boldSystemFont(ofSize: Values.smallFontSize) ], range: (message as NSString).range(of: url.absoluteString))
|
||||
messageLabel.attributedText = attributedMessage
|
||||
messageLabel.numberOfLines = 0
|
||||
messageLabel.lineBreakMode = .byWordWrapping
|
||||
messageLabel.textAlignment = .center
|
||||
|
||||
// Open button
|
||||
let openButton = UIButton()
|
||||
openButton.set(.height, to: Values.mediumButtonHeight)
|
||||
openButton.layer.cornerRadius = Modal.buttonCornerRadius
|
||||
openButton.backgroundColor = Colors.buttonBackground
|
||||
openButton.titleLabel!.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
openButton.setTitleColor(Colors.text, for: UIControl.State.normal)
|
||||
openButton.setTitle(NSLocalizedString("modal_open_url_button_title", comment: ""), for: UIControl.State.normal)
|
||||
openButton.addTarget(self, action: #selector(openUrl), for: UIControl.Event.touchUpInside)
|
||||
|
||||
// Button stack view
|
||||
let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, openButton ])
|
||||
buttonStackView.axis = .horizontal
|
||||
buttonStackView.spacing = Values.mediumSpacing
|
||||
buttonStackView.distribution = .fillEqually
|
||||
|
||||
// Content stack view
|
||||
let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ])
|
||||
contentStackView.axis = .vertical
|
||||
contentStackView.spacing = Values.largeSpacing
|
||||
|
||||
// Main stack view
|
||||
let spacing = Values.largeSpacing - Values.smallFontSize / 2
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
|
||||
mainStackView.axis = .vertical
|
||||
mainStackView.spacing = spacing
|
||||
contentView.addSubview(mainStackView)
|
||||
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
|
||||
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
|
||||
}
|
||||
|
||||
// MARK: - Interaction
|
||||
|
||||
@objc private func openUrl() {
|
||||
let url = self.url
|
||||
|
||||
presentingViewController?.dismiss(animated: true, completion: {
|
||||
UIApplication.shared.open(url, options: [:], completionHandler: nil)
|
||||
})
|
||||
}
|
||||
}
|
|
@ -1,83 +0,0 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
final class UserDetailsSheet: Sheet {
|
||||
private let profile: Profile
|
||||
|
||||
init(for profile: Profile) {
|
||||
self.profile = profile
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
override init(nibName: String?, bundle: Bundle?) {
|
||||
preconditionFailure("Use init(for:) instead.")
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init(for:) instead.")
|
||||
}
|
||||
|
||||
override func populateContentView() {
|
||||
// Profile picture view
|
||||
let profilePictureView = ProfilePictureView()
|
||||
let size = Values.largeProfilePictureSize
|
||||
profilePictureView.size = size
|
||||
profilePictureView.set(.width, to: size)
|
||||
profilePictureView.set(.height, to: size)
|
||||
profilePictureView.update(
|
||||
publicKey: profile.id,
|
||||
profile: profile,
|
||||
threadVariant: .contact
|
||||
)
|
||||
|
||||
// Display name label
|
||||
let displayNameLabel = UILabel()
|
||||
let displayName = profile.displayName()
|
||||
displayNameLabel.text = displayName
|
||||
displayNameLabel.font = .boldSystemFont(ofSize: Values.largeFontSize)
|
||||
displayNameLabel.textColor = Colors.text
|
||||
displayNameLabel.numberOfLines = 1
|
||||
displayNameLabel.lineBreakMode = .byTruncatingTail
|
||||
|
||||
// Session ID label
|
||||
let sessionIDLabel = UILabel()
|
||||
sessionIDLabel.textColor = Colors.text
|
||||
sessionIDLabel.font = Fonts.spaceMono(ofSize: isIPhone5OrSmaller ? Values.mediumFontSize : 20)
|
||||
sessionIDLabel.numberOfLines = 0
|
||||
sessionIDLabel.lineBreakMode = .byCharWrapping
|
||||
sessionIDLabel.accessibilityLabel = "Session ID label"
|
||||
sessionIDLabel.text = profile.id
|
||||
|
||||
// Session ID label container
|
||||
let sessionIDLabelContainer = UIView()
|
||||
sessionIDLabelContainer.addSubview(sessionIDLabel)
|
||||
sessionIDLabel.pin(to: sessionIDLabelContainer, withInset: Values.mediumSpacing)
|
||||
sessionIDLabelContainer.layer.cornerRadius = TextField.cornerRadius
|
||||
sessionIDLabelContainer.layer.borderWidth = 1
|
||||
sessionIDLabelContainer.layer.borderColor = isLightMode ? UIColor.black.cgColor : UIColor.white.cgColor
|
||||
|
||||
// Copy button
|
||||
let copyButton = Button(style: .prominentOutline, size: .medium)
|
||||
copyButton.setTitle(NSLocalizedString("copy", comment: ""), for: UIControl.State.normal)
|
||||
copyButton.addTarget(self, action: #selector(copySessionID), for: UIControl.Event.touchUpInside)
|
||||
copyButton.set(.width, to: 160)
|
||||
|
||||
// Stack view
|
||||
let stackView = UIStackView(arrangedSubviews: [ profilePictureView, displayNameLabel, sessionIDLabelContainer, copyButton, UIView.vSpacer(Values.largeSpacing) ])
|
||||
stackView.axis = .vertical
|
||||
stackView.spacing = Values.largeSpacing
|
||||
stackView.alignment = .center
|
||||
|
||||
// Constraints
|
||||
contentView.addSubview(stackView)
|
||||
stackView.pin(to: contentView, withInset: Values.largeSpacing)
|
||||
}
|
||||
|
||||
@objc private func copySessionID() {
|
||||
UIPasteboard.general.string = profile.id
|
||||
presentingViewController?.dismiss(animated: true, completion: nil)
|
||||
}
|
||||
}
|
|
@ -9,23 +9,37 @@ import NVActivityIndicatorView
|
|||
class EmptySearchResultCell: UITableViewCell {
|
||||
private lazy var messageLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
result.text = "CONVERSATION_SEARCH_NO_RESULTS".localized()
|
||||
result.themeTextColor = .textPrimary
|
||||
result.textAlignment = .center
|
||||
result.numberOfLines = 3
|
||||
result.textColor = Colors.text
|
||||
result.text = NSLocalizedString("CONVERSATION_SEARCH_NO_RESULTS", comment: "")
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var spinner: NVActivityIndicatorView = {
|
||||
let result = NVActivityIndicatorView(frame: CGRect.zero, type: .circleStrokeSpin, color: Colors.text, padding: nil)
|
||||
private let spinner: NVActivityIndicatorView = {
|
||||
let result: NVActivityIndicatorView = NVActivityIndicatorView(
|
||||
frame: CGRect.zero,
|
||||
type: .circleStrokeSpin,
|
||||
color: .black,
|
||||
padding: nil
|
||||
)
|
||||
result.set(.width, to: 40)
|
||||
result.set(.height, to: 40)
|
||||
|
||||
ThemeManager.onThemeChange(observer: result) { [weak result] theme, _ in
|
||||
guard let textPrimary: UIColor = theme.color(for: .textPrimary) else { return }
|
||||
|
||||
result?.color = textPrimary
|
||||
}
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
|
||||
backgroundColor = .clear
|
||||
themeBackgroundColor = .clear
|
||||
selectionStyle = .none
|
||||
|
||||
contentView.addSubview(messageLabel)
|
||||
|
@ -54,7 +68,8 @@ class EmptySearchResultCell: UITableViewCell {
|
|||
spinner.stopAnimating()
|
||||
spinner.startAnimating()
|
||||
messageLabel.isHidden = true
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
spinner.stopAnimating()
|
||||
messageLabel.isHidden = false
|
||||
}
|
||||
|
|
|
@ -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?
|
||||
|
@ -50,9 +51,10 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
|
|||
|
||||
internal lazy var searchBar: SearchBar = {
|
||||
let result: SearchBar = SearchBar()
|
||||
result.tintColor = Colors.text
|
||||
result.themeTintColor = .textPrimary
|
||||
result.delegate = self
|
||||
result.showsCancelButton = true
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
|
@ -60,6 +62,7 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
|
|||
|
||||
internal lazy var tableView: UITableView = {
|
||||
let result: UITableView = UITableView(frame: .zero, style: .grouped)
|
||||
result.themeBackgroundColor = .clear
|
||||
result.rowHeight = UITableView.automaticDimension
|
||||
result.estimatedRowHeight = 60
|
||||
result.separatorStyle = .none
|
||||
|
@ -76,8 +79,6 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
|
|||
public override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
setUpGradientBackground()
|
||||
|
||||
tableView.dataSource = self
|
||||
tableView.delegate = self
|
||||
view.addSubview(tableView)
|
||||
|
@ -123,9 +124,10 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
|
|||
if UIDevice.current.isIPad {
|
||||
let ipadCancelButton = UIButton()
|
||||
ipadCancelButton.setTitle("Cancel", for: .normal)
|
||||
ipadCancelButton.setThemeTitleColor(.textPrimary, for: .normal)
|
||||
ipadCancelButton.addTarget(self, action: #selector(cancel), for: .touchUpInside)
|
||||
ipadCancelButton.setTitleColor(Colors.text, for: .normal)
|
||||
searchBarContainer.addSubview(ipadCancelButton)
|
||||
|
||||
ipadCancelButton.pin(.trailing, to: .trailing, of: searchBarContainer)
|
||||
ipadCancelButton.autoVCenterInSuperview()
|
||||
searchBar.autoPinEdgesToSuperviewEdges(with: UIEdgeInsets.zero, excludingEdge: .trailing)
|
||||
|
@ -162,53 +164,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() {
|
||||
|
@ -319,16 +329,19 @@ extension GlobalSearchViewController {
|
|||
}
|
||||
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize)
|
||||
titleLabel.text = title
|
||||
titleLabel.textColor = Colors.text
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
titleLabel.themeTextColor = .textPrimary
|
||||
|
||||
let container = UIView()
|
||||
container.backgroundColor = Colors.cellBackground
|
||||
container.layoutMargins = UIEdgeInsets(top: Values.smallSpacing, left: Values.mediumSpacing, bottom: Values.smallSpacing, right: Values.mediumSpacing)
|
||||
container.themeBackgroundColor = .backgroundPrimary
|
||||
container.addSubview(titleLabel)
|
||||
titleLabel.autoPinEdgesToSuperviewMargins()
|
||||
|
||||
|
||||
titleLabel.pin(.top, to: .top, of: container, withInset: Values.mediumSpacing)
|
||||
titleLabel.pin(.bottom, to: .bottom, of: container, withInset: -Values.mediumSpacing)
|
||||
titleLabel.pin(.left, to: .left, of: container, withInset: Values.largeSpacing)
|
||||
titleLabel.pin(.right, to: .right, of: container, withInset: -Values.largeSpacing)
|
||||
|
||||
return container
|
||||
}
|
||||
|
||||
|
|
|
@ -3,12 +3,13 @@
|
|||
import UIKit
|
||||
import GRDB
|
||||
import DifferenceKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedReminderViewDelegate {
|
||||
private static let loadingHeaderHeight: CGFloat = 20
|
||||
private static let loadingHeaderHeight: CGFloat = 40
|
||||
public static let newConversationButtonSize: CGFloat = 60
|
||||
|
||||
private let viewModel: HomeViewModel = HomeViewModel()
|
||||
|
@ -42,22 +43,30 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
|||
private lazy var seedReminderView: SeedReminderView = {
|
||||
let result = SeedReminderView(hasContinueButton: true)
|
||||
let title = "You're almost finished! 80%"
|
||||
let attributedTitle = NSMutableAttributedString(string: title)
|
||||
attributedTitle.addAttribute(.foregroundColor, value: Colors.accent, range: (title as NSString).range(of: "80%"))
|
||||
result.title = attributedTitle
|
||||
result.subtitle = NSLocalizedString("view_seed_reminder_subtitle_1", comment: "")
|
||||
result.subtitle = "view_seed_reminder_subtitle_1".localized()
|
||||
result.setProgress(0.8, animated: false)
|
||||
result.delegate = self
|
||||
result.isHidden = !self.viewModel.state.showViewedSeedBanner
|
||||
|
||||
ThemeManager.onThemeChange(observer: result) { [weak result] _, primaryColor in
|
||||
let attributedTitle = NSMutableAttributedString(string: title)
|
||||
attributedTitle.addAttribute(
|
||||
.foregroundColor,
|
||||
value: primaryColor.color,
|
||||
range: (title as NSString).range(of: "80%")
|
||||
)
|
||||
result?.title = attributedTitle
|
||||
}
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var loadingConversationsLabel: UILabel = {
|
||||
let result: UILabel = UILabel()
|
||||
result.font = UIFont.systemFont(ofSize: Values.smallFontSize)
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
result.text = "LOADING_CONVERSATIONS".localized()
|
||||
result.textColor = Colors.text
|
||||
result.themeTextColor = .textSecondary
|
||||
result.textAlignment = .center
|
||||
result.numberOfLines = 0
|
||||
|
||||
|
@ -66,15 +75,16 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
|||
|
||||
private lazy var tableView: UITableView = {
|
||||
let result = UITableView()
|
||||
result.backgroundColor = .clear
|
||||
result.separatorStyle = .none
|
||||
result.themeBackgroundColor = .clear
|
||||
result.contentInset = UIEdgeInsets(
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: (
|
||||
Values.newConversationButtonBottomOffset +
|
||||
Values.largeSpacing +
|
||||
HomeVC.newConversationButtonSize
|
||||
HomeVC.newConversationButtonSize +
|
||||
Values.smallSpacing +
|
||||
(UIApplication.shared.keyWindow?.safeAreaInsets.bottom ?? 0)
|
||||
),
|
||||
right: 0
|
||||
)
|
||||
|
@ -93,49 +103,91 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
|||
|
||||
private lazy var newConversationButton: UIButton = {
|
||||
let result = UIButton(type: .system)
|
||||
let iconSize = CGFloat(24)
|
||||
let icon = #imageLiteral(resourceName: "Plus").scaled(to: CGSize(width: iconSize, height: iconSize))
|
||||
let glowConfiguration = UIView.CircularGlowConfiguration(
|
||||
size: Self.newConversationButtonSize,
|
||||
color: Colors.expandedButtonGlowColor,
|
||||
isAnimated: false,
|
||||
radius: isLightMode ? 4 : 6
|
||||
result.clipsToBounds = false
|
||||
result.setImage(
|
||||
UIImage(named: "Plus")?
|
||||
.withRenderingMode(.alwaysTemplate),
|
||||
for: .normal
|
||||
)
|
||||
result.setImage(icon, for: .normal)
|
||||
result.set(.width, to: Self.newConversationButtonSize)
|
||||
result.set(.height, to: Self.newConversationButtonSize)
|
||||
result.contentMode = .center
|
||||
result.backgroundColor = Colors.accent
|
||||
result.layer.cornerRadius = Self.newConversationButtonSize / 2
|
||||
result.setCircularGlow(with: glowConfiguration)
|
||||
result.layer.masksToBounds = false
|
||||
result.tintColor = .white
|
||||
result.themeBackgroundColor = .menuButton_background
|
||||
result.themeTintColor = .menuButton_icon
|
||||
result.contentEdgeInsets = UIEdgeInsets(
|
||||
top: ((HomeVC.newConversationButtonSize - 24) / 2),
|
||||
leading: ((HomeVC.newConversationButtonSize - 24) / 2),
|
||||
bottom: ((HomeVC.newConversationButtonSize - 24) / 2),
|
||||
trailing: ((HomeVC.newConversationButtonSize - 24) / 2)
|
||||
)
|
||||
result.layer.cornerRadius = (HomeVC.newConversationButtonSize / 2)
|
||||
result.addTarget(self, action: #selector(createNewConversation), for: .touchUpInside)
|
||||
result.set(.width, to: HomeVC.newConversationButtonSize)
|
||||
result.set(.height, to: HomeVC.newConversationButtonSize)
|
||||
|
||||
// Add the outer shadow
|
||||
result.themeShadowColor = .menuButton_outerShadow
|
||||
result.layer.shadowRadius = 15
|
||||
result.layer.shadowOpacity = 0.3
|
||||
result.layer.shadowOffset = .zero
|
||||
result.layer.cornerRadius = (HomeVC.newConversationButtonSize / 2)
|
||||
result.layer.shadowPath = UIBezierPath(
|
||||
ovalIn: CGRect(
|
||||
origin: CGPoint.zero,
|
||||
size: CGSize(
|
||||
width: HomeVC.newConversationButtonSize,
|
||||
height: HomeVC.newConversationButtonSize
|
||||
)
|
||||
)
|
||||
).cgPath
|
||||
|
||||
// Add the inner shadow
|
||||
let innerShadowLayer: CALayer = CALayer()
|
||||
innerShadowLayer.masksToBounds = true
|
||||
innerShadowLayer.themeShadowColor = .menuButton_innerShadow
|
||||
innerShadowLayer.position = CGPoint(
|
||||
x: (HomeVC.newConversationButtonSize / 2),
|
||||
y: (HomeVC.newConversationButtonSize / 2)
|
||||
)
|
||||
innerShadowLayer.bounds = CGRect(
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: HomeVC.newConversationButtonSize,
|
||||
height: HomeVC.newConversationButtonSize
|
||||
)
|
||||
innerShadowLayer.cornerRadius = (HomeVC.newConversationButtonSize / 2)
|
||||
innerShadowLayer.shadowOffset = .zero
|
||||
innerShadowLayer.shadowOpacity = 0.4
|
||||
innerShadowLayer.shadowRadius = 2
|
||||
|
||||
let cutout: UIBezierPath = UIBezierPath(
|
||||
roundedRect: innerShadowLayer.bounds
|
||||
.insetBy(dx: innerShadowLayer.shadowRadius, dy: innerShadowLayer.shadowRadius),
|
||||
cornerRadius: (HomeVC.newConversationButtonSize / 2)
|
||||
).reversing()
|
||||
let path: UIBezierPath = UIBezierPath(
|
||||
roundedRect: innerShadowLayer.bounds,
|
||||
cornerRadius: (HomeVC.newConversationButtonSize / 2)
|
||||
)
|
||||
path.append(cutout)
|
||||
innerShadowLayer.shadowPath = path.cgPath
|
||||
result.layer.addSublayer(innerShadowLayer)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var fadeView: UIView = {
|
||||
let result = UIView()
|
||||
let gradient = Gradients.homeVCFade
|
||||
result.setGradient(gradient)
|
||||
result.isUserInteractionEnabled = false
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var emptyStateView: UIView = {
|
||||
let explanationLabel = UILabel()
|
||||
explanationLabel.textColor = Colors.text
|
||||
explanationLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
explanationLabel.numberOfLines = 0
|
||||
explanationLabel.lineBreakMode = .byWordWrapping
|
||||
explanationLabel.text = "vc_home_empty_state_message".localized()
|
||||
explanationLabel.themeTextColor = .textPrimary
|
||||
explanationLabel.textAlignment = .center
|
||||
explanationLabel.text = NSLocalizedString("vc_home_empty_state_message", comment: "")
|
||||
let createNewPrivateChatButton = Button(style: .prominentOutline, size: .large)
|
||||
createNewPrivateChatButton.setTitle(NSLocalizedString("vc_home_empty_state_button_title", comment: ""), for: UIControl.State.normal)
|
||||
createNewPrivateChatButton.addTarget(self, action: #selector(createNewDM), for: UIControl.Event.touchUpInside)
|
||||
explanationLabel.lineBreakMode = .byWordWrapping
|
||||
explanationLabel.numberOfLines = 0
|
||||
|
||||
let createNewPrivateChatButton = SessionButton(style: .bordered, size: .large)
|
||||
createNewPrivateChatButton.setTitle("vc_home_empty_state_button_title".localized(), for: .normal)
|
||||
createNewPrivateChatButton.addTarget(self, action: #selector(createNewDM), for: .touchUpInside)
|
||||
createNewPrivateChatButton.set(.width, to: Values.iPadButtonWidth)
|
||||
|
||||
let result = UIStackView(arrangedSubviews: [ explanationLabel, createNewPrivateChatButton ])
|
||||
result.axis = .vertical
|
||||
result.spacing = Values.mediumSpacing
|
||||
|
@ -158,11 +210,6 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
|||
// Preparation
|
||||
SessionApp.homeViewController.mutate { $0 = self }
|
||||
|
||||
// Gradient & nav bar
|
||||
setUpGradientBackground()
|
||||
if navigationController?.navigationBar != nil {
|
||||
setUpNavBarStyle()
|
||||
}
|
||||
updateNavBarButtons()
|
||||
setUpNavBarSessionHeading()
|
||||
|
||||
|
@ -190,12 +237,6 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
|||
}
|
||||
tableView.pin(.trailing, to: .trailing, of: view)
|
||||
tableView.pin(.bottom, to: .bottom, of: view)
|
||||
view.addSubview(fadeView)
|
||||
fadeView.pin(.leading, to: .leading, of: view)
|
||||
let topInset = 0.15 * view.height()
|
||||
fadeView.pin(.top, to: .top, of: view, withInset: topInset)
|
||||
fadeView.pin(.trailing, to: .trailing, of: view)
|
||||
fadeView.pin(.bottom, to: .bottom, of: view)
|
||||
|
||||
// Empty state view
|
||||
view.addSubview(emptyStateView)
|
||||
|
@ -206,7 +247,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
|||
// New conversation button
|
||||
view.addSubview(newConversationButton)
|
||||
newConversationButton.center(.horizontal, in: view)
|
||||
newConversationButton.pin(.bottom, to: .bottom, of: view, withInset: -Values.newConversationButtonBottomOffset) // Negative due to how the constraint is set up
|
||||
newConversationButton.pin(.bottom, to: .bottom, of: view.safeAreaLayoutGuide, withInset: -Values.smallSpacing)
|
||||
|
||||
// Notifications
|
||||
NotificationCenter.default.addObserver(
|
||||
|
@ -290,7 +331,9 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
|||
// PagedDatabaseObserver won't have them so we need to force a re-fetch of the current
|
||||
// data to ensure everything is up to date
|
||||
if didReturnFromBackground {
|
||||
self.viewModel.pagedDataObserver?.reload()
|
||||
DispatchQueue.global(qos: .default).async { [weak self] in
|
||||
self?.viewModel.pagedDataObserver?.reload()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -401,16 +444,6 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
|||
}
|
||||
}
|
||||
|
||||
private func updateNewConversationButton() {
|
||||
let glowConfiguration = UIView.CircularGlowConfiguration(
|
||||
size: Self.newConversationButtonSize,
|
||||
color: Colors.expandedButtonGlowColor,
|
||||
isAnimated: false,
|
||||
radius: isLightMode ? 4 : 6
|
||||
)
|
||||
newConversationButton.setCircularGlow(with: glowConfiguration)
|
||||
}
|
||||
|
||||
private func updateNavBarButtons() {
|
||||
// Profile picture view
|
||||
let profilePictureSize = Values.verySmallProfilePictureSize
|
||||
|
@ -431,8 +464,6 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
|||
// Path status indicator
|
||||
let pathStatusView = PathStatusView()
|
||||
pathStatusView.accessibilityLabel = "Current onion routing path indicator"
|
||||
pathStatusView.set(.width, to: PathStatusView.size)
|
||||
pathStatusView.set(.height, to: PathStatusView.size)
|
||||
|
||||
// Container view
|
||||
let profilePictureViewContainer = UIView()
|
||||
|
@ -455,15 +486,6 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
|||
rightBarButtonItem.isAccessibilityElement = true
|
||||
navigationItem.rightBarButtonItem = rightBarButtonItem
|
||||
}
|
||||
|
||||
@objc override internal func handleAppModeChangedNotification(_ notification: Notification) {
|
||||
super.handleAppModeChangedNotification(notification)
|
||||
|
||||
let gradient = Gradients.homeVCFade
|
||||
fadeView.setGradient(gradient) // Re-do the gradient
|
||||
updateNewConversationButton()
|
||||
tableView.reloadData()
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDataSource
|
||||
|
||||
|
@ -503,7 +525,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
|||
switch section.model {
|
||||
case .loadMore:
|
||||
let loadingIndicator: UIActivityIndicatorView = UIActivityIndicatorView(style: .medium)
|
||||
loadingIndicator.tintColor = Colors.text
|
||||
loadingIndicator.themeTintColor = .textPrimary
|
||||
loadingIndicator.alpha = 0.5
|
||||
loadingIndicator.startAnimating()
|
||||
|
||||
|
@ -574,112 +596,118 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
|||
return true
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
|
||||
func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
|
||||
let section: HomeViewModel.SectionModel = self.viewModel.threadData[indexPath.section]
|
||||
let unswipeAnimationDelay: DispatchTimeInterval = .milliseconds(500)
|
||||
|
||||
switch section.model {
|
||||
case .messageRequests:
|
||||
let hide = UITableViewRowAction(style: .destructive, title: "TXT_HIDE_TITLE".localized()) { _, _ in
|
||||
let hide: UIContextualAction = UIContextualAction(style: .destructive, title: "TXT_HIDE_TITLE".localized()) { _, _, completionHandler in
|
||||
Storage.shared.write { db in db[.hasHiddenMessageRequests] = true }
|
||||
completionHandler(true)
|
||||
}
|
||||
hide.backgroundColor = Colors.destructive
|
||||
hide.themeBackgroundColor = .conversationButton_swipeDestructive
|
||||
|
||||
return [hide]
|
||||
return UISwipeActionsConfiguration(actions: [hide])
|
||||
|
||||
case .threads:
|
||||
let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row]
|
||||
let delete: UITableViewRowAction = UITableViewRowAction(
|
||||
let delete: UIContextualAction = UIContextualAction(
|
||||
style: .destructive,
|
||||
title: "TXT_DELETE_TITLE".localized()
|
||||
) { [weak self] _, _ in
|
||||
let message = (threadViewModel.currentUserIsClosedGroupAdmin == true ?
|
||||
"admin_group_leave_warning".localized() :
|
||||
"CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE".localized()
|
||||
) { [weak self] _, _, completionHandler in
|
||||
let confirmationModal: ConfirmationModal = ConfirmationModal(
|
||||
info: ConfirmationModal.Info(
|
||||
title: "CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE".localized(),
|
||||
explanation: (threadViewModel.currentUserIsClosedGroupAdmin == true ?
|
||||
"admin_group_leave_warning".localized() :
|
||||
"CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE".localized()
|
||||
),
|
||||
confirmTitle: "TXT_DELETE_TITLE".localized(),
|
||||
confirmStyle: .danger,
|
||||
cancelStyle: .alert_text,
|
||||
dismissOnConfirm: true,
|
||||
onConfirm: { [weak self] _ in
|
||||
self?.viewModel.delete(
|
||||
threadId: threadViewModel.threadId,
|
||||
threadVariant: threadViewModel.threadVariant
|
||||
)
|
||||
self?.dismiss(animated: true, completion: nil)
|
||||
|
||||
completionHandler(true)
|
||||
},
|
||||
afterClosed: { completionHandler(false) }
|
||||
)
|
||||
)
|
||||
|
||||
let alert = UIAlertController(
|
||||
title: "CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE".localized(),
|
||||
message: message,
|
||||
preferredStyle: .alert
|
||||
)
|
||||
alert.addAction(UIAlertAction(
|
||||
title: "TXT_DELETE_TITLE".localized(),
|
||||
style: .destructive
|
||||
) { _ in
|
||||
Storage.shared.writeAsync { db in
|
||||
switch threadViewModel.threadVariant {
|
||||
case .closedGroup:
|
||||
try MessageSender
|
||||
.leave(db, groupPublicKey: threadViewModel.threadId)
|
||||
.retainUntilComplete()
|
||||
|
||||
case .openGroup:
|
||||
OpenGroupManager.shared.delete(db, openGroupId: threadViewModel.threadId)
|
||||
|
||||
default: break
|
||||
}
|
||||
|
||||
_ = try SessionThread
|
||||
.filter(id: threadViewModel.threadId)
|
||||
.deleteAll(db)
|
||||
}
|
||||
})
|
||||
alert.addAction(UIAlertAction(
|
||||
title: "TXT_CANCEL_TITLE".localized(),
|
||||
style: .default
|
||||
))
|
||||
|
||||
self?.present(alert, animated: true, completion: nil)
|
||||
self?.present(confirmationModal, animated: true, completion: nil)
|
||||
}
|
||||
delete.backgroundColor = Colors.destructive
|
||||
delete.themeBackgroundColor = .conversationButton_swipeDestructive
|
||||
|
||||
let pin: UITableViewRowAction = UITableViewRowAction(
|
||||
let pin: UIContextualAction = UIContextualAction(
|
||||
style: .normal,
|
||||
title: (threadViewModel.threadIsPinned ?
|
||||
"UNPIN_BUTTON_TEXT".localized() :
|
||||
"PIN_BUTTON_TEXT".localized()
|
||||
)
|
||||
) { _, _ in
|
||||
Storage.shared.writeAsync { db in
|
||||
try SessionThread
|
||||
.filter(id: threadViewModel.threadId)
|
||||
.updateAll(db, SessionThread.Columns.isPinned.set(to: !threadViewModel.threadIsPinned))
|
||||
) { _, _, completionHandler in
|
||||
(tableView.cellForRow(at: indexPath) as? FullConversationCell)?.optimisticUpdate(
|
||||
isPinned: !threadViewModel.threadIsPinned
|
||||
)
|
||||
completionHandler(true)
|
||||
|
||||
// Delay the change to give the cell "unswipe" animation some time to complete
|
||||
DispatchQueue.global(qos: .default).asyncAfter(deadline: .now() + unswipeAnimationDelay) {
|
||||
Storage.shared.writeAsync { db in
|
||||
try SessionThread
|
||||
.filter(id: threadViewModel.threadId)
|
||||
.updateAll(db, SessionThread.Columns.isPinned.set(to: !threadViewModel.threadIsPinned))
|
||||
}
|
||||
}
|
||||
}
|
||||
pin.themeBackgroundColor = .conversationButton_swipeTertiary
|
||||
|
||||
guard threadViewModel.threadVariant == .contact && !threadViewModel.threadIsNoteToSelf else {
|
||||
return [ delete, pin ]
|
||||
return UISwipeActionsConfiguration(actions: [ delete, pin ])
|
||||
}
|
||||
|
||||
let block: UITableViewRowAction = UITableViewRowAction(
|
||||
let block: UIContextualAction = UIContextualAction(
|
||||
style: .normal,
|
||||
title: (threadViewModel.threadIsBlocked == true ?
|
||||
"BLOCK_LIST_UNBLOCK_BUTTON".localized() :
|
||||
"BLOCK_LIST_BLOCK_BUTTON".localized()
|
||||
)
|
||||
) { _, _ in
|
||||
Storage.shared.writeAsync { db in
|
||||
try Contact
|
||||
.filter(id: threadViewModel.threadId)
|
||||
.updateAll(
|
||||
db,
|
||||
Contact.Columns.isBlocked.set(
|
||||
to: (threadViewModel.threadIsBlocked == false ?
|
||||
true:
|
||||
false
|
||||
) { _, _, completionHandler in
|
||||
(tableView.cellForRow(at: indexPath) as? FullConversationCell)?.optimisticUpdate(
|
||||
isBlocked: (threadViewModel.threadIsBlocked == false)
|
||||
)
|
||||
completionHandler(true)
|
||||
|
||||
// Delay the change to give the cell "unswipe" animation some time to complete
|
||||
DispatchQueue.global(qos: .default).asyncAfter(deadline: .now() + unswipeAnimationDelay) {
|
||||
Storage.shared.writeAsync { db in
|
||||
try Contact
|
||||
.filter(id: threadViewModel.threadId)
|
||||
.updateAll(
|
||||
db,
|
||||
Contact.Columns.isBlocked.set(
|
||||
to: (threadViewModel.threadIsBlocked == false ?
|
||||
true:
|
||||
false
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
try MessageSender.syncConfiguration(db, forceSyncNow: true)
|
||||
.retainUntilComplete()
|
||||
|
||||
try MessageSender.syncConfiguration(db, forceSyncNow: true)
|
||||
.retainUntilComplete()
|
||||
}
|
||||
}
|
||||
}
|
||||
block.backgroundColor = Colors.blockActionBackground
|
||||
block.themeBackgroundColor = .conversationButton_swipeSecondary
|
||||
|
||||
return [ delete, block, pin ]
|
||||
return UISwipeActionsConfiguration(actions: [ delete, block, pin ])
|
||||
|
||||
default: return []
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -687,7 +715,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
|||
|
||||
func handleContinueButtonTapped(from seedReminderView: SeedReminderView) {
|
||||
let seedVC = SeedVC()
|
||||
let navigationController = OWSNavigationController(rootViewController: seedVC)
|
||||
let navigationController = StyledNavigationController(rootViewController: seedVC)
|
||||
present(navigationController, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
|
@ -717,8 +745,10 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
|||
}
|
||||
|
||||
@objc private func openSettings() {
|
||||
let settingsVC = SettingsVC()
|
||||
let navigationController = OWSNavigationController(rootViewController: settingsVC)
|
||||
let settingsViewController: SessionTableViewController = SessionTableViewController(
|
||||
viewModel: SettingsViewModel()
|
||||
)
|
||||
let navigationController = StyledNavigationController(rootViewController: settingsViewController)
|
||||
navigationController.modalPresentationStyle = .fullScreen
|
||||
present(navigationController, animated: true, completion: nil)
|
||||
}
|
||||
|
@ -733,7 +763,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
|||
|
||||
@objc func createNewConversation() {
|
||||
let newConversationVC = NewConversationVC()
|
||||
let navigationController = OWSNavigationController(rootViewController: newConversationVC)
|
||||
let navigationController = StyledNavigationController(rootViewController: newConversationVC)
|
||||
if UIDevice.current.isIPad {
|
||||
navigationController.modalPresentationStyle = .fullScreen
|
||||
}
|
||||
|
@ -743,7 +773,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
|||
|
||||
@objc func createNewDM() {
|
||||
let newDMVC = NewDMVC(shouldShowBackButton: false)
|
||||
let navigationController = OWSNavigationController(rootViewController: newDMVC)
|
||||
let navigationController = StyledNavigationController(rootViewController: newDMVC)
|
||||
if UIDevice.current.isIPad {
|
||||
navigationController.modalPresentationStyle = .fullScreen
|
||||
}
|
||||
|
@ -751,10 +781,9 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
|||
present(navigationController, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
@objc(createNewDMFromDeepLink:)
|
||||
func createNewDMFromDeepLink(sessionID: String) {
|
||||
let newDMVC = NewDMVC(sessionID: sessionID, shouldShowBackButton: false)
|
||||
let navigationController = OWSNavigationController(rootViewController: newDMVC)
|
||||
func createNewDMFromDeepLink(sessionId: String) {
|
||||
let newDMVC = NewDMVC(sessionId: sessionId, shouldShowBackButton: false)
|
||||
let navigationController = StyledNavigationController(rootViewController: newDMVC)
|
||||
if UIDevice.current.isIPad {
|
||||
navigationController.modalPresentationStyle = .fullScreen
|
||||
}
|
||||
|
|
|
@ -136,6 +136,16 @@ public class HomeViewModel {
|
|||
|
||||
return SQL("LEFT JOIN \(ThreadTypingIndicator.self) ON \(typingIndicator[.threadId]) = \(thread[.id])")
|
||||
}()
|
||||
),
|
||||
PagedData.ObservedChanges(
|
||||
table: Setting.self,
|
||||
columns: [.value],
|
||||
joinToPagedType: {
|
||||
let setting: TypedTableAlias<Setting> = TypedTableAlias()
|
||||
let targetSetting: String = Setting.BoolKey.showScreenshotNotifications.rawValue
|
||||
|
||||
return SQL("LEFT JOIN \(Setting.self) ON \(setting[.key]) = \(targetSetting)")
|
||||
}()
|
||||
)
|
||||
],
|
||||
/// **Note:** This `optimisedJoinSQL` value includes the required minimum joins needed for the query but differs
|
||||
|
@ -300,4 +310,26 @@ public class HomeViewModel {
|
|||
public func updateThreadData(_ updatedData: [SectionModel]) {
|
||||
self.threadData = updatedData
|
||||
}
|
||||
|
||||
// MARK: - Functions
|
||||
|
||||
public func delete(threadId: String, threadVariant: SessionThread.Variant) {
|
||||
Storage.shared.writeAsync { db in
|
||||
switch threadVariant {
|
||||
case .closedGroup:
|
||||
try MessageSender
|
||||
.leave(db, groupPublicKey: threadId)
|
||||
.retainUntilComplete()
|
||||
|
||||
case .openGroup:
|
||||
OpenGroupManager.shared.delete(db, openGroupId: threadId)
|
||||
|
||||
default: break
|
||||
}
|
||||
|
||||
_ = try SessionThread
|
||||
.filter(id: threadId)
|
||||
.deleteAll(db)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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?
|
||||
|
@ -34,19 +34,34 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
|||
}
|
||||
|
||||
// MARK: - UI
|
||||
|
||||
private lazy var loadingConversationsLabel: UILabel = {
|
||||
let result: UILabel = UILabel()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
result.text = "LOADING_CONVERSATIONS".localized()
|
||||
result.themeTextColor = .textSecondary
|
||||
result.textAlignment = .center
|
||||
result.numberOfLines = 0
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let result: UITableView = UITableView()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.backgroundColor = .clear
|
||||
result.separatorStyle = .none
|
||||
result.themeBackgroundColor = .clear
|
||||
result.showsVerticalScrollIndicator = false
|
||||
result.contentInset = UIEdgeInsets(
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: Values.footerGradientHeight(window: UIApplication.shared.keyWindow),
|
||||
right: 0
|
||||
)
|
||||
result.register(view: FullConversationCell.self)
|
||||
result.dataSource = self
|
||||
result.delegate = self
|
||||
|
||||
let bottomInset = Values.newConversationButtonBottomOffset + Values.largeSpacing + HomeVC.newConversationButtonSize
|
||||
result.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: bottomInset, right: 0)
|
||||
result.showsVerticalScrollIndicator = false
|
||||
|
||||
if #available(iOS 15.0, *) {
|
||||
result.sectionHeaderTopPadding = 0
|
||||
|
@ -59,35 +74,34 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
|||
let result: UILabel = UILabel()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.isUserInteractionEnabled = false
|
||||
result.font = UIFont.systemFont(ofSize: Values.smallFontSize)
|
||||
result.text = NSLocalizedString("MESSAGE_REQUESTS_EMPTY_TEXT", comment: "")
|
||||
result.textColor = Colors.text
|
||||
result.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
result.text = "MESSAGE_REQUESTS_EMPTY_TEXT".localized()
|
||||
result.themeTextColor = .textSecondary
|
||||
result.textAlignment = .center
|
||||
result.numberOfLines = 0
|
||||
result.isHidden = true
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var fadeView: UIView = {
|
||||
let result: UIView = UIView()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.isUserInteractionEnabled = false
|
||||
result.setGradient(Gradients.homeVCFade)
|
||||
|
||||
|
||||
private lazy var fadeView: GradientView = {
|
||||
let result: GradientView = GradientView()
|
||||
result.themeBackgroundGradient = [
|
||||
.value(.backgroundPrimary, alpha: 0), // Want this to take up 20% (~25pt)
|
||||
.backgroundPrimary,
|
||||
.backgroundPrimary,
|
||||
.backgroundPrimary,
|
||||
.backgroundPrimary
|
||||
]
|
||||
result.set(.height, to: Values.footerGradientHeight(window: UIApplication.shared.keyWindow))
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var clearAllButton: Button = {
|
||||
let result: Button = Button(style: .destructiveOutline, size: .large)
|
||||
private lazy var clearAllButton: SessionButton = {
|
||||
let result: SessionButton = SessionButton(style: .destructive, size: .large)
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.setTitle(NSLocalizedString("MESSAGE_REQUESTS_CLEAR_ALL", comment: ""), for: .normal)
|
||||
result.setBackgroundImage(
|
||||
Colors.destructive
|
||||
.withAlphaComponent(isDarkMode ? 0.2 : 0.06)
|
||||
.toImage(isDarkMode: isDarkMode),
|
||||
for: .highlighted
|
||||
)
|
||||
result.setTitle("MESSAGE_REQUESTS_CLEAR_ALL".localized(), for: .normal)
|
||||
result.addTarget(self, action: #selector(clearAllTapped), for: .touchUpInside)
|
||||
|
||||
return result
|
||||
|
@ -106,6 +120,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
|||
|
||||
// Add the UI (MUST be done after the thread freeze so the 'tableView' creation and setting
|
||||
// the dataSource has the correct data)
|
||||
view.addSubview(loadingConversationsLabel)
|
||||
view.addSubview(tableView)
|
||||
view.addSubview(emptyStateLabel)
|
||||
view.addSubview(fadeView)
|
||||
|
@ -159,6 +174,10 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
|||
|
||||
private func setupLayout() {
|
||||
NSLayoutConstraint.activate([
|
||||
loadingConversationsLabel.topAnchor.constraint(equalTo: view.topAnchor, constant: Values.veryLargeSpacing),
|
||||
loadingConversationsLabel.leftAnchor.constraint(equalTo: view.leftAnchor, constant: Values.massiveSpacing),
|
||||
loadingConversationsLabel.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -Values.massiveSpacing),
|
||||
|
||||
tableView.topAnchor.constraint(equalTo: view.topAnchor, constant: Values.smallSpacing),
|
||||
tableView.leftAnchor.constraint(equalTo: view.leftAnchor),
|
||||
tableView.rightAnchor.constraint(equalTo: view.rightAnchor),
|
||||
|
@ -168,19 +187,17 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
|||
emptyStateLabel.leftAnchor.constraint(equalTo: view.leftAnchor, constant: Values.mediumSpacing),
|
||||
emptyStateLabel.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -Values.mediumSpacing),
|
||||
emptyStateLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
|
||||
fadeView.topAnchor.constraint(equalTo: view.topAnchor, constant: (0.15 * view.bounds.height)),
|
||||
|
||||
fadeView.leftAnchor.constraint(equalTo: view.leftAnchor),
|
||||
fadeView.rightAnchor.constraint(equalTo: view.rightAnchor),
|
||||
fadeView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
|
||||
|
||||
clearAllButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
clearAllButton.bottomAnchor.constraint(
|
||||
equalTo: view.safeAreaLayoutGuide.bottomAnchor,
|
||||
constant: -Values.largeSpacing
|
||||
constant: -Values.smallSpacing
|
||||
),
|
||||
clearAllButton.widthAnchor.constraint(equalToConstant: Values.iPadButtonWidth),
|
||||
clearAllButton.heightAnchor.constraint(equalToConstant: HomeVC.newConversationButtonSize)
|
||||
clearAllButton.widthAnchor.constraint(equalToConstant: Values.iPadButtonWidth)
|
||||
])
|
||||
}
|
||||
|
||||
|
@ -208,6 +225,9 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
|||
return
|
||||
}
|
||||
|
||||
// Hide the 'loading conversations' label (now that we have received conversation data)
|
||||
loadingConversationsLabel.isHidden = true
|
||||
|
||||
// Show the empty state if there is no data
|
||||
clearAllButton.isHidden = updatedData.isEmpty
|
||||
emptyStateLabel.isHidden = !updatedData.isEmpty
|
||||
|
@ -265,14 +285,6 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc override internal func handleAppModeChangedNotification(_ notification: Notification) {
|
||||
super.handleAppModeChangedNotification(notification)
|
||||
|
||||
let gradient = Gradients.homeVCFade
|
||||
fadeView.setGradient(gradient) // Re-do the gradient
|
||||
tableView.reloadData()
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDataSource
|
||||
|
||||
|
@ -306,7 +318,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
|||
switch section.model {
|
||||
case .loadMore:
|
||||
let loadingIndicator: UIActivityIndicatorView = UIActivityIndicatorView(style: .medium)
|
||||
loadingIndicator.tintColor = Colors.text
|
||||
loadingIndicator.themeTintColor = .textPrimary
|
||||
loadingIndicator.alpha = 0.5
|
||||
loadingIndicator.startAnimating()
|
||||
|
||||
|
@ -369,33 +381,34 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
|||
func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
|
||||
|
||||
func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
|
||||
let section: MessageRequestsViewModel.SectionModel = self.viewModel.threadData[indexPath.section]
|
||||
|
||||
switch section.model {
|
||||
case .threads:
|
||||
let threadId: String = section.elements[indexPath.row].threadId
|
||||
|
||||
let delete = UITableViewRowAction(
|
||||
let delete: UIContextualAction = UIContextualAction(
|
||||
style: .destructive,
|
||||
title: "TXT_DELETE_TITLE".localized()
|
||||
) { [weak self] _, _ in
|
||||
) { [weak self] _, _, completionHandler in
|
||||
self?.delete(threadId)
|
||||
completionHandler(true)
|
||||
}
|
||||
delete.backgroundColor = Colors.destructive
|
||||
delete.themeBackgroundColor = .conversationButton_swipeDestructive
|
||||
|
||||
let block: UITableViewRowAction = UITableViewRowAction(
|
||||
let block: UIContextualAction = UIContextualAction(
|
||||
style: .normal,
|
||||
title: "BLOCK_LIST_BLOCK_BUTTON".localized()
|
||||
) { [weak self] _, _ in
|
||||
) { [weak self] _, _, completionHandler in
|
||||
self?.block(threadId)
|
||||
completionHandler(true)
|
||||
}
|
||||
block.backgroundColor = Colors.blockActionBackground
|
||||
block.themeBackgroundColor = .conversationButton_swipeSecondary
|
||||
|
||||
return [ delete, block ]
|
||||
return UISwipeActionsConfiguration(actions: [ delete, block ])
|
||||
|
||||
default: return []
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -17,7 +17,7 @@ public class MessageRequestsViewModel {
|
|||
|
||||
// MARK: - Variables
|
||||
|
||||
public static let pageSize: Int = 20
|
||||
public static let pageSize: Int = 15
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
|
|
|
@ -28,7 +28,7 @@ class MessageRequestsCell: UITableViewCell {
|
|||
let result: UIView = UIView()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.clipsToBounds = true
|
||||
result.backgroundColor = Colors.sessionMessageRequestsBubble
|
||||
result.themeBackgroundColor = .conversationButton_unreadBubbleBackground
|
||||
result.layer.cornerRadius = (Values.mediumProfilePictureSize / 2)
|
||||
|
||||
return result
|
||||
|
@ -37,7 +37,7 @@ class MessageRequestsCell: UITableViewCell {
|
|||
private let iconImageView: UIImageView = {
|
||||
let result: UIImageView = UIImageView(image: #imageLiteral(resourceName: "message_requests").withRenderingMode(.alwaysTemplate))
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.tintColor = Colors.sessionMessageRequestsIcon
|
||||
result.themeTintColor = .conversationButton_unreadBubbleText
|
||||
|
||||
return result
|
||||
}()
|
||||
|
@ -48,8 +48,8 @@ class MessageRequestsCell: UITableViewCell {
|
|||
result.setContentHuggingPriority(.defaultHigh, for: .horizontal)
|
||||
result.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
result.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
result.text = NSLocalizedString("MESSAGE_REQUESTS_TITLE", comment: "")
|
||||
result.textColor = Colors.sessionMessageRequestsTitle
|
||||
result.text = "MESSAGE_REQUESTS_TITLE".localized()
|
||||
result.themeTextColor = .textPrimary
|
||||
result.lineBreakMode = .byTruncatingTail
|
||||
|
||||
return result
|
||||
|
@ -59,7 +59,7 @@ class MessageRequestsCell: UITableViewCell {
|
|||
let result: UIView = UIView()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.clipsToBounds = true
|
||||
result.backgroundColor = Colors.text.withAlphaComponent(Values.veryLowOpacity)
|
||||
result.themeBackgroundColor = .conversationButton_unreadBubbleBackground
|
||||
result.layer.cornerRadius = (FullConversationCell.unreadCountViewSize / 2)
|
||||
|
||||
return result
|
||||
|
@ -69,17 +69,16 @@ class MessageRequestsCell: UITableViewCell {
|
|||
let result = UILabel()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.font = .boldSystemFont(ofSize: Values.verySmallFontSize)
|
||||
result.textColor = Colors.text
|
||||
result.themeTextColor = .conversationButton_unreadBubbleText
|
||||
result.textAlignment = .center
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
backgroundColor = Colors.cellPinned
|
||||
themeBackgroundColor = .conversationButton_unreadBackground
|
||||
selectedBackgroundView = UIView()
|
||||
selectedBackgroundView?.backgroundColor = Colors.cellSelected
|
||||
|
||||
selectedBackgroundView?.themeBackgroundColor = .conversationButton_unreadHighlight
|
||||
|
||||
contentView.addSubview(iconContainerView)
|
||||
contentView.addSubview(titleLabel)
|
||||
|
@ -115,12 +114,12 @@ class MessageRequestsCell: UITableViewCell {
|
|||
|
||||
unreadCountView.leftAnchor.constraint(equalTo: titleLabel.rightAnchor, constant: (Values.smallSpacing / 2)),
|
||||
unreadCountView.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor),
|
||||
unreadCountView.widthAnchor.constraint(equalToConstant: FullConversationCell.unreadCountViewSize),
|
||||
unreadCountView.widthAnchor.constraint(greaterThanOrEqualToConstant: FullConversationCell.unreadCountViewSize),
|
||||
unreadCountView.heightAnchor.constraint(equalToConstant: FullConversationCell.unreadCountViewSize),
|
||||
|
||||
unreadCountLabel.topAnchor.constraint(equalTo: unreadCountView.topAnchor),
|
||||
unreadCountLabel.leftAnchor.constraint(equalTo: unreadCountView.leftAnchor),
|
||||
unreadCountLabel.rightAnchor.constraint(equalTo: unreadCountView.rightAnchor),
|
||||
unreadCountLabel.leftAnchor.constraint(equalTo: unreadCountView.leftAnchor, constant: 4),
|
||||
unreadCountLabel.rightAnchor.constraint(equalTo: unreadCountView.rightAnchor, constant: -4),
|
||||
unreadCountLabel.bottomAnchor.constraint(equalTo: unreadCountView.bottomAnchor)
|
||||
])
|
||||
}
|
||||
|
|
|
@ -6,24 +6,26 @@ import PromiseKit
|
|||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
final class NewConversationVC: BaseVC, UITableViewDelegate, UITableViewDataSource {
|
||||
final class NewConversationVC: BaseVC, ThemedNavigation, UITableViewDelegate, UITableViewDataSource {
|
||||
private let newConversationViewModel = NewConversationViewModel()
|
||||
private var groupedContacts: OrderedDictionary<String, [Profile]> = OrderedDictionary()
|
||||
|
||||
// MARK: - UI
|
||||
|
||||
var navigationBackground: ThemeValue { .newConversation_background }
|
||||
|
||||
private lazy var newDMButton: NewConversationButton = NewConversationButton(icon: #imageLiteral(resourceName: "Message"), title: "vc_create_private_chat_title".localized())
|
||||
private lazy var newGroupButton: NewConversationButton = NewConversationButton(icon: #imageLiteral(resourceName: "Group"), title: "vc_create_closed_group_title".localized())
|
||||
private lazy var joinCommunityButton: NewConversationButton = NewConversationButton(icon: #imageLiteral(resourceName: "Globe"), title: "vc_join_public_chat_title".localized(), shouldShowSeparator: false)
|
||||
|
||||
private lazy var buttonStackView: UIStackView = {
|
||||
let lineTop = UIView()
|
||||
lineTop.set(.height, to: 0.5)
|
||||
lineTop.backgroundColor = Colors.border.withAlphaComponent(0.3)
|
||||
let lineTop: UIView = UIView()
|
||||
lineTop.themeBackgroundColor = .borderSeparator
|
||||
lineTop.set(.height, to: Values.separatorThickness)
|
||||
|
||||
let lineBottom = UIView()
|
||||
lineBottom.set(.height, to: 0.5)
|
||||
lineBottom.backgroundColor = Colors.border.withAlphaComponent(0.3)
|
||||
lineBottom.themeBackgroundColor = .borderSeparator
|
||||
lineBottom.set(.height, to: Values.separatorThickness)
|
||||
|
||||
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
|
||||
tapGestureRecognizer.numberOfTapsRequired = 1
|
||||
|
@ -39,29 +41,32 @@ final class NewConversationVC: BaseVC, UITableViewDelegate, UITableViewDataSourc
|
|||
)
|
||||
result.axis = .vertical
|
||||
result.addGestureRecognizer(tapGestureRecognizer)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var buttonStackViewContainer = UIView(wrapping: buttonStackView, withInsets: .zero)
|
||||
|
||||
private lazy var contactsTitleLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
result.textColor = Colors.text
|
||||
result.text = "Contacts"
|
||||
let result: UILabel = UILabel()
|
||||
result.font = .systemFont(ofSize: Values.mediumFontSize)
|
||||
result.text = "Contacts"
|
||||
result.themeTextColor = .textPrimary
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var contactsTableView: UITableView = {
|
||||
let result = UITableView()
|
||||
let result: UITableView = UITableView()
|
||||
result.delegate = self
|
||||
result.dataSource = self
|
||||
result.separatorStyle = .none
|
||||
result.backgroundColor = Colors.navigationBarBackground
|
||||
result.themeBackgroundColor = .newConversation_background
|
||||
result.register(view: SessionCell.self)
|
||||
|
||||
if #available(iOS 15.0, *) {
|
||||
result.sectionHeaderTopPadding = 0
|
||||
}
|
||||
result.register(view: UserCell.self)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
@ -70,17 +75,19 @@ final class NewConversationVC: BaseVC, UITableViewDelegate, UITableViewDataSourc
|
|||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
setUpNavBarStyle()
|
||||
setNavBarTitle(NSLocalizedString("vc_new_conversation_title", comment: ""))
|
||||
|
||||
setNavBarTitle("vc_new_conversation_title".localized())
|
||||
view.themeBackgroundColor = .newConversation_background
|
||||
|
||||
// Set up navigation bar buttons
|
||||
let closeButton = UIBarButtonItem(image: #imageLiteral(resourceName: "X"), style: .plain, target: self, action: #selector(close))
|
||||
closeButton.tintColor = Colors.text
|
||||
closeButton.themeTintColor = .textPrimary
|
||||
navigationItem.leftBarButtonItem = closeButton
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
buttonStackViewContainer.backgroundColor = Colors.cellBackground
|
||||
buttonStackViewContainer.themeBackgroundColor = .newConversation_background
|
||||
|
||||
let headerView = UIView(
|
||||
frame: CGRect(
|
||||
|
@ -112,29 +119,38 @@ final class NewConversationVC: BaseVC, UITableViewDelegate, UITableViewDataSourc
|
|||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell: UserCell = tableView.dequeue(type: UserCell.self, for: indexPath)
|
||||
let cell: SessionCell = tableView.dequeue(type: SessionCell.self, for: indexPath)
|
||||
let profile = newConversationViewModel.sectionData[indexPath.section].contacts[indexPath.row]
|
||||
cell.backgroundColor = Colors.sessionNewConversationCellBackground
|
||||
cell.update(
|
||||
with: profile.id,
|
||||
profile: profile,
|
||||
isZombie: false,
|
||||
accessory: .none
|
||||
with: SessionCell.Info(
|
||||
id: profile,
|
||||
leftAccessory: .profile(profile.id, profile),
|
||||
title: profile.displayName()
|
||||
),
|
||||
style: .edgeToEdge,
|
||||
position: Position.with(
|
||||
indexPath.row,
|
||||
count: newConversationViewModel.sectionData[indexPath.section].contacts.count
|
||||
)
|
||||
)
|
||||
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
|
||||
let label = UILabel()
|
||||
label.textColor = Colors.sessionMessageRequestsInfoText
|
||||
let label: UILabel = UILabel()
|
||||
label.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
label.text = newConversationViewModel.sectionData[section].sectionName
|
||||
let headerView = UIView()
|
||||
headerView.backgroundColor = self.view.backgroundColor
|
||||
label.themeTextColor = .textPrimary
|
||||
|
||||
let headerView: UIView = UIView()
|
||||
headerView.themeBackgroundColor = .newConversation_background
|
||||
headerView.addSubview(label)
|
||||
|
||||
label.pin(.left, to: .left, of: headerView, withInset: Values.mediumSpacing)
|
||||
label.pin(.top, to: .top, of: headerView, withInset: Values.verySmallSpacing)
|
||||
label.pin(.bottom, to: .bottom, of: headerView, withInset: -Values.verySmallSpacing)
|
||||
|
||||
return headerView
|
||||
}
|
||||
|
||||
|
@ -142,6 +158,7 @@ final class NewConversationVC: BaseVC, UITableViewDelegate, UITableViewDataSourc
|
|||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
|
||||
let sessionId = newConversationViewModel.sectionData[indexPath.section].contacts[indexPath.row].id
|
||||
let maybeThread: SessionThread? = Storage.shared.write { db in
|
||||
try SessionThread.fetchOrCreate(db, id: sessionId, variant: .contact)
|
||||
|
@ -155,13 +172,14 @@ final class NewConversationVC: BaseVC, UITableViewDelegate, UITableViewDataSourc
|
|||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) {
|
||||
view.backgroundColor = Colors.navigationBarBackground
|
||||
view.themeBackgroundColor = .newConversation_background
|
||||
}
|
||||
|
||||
// MARK: - Interaction
|
||||
|
||||
@objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) {
|
||||
let location = gestureRecognizer.location(in: self.view)
|
||||
|
||||
if newDMButton.frame.contains(location) {
|
||||
createNewDM()
|
||||
}
|
||||
|
@ -193,22 +211,33 @@ final class NewConversationVC: BaseVC, UITableViewDelegate, UITableViewDataSourc
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: NewConversationButton
|
||||
// MARK: - NewConversationButton
|
||||
|
||||
private final class NewConversationButton: UIView {
|
||||
private let icon: UIImage
|
||||
private let title: String
|
||||
private let shouldShowSeparator: Bool
|
||||
private var didTouchDownInside: Bool = false
|
||||
|
||||
public static let height: CGFloat = 56
|
||||
private static let iconSize: CGFloat = 38
|
||||
|
||||
private let selectedBackgroundView: UIView = {
|
||||
let result: UIView = UIView()
|
||||
result.themeBackgroundColor = .settings_tabHighlight
|
||||
result.isHidden = true
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
init(icon: UIImage, title: String, shouldShowSeparator: Bool = true) {
|
||||
self.icon = icon.withRenderingMode(.alwaysTemplate)
|
||||
self.title = title
|
||||
self.shouldShowSeparator = shouldShowSeparator
|
||||
|
||||
super.init(frame: .zero)
|
||||
self.backgroundColor = Colors.sessionNewConversationCellBackground
|
||||
|
||||
self.themeBackgroundColor = .settings_tabBackground
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
|
@ -221,15 +250,18 @@ private final class NewConversationButton: UIView {
|
|||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
addSubview(selectedBackgroundView)
|
||||
selectedBackgroundView.pin(to: self)
|
||||
|
||||
let iconImageView = UIImageView(image: self.icon)
|
||||
iconImageView.contentMode = .center
|
||||
iconImageView.tintColor = Colors.text
|
||||
iconImageView.set(.width, to: Self.iconSize)
|
||||
iconImageView.themeTintColor = .textPrimary
|
||||
iconImageView.set(.width, to: NewConversationButton.iconSize)
|
||||
|
||||
let titleLable = UILabel()
|
||||
titleLable.text = self.title
|
||||
titleLable.textColor = Colors.text
|
||||
let titleLable: UILabel = UILabel()
|
||||
titleLable.font = .systemFont(ofSize: Values.mediumFontSize)
|
||||
titleLable.text = self.title
|
||||
titleLable.themeTextColor = .textPrimary
|
||||
|
||||
let stackView = UIStackView(
|
||||
arrangedSubviews: [
|
||||
|
@ -246,15 +278,58 @@ private final class NewConversationButton: UIView {
|
|||
addSubview(stackView)
|
||||
stackView.pin(to: self)
|
||||
stackView.set(.width, to: UIScreen.main.bounds.width)
|
||||
stackView.set(.height, to: Self.height)
|
||||
stackView.set(.height, to: NewConversationButton.height)
|
||||
|
||||
let line = UIView()
|
||||
line.set(.height, to: 0.5)
|
||||
line.backgroundColor = Colors.border.withAlphaComponent(0.3)
|
||||
let line: UIView = UIView()
|
||||
line.themeBackgroundColor = .borderSeparator
|
||||
addSubview(line)
|
||||
line.pin([ UIView.VerticalEdge.bottom, UIView.HorizontalEdge.trailing ], to: self)
|
||||
line.pin(.leading, to: .leading, of: self, withInset: (Self.iconSize + 2 * Values.mediumSpacing))
|
||||
|
||||
line.pin([UIView.VerticalEdge.bottom, UIView.HorizontalEdge.trailing], to: self)
|
||||
line.pin(
|
||||
.leading,
|
||||
to: .leading,
|
||||
of: self,
|
||||
withInset: (NewConversationButton.iconSize + 2 * Values.mediumSpacing)
|
||||
)
|
||||
line.set(.height, to: Values.separatorThickness)
|
||||
|
||||
line.isHidden = !shouldShowSeparator
|
||||
}
|
||||
|
||||
// MARK: - Interaction
|
||||
|
||||
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
guard
|
||||
isUserInteractionEnabled,
|
||||
let location: CGPoint = touches.first?.location(in: self),
|
||||
bounds.contains(location)
|
||||
else { return }
|
||||
|
||||
didTouchDownInside = true
|
||||
selectedBackgroundView.isHidden = false
|
||||
}
|
||||
|
||||
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
guard
|
||||
isUserInteractionEnabled,
|
||||
let location: CGPoint = touches.first?.location(in: self),
|
||||
bounds.contains(location),
|
||||
didTouchDownInside
|
||||
else {
|
||||
selectedBackgroundView.isHidden = true
|
||||
return
|
||||
}
|
||||
|
||||
selectedBackgroundView.isHidden = false
|
||||
}
|
||||
|
||||
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
selectedBackgroundView.isHidden = true
|
||||
didTouchDownInside = false
|
||||
}
|
||||
|
||||
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
selectedBackgroundView.isHidden = true
|
||||
didTouchDownInside = false
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,59 +1,68 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import AVFoundation
|
||||
import GRDB
|
||||
import Curve25519Kit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
final class NewDMVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControllerDelegate, OWSQRScannerDelegate {
|
||||
final class NewDMVC: BaseVC, UIPageViewControllerDataSource, UIPageViewControllerDelegate, QRScannerDelegate {
|
||||
private var shouldShowBackButton: Bool = true
|
||||
private let pageVC = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil)
|
||||
private var pages: [UIViewController] = []
|
||||
private var targetVCIndex: Int?
|
||||
|
||||
// MARK: Components
|
||||
// MARK: - Components
|
||||
|
||||
private lazy var tabBar: TabBar = {
|
||||
let tabs = [
|
||||
TabBar.Tab(title: NSLocalizedString("vc_create_private_chat_enter_session_id_tab_title", comment: "")) { [weak self] in
|
||||
TabBar.Tab(title: "vc_create_private_chat_enter_session_id_tab_title".localized()) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.pageVC.setViewControllers([ self.pages[0] ], direction: .forward, animated: false, completion: nil)
|
||||
},
|
||||
TabBar.Tab(title: NSLocalizedString("vc_create_private_chat_scan_qr_code_tab_title", comment: "")) { [weak self] in
|
||||
TabBar.Tab(title: "vc_create_private_chat_scan_qr_code_tab_title".localized()) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.pageVC.setViewControllers([ self.pages[1] ], direction: .forward, animated: false, completion: nil)
|
||||
}
|
||||
]
|
||||
|
||||
return TabBar(tabs: tabs)
|
||||
}()
|
||||
|
||||
private lazy var enterPublicKeyVC: EnterPublicKeyVC = {
|
||||
let result = EnterPublicKeyVC()
|
||||
result.NewDMVC = self
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var scanQRCodePlaceholderVC: ScanQRCodePlaceholderVC = {
|
||||
let result = ScanQRCodePlaceholderVC()
|
||||
result.NewDMVC = self
|
||||
let result: ScanQRCodePlaceholderVC = ScanQRCodePlaceholderVC()
|
||||
result.newDMVC = self
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var scanQRCodeWrapperVC: ScanQRCodeWrapperVC = {
|
||||
let result = ScanQRCodeWrapperVC(message: nil)
|
||||
let result: ScanQRCodeWrapperVC = ScanQRCodeWrapperVC(message: nil)
|
||||
result.delegate = self
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
init(shouldShowBackButton: Bool) {
|
||||
self.shouldShowBackButton = shouldShowBackButton
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
// MARK: - Initialization
|
||||
|
||||
init(sessionID: String, shouldShowBackButton: Bool = true) {
|
||||
init(sessionId: String? = nil, shouldShowBackButton: Bool = true) {
|
||||
self.shouldShowBackButton = shouldShowBackButton
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
enterPublicKeyVC.setSessionID(to: sessionID)
|
||||
|
||||
if let sessionId: String = sessionId {
|
||||
enterPublicKeyVC.setSessionId(to: sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
|
@ -64,42 +73,49 @@ final class NewDMVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControll
|
|||
super.init(nibName: nibName, bundle: bundle)
|
||||
}
|
||||
|
||||
// MARK: Lifecycle
|
||||
// MARK: - Lifecycle
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
setUpGradientBackground()
|
||||
setUpNavBarStyle()
|
||||
setNavBarTitle(NSLocalizedString("vc_create_private_chat_title", comment: ""))
|
||||
let navigationBar = navigationController!.navigationBar
|
||||
|
||||
setNavBarTitle("vc_create_private_chat_title".localized())
|
||||
view.themeBackgroundColor = .newConversation_background
|
||||
|
||||
// Set up navigation bar buttons
|
||||
let closeButton = UIBarButtonItem(image: #imageLiteral(resourceName: "X"), style: .plain, target: self, action: #selector(close))
|
||||
closeButton.tintColor = Colors.text
|
||||
closeButton.themeTintColor = .textPrimary
|
||||
|
||||
if shouldShowBackButton {
|
||||
navigationItem.rightBarButtonItem = closeButton
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
navigationItem.leftBarButtonItem = closeButton
|
||||
}
|
||||
|
||||
// Set up tab bar
|
||||
view.addSubview(tabBar)
|
||||
tabBar.pin(.top, to: .top, of: view)
|
||||
tabBar.pin(.leading, to: .leading, of: view)
|
||||
tabBar.pin(.trailing, to: .trailing, of: view)
|
||||
|
||||
// Set up page VC
|
||||
let containerView: UIView = UIView()
|
||||
view.addSubview(containerView)
|
||||
containerView.pin(.top, to: .bottom, of: tabBar)
|
||||
containerView.pin(.leading, to: .leading, of: view)
|
||||
containerView.pin(.trailing, to: .trailing, of: view)
|
||||
containerView.pin(.bottom, to: .bottom, of: view)
|
||||
|
||||
let hasCameraAccess = (AVCaptureDevice.authorizationStatus(for: .video) == .authorized)
|
||||
pages = [ enterPublicKeyVC, (hasCameraAccess ? scanQRCodeWrapperVC : scanQRCodePlaceholderVC) ]
|
||||
pageVC.dataSource = self
|
||||
pageVC.delegate = self
|
||||
pageVC.setViewControllers([ enterPublicKeyVC ], direction: .forward, animated: false, completion: nil)
|
||||
// Set up tab bar
|
||||
view.addSubview(tabBar)
|
||||
tabBar.pin(.leading, to: .leading, of: view)
|
||||
tabBar.pin(.top, to: .top, of: view)
|
||||
tabBar.pin(.trailing, to: .trailing, of: view)
|
||||
// Set up page VC constraints
|
||||
let pageVCView = pageVC.view!
|
||||
view.addSubview(pageVCView)
|
||||
pageVCView.pin(.leading, to: .leading, of: view)
|
||||
pageVCView.pin(.top, to: .bottom, of: tabBar)
|
||||
pageVCView.pin(.trailing, to: .trailing, of: view)
|
||||
pageVCView.pin(.bottom, to: .bottom, of: view)
|
||||
let height: CGFloat = (navigationController!.view.bounds.height - navigationBar.height() - TabBar.snHeight)
|
||||
enterPublicKeyVC.constrainHeight(to: height)
|
||||
scanQRCodePlaceholderVC.constrainHeight(to: height)
|
||||
addChild(pageVC)
|
||||
containerView.addSubview(pageVC.view)
|
||||
|
||||
pageVC.view.pin(to: containerView)
|
||||
pageVC.didMove(toParent: self)
|
||||
}
|
||||
|
||||
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
|
||||
|
@ -107,8 +123,9 @@ final class NewDMVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControll
|
|||
enterPublicKeyVC.viewWidth?.constant = size.width
|
||||
scanQRCodePlaceholderVC.viewWidth?.constant = size.width
|
||||
}
|
||||
|
||||
// MARK: - General
|
||||
|
||||
// MARK: General
|
||||
func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
|
||||
guard let index = pages.firstIndex(of: viewController), index != 0 else { return nil }
|
||||
return pages[index - 1]
|
||||
|
@ -124,7 +141,8 @@ final class NewDMVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControll
|
|||
pageVC.setViewControllers([ scanQRCodeWrapperVC ], direction: .forward, animated: false, completion: nil)
|
||||
}
|
||||
|
||||
// MARK: Updating
|
||||
// MARK: - Updating
|
||||
|
||||
func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) {
|
||||
guard let targetVC = pendingViewControllers.first, let index = pages.firstIndex(of: targetVC) else { return }
|
||||
targetVCIndex = index
|
||||
|
@ -135,12 +153,13 @@ final class NewDMVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControll
|
|||
tabBar.selectTab(at: index)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
// MARK: - Interaction
|
||||
|
||||
@objc private func close() {
|
||||
dismiss(animated: true, completion: nil)
|
||||
}
|
||||
|
||||
func controller(_ controller: OWSQRCodeScanningViewController, didDetectQRCodeWith string: String) {
|
||||
func controller(_ controller: QRCodeScanningViewController, didDetectQRCodeWith string: String) {
|
||||
let hexEncodedPublicKey = string
|
||||
startNewDMIfPossible(with: hexEncodedPublicKey)
|
||||
}
|
||||
|
@ -154,36 +173,48 @@ final class NewDMVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControll
|
|||
}
|
||||
|
||||
// This could be an ONS name
|
||||
ModalActivityIndicatorViewController.present(fromViewController: navigationController!, canCancel: false) { [weak self] modalActivityIndicator in
|
||||
SnodeAPI.getSessionID(for: onsNameOrPublicKey).done { sessionID in
|
||||
modalActivityIndicator.dismiss {
|
||||
self?.startNewDM(with: sessionID)
|
||||
}
|
||||
}.catch { error in
|
||||
modalActivityIndicator.dismiss {
|
||||
var messageOrNil: String?
|
||||
if let error = error as? SnodeAPIError {
|
||||
switch error {
|
||||
case .decryptionFailed, .hashingFailed, .validationFailed:
|
||||
messageOrNil = error.errorDescription
|
||||
default: break
|
||||
ModalActivityIndicatorViewController
|
||||
.present(fromViewController: navigationController!, canCancel: false) { [weak self] modalActivityIndicator in
|
||||
SnodeAPI
|
||||
.getSessionID(for: onsNameOrPublicKey)
|
||||
.done { sessionID in
|
||||
modalActivityIndicator.dismiss {
|
||||
self?.startNewDM(with: sessionID)
|
||||
}
|
||||
}
|
||||
let message: String = {
|
||||
if let messageOrNil: String = messageOrNil {
|
||||
return messageOrNil
|
||||
.catch { error in
|
||||
modalActivityIndicator.dismiss {
|
||||
var messageOrNil: String?
|
||||
if let error = error as? SnodeAPIError {
|
||||
switch error {
|
||||
case .decryptionFailed, .hashingFailed, .validationFailed:
|
||||
messageOrNil = error.errorDescription
|
||||
default: break
|
||||
}
|
||||
}
|
||||
let message: String = {
|
||||
if let messageOrNil: String = messageOrNil {
|
||||
return messageOrNil
|
||||
}
|
||||
|
||||
return (maybeSessionId?.prefix == .blinded ?
|
||||
"You can only send messages to Blinded IDs from within a Community" :
|
||||
"Please check the Session ID or ONS name and try again"
|
||||
)
|
||||
}()
|
||||
|
||||
let modal: ConfirmationModal = ConfirmationModal(
|
||||
targetView: self?.view,
|
||||
info: ConfirmationModal.Info(
|
||||
title: "Error",
|
||||
explanation: message,
|
||||
cancelTitle: "BUTTON_OK".localized(),
|
||||
cancelStyle: .alert_text
|
||||
)
|
||||
)
|
||||
self?.present(modal, animated: true)
|
||||
}
|
||||
|
||||
return (maybeSessionId?.prefix == .blinded ?
|
||||
"You can only send messages to Blinded IDs from within an Open Group" :
|
||||
"Please check the Session ID or ONS name and try again"
|
||||
)
|
||||
}()
|
||||
let alert = UIAlertController(title: "Error", message: message, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "BUTTON_OK".localized(), style: .default, handler: nil))
|
||||
self?.presentAlert(alert)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -200,34 +231,37 @@ final class NewDMVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControll
|
|||
}
|
||||
}
|
||||
|
||||
private final class EnterPublicKeyVC : UIViewController {
|
||||
// MARK: - EnterPublicKeyVC
|
||||
|
||||
private final class EnterPublicKeyVC: UIViewController {
|
||||
weak var NewDMVC: NewDMVC!
|
||||
private var isKeyboardShowing = false
|
||||
private var simulatorWillResignFirstResponder = false
|
||||
private var bottomConstraint: NSLayoutConstraint!
|
||||
private let bottomMargin: CGFloat = UIDevice.current.isIPad ? Values.largeSpacing : 0
|
||||
|
||||
// MARK: Components
|
||||
// MARK: - Components
|
||||
|
||||
private lazy var publicKeyTextView: TextView = {
|
||||
let result = TextView(placeholder: NSLocalizedString("vc_enter_public_key_text_field_hint", comment: ""))
|
||||
let result = TextView(placeholder: "vc_enter_public_key_text_field_hint".localized()) { [weak self] text in
|
||||
self?.nextButton.isEnabled = (SessionId(from: text) != nil)
|
||||
}
|
||||
result.autocapitalizationType = .none
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var copyButton: Button = {
|
||||
let result = Button(style: .prominentOutline, size: .medium)
|
||||
result.setTitle(NSLocalizedString("copy", comment: ""), for: UIControl.State.normal)
|
||||
result.addTarget(self, action: #selector(copyPublicKey), for: UIControl.Event.touchUpInside)
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var userPublicKeyLabel: SRCopyableLabel = {
|
||||
let result = SRCopyableLabel()
|
||||
result.textColor = Colors.text
|
||||
result.font = Fonts.spaceMono(ofSize: Values.mediumFontSize)
|
||||
result.numberOfLines = 0
|
||||
private lazy var explanationLabel: UILabel = {
|
||||
let result: UILabel = UILabel()
|
||||
result.setContentHuggingPriority(.required, for: .vertical)
|
||||
result.setContentCompressionResistancePriority(.required, for: .vertical)
|
||||
result.font = .systemFont(ofSize: Values.verySmallFontSize)
|
||||
result.text = "vc_enter_public_key_explanation".localized()
|
||||
result.themeTextColor = .textSecondary
|
||||
result.textAlignment = .center
|
||||
result.lineBreakMode = .byCharWrapping
|
||||
result.text = getUserHexEncodedPublicKey()
|
||||
result.lineBreakMode = .byWordWrapping
|
||||
result.numberOfLines = 0
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
|
@ -236,31 +270,112 @@ private final class EnterPublicKeyVC : UIViewController {
|
|||
private lazy var spacer3 = UIView.spacer(withHeight: Values.largeSpacing)
|
||||
private lazy var spacer4 = UIView.spacer(withHeight: Values.largeSpacing)
|
||||
|
||||
private lazy var separator = Separator(title: NSLocalizedString("your_session_id", comment: ""))
|
||||
private lazy var separator = Separator(title: "your_session_id".localized())
|
||||
|
||||
private lazy var qrCodeView: UIView = {
|
||||
let result: UIView = UIView()
|
||||
result.layer.cornerRadius = 8
|
||||
|
||||
let qrCodeImageView: UIImageView = UIImageView()
|
||||
qrCodeImageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
||||
qrCodeImageView.image = QRCode.generate(for: getUserHexEncodedPublicKey(), hasBackground: false)
|
||||
.withRenderingMode(.alwaysTemplate)
|
||||
qrCodeImageView.set(.width, to: .height, of: qrCodeImageView)
|
||||
qrCodeImageView.heightAnchor
|
||||
.constraint(lessThanOrEqualToConstant: (isIPhone5OrSmaller ? 160 : 220))
|
||||
.isActive = true
|
||||
|
||||
#if targetEnvironment(simulator)
|
||||
#else
|
||||
// Note: For some reason setting this seems to stop the QRCode from rendering on the
|
||||
// simulator so only doing it on device
|
||||
qrCodeImageView.contentMode = .scaleAspectFit
|
||||
#endif
|
||||
|
||||
result.addSubview(qrCodeImageView)
|
||||
qrCodeImageView.pin(
|
||||
to: result,
|
||||
withInset: 5 // The QRCode image has about 6pt of padding and we want 11 in total
|
||||
)
|
||||
|
||||
ThemeManager.onThemeChange(observer: qrCodeImageView) { [weak qrCodeImageView, weak result] theme, _ in
|
||||
switch theme.interfaceStyle {
|
||||
case .light:
|
||||
qrCodeImageView?.themeTintColorForced = .theme(theme, color: .textPrimary)
|
||||
result?.themeBackgroundColorForced = nil
|
||||
|
||||
default:
|
||||
qrCodeImageView?.themeTintColorForced = .theme(theme, color: .backgroundPrimary)
|
||||
result?.themeBackgroundColorForced = .color(.white)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var qrCodeImageViewContainer: UIView = {
|
||||
let result = UIView()
|
||||
let result: UIView = UIView()
|
||||
result.accessibilityLabel = "Your QR code"
|
||||
result.isAccessibilityElement = true
|
||||
result.addSubview(qrCodeView)
|
||||
qrCodeView.center(.horizontal, in: result)
|
||||
qrCodeView.pin(.top, to: .top, of: result)
|
||||
qrCodeView.pin(.bottom, to: .bottom, of: result)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var userPublicKeyLabel: SRCopyableLabel = {
|
||||
let result: SRCopyableLabel = SRCopyableLabel()
|
||||
result.setContentCompressionResistancePriority(.required, for: .vertical)
|
||||
result.font = Fonts.spaceMono(ofSize: Values.mediumFontSize)
|
||||
result.text = getUserHexEncodedPublicKey()
|
||||
result.themeTextColor = .textPrimary
|
||||
result.textAlignment = .center
|
||||
result.lineBreakMode = .byCharWrapping
|
||||
result.numberOfLines = 0
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var userPublicKeyContainer: UIView = {
|
||||
let result: UIView = UIView(
|
||||
wrapping: userPublicKeyLabel,
|
||||
withInsets: .zero,
|
||||
shouldAdaptForIPadWithWidth: Values.iPadUserSessionIdContainerWidth
|
||||
)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var buttonContainer: UIStackView = {
|
||||
let result = UIStackView()
|
||||
let result = UIStackView(arrangedSubviews: [ copyButton, shareButton ])
|
||||
result.axis = .horizontal
|
||||
result.spacing = UIDevice.current.isIPad ? Values.iPadButtonSpacing : Values.mediumSpacing
|
||||
result.distribution = .fillEqually
|
||||
|
||||
if (UIDevice.current.isIPad) {
|
||||
result.layoutMargins = UIEdgeInsets(top: 0, left: Values.iPadButtonContainerMargin, bottom: 0, right: Values.iPadButtonContainerMargin)
|
||||
result.isLayoutMarginsRelativeArrangement = true
|
||||
}
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var nextButton: Button = {
|
||||
let result = Button(style: .prominentOutline, size: .large)
|
||||
result.setTitle(NSLocalizedString("next", comment: ""), for: UIControl.State.normal)
|
||||
result.addTarget(self, action: #selector(startNewDMIfPossible), for: UIControl.Event.touchUpInside)
|
||||
private lazy var copyButton: SessionButton = {
|
||||
let result = SessionButton(style: .bordered, size: .small)
|
||||
result.setTitle("copy".localized(), for: .normal)
|
||||
result.addTarget(self, action: #selector(copyPublicKey), for: .touchUpInside)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var shareButton: SessionButton = {
|
||||
let result = SessionButton(style: .bordered, size: .small)
|
||||
result.setTitle("share".localized(), for: .normal)
|
||||
result.addTarget(self, action: #selector(sharePublicKey), for: .touchUpInside)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
|
@ -270,52 +385,29 @@ private final class EnterPublicKeyVC : UIViewController {
|
|||
withInsets: UIEdgeInsets(top: 0, leading: 80, bottom: 0, trailing: 80),
|
||||
shouldAdaptForIPadWithWidth: Values.iPadButtonWidth
|
||||
)
|
||||
result.alpha = isKeyboardShowing ? 1 : 0
|
||||
result.alpha = (isKeyboardShowing ? 1 : 0)
|
||||
result.isHidden = !isKeyboardShowing
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var nextButton: SessionButton = {
|
||||
let result = SessionButton(style: .bordered, size: .large)
|
||||
result.setTitle("next".localized(), for: .normal)
|
||||
result.isEnabled = false
|
||||
result.addTarget(self, action: #selector(startNewDMIfPossible), for: .touchUpInside)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
var viewWidth: NSLayoutConstraint?
|
||||
|
||||
// MARK: Lifecycle
|
||||
// MARK: - Lifecycle
|
||||
|
||||
override func viewDidLoad() {
|
||||
// Remove background color
|
||||
view.backgroundColor = .clear
|
||||
// User session id container
|
||||
let userPublicKeyContainer = UIView(
|
||||
wrapping: userPublicKeyLabel,
|
||||
withInsets: .zero,
|
||||
shouldAdaptForIPadWithWidth: Values.iPadUserSessionIdContainerWidth
|
||||
)
|
||||
// Explanation label
|
||||
let explanationLabel = UILabel()
|
||||
explanationLabel.textColor = Colors.text.withAlphaComponent(Values.mediumOpacity)
|
||||
explanationLabel.font = .systemFont(ofSize: Values.verySmallFontSize)
|
||||
explanationLabel.text = NSLocalizedString("vc_enter_public_key_explanation", comment: "")
|
||||
explanationLabel.numberOfLines = 0
|
||||
explanationLabel.textAlignment = .center
|
||||
explanationLabel.lineBreakMode = .byWordWrapping
|
||||
// Set up QR code image view
|
||||
let qrCodeImageView = UIImageView()
|
||||
let qrCode = QRCode.generate(for: getUserHexEncodedPublicKey(), hasBackground: true)
|
||||
qrCodeImageView.image = qrCode
|
||||
qrCodeImageView.contentMode = .scaleAspectFit
|
||||
qrCodeImageView.set(.height, to: isIPhone5OrSmaller ? 160 : 220)
|
||||
qrCodeImageView.set(.width, to: isIPhone5OrSmaller ? 160 : 220)
|
||||
qrCodeImageView.layer.cornerRadius = 8
|
||||
qrCodeImageView.layer.masksToBounds = true
|
||||
// Set up QR code image view container
|
||||
qrCodeImageViewContainer.addSubview(qrCodeImageView)
|
||||
qrCodeImageView.center(.horizontal, in: qrCodeImageViewContainer)
|
||||
qrCodeImageView.pin(.top, to: .top, of: qrCodeImageViewContainer)
|
||||
qrCodeImageView.pin(.bottom, to: .bottom, of: qrCodeImageViewContainer)
|
||||
// Share button
|
||||
let shareButton = Button(style: .prominentOutline, size: .medium)
|
||||
shareButton.setTitle(NSLocalizedString("share", comment: ""), for: UIControl.State.normal)
|
||||
shareButton.addTarget(self, action: #selector(sharePublicKey), for: UIControl.Event.touchUpInside)
|
||||
// Button container
|
||||
buttonContainer.addArrangedSubview(copyButton)
|
||||
buttonContainer.addArrangedSubview(shareButton)
|
||||
view.themeBackgroundColor = .clear
|
||||
|
||||
// Main stack view
|
||||
let mainStackView = UIStackView(arrangedSubviews: [
|
||||
publicKeyTextView,
|
||||
|
@ -334,16 +426,24 @@ private final class EnterPublicKeyVC : UIViewController {
|
|||
])
|
||||
mainStackView.axis = .vertical
|
||||
mainStackView.alignment = .fill
|
||||
mainStackView.layoutMargins = UIEdgeInsets(top: Values.largeSpacing, left: Values.largeSpacing, bottom: Values.largeSpacing, right: Values.largeSpacing)
|
||||
mainStackView.layoutMargins = UIEdgeInsets(
|
||||
top: Values.largeSpacing,
|
||||
left: Values.largeSpacing,
|
||||
bottom: Values.smallSpacing,
|
||||
right: Values.largeSpacing
|
||||
)
|
||||
mainStackView.isLayoutMarginsRelativeArrangement = true
|
||||
view.addSubview(mainStackView)
|
||||
|
||||
mainStackView.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing, UIView.VerticalEdge.top ], to: view)
|
||||
bottomConstraint = view.pin(.bottom, to: .bottom, of: mainStackView, withInset: bottomMargin)
|
||||
// Width constraint
|
||||
viewWidth = view.set(.width, to: UIScreen.main.bounds.width)
|
||||
|
||||
// Dismiss keyboard on tap
|
||||
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard))
|
||||
view.addGestureRecognizer(tapGestureRecognizer)
|
||||
|
||||
// Listen to keyboard notifications
|
||||
let notificationCenter = NotificationCenter.default
|
||||
notificationCenter.addObserver(self, selector: #selector(handleKeyboardWillChangeFrameNotification(_:)), name: UIResponder.keyboardWillChangeFrameNotification, object: nil)
|
||||
|
@ -354,76 +454,153 @@ private final class EnterPublicKeyVC : UIViewController {
|
|||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
// MARK: General
|
||||
func setSessionID(to sessionID: String){
|
||||
publicKeyTextView.insertText(sessionID)
|
||||
}
|
||||
|
||||
func constrainHeight(to height: CGFloat) {
|
||||
view.set(.height, to: height)
|
||||
// MARK: - General
|
||||
|
||||
func setSessionId(to sessionId: String) {
|
||||
publicKeyTextView.insertText(sessionId)
|
||||
}
|
||||
|
||||
@objc private func dismissKeyboard() {
|
||||
simulatorWillResignFirstResponder = true
|
||||
publicKeyTextView.resignFirstResponder()
|
||||
simulatorWillResignFirstResponder = false
|
||||
}
|
||||
|
||||
@objc private func enableCopyButton() {
|
||||
copyButton.isUserInteractionEnabled = true
|
||||
|
||||
UIView.transition(with: copyButton, duration: 0.25, options: .transitionCrossDissolve, animations: {
|
||||
self.copyButton.setTitle(NSLocalizedString("copy", comment: ""), for: UIControl.State.normal)
|
||||
self.copyButton.setTitle("copy".localized(), for: .normal)
|
||||
}, completion: nil)
|
||||
}
|
||||
|
||||
// MARK: Updating
|
||||
// MARK: - Updating
|
||||
|
||||
@objc private func handleKeyboardWillChangeFrameNotification(_ notification: Notification) {
|
||||
#if targetEnvironment(simulator)
|
||||
// Note: See 'handleKeyboardWillHideNotification' for the explanation
|
||||
guard !simulatorWillResignFirstResponder else { return }
|
||||
#else
|
||||
guard !isKeyboardShowing else { return }
|
||||
#endif
|
||||
|
||||
isKeyboardShowing = true
|
||||
|
||||
guard let newHeight = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue.size.height else { return }
|
||||
bottomConstraint.constant = newHeight + bottomMargin
|
||||
UIView.animate(withDuration: 0.25) {
|
||||
self.nextButtonContainer.alpha = 1
|
||||
self.nextButtonContainer.isHidden = false
|
||||
[ self.spacer1, self.separator, self.spacer2, self.qrCodeImageViewContainer, self.spacer3, self.userPublicKeyLabel, self.spacer4, self.buttonContainer ].forEach {
|
||||
$0.alpha = 0
|
||||
$0.isHidden = true
|
||||
}
|
||||
self.view.layoutIfNeeded()
|
||||
|
||||
let duration = max(0.25, ((notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval) ?? 0))
|
||||
let viewsToHide: [UIView] = [ self.spacer1, self.separator, self.spacer2, self.qrCodeImageViewContainer, self.spacer3, self.userPublicKeyContainer, self.spacer4, self.buttonContainer ]
|
||||
|
||||
// We dispatch to the next run loop to prevent the animation getting stuck within the
|
||||
// keyboard appearance animation (which would make the second animation start once the
|
||||
// keyboard finishes appearing)
|
||||
DispatchQueue.main.async {
|
||||
UIView.animate(
|
||||
withDuration: (duration / 2),
|
||||
delay: 0,
|
||||
options: .curveEaseOut,
|
||||
animations: {
|
||||
viewsToHide.forEach { $0.alpha = 0 }
|
||||
},
|
||||
completion: { [weak self] _ in
|
||||
UIView.performWithoutAnimation {
|
||||
viewsToHide.forEach { $0.isHidden = true }
|
||||
|
||||
self?.nextButtonContainer.alpha = 0
|
||||
self?.nextButtonContainer.isHidden = false
|
||||
self?.bottomConstraint.constant = -(newHeight + (self?.bottomMargin ?? 0))
|
||||
self?.view.layoutIfNeeded()
|
||||
}
|
||||
|
||||
UIView.animate(
|
||||
withDuration: (duration / 2),
|
||||
delay: 0,
|
||||
options: .curveEaseIn,
|
||||
animations: {
|
||||
self?.nextButtonContainer.alpha = 1
|
||||
},
|
||||
completion: nil
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handleKeyboardWillHideNotification(_ notification: Notification) {
|
||||
#if targetEnvironment(simulator)
|
||||
// Note: On the simulator the keyboard won't appear by default (unless you enable
|
||||
// it) this results in the "keyboard will hide" notification incorrectly getting
|
||||
// triggered immediately - the 'simulatorWillResignFirstResponder' value is a workaround
|
||||
// to make this behave more like a real device when testing
|
||||
guard isKeyboardShowing && simulatorWillResignFirstResponder else { return }
|
||||
#else
|
||||
guard isKeyboardShowing else { return }
|
||||
#endif
|
||||
|
||||
let duration = max(0.25, ((notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval) ?? 0))
|
||||
let viewsToShow: [UIView] = [ self.spacer1, self.separator, self.spacer2, self.qrCodeImageViewContainer, self.spacer3, self.userPublicKeyContainer, self.spacer4, self.buttonContainer ]
|
||||
isKeyboardShowing = false
|
||||
bottomConstraint.constant = bottomMargin
|
||||
UIView.animate(withDuration: 0.25) {
|
||||
self.nextButtonContainer.alpha = 0
|
||||
self.nextButtonContainer.isHidden = true
|
||||
[ self.spacer1, self.separator, self.spacer2, self.qrCodeImageViewContainer, self.spacer3, self.userPublicKeyLabel, self.spacer4, self.buttonContainer ].forEach {
|
||||
$0.alpha = 1
|
||||
$0.isHidden = false
|
||||
}
|
||||
self.view.layoutIfNeeded()
|
||||
|
||||
// We dispatch to the next run loop to prevent the animation getting stuck within the
|
||||
// keyboard hide animation (which would make the second animation start once the keyboard
|
||||
// finishes disappearing)
|
||||
DispatchQueue.main.async {
|
||||
UIView.animate(
|
||||
withDuration: (duration / 2),
|
||||
delay: 0,
|
||||
options: .curveEaseOut,
|
||||
animations: { [weak self] in
|
||||
self?.nextButtonContainer.alpha = 0
|
||||
},
|
||||
completion: { [weak self] _ in
|
||||
UIView.performWithoutAnimation {
|
||||
viewsToShow.forEach {
|
||||
$0.alpha = 0
|
||||
$0.isHidden = false
|
||||
}
|
||||
|
||||
self?.nextButtonContainer.isHidden = true
|
||||
self?.bottomConstraint.constant = -(self?.bottomMargin ?? 0)
|
||||
self?.view.layoutIfNeeded()
|
||||
}
|
||||
|
||||
UIView.animate(
|
||||
withDuration: (duration / 2),
|
||||
delay: 0,
|
||||
options: .curveEaseIn,
|
||||
animations: {
|
||||
viewsToShow.forEach { $0.alpha = 1 }
|
||||
},
|
||||
completion: nil
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
// MARK: - Interaction
|
||||
|
||||
@objc private func copyPublicKey() {
|
||||
UIPasteboard.general.string = getUserHexEncodedPublicKey()
|
||||
|
||||
copyButton.isUserInteractionEnabled = false
|
||||
|
||||
UIView.transition(with: copyButton, duration: 0.25, options: .transitionCrossDissolve, animations: {
|
||||
self.copyButton.setTitle(NSLocalizedString("copied", comment: ""), for: UIControl.State.normal)
|
||||
self.copyButton.setTitle("copied".localized(), for: .normal)
|
||||
}, completion: nil)
|
||||
Timer.scheduledTimer(timeInterval: 4, target: self, selector: #selector(enableCopyButton), userInfo: nil, repeats: false)
|
||||
}
|
||||
|
||||
@objc private func sharePublicKey() {
|
||||
let shareVC = UIActivityViewController(activityItems: [ getUserHexEncodedPublicKey() ], applicationActivities: nil)
|
||||
|
||||
if UIDevice.current.isIPad {
|
||||
shareVC.excludedActivityTypes = []
|
||||
shareVC.popoverPresentationController?.permittedArrowDirections = []
|
||||
shareVC.popoverPresentationController?.sourceView = self.view
|
||||
shareVC.popoverPresentationController?.sourceRect = self.view.bounds
|
||||
}
|
||||
|
||||
NewDMVC.navigationController!.present(shareVC, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
|
@ -433,53 +610,51 @@ private final class EnterPublicKeyVC : UIViewController {
|
|||
}
|
||||
}
|
||||
|
||||
private final class ScanQRCodePlaceholderVC : UIViewController {
|
||||
weak var NewDMVC: NewDMVC!
|
||||
// MARK: - ScanQRCodePlaceholderVC
|
||||
|
||||
private final class ScanQRCodePlaceholderVC: UIViewController {
|
||||
weak var newDMVC: NewDMVC!
|
||||
|
||||
var viewWidth: NSLayoutConstraint?
|
||||
|
||||
override func viewDidLoad() {
|
||||
// Remove background color
|
||||
view.backgroundColor = .clear
|
||||
view.themeBackgroundColor = .clear
|
||||
|
||||
// Set up explanation label
|
||||
let explanationLabel = UILabel()
|
||||
explanationLabel.textColor = Colors.text
|
||||
explanationLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
explanationLabel.text = NSLocalizedString("vc_scan_qr_code_camera_access_explanation", comment: "")
|
||||
explanationLabel.numberOfLines = 0
|
||||
explanationLabel.text = "vc_scan_qr_code_camera_access_explanation".localized()
|
||||
explanationLabel.themeTextColor = .textPrimary
|
||||
explanationLabel.textAlignment = .center
|
||||
explanationLabel.lineBreakMode = .byWordWrapping
|
||||
explanationLabel.numberOfLines = 0
|
||||
|
||||
// Set up call to action button
|
||||
let callToActionButton = UIButton()
|
||||
callToActionButton.titleLabel!.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
callToActionButton.setTitleColor(Colors.accent, for: UIControl.State.normal)
|
||||
callToActionButton.setTitle(NSLocalizedString("vc_scan_qr_code_grant_camera_access_button_title", comment: ""), for: UIControl.State.normal)
|
||||
callToActionButton.titleLabel?.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
callToActionButton.setTitle("vc_scan_qr_code_grant_camera_access_button_title".localized(), for: UIControl.State.normal)
|
||||
callToActionButton.setThemeTitleColor(.primary, for: .normal)
|
||||
callToActionButton.addTarget(self, action: #selector(requestCameraAccess), for: UIControl.Event.touchUpInside)
|
||||
|
||||
// Set up stack view
|
||||
let stackView = UIStackView(arrangedSubviews: [ explanationLabel, callToActionButton ])
|
||||
stackView.axis = .vertical
|
||||
stackView.spacing = Values.mediumSpacing
|
||||
stackView.alignment = .center
|
||||
|
||||
// Set up constraints
|
||||
viewWidth = view.set(.width, to: UIScreen.main.bounds.width)
|
||||
|
||||
view.addSubview(stackView)
|
||||
stackView.pin(.leading, to: .leading, of: view, withInset: Values.massiveSpacing)
|
||||
view.pin(.trailing, to: .trailing, of: stackView, withInset: Values.massiveSpacing)
|
||||
let verticalCenteringConstraint = stackView.center(.vertical, in: view)
|
||||
verticalCenteringConstraint.constant = -16 // Makes things appear centered visually
|
||||
}
|
||||
|
||||
func constrainHeight(to height: CGFloat) {
|
||||
view.set(.height, to: height)
|
||||
stackView.pin(.trailing, to: .trailing, of: view, withInset: -Values.massiveSpacing)
|
||||
stackView.center(.vertical, in: view, withInset: -16) // Makes things appear centered visually
|
||||
}
|
||||
|
||||
@objc private func requestCameraAccess() {
|
||||
ows_ask(forCameraPermissions: { [weak self] hasCameraAccess in
|
||||
if hasCameraAccess {
|
||||
self?.NewDMVC.handleCameraAccessGranted()
|
||||
} else {
|
||||
// Do nothing
|
||||
}
|
||||
})
|
||||
Permissions.requestCameraPermissionIfNeeded { [weak self] in
|
||||
self?.newDMVC.handleCameraAccessGranted()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,22 +12,27 @@ public class AllMediaViewController: UIViewController, UIPageViewControllerDataS
|
|||
private var pages: [UIViewController] = []
|
||||
private var targetVCIndex: Int?
|
||||
|
||||
// MARK: Components
|
||||
// MARK: - Components
|
||||
|
||||
private lazy var tabBar: TabBar = {
|
||||
let tabs = [
|
||||
TabBar.Tab(title: MediaStrings.media) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.pageVC.setViewControllers([ self.pages[0] ], direction: .forward, animated: false, completion: nil)
|
||||
self.updateSelectButton(updatedData: self.mediaTitleViewController.viewModel.galleryData, inBatchSelectMode: self.mediaTitleViewController.isInBatchSelectMode)
|
||||
},
|
||||
TabBar.Tab(title: MediaStrings.document) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.pageVC.setViewControllers([ self.pages[1] ], direction: .forward, animated: false, completion: nil)
|
||||
self.endSelectMode()
|
||||
self.navigationItem.rightBarButtonItem = nil
|
||||
}
|
||||
]
|
||||
return TabBar(tabs: tabs)
|
||||
let result: TabBar = TabBar(
|
||||
tabs: [
|
||||
TabBar.Tab(title: MediaStrings.media) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.pageVC.setViewControllers([ self.pages[0] ], direction: .forward, animated: false, completion: nil)
|
||||
self.updateSelectButton(updatedData: self.mediaTitleViewController.viewModel.galleryData, inBatchSelectMode: self.mediaTitleViewController.isInBatchSelectMode)
|
||||
},
|
||||
TabBar.Tab(title: MediaStrings.document) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.pageVC.setViewControllers([ self.pages[1] ], direction: .forward, animated: false, completion: nil)
|
||||
self.endSelectMode()
|
||||
self.navigationItem.rightBarButtonItem = nil
|
||||
}
|
||||
]
|
||||
)
|
||||
result.themeBackgroundColor = .backgroundPrimary
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private var mediaTitleViewController: MediaTileViewController
|
||||
|
@ -41,6 +46,7 @@ public class AllMediaViewController: UIViewController, UIPageViewControllerDataS
|
|||
|
||||
self.mediaTitleViewController.delegate = self
|
||||
self.documentTitleViewController.delegate = self
|
||||
|
||||
addChild(self.mediaTitleViewController)
|
||||
addChild(self.documentTitleViewController)
|
||||
}
|
||||
|
@ -53,9 +59,11 @@ public class AllMediaViewController: UIViewController, UIPageViewControllerDataS
|
|||
public override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
view.themeBackgroundColor = .newConversation_background
|
||||
|
||||
// Add a custom back button if this is the only view controller
|
||||
if self.navigationController?.viewControllers.first == self {
|
||||
let backButton = OWSViewController.createOWSBackButton(withTarget: self, selector: #selector(didPressDismissButton))
|
||||
let backButton = UIViewController.createOWSBackButton(target: self, selector: #selector(didPressDismissButton))
|
||||
self.navigationItem.leftBarButtonItem = backButton
|
||||
}
|
||||
|
||||
|
|
|
@ -1,99 +0,0 @@
|
|||
//
|
||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
@objc public class AudioProgressView: UIView {
|
||||
|
||||
@objc public override var bounds: CGRect {
|
||||
didSet {
|
||||
if oldValue != bounds {
|
||||
updateSubviews()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc public override var frame: CGRect {
|
||||
didSet {
|
||||
if oldValue != frame {
|
||||
updateSubviews()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc public var horizontalBarColor = UIColor.black {
|
||||
didSet {
|
||||
updateContent()
|
||||
}
|
||||
}
|
||||
|
||||
@objc public var progressColor = UIColor.blue {
|
||||
didSet {
|
||||
updateContent()
|
||||
}
|
||||
}
|
||||
|
||||
private let horizontalBarLayer: CAShapeLayer
|
||||
private let progressLayer: CAShapeLayer
|
||||
|
||||
@objc public var progress: CGFloat = 0 {
|
||||
didSet {
|
||||
if oldValue != progress {
|
||||
updateContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable, message:"use other constructor instead.")
|
||||
@objc public required init?(coder aDecoder: NSCoder) {
|
||||
notImplemented()
|
||||
}
|
||||
|
||||
public required init() {
|
||||
self.horizontalBarLayer = CAShapeLayer()
|
||||
self.progressLayer = CAShapeLayer()
|
||||
|
||||
super.init(frame: CGRect.zero)
|
||||
|
||||
self.layer.addSublayer(self.horizontalBarLayer)
|
||||
self.layer.addSublayer(self.progressLayer)
|
||||
}
|
||||
|
||||
internal func updateSubviews() {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
self.horizontalBarLayer.frame = self.bounds
|
||||
self.progressLayer.frame = self.bounds
|
||||
|
||||
updateContent()
|
||||
}
|
||||
|
||||
internal func updateContent() {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
// Prevent the shape layer from animating changes.
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
|
||||
let horizontalBarPath = UIBezierPath()
|
||||
let horizontalBarHeightFraction = CGFloat(0.25)
|
||||
let horizontalBarHeight = bounds.size.height * horizontalBarHeightFraction
|
||||
horizontalBarPath.append(UIBezierPath(rect: CGRect(x: 0, y: (bounds.size.height - horizontalBarHeight) * 0.5, width: bounds.size.width, height: horizontalBarHeight)))
|
||||
horizontalBarLayer.path = horizontalBarPath.cgPath
|
||||
horizontalBarLayer.fillColor = horizontalBarColor.cgColor
|
||||
|
||||
let progressHeight = bounds.self.height
|
||||
let progressWidth = progressHeight * 0.15
|
||||
let progressX = (bounds.self.width - progressWidth) * max(0.0, min(1.0, progress))
|
||||
let progressBounds = CGRect(x: progressX, y: 0, width: progressWidth, height: progressHeight)
|
||||
let progressCornerRadius = progressWidth * 0.5
|
||||
let progressPath = UIBezierPath()
|
||||
progressPath.append(UIBezierPath(roundedRect: progressBounds, cornerRadius: progressCornerRadius))
|
||||
progressLayer.path = progressPath.cgPath
|
||||
progressLayer.fillColor = progressColor.cgColor
|
||||
|
||||
CATransaction.commit()
|
||||
}
|
||||
}
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
import Foundation
|
||||
import MediaPlayer
|
||||
import SessionUIKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
// This kind of view is tricky. I've tried to organize things in the
|
||||
|
@ -145,13 +146,37 @@ import SignalUtilitiesKit
|
|||
// MARK: - Create Views
|
||||
|
||||
private func createViews() {
|
||||
|
||||
view.backgroundColor = .black
|
||||
view.themeBackgroundColor = .backgroundPrimary
|
||||
|
||||
let contentView = UIView()
|
||||
contentView.backgroundColor = .black
|
||||
contentView.themeBackgroundColor = .backgroundPrimary
|
||||
self.view.addSubview(contentView)
|
||||
contentView.autoPinEdgesToSuperviewEdges()
|
||||
|
||||
let titleLabel: UILabel = UILabel()
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.veryLargeFontSize)
|
||||
titleLabel.text = "CROP_SCALE_IMAGE_VIEW_TITLE".localized()
|
||||
titleLabel.themeTextColor = .textPrimary
|
||||
titleLabel.textAlignment = .center
|
||||
contentView.addSubview(titleLabel)
|
||||
titleLabel.autoPinWidthToSuperview()
|
||||
|
||||
let titleLabelMargin = ScaleFromIPhone5(16)
|
||||
titleLabel.autoPinEdge(toSuperviewSafeArea: .top, withInset: titleLabelMargin)
|
||||
|
||||
let buttonRow: UIView = createButtonRow()
|
||||
contentView.addSubview(buttonRow)
|
||||
buttonRow.pin(.leading, to: .leading, of: contentView)
|
||||
buttonRow.pin(.trailing, to: .trailing, of: contentView)
|
||||
buttonRow.pin(.bottom, to: .bottom, of: contentView)
|
||||
buttonRow.set(
|
||||
.height,
|
||||
to: (
|
||||
ScaleFromIPhone5To7Plus(35, 45) +
|
||||
Values.mediumSpacing +
|
||||
(UIApplication.shared.keyWindow?.safeAreaInsets.bottom ?? Values.mediumSpacing)
|
||||
)
|
||||
)
|
||||
|
||||
let imageView = OWSLayerView(frame: CGRect.zero, layoutCallback: { [weak self] _ in
|
||||
guard let strongSelf = self else { return }
|
||||
|
@ -160,7 +185,10 @@ import SignalUtilitiesKit
|
|||
imageView.clipsToBounds = true
|
||||
self.imageView = imageView
|
||||
contentView.addSubview(imageView)
|
||||
imageView.autoPinEdgesToSuperviewEdges()
|
||||
imageView.pin(.top, to: .top, of: contentView, withInset: (Values.massiveSpacing + Values.smallSpacing))
|
||||
imageView.pin(.leading, to: .leading, of: contentView)
|
||||
imageView.pin(.trailing, to: .trailing, of: contentView)
|
||||
imageView.pin(.bottom, to: .top, of: buttonRow)
|
||||
|
||||
let imageLayer = CALayer()
|
||||
self.imageLayer = imageLayer
|
||||
|
@ -185,23 +213,13 @@ import SignalUtilitiesKit
|
|||
|
||||
layer.path = path.cgPath
|
||||
layer.fillRule = .evenOdd
|
||||
layer.fillColor = UIColor.black.cgColor
|
||||
layer.themeFillColor = .black
|
||||
layer.opacity = 0.75
|
||||
}
|
||||
maskingView.autoPinEdgesToSuperviewEdges()
|
||||
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.textColor = .white
|
||||
titleLabel.textAlignment = .center
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.veryLargeFontSize)
|
||||
titleLabel.text = NSLocalizedString("CROP_SCALE_IMAGE_VIEW_TITLE",
|
||||
comment: "Title for the 'crop/scale image' dialog.")
|
||||
contentView.addSubview(titleLabel)
|
||||
titleLabel.autoPinWidthToSuperview()
|
||||
let titleLabelMargin = ScaleFromIPhone5(16)
|
||||
titleLabel.autoPinEdge(toSuperviewSafeArea: .top, withInset: titleLabelMargin)
|
||||
|
||||
createButtonRow(contentView: contentView)
|
||||
maskingView.pin(.top, to: .top, of: contentView, withInset: (Values.massiveSpacing + Values.smallSpacing))
|
||||
maskingView.pin(.leading, to: .leading, of: contentView)
|
||||
maskingView.pin(.trailing, to: .trailing, of: contentView)
|
||||
maskingView.pin(.bottom, to: .top, of: buttonRow)
|
||||
|
||||
contentView.isUserInteractionEnabled = true
|
||||
contentView.addGestureRecognizer(UIPinchGestureRecognizer(target: self, action: #selector(handlePinch(sender:))))
|
||||
|
@ -427,45 +445,35 @@ import SignalUtilitiesKit
|
|||
updateImageLayout()
|
||||
}
|
||||
|
||||
private func createButtonRow(contentView: UIView) {
|
||||
let buttonTopMargin = ScaleFromIPhone5To7Plus(30, 40)
|
||||
let buttonBottomMargin = ScaleFromIPhone5To7Plus(25, 40)
|
||||
private func createButtonRow() -> UIView {
|
||||
let result: UIStackView = UIStackView()
|
||||
result.axis = .horizontal
|
||||
result.distribution = .fillEqually
|
||||
result.alignment = .fill
|
||||
|
||||
let buttonRow = UIView()
|
||||
self.view.addSubview(buttonRow)
|
||||
buttonRow.autoPinWidthToSuperview()
|
||||
buttonRow.autoPinEdge(toSuperviewEdge: .bottom, withInset: buttonBottomMargin)
|
||||
buttonRow.autoPinEdge(.top, to: .bottom, of: contentView, withOffset: buttonTopMargin)
|
||||
let cancelButton = createButton(title: CommonStrings.cancelButton, action: #selector(cancelPressed))
|
||||
result.addArrangedSubview(cancelButton)
|
||||
|
||||
let cancelButton = createButton(title: CommonStrings.cancelButton,
|
||||
action: #selector(cancelPressed))
|
||||
cancelButton.titleLabel!.font = .systemFont(ofSize: 18) // Match iOS UI
|
||||
buttonRow.addSubview(cancelButton)
|
||||
cancelButton.autoPinEdge(toSuperviewEdge: .top)
|
||||
cancelButton.autoPinEdge(toSuperviewEdge: .bottom)
|
||||
cancelButton.autoPinEdge(toSuperviewEdge: .left)
|
||||
|
||||
let doneButton = createButton(title: CommonStrings.doneButton,
|
||||
action: #selector(donePressed))
|
||||
doneButton.titleLabel!.font = .systemFont(ofSize: 18) // Match iOS UI
|
||||
buttonRow.addSubview(doneButton)
|
||||
doneButton.autoPinEdge(toSuperviewEdge: .top)
|
||||
doneButton.autoPinEdge(toSuperviewEdge: .bottom)
|
||||
doneButton.autoPinEdge(toSuperviewEdge: .right)
|
||||
let doneButton = createButton(title: CommonStrings.doneButton, action: #selector(donePressed))
|
||||
result.addArrangedSubview(doneButton)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private func createButton(title: String, action: Selector) -> UIButton {
|
||||
let buttonFont = UIFont.ows_mediumFont(withSize: ScaleFromIPhone5To7Plus(18, 22))
|
||||
let buttonWidth = ScaleFromIPhone5To7Plus(110, 140)
|
||||
let buttonHeight = ScaleFromIPhone5To7Plus(35, 45)
|
||||
|
||||
let button = UIButton()
|
||||
let button: UIButton = UIButton()
|
||||
button.titleLabel?.font = .systemFont(ofSize: 18)
|
||||
button.setTitle(title, for: .normal)
|
||||
button.setTitleColor(UIColor.white, for: .normal)
|
||||
button.titleLabel!.font = buttonFont
|
||||
button.setThemeTitleColor(.textPrimary, for: .normal)
|
||||
button.setThemeBackgroundColor(.backgroundSecondary, for: .highlighted)
|
||||
button.contentEdgeInsets = UIEdgeInsets(
|
||||
top: Values.mediumSpacing,
|
||||
leading: 0,
|
||||
bottom: (UIApplication.shared.keyWindow?.safeAreaInsets.bottom ?? Values.mediumSpacing),
|
||||
trailing: 0
|
||||
)
|
||||
button.addTarget(self, action: action, for: .touchUpInside)
|
||||
button.autoSetDimension(.width, toSize: buttonWidth)
|
||||
button.autoSetDimension(.height, toSize: buttonHeight)
|
||||
|
||||
return button
|
||||
}
|
||||
|
||||
|
|
|
@ -23,7 +23,7 @@ public class DocumentTileViewController: UIViewController, UITableViewDelegate,
|
|||
private var isAutoLoadingNextPage: Bool = false
|
||||
private var currentTargetOffset: CGPoint?
|
||||
|
||||
public var delegate: DocumentTileViewControllerDelegate?
|
||||
public weak var delegate: DocumentTileViewControllerDelegate?
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
|
@ -49,8 +49,8 @@ public class DocumentTileViewController: UIViewController, UITableViewDelegate,
|
|||
}
|
||||
|
||||
lazy var tableView: UITableView = {
|
||||
let result = UITableView(frame: .zero, style: .grouped)
|
||||
result.backgroundColor = Colors.navigationBarBackground
|
||||
let result: UITableView = UITableView()
|
||||
result.themeBackgroundColor = .newConversation_background
|
||||
result.separatorStyle = .none
|
||||
result.showsVerticalScrollIndicator = false
|
||||
result.register(view: DocumentCell.self)
|
||||
|
@ -59,6 +59,10 @@ public class DocumentTileViewController: UIViewController, UITableViewDelegate,
|
|||
// Feels a bit weird to have content smashed all the way to the bottom edge.
|
||||
result.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 20, right: 0)
|
||||
|
||||
if #available(iOS 15.0, *) {
|
||||
result.sectionHeaderTopPadding = 0
|
||||
}
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
|
@ -69,7 +73,7 @@ public class DocumentTileViewController: UIViewController, UITableViewDelegate,
|
|||
|
||||
// Add a custom back button if this is the only view controller
|
||||
if self.navigationController?.viewControllers.first == self {
|
||||
let backButton = OWSViewController.createOWSBackButton(withTarget: self, selector: #selector(didPressDismissButton))
|
||||
let backButton = UIViewController.createOWSBackButton(target: self, selector: #selector(didPressDismissButton))
|
||||
self.navigationItem.leftBarButtonItem = backButton
|
||||
}
|
||||
|
||||
|
@ -363,7 +367,8 @@ class DocumentCell: UITableViewCell {
|
|||
private let iconImageView: UIImageView = {
|
||||
let result: UIImageView = UIImageView(image: #imageLiteral(resourceName: "File").withRenderingMode(.alwaysTemplate))
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.tintColor = Colors.text
|
||||
result.themeTintColor = .textPrimary
|
||||
result.contentMode = .scaleAspectFit
|
||||
|
||||
return result
|
||||
}()
|
||||
|
@ -374,7 +379,20 @@ class DocumentCell: UITableViewCell {
|
|||
result.setContentHuggingPriority(.defaultHigh, for: .horizontal)
|
||||
result.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
result.font = .boldSystemFont(ofSize: Values.smallFontSize)
|
||||
result.textColor = Colors.text
|
||||
result.themeTextColor = .textPrimary
|
||||
result.lineBreakMode = .byTruncatingTail
|
||||
result.numberOfLines = 2
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private let timeLabel: UILabel = {
|
||||
let result: UILabel = UILabel()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.setContentHuggingPriority(.defaultHigh, for: .horizontal)
|
||||
result.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
|
||||
result.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
result.themeTextColor = .textSecondary
|
||||
result.lineBreakMode = .byTruncatingTail
|
||||
|
||||
return result
|
||||
|
@ -386,20 +404,26 @@ class DocumentCell: UITableViewCell {
|
|||
result.setContentHuggingPriority(.defaultHigh, for: .horizontal)
|
||||
result.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
result.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
result.textColor = Colors.text
|
||||
result.themeTextColor = .textSecondary
|
||||
result.lineBreakMode = .byTruncatingTail
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
backgroundColor = Colors.cellBackground
|
||||
selectedBackgroundView = UIView()
|
||||
selectedBackgroundView?.backgroundColor = Colors.cellSelected
|
||||
themeBackgroundColor = .clear
|
||||
|
||||
backgroundView = UIView()
|
||||
backgroundView?.themeBackgroundColor = .settings_tabBackground
|
||||
backgroundView?.layer.cornerRadius = 5
|
||||
|
||||
selectedBackgroundView = UIView()
|
||||
selectedBackgroundView?.themeBackgroundColor = .settings_tabHighlight
|
||||
selectedBackgroundView?.layer.cornerRadius = 5
|
||||
|
||||
contentView.addSubview(iconImageView)
|
||||
contentView.addSubview(titleLabel)
|
||||
contentView.addSubview(timeLabel)
|
||||
contentView.addSubview(detailLabel)
|
||||
}
|
||||
|
||||
|
@ -407,57 +431,110 @@ class DocumentCell: UITableViewCell {
|
|||
|
||||
private func setupLayout() {
|
||||
NSLayoutConstraint.activate([
|
||||
contentView.heightAnchor.constraint(equalToConstant: 68),
|
||||
|
||||
iconImageView.leftAnchor.constraint(equalTo: contentView.leftAnchor, constant: Values.mediumSpacing),
|
||||
iconImageView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
|
||||
iconImageView.widthAnchor.constraint(equalToConstant: Self.iconImageViewSize.width),
|
||||
iconImageView.heightAnchor.constraint(equalToConstant: Self.iconImageViewSize.height),
|
||||
iconImageView.topAnchor.constraint(
|
||||
greaterThanOrEqualTo: contentView.topAnchor,
|
||||
constant: (Values.verySmallSpacing + Values.verySmallSpacing)
|
||||
),
|
||||
iconImageView.leftAnchor.constraint(
|
||||
equalTo: contentView.leftAnchor,
|
||||
constant: (Values.largeSpacing + Values.mediumSpacing)
|
||||
),
|
||||
iconImageView.bottomAnchor.constraint(
|
||||
lessThanOrEqualTo: contentView.bottomAnchor,
|
||||
constant: -(Values.verySmallSpacing + Values.verySmallSpacing)
|
||||
),
|
||||
|
||||
titleLabel.topAnchor.constraint(
|
||||
equalTo: contentView.topAnchor,
|
||||
constant: (Values.verySmallSpacing + Values.verySmallSpacing)
|
||||
),
|
||||
titleLabel.leftAnchor.constraint(equalTo: iconImageView.rightAnchor, constant: Values.mediumSpacing),
|
||||
titleLabel.rightAnchor.constraint(lessThanOrEqualTo: contentView.rightAnchor, constant: -Values.mediumSpacing),
|
||||
titleLabel.topAnchor.constraint(equalTo: iconImageView.topAnchor),
|
||||
titleLabel.rightAnchor.constraint(
|
||||
lessThanOrEqualTo: timeLabel.leftAnchor,
|
||||
constant: -Values.mediumSpacing
|
||||
),
|
||||
|
||||
timeLabel.topAnchor.constraint(equalTo: iconImageView.topAnchor),
|
||||
timeLabel.rightAnchor.constraint(
|
||||
equalTo: contentView.rightAnchor,
|
||||
constant: -(Values.mediumSpacing + Values.largeSpacing)
|
||||
),
|
||||
|
||||
detailLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: Values.smallSpacing),
|
||||
detailLabel.leftAnchor.constraint(equalTo: iconImageView.rightAnchor, constant: Values.mediumSpacing),
|
||||
detailLabel.rightAnchor.constraint(lessThanOrEqualTo: contentView.rightAnchor, constant: -Values.mediumSpacing),
|
||||
detailLabel.bottomAnchor.constraint(equalTo: iconImageView.bottomAnchor),
|
||||
detailLabel.rightAnchor.constraint(
|
||||
lessThanOrEqualTo: contentView.rightAnchor,
|
||||
constant: -(Values.verySmallSpacing + Values.largeSpacing)
|
||||
),
|
||||
detailLabel.bottomAnchor.constraint(
|
||||
lessThanOrEqualTo: contentView.bottomAnchor,
|
||||
constant: -(Values.verySmallSpacing + Values.smallSpacing)
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
backgroundView?.frame = CGRect(
|
||||
x: Values.largeSpacing,
|
||||
y: Values.verySmallSpacing,
|
||||
width: (contentView.bounds.width - (Values.largeSpacing * 2)),
|
||||
height: (contentView.bounds.height - (Values.verySmallSpacing * 2))
|
||||
)
|
||||
selectedBackgroundView?.frame = (backgroundView?.frame ?? .zero)
|
||||
}
|
||||
|
||||
// MARK: - Content
|
||||
|
||||
func update(with item: MediaGalleryViewModel.Item) {
|
||||
let attachment = item.attachment
|
||||
titleLabel.text = attachment.sourceFilename ?? "File"
|
||||
titleLabel.text = (attachment.sourceFilename ?? "File")
|
||||
detailLabel.text = "\(OWSFormat.formatFileSize(UInt(attachment.byteCount)))"
|
||||
timeLabel.text = Date(
|
||||
timeIntervalSince1970: TimeInterval(item.interactionTimestampMs / 1000)
|
||||
).formattedForDisplay
|
||||
}
|
||||
}
|
||||
|
||||
class DocumentSectionHeaderView: UIView {
|
||||
// HACK: scrollbar incorrectly appears *behind* section headers
|
||||
// in collection view on iOS11 =(
|
||||
private class AlwaysOnTopLayer: CALayer {
|
||||
override var zPosition: CGFloat {
|
||||
get { return 0 }
|
||||
set {}
|
||||
}
|
||||
}
|
||||
|
||||
let label: UILabel
|
||||
|
||||
override class var layerClass: AnyClass {
|
||||
get {
|
||||
// HACK: scrollbar incorrectly appears *behind* section headers
|
||||
// in collection view on iOS11 =(
|
||||
return AlwaysOnTopLayer.self
|
||||
}
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
label = UILabel()
|
||||
label.textColor = Colors.text
|
||||
|
||||
let blurEffect = UIBlurEffect(style: .dark)
|
||||
let blurEffectView = UIVisualEffectView(effect: blurEffect)
|
||||
|
||||
blurEffectView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
label.themeTextColor = .textPrimary
|
||||
|
||||
super.init(frame: frame)
|
||||
|
||||
self.backgroundColor = isLightMode ? Colors.cellBackground : UIColor.ows_black.withAlphaComponent(OWSNavigationBar.backgroundBlurMutingFactor)
|
||||
self.themeBackgroundColor = .clear
|
||||
|
||||
let backgroundView: UIView = UIView()
|
||||
backgroundView.themeBackgroundColor = .newConversation_background
|
||||
addSubview(backgroundView)
|
||||
backgroundView.pin(to: self)
|
||||
|
||||
self.addSubview(blurEffectView)
|
||||
self.addSubview(label)
|
||||
|
||||
blurEffectView.autoPinEdgesToSuperviewEdges()
|
||||
blurEffectView.isHidden = isLightMode
|
||||
label.autoPinEdge(toSuperviewMargin: .trailing)
|
||||
label.autoPinEdge(toSuperviewMargin: .leading)
|
||||
label.autoVCenterInSuperview()
|
||||
label.pin(.leading, to: .leading, of: self, withInset: Values.largeSpacing)
|
||||
label.pin(.trailing, to: .trailing, of: self, withInset: -Values.largeSpacing)
|
||||
label.center(.vertical, in: self)
|
||||
}
|
||||
|
||||
@available(*, unavailable, message: "Unimplemented")
|
||||
|
@ -479,7 +556,7 @@ class DocumentStaticHeaderView: UIView {
|
|||
|
||||
addSubview(label)
|
||||
|
||||
label.textColor = Colors.text
|
||||
label.themeTextColor = .textPrimary
|
||||
label.textAlignment = .center
|
||||
label.numberOfLines = 0
|
||||
label.autoPinEdgesToSuperviewMargins(with: UIEdgeInsets(top: 0, leading: Values.largeSpacing, bottom: 0, trailing: Values.largeSpacing))
|
||||
|
|
|
@ -218,7 +218,7 @@ class GifPickerCell: UICollectionViewCell {
|
|||
return
|
||||
}
|
||||
imageView.image = image
|
||||
self.backgroundColor = nil
|
||||
self.themeBackgroundColor = nil
|
||||
|
||||
if self.isCellSelected {
|
||||
let activityIndicator = UIActivityIndicatorView(style: .gray)
|
||||
|
@ -229,11 +229,12 @@ class GifPickerCell: UICollectionViewCell {
|
|||
|
||||
// Render activityIndicator on a white tile to ensure it's visible on
|
||||
// when overlayed on a variety of potential gifs.
|
||||
activityIndicator.backgroundColor = UIColor.white.withAlphaComponent(0.3)
|
||||
activityIndicator.themeBackgroundColor = .white
|
||||
activityIndicator.alpha = 0.3
|
||||
activityIndicator.autoSetDimension(.width, toSize: 30)
|
||||
activityIndicator.autoSetDimension(.height, toSize: 30)
|
||||
activityIndicator.themeShadowColor = .black
|
||||
activityIndicator.layer.cornerRadius = 3
|
||||
activityIndicator.layer.shadowColor = UIColor.black.cgColor
|
||||
activityIndicator.layer.shadowOffset = CGSize(width: 1, height: 1)
|
||||
activityIndicator.layer.shadowOpacity = 0.7
|
||||
activityIndicator.layer.shadowRadius = 1.0
|
||||
|
@ -270,9 +271,7 @@ class GifPickerCell: UICollectionViewCell {
|
|||
|
||||
private func clearViewState() {
|
||||
imageView?.image = nil
|
||||
self.backgroundColor = (isDarkMode
|
||||
? UIColor(white: 0.25, alpha: 1.0)
|
||||
: UIColor(white: 0.95, alpha: 1.0))
|
||||
self.themeBackgroundColor = .backgroundSecondary
|
||||
}
|
||||
|
||||
private func pickBestAsset() -> ProxiedContentAsset? {
|
||||
|
|
|
@ -99,28 +99,35 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
|||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
self.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel,
|
||||
target: self,
|
||||
action: #selector(donePressed))
|
||||
self.navigationItem.leftBarButtonItem = UIBarButtonItem(
|
||||
barButtonSystemItem: .cancel,
|
||||
target: self,
|
||||
action: #selector(donePressed)
|
||||
)
|
||||
|
||||
// Loki: Customize title
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.text = "accessibility_gif_button".localized().uppercased()
|
||||
titleLabel.textColor = Colors.text
|
||||
let titleLabel: UILabel = UILabel()
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.veryLargeFontSize)
|
||||
titleLabel.text = "accessibility_gif_button".localized().uppercased()
|
||||
titleLabel.themeTextColor = .textPrimary
|
||||
navigationItem.titleView = titleLabel
|
||||
|
||||
createViews()
|
||||
|
||||
reachability = Reachability.forInternetConnection()
|
||||
NotificationCenter.default.addObserver(self,
|
||||
selector: #selector(reachabilityChanged),
|
||||
name: NSNotification.Name.reachabilityChanged,
|
||||
object: nil)
|
||||
NotificationCenter.default.addObserver(self,
|
||||
selector: #selector(didBecomeActive),
|
||||
name: NSNotification.Name.OWSApplicationDidBecomeActive,
|
||||
object: nil)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(reachabilityChanged),
|
||||
name: .reachabilityChanged,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(didBecomeActive),
|
||||
name: .OWSApplicationDidBecomeActive,
|
||||
object: nil
|
||||
)
|
||||
|
||||
loadTrending()
|
||||
}
|
||||
|
||||
|
@ -140,14 +147,8 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
|||
// MARK: Views
|
||||
|
||||
private func createViews() {
|
||||
|
||||
let backgroundColor = Colors.navigationBarBackground
|
||||
self.view.backgroundColor = backgroundColor
|
||||
|
||||
// Block UIKit from adjust insets of collection view which screws up
|
||||
// min/max scroll positions.
|
||||
self.automaticallyAdjustsScrollViewInsets = false
|
||||
|
||||
self.view.themeBackgroundColor = .backgroundPrimary
|
||||
|
||||
// Search
|
||||
searchBar.delegate = self
|
||||
|
||||
|
@ -157,7 +158,7 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
|||
|
||||
self.collectionView.delegate = self
|
||||
self.collectionView.dataSource = self
|
||||
self.collectionView.backgroundColor = backgroundColor
|
||||
self.collectionView.themeBackgroundColor = .backgroundPrimary
|
||||
self.collectionView.register(GifPickerCell.self, forCellWithReuseIdentifier: kCellReuseIdentifier)
|
||||
// Inserted below searchbar because we later occlude the collectionview
|
||||
// by inserting a masking layer between the search bar and collectionview
|
||||
|
@ -165,10 +166,14 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
|||
self.collectionView.autoPinEdge(toSuperviewSafeArea: .leading)
|
||||
self.collectionView.autoPinEdge(toSuperviewSafeArea: .trailing)
|
||||
self.collectionView.autoPinEdge(.top, to: .bottom, of: searchBar)
|
||||
|
||||
// Block UIKit from adjust insets of collection view which screws up
|
||||
// min/max scroll positions
|
||||
self.collectionView.contentInsetAdjustmentBehavior = .never
|
||||
|
||||
// for iPhoneX devices, extends the black background to the bottom edge of the view.
|
||||
let bottomBannerContainer = UIView()
|
||||
bottomBannerContainer.backgroundColor = isLightMode ? UIColor.black : Colors.navigationBarBackground
|
||||
bottomBannerContainer.themeBackgroundColor = .backgroundPrimary
|
||||
self.view.addSubview(bottomBannerContainer)
|
||||
bottomBannerContainer.autoPinWidthToSuperview()
|
||||
bottomBannerContainer.autoPinEdge(.top, to: .bottom, of: self.collectionView)
|
||||
|
@ -188,15 +193,13 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
|||
logoImageView.autoPinHeightToSuperview(withMargin: 3)
|
||||
logoImageView.autoHCenterInSuperview()
|
||||
|
||||
let noResultsView = createErrorLabel(text: NSLocalizedString("GIF_VIEW_SEARCH_NO_RESULTS",
|
||||
comment: "Indicates that the user's search had no results."))
|
||||
let noResultsView = createErrorLabel(text: "GIF_VIEW_SEARCH_NO_RESULTS".localized())
|
||||
self.noResultsView = noResultsView
|
||||
self.view.addSubview(noResultsView)
|
||||
noResultsView.autoPinWidthToSuperview(withMargin: 20)
|
||||
noResultsView.autoAlignAxis(.horizontal, toSameAxisOf: self.collectionView)
|
||||
|
||||
let searchErrorView = createErrorLabel(text: NSLocalizedString("GIF_VIEW_SEARCH_ERROR",
|
||||
comment: "Indicates that an error occurred while searching."))
|
||||
let searchErrorView = createErrorLabel(text: "GIF_VIEW_SEARCH_ERROR".localized())
|
||||
self.searchErrorView = searchErrorView
|
||||
self.view.addSubview(searchErrorView)
|
||||
searchErrorView.autoPinWidthToSuperview(withMargin: 20)
|
||||
|
@ -205,29 +208,24 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
|||
searchErrorView.isUserInteractionEnabled = true
|
||||
searchErrorView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(retryTapped)))
|
||||
|
||||
let activityIndicator = UIActivityIndicatorView(style: .whiteLarge)
|
||||
let activityIndicator = UIActivityIndicatorView(style: .large)
|
||||
self.activityIndicator = activityIndicator
|
||||
self.view.addSubview(activityIndicator)
|
||||
activityIndicator.autoHCenterInSuperview()
|
||||
activityIndicator.autoAlignAxis(.horizontal, toSameAxisOf: self.collectionView)
|
||||
|
||||
let navigationBar = navigationController!.navigationBar
|
||||
navigationBar.setBackgroundImage(UIImage(), for: UIBarMetrics.default)
|
||||
navigationBar.shadowImage = UIImage()
|
||||
navigationBar.isTranslucent = false
|
||||
navigationBar.barTintColor = Colors.navigationBarBackground
|
||||
|
||||
self.updateContents()
|
||||
}
|
||||
|
||||
private func createErrorLabel(text: String) -> UILabel {
|
||||
let label = UILabel()
|
||||
let label: UILabel = UILabel()
|
||||
label.font = .ows_mediumFont(withSize: 20)
|
||||
label.text = text
|
||||
label.textColor = Colors.text
|
||||
label.font = UIFont.ows_mediumFont(withSize: 20)
|
||||
label.themeTextColor = .textPrimary
|
||||
label.textAlignment = .center
|
||||
label.numberOfLines = 0
|
||||
label.lineBreakMode = .byWordWrapping
|
||||
label.numberOfLines = 0
|
||||
|
||||
return label
|
||||
}
|
||||
|
||||
|
@ -246,39 +244,43 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
|||
}
|
||||
|
||||
switch viewMode {
|
||||
case .idle:
|
||||
self.collectionView.isHidden = true
|
||||
noResultsView.isHidden = true
|
||||
searchErrorView.isHidden = true
|
||||
activityIndicator.isHidden = true
|
||||
activityIndicator.stopAnimating()
|
||||
case .searching:
|
||||
self.collectionView.isHidden = true
|
||||
noResultsView.isHidden = true
|
||||
searchErrorView.isHidden = true
|
||||
activityIndicator.isHidden = false
|
||||
activityIndicator.startAnimating()
|
||||
case .results:
|
||||
self.collectionView.isHidden = false
|
||||
noResultsView.isHidden = true
|
||||
searchErrorView.isHidden = true
|
||||
activityIndicator.isHidden = true
|
||||
activityIndicator.stopAnimating()
|
||||
case .idle:
|
||||
self.collectionView.isHidden = true
|
||||
noResultsView.isHidden = true
|
||||
searchErrorView.isHidden = true
|
||||
activityIndicator.isHidden = true
|
||||
activityIndicator.stopAnimating()
|
||||
|
||||
case .searching:
|
||||
self.collectionView.isHidden = true
|
||||
noResultsView.isHidden = true
|
||||
searchErrorView.isHidden = true
|
||||
activityIndicator.isHidden = false
|
||||
activityIndicator.startAnimating()
|
||||
|
||||
case .results:
|
||||
self.collectionView.isHidden = false
|
||||
noResultsView.isHidden = true
|
||||
searchErrorView.isHidden = true
|
||||
activityIndicator.isHidden = true
|
||||
activityIndicator.stopAnimating()
|
||||
|
||||
self.collectionView.collectionViewLayout.invalidateLayout()
|
||||
self.collectionView.reloadData()
|
||||
case .noResults:
|
||||
self.collectionView.isHidden = true
|
||||
noResultsView.isHidden = false
|
||||
searchErrorView.isHidden = true
|
||||
activityIndicator.isHidden = true
|
||||
activityIndicator.stopAnimating()
|
||||
case .error:
|
||||
self.collectionView.isHidden = true
|
||||
noResultsView.isHidden = true
|
||||
searchErrorView.isHidden = false
|
||||
activityIndicator.isHidden = true
|
||||
activityIndicator.stopAnimating()
|
||||
self.collectionView.collectionViewLayout.invalidateLayout()
|
||||
self.collectionView.reloadData()
|
||||
|
||||
case .noResults:
|
||||
self.collectionView.isHidden = true
|
||||
noResultsView.isHidden = false
|
||||
searchErrorView.isHidden = true
|
||||
activityIndicator.isHidden = true
|
||||
activityIndicator.stopAnimating()
|
||||
|
||||
case .error:
|
||||
self.collectionView.isHidden = true
|
||||
noResultsView.isHidden = true
|
||||
searchErrorView.isHidden = false
|
||||
activityIndicator.isHidden = true
|
||||
activityIndicator.stopAnimating()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -314,7 +316,6 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
|||
// MARK: - UICollectionViewDelegate
|
||||
|
||||
public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
|
||||
guard let cell = collectionView.cellForItem(at: indexPath) as? GifPickerCell else {
|
||||
owsFailDebug("unexpected cell.")
|
||||
return
|
||||
|
@ -345,7 +346,7 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
|||
|
||||
layer.path = path.cgPath
|
||||
layer.fillRule = .evenOdd
|
||||
layer.fillColor = UIColor.black.cgColor
|
||||
layer.themeFillColor = .black
|
||||
layer.opacity = 0.7
|
||||
}
|
||||
maskingView.autoPinEdgesToSuperviewEdges()
|
||||
|
@ -384,22 +385,20 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
|||
strongSelf.delegate?.gifPickerDidSelect(attachment: attachment)
|
||||
}
|
||||
}.catch { [weak self] error in
|
||||
guard let strongSelf = self else {
|
||||
Logger.info("ignoring failure, since VC was dismissed before fetching finished.")
|
||||
return
|
||||
}
|
||||
|
||||
let alert = UIAlertController(title: NSLocalizedString("GIF_PICKER_FAILURE_ALERT_TITLE", comment: "Shown when selected GIF couldn't be fetched"),
|
||||
message: error.localizedDescription,
|
||||
preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: CommonStrings.retryButton, style: .default) { _ in
|
||||
strongSelf.getFileForCell(cell)
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: CommonStrings.dismissButton, style: .cancel) { _ in
|
||||
strongSelf.dismiss(animated: true, completion: nil)
|
||||
})
|
||||
|
||||
strongSelf.presentAlert(alert)
|
||||
let modal: ConfirmationModal = ConfirmationModal(
|
||||
targetView: self?.view,
|
||||
info: ConfirmationModal.Info(
|
||||
title: "GIF_PICKER_FAILURE_ALERT_TITLE".localized(),
|
||||
explanation: error.localizedDescription,
|
||||
confirmTitle: CommonStrings.retryButton,
|
||||
cancelTitle: CommonStrings.dismissButton,
|
||||
cancelStyle: .alert_text,
|
||||
onConfirm: { _ in
|
||||
self?.getFileForCell(cell)
|
||||
}
|
||||
)
|
||||
)
|
||||
self?.present(modal, animated: true)
|
||||
}.retainUntilComplete()
|
||||
}
|
||||
|
||||
|
@ -439,11 +438,7 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
|||
progressiveSearchTimer = nil
|
||||
let kProgressiveSearchDelaySeconds = 1.0
|
||||
progressiveSearchTimer = WeakTimer.scheduledTimer(timeInterval: kProgressiveSearchDelaySeconds, target: self, userInfo: nil, repeats: true) { [weak self] _ in
|
||||
guard let strongSelf = self else {
|
||||
return
|
||||
}
|
||||
|
||||
strongSelf.tryToSearch()
|
||||
self?.tryToSearch()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -457,19 +452,33 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
|||
progressiveSearchTimer?.invalidate()
|
||||
progressiveSearchTimer = nil
|
||||
|
||||
guard let text = searchBar.text else {
|
||||
guard let text: String = searchBar.text else {
|
||||
// Alert message shown when user tries to search for GIFs without entering any search terms
|
||||
OWSAlerts.showErrorAlert(message: "GIF_PICKER_VIEW_MISSING_QUERY".localized())
|
||||
let modal: ConfirmationModal = ConfirmationModal(
|
||||
targetView: self.view,
|
||||
info: ConfirmationModal.Info(
|
||||
title: CommonStrings.errorAlertTitle,
|
||||
explanation: "GIF_PICKER_VIEW_MISSING_QUERY".localized(),
|
||||
cancelTitle: "BUTTON_OK".localized(),
|
||||
cancelStyle: .alert_text
|
||||
)
|
||||
)
|
||||
self.present(modal, animated: true)
|
||||
return
|
||||
}
|
||||
|
||||
let query = (text as String).trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
||||
|
||||
let query: String = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
if (viewMode == .searching || viewMode == .results) && lastQuery == query {
|
||||
Logger.info("ignoring duplicate search: \(query)")
|
||||
return
|
||||
}
|
||||
|
||||
guard !query.isEmpty else {
|
||||
loadTrending()
|
||||
return
|
||||
}
|
||||
|
||||
search(query: query)
|
||||
}
|
||||
|
||||
|
@ -477,20 +486,22 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
|||
assert(progressiveSearchTimer == nil)
|
||||
assert(searchBar.text == nil || searchBar.text?.count == 0)
|
||||
|
||||
GiphyAPI.sharedInstance.trending().done { [weak self] imageInfos in
|
||||
guard let self = self else { return }
|
||||
|
||||
Logger.info("showing trending")
|
||||
if imageInfos.count > 0 {
|
||||
self.imageInfos = imageInfos
|
||||
self.viewMode = .results
|
||||
} else {
|
||||
owsFailDebug("trending results was unexpectedly empty")
|
||||
GiphyAPI.sharedInstance.trending()
|
||||
.done { [weak self] imageInfos in
|
||||
Logger.info("showing trending")
|
||||
|
||||
if imageInfos.count > 0 {
|
||||
self?.imageInfos = imageInfos
|
||||
self?.viewMode = .results
|
||||
}
|
||||
else {
|
||||
owsFailDebug("trending results was unexpectedly empty")
|
||||
}
|
||||
}
|
||||
.catch { error in
|
||||
// Don't both showing error UI feedback for default "trending" results.
|
||||
Logger.error("error: \(error)")
|
||||
}
|
||||
}.catch { error in
|
||||
// Don't both showing error UI feedback for default "trending" results.
|
||||
Logger.error("error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func search(query: String) {
|
||||
|
@ -503,22 +514,26 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
|||
lastQuery = query
|
||||
self.collectionView.contentOffset = CGPoint.zero
|
||||
|
||||
GiphyAPI.sharedInstance.search(query: query, success: { [weak self] imageInfos in
|
||||
guard let strongSelf = self else { return }
|
||||
Logger.info("search complete")
|
||||
strongSelf.imageInfos = imageInfos
|
||||
if imageInfos.count > 0 {
|
||||
strongSelf.viewMode = .results
|
||||
} else {
|
||||
strongSelf.viewMode = .noResults
|
||||
}
|
||||
},
|
||||
failure: { [weak self] _ in
|
||||
guard let strongSelf = self else { return }
|
||||
Logger.info("search failed.")
|
||||
// TODO: Present this error to the user.
|
||||
strongSelf.viewMode = .error
|
||||
})
|
||||
GiphyAPI.sharedInstance
|
||||
.search(
|
||||
query: query,
|
||||
success: { [weak self] imageInfos in
|
||||
Logger.info("search complete")
|
||||
self?.imageInfos = imageInfos
|
||||
|
||||
if imageInfos.count > 0 {
|
||||
self?.viewMode = .results
|
||||
}
|
||||
else {
|
||||
self?.viewMode = .noResults
|
||||
}
|
||||
},
|
||||
failure: { [weak self] _ in
|
||||
Logger.info("search failed.")
|
||||
// TODO: Present this error to the user.
|
||||
self?.viewMode = .error
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - GifPickerLayoutDelegate
|
||||
|
|
|
@ -6,6 +6,7 @@ import Foundation
|
|||
import Photos
|
||||
import PromiseKit
|
||||
import SessionUIKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
protocol ImagePickerGridControllerDelegate: AnyObject {
|
||||
func imagePickerDidCompleteSelection(_ imagePicker: ImagePickerGridController)
|
||||
|
@ -19,7 +20,7 @@ protocol ImagePickerGridControllerDelegate: AnyObject {
|
|||
func imagePickerCanSelectAdditionalItems(_ imagePicker: ImagePickerGridController) -> Bool
|
||||
}
|
||||
|
||||
class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegate, PhotoCollectionPickerDelegate {
|
||||
class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegate {
|
||||
|
||||
weak var delegate: ImagePickerGridControllerDelegate?
|
||||
|
||||
|
@ -48,7 +49,8 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
self.view.backgroundColor = Colors.navigationBarBackground
|
||||
navigationItem.backButtonTitle = ""
|
||||
self.view.themeBackgroundColor = .newConversation_background
|
||||
|
||||
library.add(delegate: self)
|
||||
|
||||
|
@ -71,7 +73,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
let cancelImage = UIImage(imageLiteralResourceName: "X")
|
||||
let cancelButton = UIBarButtonItem(image: cancelImage, style: .plain, target: self, action: #selector(didPressCancel))
|
||||
|
||||
cancelButton.tintColor = Colors.text
|
||||
cancelButton.themeTintColor = .textPrimary
|
||||
navigationItem.leftBarButtonItem = cancelButton
|
||||
|
||||
let titleView = TitleView()
|
||||
|
@ -80,7 +82,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
navigationItem.titleView = titleView
|
||||
self.titleView = titleView
|
||||
|
||||
collectionView.backgroundColor = Colors.navigationBarBackground
|
||||
collectionView.themeBackgroundColor = .newConversation_background
|
||||
|
||||
let selectionPanGesture = DirectionalPanGestureRecognizer(direction: [.horizontal], target: self, action: #selector(didPanSelection))
|
||||
selectionPanGesture.delegate = self
|
||||
|
@ -105,7 +107,9 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
enum BatchSelectionGestureMode {
|
||||
case select, deselect
|
||||
}
|
||||
|
||||
var selectionPanGestureMode: BatchSelectionGestureMode = .select
|
||||
var hasEverAppeared: Bool = false
|
||||
|
||||
@objc
|
||||
func didPanSelection(_ selectionPanGesture: UIPanGestureRecognizer) {
|
||||
|
@ -189,20 +193,9 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
super.viewWillLayoutSubviews()
|
||||
updateLayout()
|
||||
}
|
||||
|
||||
var hasEverAppeared: Bool = false
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
let backgroundImage: UIImage = UIImage(color: Colors.navigationBarBackground)
|
||||
self.navigationItem.title = nil
|
||||
self.navigationController?.navigationBar.setBackgroundImage(UIImage(), for: .default)
|
||||
self.navigationController?.navigationBar.shadowImage = UIImage()
|
||||
self.navigationController?.navigationBar.isTranslucent = false
|
||||
self.navigationController?.navigationBar.barTintColor = Colors.navigationBarBackground
|
||||
(self.navigationController?.navigationBar as? OWSNavigationBar)?.respectsTheme = true
|
||||
self.navigationController?.navigationBar.backgroundColor = Colors.navigationBarBackground
|
||||
self.navigationController?.navigationBar.setBackgroundImage(backgroundImage, for: .default)
|
||||
|
||||
// Determine the size of the thumbnails to request
|
||||
let scale = UIScreen.main.scale
|
||||
|
@ -362,9 +355,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
// MARK: - Batch Selection
|
||||
|
||||
func batchSelectModeDidChange() {
|
||||
guard let delegate = delegate else {
|
||||
return
|
||||
}
|
||||
guard let delegate = delegate else { return }
|
||||
|
||||
guard let collectionView = collectionView else {
|
||||
owsFailDebug("collectionView was unexpectedly nil")
|
||||
|
@ -397,7 +388,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
|
||||
let toastText = String(format: toastFormat, NSNumber(value: SignalAttachment.maxAttachmentsAllowed))
|
||||
|
||||
let toastController = ToastController(text: toastText)
|
||||
let toastController = ToastController(text: toastText, background: .backgroundPrimary)
|
||||
|
||||
let kToastInset: CGFloat = 10
|
||||
let bottomInset = kToastInset + collectionView.contentInset.bottom + view.layoutMargins.bottom
|
||||
|
@ -415,11 +406,27 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
// MARK: - PhotoCollectionPicker Presentation
|
||||
|
||||
var isShowingCollectionPickerController: Bool = false
|
||||
|
||||
lazy var collectionPickerController: SessionTableViewController = SessionTableViewController(
|
||||
viewModel: PhotoCollectionPickerViewModel(library: library) { [weak self] collection in
|
||||
guard self?.photoCollection != collection else {
|
||||
self?.hideCollectionPicker()
|
||||
return
|
||||
}
|
||||
|
||||
lazy var collectionPickerController: PhotoCollectionPickerController = {
|
||||
return PhotoCollectionPickerController(library: library,
|
||||
collectionDelegate: self)
|
||||
}()
|
||||
// Any selections are invalid as they refer to indices in a different collection
|
||||
self?.clearCollectionViewSelection()
|
||||
|
||||
self?.photoCollection = collection
|
||||
self?.photoCollectionContents = collection.contents()
|
||||
|
||||
self?.titleView.text = collection.localizedTitle()
|
||||
|
||||
self?.collectionView?.reloadData()
|
||||
self?.scrollToBottom(animated: false)
|
||||
self?.hideCollectionPicker()
|
||||
}
|
||||
)
|
||||
|
||||
func showCollectionPicker() {
|
||||
Logger.debug("")
|
||||
|
@ -437,8 +444,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
collectionPickerView.autoPinEdgesToSuperviewEdges(with: .zero, excludingEdge: .top)
|
||||
collectionPickerView.autoPinEdge(toSuperviewSafeArea: .top)
|
||||
collectionPickerView.layoutIfNeeded()
|
||||
collectionPickerView.backgroundColor = .white
|
||||
|
||||
|
||||
// Initially position offscreen, we'll animate it in.
|
||||
collectionPickerView.frame = collectionPickerView.frame.offsetBy(dx: 0, dy: collectionPickerView.frame.height)
|
||||
|
||||
|
@ -462,28 +468,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
self.collectionPickerController.removeFromParent()
|
||||
}.retainUntilComplete()
|
||||
}
|
||||
|
||||
// MARK: - PhotoCollectionPickerDelegate
|
||||
|
||||
func photoCollectionPicker(_ photoCollectionPicker: PhotoCollectionPickerController, didPickCollection collection: PhotoCollection) {
|
||||
guard photoCollection != collection else {
|
||||
hideCollectionPicker()
|
||||
return
|
||||
}
|
||||
|
||||
// Any selections are invalid as they refer to indices in a different collection
|
||||
clearCollectionViewSelection()
|
||||
|
||||
photoCollection = collection
|
||||
photoCollectionContents = photoCollection.contents()
|
||||
|
||||
titleView.text = photoCollection.localizedTitle()
|
||||
|
||||
collectionView?.reloadData()
|
||||
scrollToBottom(animated: false)
|
||||
hideCollectionPicker()
|
||||
}
|
||||
|
||||
|
||||
// MARK: - UICollectionView
|
||||
|
||||
override func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
|
||||
|
@ -537,8 +522,6 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
}
|
||||
|
||||
let cell: PhotoGridViewCell = collectionView.dequeue(type: PhotoGridViewCell.self, for: indexPath)
|
||||
cell.loadingColor = UIColor(white: 0.2, alpha: 1)
|
||||
|
||||
let assetItem = photoCollectionContents.assetItem(at: indexPath.item, photoMediaSize: photoMediaSize)
|
||||
cell.configure(item: assetItem)
|
||||
|
||||
|
@ -592,12 +575,12 @@ class TitleView: UIView {
|
|||
|
||||
addSubview(stackView)
|
||||
stackView.autoPinEdgesToSuperviewEdges()
|
||||
|
||||
label.textColor = Colors.text
|
||||
|
||||
label.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
label.themeTextColor = .textPrimary
|
||||
|
||||
iconView.tintColor = Colors.text
|
||||
iconView.image = UIImage(named: "navbar_disclosure_down")?.withRenderingMode(.alwaysTemplate)
|
||||
iconView.themeTintColor = .textPrimary
|
||||
|
||||
addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(titleTapped)))
|
||||
}
|
||||
|
|
|
@ -93,7 +93,7 @@ class MediaDetailViewController: OWSViewController, UIScrollViewDelegate, OWSVid
|
|||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
self.view.backgroundColor = Colors.navigationBarBackground
|
||||
self.view.themeBackgroundColor = .newConversation_background
|
||||
|
||||
self.view.addSubview(scrollView)
|
||||
scrollView.pin(to: self.view)
|
||||
|
@ -185,13 +185,13 @@ class MediaDetailViewController: OWSViewController, UIScrollViewDelegate, OWSVid
|
|||
}
|
||||
else {
|
||||
self.mediaView = UIView()
|
||||
self.mediaView.backgroundColor = Colors.unimportant
|
||||
self.mediaView.themeBackgroundColor = .newConversation_background
|
||||
}
|
||||
}
|
||||
else if self.image == nil {
|
||||
// Still loading thumbnail.
|
||||
self.mediaView = UIView()
|
||||
self.mediaView.backgroundColor = Colors.unimportant
|
||||
self.mediaView.themeBackgroundColor = .newConversation_background
|
||||
}
|
||||
else if self.galleryItem.attachment.isVideo {
|
||||
if self.galleryItem.attachment.isValid {
|
||||
|
@ -199,7 +199,7 @@ class MediaDetailViewController: OWSViewController, UIScrollViewDelegate, OWSVid
|
|||
}
|
||||
else {
|
||||
self.mediaView = UIView()
|
||||
self.mediaView.backgroundColor = Colors.unimportant
|
||||
self.mediaView.themeBackgroundColor = .newConversation_background
|
||||
}
|
||||
}
|
||||
else {
|
||||
|
|
|
@ -4,7 +4,7 @@ import UIKit
|
|||
import SignalUtilitiesKit
|
||||
import SessionUIKit
|
||||
|
||||
class MediaGalleryNavigationController: OWSNavigationController {
|
||||
class MediaGalleryNavigationController: UINavigationController {
|
||||
// HACK: Though we don't have an input accessory view, the VC we are presented above (ConversationVC) does.
|
||||
// If the app is backgrounded and then foregrounded, when OWSWindowManager calls mainWindow.makeKeyAndVisible
|
||||
// the ConversationVC's inputAccessoryView will appear *above* us unless we'd previously become first responder.
|
||||
|
@ -16,31 +16,19 @@ class MediaGalleryNavigationController: OWSNavigationController {
|
|||
|
||||
private lazy var backgroundView: UIView = {
|
||||
let result: UIView = UIView()
|
||||
result.backgroundColor = Colors.navigationBarBackground
|
||||
result.themeBackgroundColor = .newConversation_background
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: - View Lifecycle
|
||||
|
||||
override var preferredStatusBarStyle: UIStatusBarStyle {
|
||||
return (isLightMode ? .default : .lightContent)
|
||||
}
|
||||
override var preferredStatusBarStyle: UIStatusBarStyle { return ThemeManager.currentTheme.statusBarStyle }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
guard let navigationBar = self.navigationBar as? OWSNavigationBar else {
|
||||
owsFailDebug("navigationBar had unexpected class: \(self.navigationBar)")
|
||||
return
|
||||
}
|
||||
|
||||
view.backgroundColor = Colors.navigationBarBackground
|
||||
|
||||
navigationBar.setBackgroundImage(UIImage(), for: UIBarMetrics.default)
|
||||
navigationBar.shadowImage = UIImage()
|
||||
navigationBar.isTranslucent = false
|
||||
navigationBar.barTintColor = Colors.navigationBarBackground
|
||||
|
||||
view.themeBackgroundColor = .newConversation_background
|
||||
|
||||
// Insert a view to ensure the nav bar colour goes to the top of the screen
|
||||
relayoutBackgroundView()
|
||||
|
@ -77,7 +65,7 @@ class MediaGalleryNavigationController: OWSNavigationController {
|
|||
|
||||
override func setNavigationBarHidden(_ hidden: Bool, animated: Bool) {
|
||||
super.setNavigationBarHidden(hidden, animated: animated)
|
||||
|
||||
|
||||
backgroundView.isHidden = hidden
|
||||
relayoutBackgroundView()
|
||||
}
|
||||
|
|
|
@ -402,6 +402,7 @@ public class MediaGalleryViewModel {
|
|||
.baseQuery(
|
||||
orderSQL: SQL(interactionAttachment[.albumIndex]),
|
||||
customFilters: SQL("""
|
||||
\(attachment[.isVisualMedia]) = true AND
|
||||
\(attachment[.isValid]) = true AND
|
||||
\(interaction[.id]) = \(interactionId)
|
||||
""")
|
||||
|
@ -416,6 +417,8 @@ public class MediaGalleryViewModel {
|
|||
.baseQuery(
|
||||
orderSQL: Item.galleryReverseOrderSQL,
|
||||
customFilters: SQL("""
|
||||
\(attachment[.isVisualMedia]) = true AND
|
||||
\(attachment[.isValid]) = true AND
|
||||
\(interaction[.timestampMs]) > \(albumTimestampMs) AND
|
||||
\(interaction[.threadId]) = \(threadId)
|
||||
""")
|
||||
|
@ -425,6 +428,8 @@ public class MediaGalleryViewModel {
|
|||
.baseQuery(
|
||||
orderSQL: Item.galleryOrderSQL,
|
||||
customFilters: SQL("""
|
||||
\(attachment[.isVisualMedia]) = true AND
|
||||
\(attachment[.isValid]) = true AND
|
||||
\(interaction[.timestampMs]) < \(albumTimestampMs) AND
|
||||
\(interaction[.threadId]) = \(threadId)
|
||||
""")
|
||||
|
@ -608,17 +613,20 @@ public class MediaGalleryViewModel {
|
|||
threadId: threadId,
|
||||
threadVariant: threadVariant,
|
||||
focusedAttachmentId: focusedAttachmentId,
|
||||
performInitialQuerySync: performInitialQuerySync)
|
||||
performInitialQuerySync: performInitialQuerySync
|
||||
)
|
||||
|
||||
let documentTitleViewController = createDocumentTitleViewController(
|
||||
threadId: threadId,
|
||||
threadVariant: threadVariant,
|
||||
focusedAttachmentId: focusedAttachmentId,
|
||||
performInitialQuerySync: performInitialQuerySync)
|
||||
performInitialQuerySync: performInitialQuerySync
|
||||
)
|
||||
|
||||
return AllMediaViewController(
|
||||
mediaTitleViewController: mediaTitleViewController,
|
||||
documentTitleViewController: documentTitleViewController)
|
||||
documentTitleViewController: documentTitleViewController
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -629,7 +637,7 @@ public class MediaGalleryViewModel {
|
|||
@objc(SNMediaGallery)
|
||||
public class SNMediaGallery: NSObject {
|
||||
@objc(pushTileViewWithSliderEnabledForThreadId:isClosedGroup:isOpenGroup:fromNavController:)
|
||||
static func pushTileView(threadId: String, isClosedGroup: Bool, isOpenGroup: Bool, fromNavController: OWSNavigationController) {
|
||||
static func pushTileView(threadId: String, isClosedGroup: Bool, isOpenGroup: Bool, fromNavController: UINavigationController) {
|
||||
fromNavController.pushViewController(
|
||||
MediaGalleryViewModel.createAllMediaViewController(
|
||||
threadId: threadId,
|
||||
|
|
|
@ -32,6 +32,17 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
|||
owsFailDebug("unexpectedly unable to build new gallery page")
|
||||
return
|
||||
}
|
||||
|
||||
// Cache and retrieve the new album items
|
||||
viewModel.loadAndCacheAlbumData(
|
||||
for: item.interactionId,
|
||||
in: self.viewModel.threadId
|
||||
)
|
||||
|
||||
// Swap out the database observer
|
||||
dataChangeObservable?.cancel()
|
||||
viewModel.replaceAlbumObservation(toObservationFor: item.interactionId)
|
||||
startObservingChanges()
|
||||
|
||||
updateTitle(item: item)
|
||||
updateCaption(item: item)
|
||||
|
@ -93,12 +104,12 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
|||
var footerBar: UIToolbar = {
|
||||
let result: UIToolbar = UIToolbar()
|
||||
result.clipsToBounds = true // hide 1px top-border
|
||||
result.tintColor = Colors.text
|
||||
result.barTintColor = Colors.navigationBarBackground
|
||||
result.themeTintColor = .textPrimary
|
||||
result.themeBarTintColor = .backgroundPrimary
|
||||
result.themeBackgroundColor = .backgroundPrimary
|
||||
result.setBackgroundImage(UIImage(), forToolbarPosition: .any, barMetrics: UIBarMetrics.default)
|
||||
result.setShadowImage(UIImage(), forToolbarPosition: .any)
|
||||
result.isTranslucent = false
|
||||
result.backgroundColor = Colors.navigationBarBackground
|
||||
|
||||
return result
|
||||
}()
|
||||
|
@ -115,7 +126,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
|||
|
||||
// Navigation
|
||||
|
||||
let backButton = OWSViewController.createOWSBackButton(withTarget: self, selector: #selector(didPressDismissButton))
|
||||
let backButton = UIViewController.createOWSBackButton(target: self, selector: #selector(didPressDismissButton))
|
||||
self.navigationItem.leftBarButtonItem = backButton
|
||||
self.navigationItem.titleView = portraitHeaderView
|
||||
|
||||
|
@ -154,9 +165,9 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
|||
}
|
||||
|
||||
// Views
|
||||
pagerScrollView.backgroundColor = Colors.navigationBarBackground
|
||||
pagerScrollView.themeBackgroundColor = .newConversation_background
|
||||
|
||||
view.backgroundColor = Colors.navigationBarBackground
|
||||
view.themeBackgroundColor = .newConversation_background
|
||||
|
||||
captionContainerView.delegate = self
|
||||
updateCaptionContainerVisibility()
|
||||
|
@ -169,7 +180,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
|||
let bottomContainer: DynamicallySizedView = DynamicallySizedView()
|
||||
bottomContainer.clipsToBounds = true
|
||||
bottomContainer.autoresizingMask = .flexibleHeight
|
||||
bottomContainer.backgroundColor = Colors.navigationBarBackground
|
||||
bottomContainer.themeBackgroundColor = .backgroundPrimary
|
||||
self.bottomContainer = bottomContainer
|
||||
|
||||
let bottomStack = UIStackView(arrangedSubviews: [captionContainerView, galleryRailView, footerBar])
|
||||
|
@ -179,7 +190,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
|||
bottomStack.autoPinEdgesToSuperviewEdges()
|
||||
|
||||
let galleryRailBlockingView: UIView = UIView()
|
||||
galleryRailBlockingView.backgroundColor = Colors.navigationBarBackground
|
||||
galleryRailBlockingView.themeBackgroundColor = .backgroundPrimary
|
||||
bottomStack.addSubview(galleryRailBlockingView)
|
||||
galleryRailBlockingView.pin(.top, to: .bottom, of: footerBar)
|
||||
galleryRailBlockingView.pin(.left, to: .left, of: bottomStack)
|
||||
|
@ -196,12 +207,6 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
|||
let verticalSwipe = UISwipeGestureRecognizer(target: self, action: #selector(didSwipeView))
|
||||
verticalSwipe.direction = [.up, .down]
|
||||
view.addGestureRecognizer(verticalSwipe)
|
||||
|
||||
let navigationBar = navigationController!.navigationBar
|
||||
navigationBar.setBackgroundImage(UIImage(), for: UIBarMetrics.default)
|
||||
navigationBar.shadowImage = UIImage()
|
||||
navigationBar.isTranslucent = false
|
||||
navigationBar.barTintColor = Colors.navigationBarBackground
|
||||
|
||||
// Notifications
|
||||
NotificationCenter.default.addObserver(
|
||||
|
@ -295,10 +300,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
|||
private var shouldHideToolbars: Bool = false {
|
||||
didSet {
|
||||
guard oldValue != shouldHideToolbars else { return }
|
||||
|
||||
// Hiding the status bar affects the positioning of the navbar. We don't want to show
|
||||
// that in an animation, it's better to just have everythign "flit" in/out
|
||||
UIApplication.shared.setStatusBarHidden(shouldHideToolbars, with: .none)
|
||||
|
||||
self.navigationController?.setNavigationBarHidden(shouldHideToolbars, animated: false)
|
||||
|
||||
UIView.animate(withDuration: 0.1) {
|
||||
|
@ -316,7 +318,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
|||
target: self,
|
||||
action: #selector(didPressShare)
|
||||
)
|
||||
shareBarButton.tintColor = Colors.text
|
||||
shareBarButton.themeTintColor = .textPrimary
|
||||
|
||||
return shareBarButton
|
||||
}()
|
||||
|
@ -327,7 +329,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
|||
target: self,
|
||||
action: #selector(didPressDelete)
|
||||
)
|
||||
deleteBarButton.tintColor = Colors.text
|
||||
deleteBarButton.themeTintColor = .textPrimary
|
||||
|
||||
return deleteBarButton
|
||||
}()
|
||||
|
@ -342,7 +344,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
|||
target: self,
|
||||
action: #selector(didPressPlayBarButton)
|
||||
)
|
||||
videoPlayBarButton.tintColor = Colors.text
|
||||
videoPlayBarButton.themeTintColor = .textPrimary
|
||||
|
||||
return videoPlayBarButton
|
||||
}()
|
||||
|
@ -353,7 +355,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
|||
target: self,
|
||||
action: #selector(didPressPauseBarButton)
|
||||
)
|
||||
videoPauseBarButton.tintColor = Colors.text
|
||||
videoPauseBarButton.themeTintColor = .textPrimary
|
||||
|
||||
return videoPauseBarButton
|
||||
}()
|
||||
|
@ -579,10 +581,11 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
|||
.deleteAll(db)
|
||||
}
|
||||
}
|
||||
actionSheet.addAction(OWSAlerts.cancelAction)
|
||||
actionSheet.addAction(UIAlertAction(title: "TXT_CANCEL_TITLE".localized(), style: .cancel))
|
||||
actionSheet.addAction(deleteAction)
|
||||
|
||||
self.presentAlert(actionSheet)
|
||||
Modal.setupForIPadIfNeeded(actionSheet, targetView: self.view)
|
||||
self.present(actionSheet, animated: true)
|
||||
}
|
||||
|
||||
// MARK: - Video interaction
|
||||
|
@ -818,9 +821,9 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
|||
}()
|
||||
|
||||
lazy private var portraitHeaderNameLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.textColor = Colors.text
|
||||
let label: UILabel = UILabel()
|
||||
label.font = .systemFont(ofSize: Values.mediumFontSize)
|
||||
label.themeTextColor = .textPrimary
|
||||
label.textAlignment = .center
|
||||
label.adjustsFontSizeToFitWidth = true
|
||||
label.minimumScaleFactor = 0.8
|
||||
|
@ -829,9 +832,9 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
|||
}()
|
||||
|
||||
lazy private var portraitHeaderDateLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.textColor = Colors.text
|
||||
let label: UILabel = UILabel()
|
||||
label.font = .systemFont(ofSize: Values.verySmallFontSize)
|
||||
label.themeTextColor = .textPrimary
|
||||
label.textAlignment = .center
|
||||
label.adjustsFontSizeToFitWidth = true
|
||||
label.minimumScaleFactor = 0.8
|
||||
|
@ -840,7 +843,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
|||
}()
|
||||
|
||||
private lazy var portraitHeaderView: UIView = {
|
||||
let stackView = UIStackView()
|
||||
let stackView: UIStackView = UIStackView()
|
||||
stackView.axis = .vertical
|
||||
stackView.alignment = .center
|
||||
stackView.spacing = 0
|
||||
|
@ -915,12 +918,15 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
|||
|
||||
extension MediaGalleryViewModel.Item: GalleryRailItem {
|
||||
public func buildRailItemView() -> UIView {
|
||||
let imageView = UIImageView()
|
||||
let imageView: UIImageView = UIImageView()
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
getRailImage().map { [weak imageView] image in
|
||||
guard let imageView = imageView else { return }
|
||||
imageView.image = image
|
||||
}.retainUntilComplete()
|
||||
|
||||
getRailImage()
|
||||
.map { [weak imageView] image in
|
||||
guard let imageView = imageView else { return }
|
||||
imageView.image = image
|
||||
}
|
||||
.retainUntilComplete()
|
||||
|
||||
return imageView
|
||||
}
|
||||
|
|
|
@ -23,7 +23,7 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour
|
|||
private var isAutoLoadingNextPage: Bool = false
|
||||
private var currentTargetOffset: CGPoint?
|
||||
|
||||
public var delegate: MediaTileViewControllerDelegate?
|
||||
public weak var delegate: MediaTileViewControllerDelegate?
|
||||
|
||||
var isInBatchSelectMode = false {
|
||||
didSet {
|
||||
|
@ -71,7 +71,7 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour
|
|||
lazy var collectionView: UICollectionView = {
|
||||
let result: UICollectionView = UICollectionView(frame: .zero, collectionViewLayout: mediaTileViewLayout)
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.backgroundColor = Colors.navigationBarBackground
|
||||
result.themeBackgroundColor = .newConversation_background
|
||||
result.delegate = self
|
||||
result.dataSource = self
|
||||
result.register(view: PhotoGridViewCell.self)
|
||||
|
@ -95,8 +95,8 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour
|
|||
animated: false
|
||||
)
|
||||
|
||||
result.barTintColor = Colors.navigationBarBackground
|
||||
result.tintColor = Colors.text
|
||||
result.themeBarTintColor = .backgroundPrimary
|
||||
result.themeTintColor = .textPrimary
|
||||
|
||||
return result
|
||||
}()
|
||||
|
@ -107,19 +107,21 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour
|
|||
target: self,
|
||||
action: #selector(didPressDelete)
|
||||
)
|
||||
result.tintColor = Colors.text
|
||||
result.themeTintColor = .textPrimary
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
|
||||
override public func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
view.themeBackgroundColor = .newConversation_background
|
||||
|
||||
// Add a custom back button if this is the only view controller
|
||||
if self.navigationController?.viewControllers.first == self {
|
||||
let backButton = OWSViewController.createOWSBackButton(withTarget: self, selector: #selector(didPressDismissButton))
|
||||
let backButton = UIViewController.createOWSBackButton(target: self, selector: #selector(didPressDismissButton))
|
||||
self.navigationItem.leftBarButtonItem = backButton
|
||||
}
|
||||
|
||||
|
@ -706,9 +708,10 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour
|
|||
|
||||
let actionSheet = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
|
||||
actionSheet.addAction(deleteAction)
|
||||
actionSheet.addAction(OWSAlerts.cancelAction)
|
||||
actionSheet.addAction(UIAlertAction(title: "TXT_CANCEL_TITLE".localized(), style: .cancel))
|
||||
|
||||
presentAlert(actionSheet)
|
||||
Modal.setupForIPadIfNeeded(actionSheet, targetView: self.view)
|
||||
self.present(actionSheet, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -763,25 +766,21 @@ private class MediaGallerySectionHeader: UICollectionReusableView {
|
|||
|
||||
override init(frame: CGRect) {
|
||||
label = UILabel()
|
||||
label.textColor = Colors.text
|
||||
|
||||
let blurEffect = UIBlurEffect(style: .dark)
|
||||
let blurEffectView = UIVisualEffectView(effect: blurEffect)
|
||||
|
||||
blurEffectView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
label.themeTextColor = .textPrimary
|
||||
|
||||
super.init(frame: frame)
|
||||
|
||||
self.backgroundColor = isLightMode ? Colors.cellBackground : UIColor.ows_black.withAlphaComponent(OWSNavigationBar.backgroundBlurMutingFactor)
|
||||
self.themeBackgroundColor = .clear
|
||||
|
||||
let backgroundView: UIView = UIView()
|
||||
backgroundView.themeBackgroundColor = .newConversation_background
|
||||
addSubview(backgroundView)
|
||||
backgroundView.pin(to: self)
|
||||
|
||||
self.addSubview(blurEffectView)
|
||||
self.addSubview(label)
|
||||
|
||||
blurEffectView.autoPinEdgesToSuperviewEdges()
|
||||
blurEffectView.isHidden = isLightMode
|
||||
label.autoPinEdge(toSuperviewMargin: .trailing)
|
||||
label.autoPinEdge(toSuperviewMargin: .leading)
|
||||
label.autoVCenterInSuperview()
|
||||
label.pin(.leading, to: .leading, of: self, withInset: Values.largeSpacing)
|
||||
label.pin(.trailing, to: .trailing, of: self, withInset: -Values.largeSpacing)
|
||||
label.center(.vertical, in: self)
|
||||
}
|
||||
|
||||
@available(*, unavailable, message: "Unimplemented")
|
||||
|
@ -811,7 +810,7 @@ private class MediaGalleryStaticHeader: UICollectionViewCell {
|
|||
|
||||
addSubview(label)
|
||||
|
||||
label.textColor = Colors.text
|
||||
label.themeTextColor = .textPrimary
|
||||
label.textAlignment = .center
|
||||
label.numberOfLines = 0
|
||||
label.autoPinEdgesToSuperviewMargins(with: UIEdgeInsets(top: 0, leading: Values.largeSpacing, bottom: 0, trailing: Values.largeSpacing))
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
import PromiseKit
|
||||
import CoreServices
|
||||
|
||||
|
@ -54,9 +55,9 @@ class PhotoCapture: NSObject {
|
|||
self.session.beginConfiguration()
|
||||
defer { self.session.commitConfiguration() }
|
||||
|
||||
let audioDevice = AVCaptureDevice.default(for: .audio)
|
||||
guard let audioDevice: AVCaptureDevice = AVCaptureDevice.default(for: .audio) else { return }
|
||||
// verify works without audio permissions
|
||||
let audioDeviceInput = try AVCaptureDeviceInput(device: audioDevice!)
|
||||
let audioDeviceInput = try AVCaptureDeviceInput(device: audioDevice)
|
||||
if session.canAddInput(audioDeviceInput) {
|
||||
// self.session.addInputWithNoConnections(audioDeviceInput)
|
||||
session.addInput(audioDeviceInput)
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import AVFoundation
|
||||
import PromiseKit
|
||||
import SessionUIKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
protocol PhotoCaptureViewControllerDelegate: AnyObject {
|
||||
func photoCaptureViewController(_ photoCaptureViewController: PhotoCaptureViewController, didFinishProcessingAttachment attachment: SignalAttachment)
|
||||
|
@ -49,7 +49,7 @@ class PhotoCaptureViewController: OWSViewController {
|
|||
|
||||
override func loadView() {
|
||||
self.view = UIView()
|
||||
self.view.backgroundColor = .black
|
||||
self.view.themeBackgroundColor = .newConversation_background
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
|
@ -82,7 +82,8 @@ class PhotoCaptureViewController: OWSViewController {
|
|||
navigationItem.rightBarButtonItems = nil
|
||||
navigationItem.titleView = recordingTimerView
|
||||
recordingTimerView.sizeToFit()
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
navigationItem.titleView = nil
|
||||
navigationItem.leftBarButtonItem = dismissControl.barButtonItem
|
||||
let fixedSpace = UIBarButtonItem(barButtonSystemItem: .fixedSpace, target: nil, action: nil)
|
||||
|
@ -114,8 +115,9 @@ class PhotoCaptureViewController: OWSViewController {
|
|||
let barButtonItem: UIBarButtonItem
|
||||
|
||||
init(imageName: String, block: @escaping () -> Void) {
|
||||
self.button = OWSButton(imageName: imageName, tintColor: .ows_white, block: block)
|
||||
self.button = OWSButton(imageName: imageName, tintColor: .white, block: block)
|
||||
button.autoPinToSquareAspectRatio()
|
||||
button.themeShadowColor = .black
|
||||
button.layer.shadowOffset = CGSize.zero
|
||||
button.layer.shadowOpacity = 0.35
|
||||
button.layer.shadowRadius = 4
|
||||
|
@ -222,10 +224,12 @@ class PhotoCaptureViewController: OWSViewController {
|
|||
private func setupOrientationMonitoring() {
|
||||
UIDevice.current.beginGeneratingDeviceOrientationNotifications()
|
||||
|
||||
NotificationCenter.default.addObserver(self,
|
||||
selector: #selector(didChangeDeviceOrientation),
|
||||
name: UIDevice.orientationDidChangeNotification,
|
||||
object: UIDevice.current)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(didChangeDeviceOrientation),
|
||||
name: UIDevice.orientationDidChangeNotification,
|
||||
object: UIDevice.current
|
||||
)
|
||||
}
|
||||
|
||||
var lastKnownCaptureOrientation: AVCaptureVideoOrientation = .portrait
|
||||
|
@ -282,15 +286,14 @@ class PhotoCaptureViewController: OWSViewController {
|
|||
captureButton.delegate = photoCapture
|
||||
previewView = CapturePreviewView(session: photoCapture.session)
|
||||
|
||||
photoCapture.startCapture().done { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
self.showCaptureUI()
|
||||
}.catch { [weak self] error in
|
||||
guard let self = self else { return }
|
||||
|
||||
self.showFailureUI(error: error)
|
||||
}.retainUntilComplete()
|
||||
photoCapture.startCapture()
|
||||
.done { [weak self] in
|
||||
self?.showCaptureUI()
|
||||
}
|
||||
.catch { [weak self] error in
|
||||
self?.showFailureUI(error: error)
|
||||
}
|
||||
.retainUntilComplete()
|
||||
}
|
||||
|
||||
private func showCaptureUI() {
|
||||
|
@ -309,11 +312,17 @@ class PhotoCaptureViewController: OWSViewController {
|
|||
|
||||
private func showFailureUI(error: Error) {
|
||||
Logger.error("error: \(error)")
|
||||
|
||||
OWSAlerts.showAlert(title: nil,
|
||||
message: error.localizedDescription,
|
||||
buttonTitle: CommonStrings.dismissButton,
|
||||
buttonAction: { [weak self] _ in self?.dismiss(animated: true) })
|
||||
let modal: ConfirmationModal = ConfirmationModal(
|
||||
info: ConfirmationModal.Info(
|
||||
title: CommonStrings.errorAlertTitle,
|
||||
explanation: error.localizedDescription,
|
||||
cancelTitle: CommonStrings.dismissButton,
|
||||
cancelStyle: .alert_text,
|
||||
afterClosed: { [weak self] in self?.dismiss(animated: true) }
|
||||
)
|
||||
)
|
||||
|
||||
present(modal, animated: true)
|
||||
}
|
||||
|
||||
private func updateFlashModeControl() {
|
||||
|
@ -421,16 +430,17 @@ class CaptureButton: UIView {
|
|||
|
||||
addSubview(innerButton)
|
||||
innerButtonSizeConstraints = autoSetDimensions(to: CGSize(width: defaultDiameter, height: defaultDiameter))
|
||||
innerButton.backgroundColor = UIColor.ows_white.withAlphaComponent(0.33)
|
||||
innerButton.themeBackgroundColor = .white
|
||||
innerButton.layer.shadowOffset = .zero
|
||||
innerButton.layer.shadowOpacity = 0.33
|
||||
innerButton.layer.shadowRadius = 2
|
||||
innerButton.alpha = 0.33
|
||||
innerButton.autoPinEdgesToSuperviewEdges()
|
||||
|
||||
addSubview(zoomIndicator)
|
||||
zoomIndicatorSizeConstraints = zoomIndicator.autoSetDimensions(to: CGSize(width: defaultDiameter, height: defaultDiameter))
|
||||
zoomIndicator.isUserInteractionEnabled = false
|
||||
zoomIndicator.layer.borderColor = UIColor.ows_white.cgColor
|
||||
zoomIndicator.themeBorderColor = .white
|
||||
zoomIndicator.layer.borderWidth = 1.5
|
||||
zoomIndicator.autoAlignAxis(.horizontal, toSameAxisOf: innerButton)
|
||||
zoomIndicator.autoAlignAxis(.vertical, toSameAxisOf: innerButton)
|
||||
|
@ -569,10 +579,10 @@ class RecordingTimerView: UIView {
|
|||
// MARK: - Subviews
|
||||
|
||||
private lazy var label: UILabel = {
|
||||
let label = UILabel()
|
||||
label.font = UIFont.ows_monospacedDigitFont(withSize: 20)
|
||||
let label: UILabel = UILabel()
|
||||
label.font = .ows_monospacedDigitFont(withSize: 20)
|
||||
label.themeTextColor = .textPrimary
|
||||
label.textAlignment = .center
|
||||
label.textColor = UIColor.white
|
||||
label.layer.shadowOffset = CGSize.zero
|
||||
label.layer.shadowOpacity = 0.35
|
||||
label.layer.shadowRadius = 4
|
||||
|
@ -587,8 +597,7 @@ class RecordingTimerView: UIView {
|
|||
icon.layer.shadowOffset = CGSize.zero
|
||||
icon.layer.shadowOpacity = 0.35
|
||||
icon.layer.shadowRadius = 4
|
||||
|
||||
icon.backgroundColor = .red
|
||||
icon.themeBackgroundColor = .danger
|
||||
icon.autoSetDimensions(to: CGSize(width: iconWidth, height: iconWidth))
|
||||
icon.alpha = 0
|
||||
|
||||
|
@ -601,10 +610,12 @@ class RecordingTimerView: UIView {
|
|||
func startCounting() {
|
||||
recordingStartTime = CACurrentMediaTime()
|
||||
timer = Timer.weakScheduledTimer(withTimeInterval: 0.1, target: self, selector: #selector(updateView), userInfo: nil, repeats: true)
|
||||
UIView.animate(withDuration: 0.5,
|
||||
delay: 0,
|
||||
options: [.autoreverse, .repeat],
|
||||
animations: { self.icon.alpha = 1 })
|
||||
UIView.animate(
|
||||
withDuration: 0.5,
|
||||
delay: 0,
|
||||
options: [.autoreverse, .repeat],
|
||||
animations: { self.icon.alpha = 1 }
|
||||
)
|
||||
}
|
||||
|
||||
func stopCounting() {
|
||||
|
|
|
@ -1,138 +0,0 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Photos
|
||||
import PromiseKit
|
||||
|
||||
protocol PhotoCollectionPickerDelegate: AnyObject {
|
||||
func photoCollectionPicker(_ photoCollectionPicker: PhotoCollectionPickerController, didPickCollection collection: PhotoCollection)
|
||||
}
|
||||
|
||||
class PhotoCollectionPickerController: OWSTableViewController, PhotoLibraryDelegate {
|
||||
|
||||
private weak var collectionDelegate: PhotoCollectionPickerDelegate?
|
||||
|
||||
private let library: PhotoLibrary
|
||||
private var photoCollections: [PhotoCollection]
|
||||
|
||||
required init(library: PhotoLibrary,
|
||||
collectionDelegate: PhotoCollectionPickerDelegate) {
|
||||
self.library = library
|
||||
self.photoCollections = library.allPhotoCollections()
|
||||
self.collectionDelegate = collectionDelegate
|
||||
super.init()
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
// MARK: View Lifecycle
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
view.backgroundColor = .white
|
||||
tableView.backgroundColor = .white
|
||||
tableView.separatorColor = .clear
|
||||
|
||||
library.add(delegate: self)
|
||||
|
||||
updateContents()
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
private func updateContents() {
|
||||
photoCollections = library.allPhotoCollections()
|
||||
|
||||
let sectionItems = photoCollections.map { collection in
|
||||
return OWSTableItem(customCellBlock: { self.buildTableCell(collection: collection) },
|
||||
customRowHeight: UITableView.automaticDimension,
|
||||
actionBlock: { [weak self] in
|
||||
guard let strongSelf = self else { return }
|
||||
strongSelf.didSelectCollection(collection: collection)
|
||||
})
|
||||
}
|
||||
|
||||
let section = OWSTableSection(title: nil, items: sectionItems)
|
||||
let contents = OWSTableContents()
|
||||
contents.addSection(section)
|
||||
self.contents = contents
|
||||
}
|
||||
|
||||
private let numberFormatter: NumberFormatter = NumberFormatter()
|
||||
|
||||
private func buildTableCell(collection: PhotoCollection) -> UITableViewCell {
|
||||
let cell = OWSTableItem.newCell()
|
||||
|
||||
cell.backgroundColor = .white
|
||||
cell.contentView.backgroundColor = .white
|
||||
cell.selectedBackgroundView?.backgroundColor = UIColor(white: 0.2, alpha: 1)
|
||||
|
||||
let contents = collection.contents()
|
||||
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.text = collection.localizedTitle()
|
||||
titleLabel.font = .systemFont(ofSize: Values.mediumFontSize)
|
||||
titleLabel.textColor = .black
|
||||
|
||||
let countLabel = UILabel()
|
||||
countLabel.text = numberFormatter.string(for: contents.assetCount)
|
||||
countLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
countLabel.textColor = .black
|
||||
|
||||
let textStack = UIStackView(arrangedSubviews: [titleLabel, countLabel])
|
||||
textStack.axis = .vertical
|
||||
textStack.alignment = .leading
|
||||
textStack.spacing = 2
|
||||
|
||||
let imageView = UIImageView()
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
imageView.clipsToBounds = true
|
||||
let kImageSize = 80
|
||||
imageView.autoSetDimensions(to: CGSize(width: kImageSize, height: kImageSize))
|
||||
|
||||
let hStackView = UIStackView(arrangedSubviews: [imageView, textStack])
|
||||
hStackView.axis = .horizontal
|
||||
hStackView.alignment = .center
|
||||
hStackView.spacing = Values.mediumSpacing
|
||||
|
||||
let photoMediaSize = PhotoMediaSize(thumbnailSize: CGSize(width: kImageSize, height: kImageSize))
|
||||
if let assetItem = contents.lastAssetItem(photoMediaSize: photoMediaSize) {
|
||||
assetItem.asyncThumbnail { [weak imageView] image in
|
||||
AssertIsOnMainThread()
|
||||
|
||||
guard let imageView = imageView else {
|
||||
return
|
||||
}
|
||||
|
||||
guard let image = image else {
|
||||
owsFailDebug("image was unexpectedly nil")
|
||||
return
|
||||
}
|
||||
|
||||
imageView.image = image
|
||||
}
|
||||
}
|
||||
|
||||
cell.contentView.addSubview(hStackView)
|
||||
hStackView.ows_autoPinToSuperviewMargins()
|
||||
|
||||
return cell
|
||||
}
|
||||
|
||||
// MARK: Actions
|
||||
|
||||
func didSelectCollection(collection: PhotoCollection) {
|
||||
collectionDelegate?.photoCollectionPicker(self, didPickCollection: collection)
|
||||
}
|
||||
|
||||
// MARK: PhotoLibraryDelegate
|
||||
|
||||
func photoLibraryDidChange(_ photoLibrary: PhotoLibrary) {
|
||||
updateContents()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,92 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import GRDB
|
||||
import DifferenceKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
class PhotoCollectionPickerViewModel: SessionTableViewModel<NoNav, PhotoCollectionPickerViewModel.Section, PhotoCollectionPickerViewModel.Item> {
|
||||
// MARK: - Config
|
||||
|
||||
public enum Section: SessionTableSection {
|
||||
case content
|
||||
}
|
||||
|
||||
public struct Item: Equatable, Hashable, Differentiable {
|
||||
let id: String
|
||||
}
|
||||
|
||||
private let library: PhotoLibrary
|
||||
private let onCollectionSelected: (PhotoCollection) -> Void
|
||||
private var photoCollections: CurrentValueSubject<[PhotoCollection], Error>
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(library: PhotoLibrary, onCollectionSelected: @escaping (PhotoCollection) -> Void) {
|
||||
self.library = library
|
||||
self.onCollectionSelected = onCollectionSelected
|
||||
self.photoCollections = CurrentValueSubject(library.allPhotoCollections())
|
||||
}
|
||||
|
||||
// MARK: - Content
|
||||
|
||||
override var title: String { "NOTIFICATIONS_STYLE_SOUND_TITLE".localized() }
|
||||
|
||||
private var _settingsData: [SectionModel] = []
|
||||
public override var settingsData: [SectionModel] { _settingsData }
|
||||
public override var observableSettingsData: ObservableData { _observableSettingsData }
|
||||
|
||||
private lazy var _observableSettingsData: ObservableData = {
|
||||
self.photoCollections
|
||||
.map { collections in
|
||||
[
|
||||
SectionModel(
|
||||
model: .content,
|
||||
elements: collections.map { collection in
|
||||
let contents: PhotoCollectionContents = collection.contents()
|
||||
let photoMediaSize: PhotoMediaSize = PhotoMediaSize(
|
||||
thumbnailSize: CGSize(
|
||||
width: IconSize.veryLarge.size,
|
||||
height: IconSize.veryLarge.size
|
||||
)
|
||||
)
|
||||
let lastAssetItem: PhotoPickerAssetItem? = contents.lastAssetItem(photoMediaSize: photoMediaSize)
|
||||
|
||||
return SessionCell.Info(
|
||||
id: Item(id: collection.id),
|
||||
leftAccessory: .iconAsync(size: .veryLarge, shouldFill: true) { imageView in
|
||||
// Note: We need to capture 'lastAssetItem' otherwise it'll be released and we won't
|
||||
// be able to load the thumbnail
|
||||
lastAssetItem?.asyncThumbnail { [weak imageView] image in
|
||||
imageView?.image = image
|
||||
}
|
||||
},
|
||||
title: collection.localizedTitle(),
|
||||
subtitle: "\(contents.assetCount)",
|
||||
onTap: { [weak self] in
|
||||
self?.onCollectionSelected(collection)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
]
|
||||
}
|
||||
.removeDuplicates()
|
||||
.eraseToAnyPublisher()
|
||||
}()
|
||||
|
||||
// MARK: - Functions
|
||||
|
||||
public override func updateSettings(_ updatedSettings: [SectionModel]) {
|
||||
self._settingsData = updatedSettings
|
||||
}
|
||||
|
||||
// MARK: PhotoLibraryDelegate
|
||||
|
||||
func photoLibraryDidChange(_ photoLibrary: PhotoLibrary) {
|
||||
self.photoCollections.send(library.allPhotoCollections())
|
||||
}
|
||||
}
|
|
@ -28,9 +28,9 @@ public class PhotoGridViewCell: UICollectionViewCell {
|
|||
|
||||
private static let videoBadgeImage = #imageLiteral(resourceName: "ic_gallery_badge_video")
|
||||
private static let animatedBadgeImage = #imageLiteral(resourceName: "ic_gallery_badge_gif")
|
||||
private static let selectedBadgeImage = #imageLiteral(resourceName: "selected_blue_circle")
|
||||
private static let selectedBadgeImage = UIImage(systemName: "checkmark.circle.fill")
|
||||
|
||||
public var loadingColor = Colors.unimportant
|
||||
public var loadingColor: ThemeValue = .textSecondary
|
||||
|
||||
override public var isSelected: Bool {
|
||||
didSet {
|
||||
|
@ -52,18 +52,23 @@ public class PhotoGridViewCell: UICollectionViewCell {
|
|||
self.contentTypeBadgeView = UIImageView()
|
||||
contentTypeBadgeView.isHidden = true
|
||||
|
||||
let kSelectedBadgeSize = CGSize(width: 32, height: 32)
|
||||
self.selectedBadgeView = UIImageView()
|
||||
selectedBadgeView.image = PhotoGridViewCell.selectedBadgeImage
|
||||
selectedBadgeView.image = PhotoGridViewCell.selectedBadgeImage?.withRenderingMode(.alwaysTemplate)
|
||||
selectedBadgeView.themeTintColor = .primary
|
||||
selectedBadgeView.themeBorderColor = .textPrimary
|
||||
selectedBadgeView.themeBackgroundColor = .textPrimary
|
||||
selectedBadgeView.isHidden = true
|
||||
selectedBadgeView.layer.cornerRadius = (kSelectedBadgeSize.width / 2)
|
||||
|
||||
self.highlightedView = UIView()
|
||||
highlightedView.alpha = 0.2
|
||||
highlightedView.backgroundColor = Colors.cellSelected
|
||||
highlightedView.themeBackgroundColor = .black
|
||||
highlightedView.isHidden = true
|
||||
|
||||
self.selectedView = UIView()
|
||||
selectedView.alpha = 0.3
|
||||
selectedView.backgroundColor = Colors.cellSelected
|
||||
selectedView.themeBackgroundColor = .black
|
||||
selectedView.isHidden = true
|
||||
|
||||
super.init(frame: frame)
|
||||
|
@ -87,9 +92,8 @@ public class PhotoGridViewCell: UICollectionViewCell {
|
|||
contentTypeBadgeView.autoPinEdge(toSuperviewEdge: .bottom, withInset: 3)
|
||||
contentTypeBadgeView.autoSetDimensions(to: kContentTypeBadgeSize)
|
||||
|
||||
let kSelectedBadgeSize = CGSize(width: 31, height: 31)
|
||||
selectedBadgeView.autoPinEdge(toSuperviewEdge: .trailing, withInset: 0)
|
||||
selectedBadgeView.autoPinEdge(toSuperviewEdge: .bottom, withInset: 0)
|
||||
selectedBadgeView.autoPinEdge(toSuperviewEdge: .trailing, withInset: Values.verySmallSpacing)
|
||||
selectedBadgeView.autoPinEdge(toSuperviewEdge: .bottom, withInset: Values.verySmallSpacing)
|
||||
selectedBadgeView.autoSetDimensions(to: kSelectedBadgeSize)
|
||||
}
|
||||
|
||||
|
@ -102,7 +106,7 @@ public class PhotoGridViewCell: UICollectionViewCell {
|
|||
get { return imageView.image }
|
||||
set {
|
||||
imageView.image = newValue
|
||||
imageView.backgroundColor = newValue == nil ? loadingColor : .clear
|
||||
imageView.themeBackgroundColor = (newValue == nil ? loadingColor : .clear)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -216,13 +216,15 @@ class PhotoCollectionContents {
|
|||
}
|
||||
|
||||
class PhotoCollection {
|
||||
public let id: String
|
||||
private let collection: PHAssetCollection
|
||||
|
||||
// The user never sees this collection, but we use it for a null object pattern
|
||||
// when the user has denied photos access.
|
||||
static let empty = PhotoCollection(collection: PHAssetCollection())
|
||||
static let empty = PhotoCollection(id: "", collection: PHAssetCollection())
|
||||
|
||||
init(collection: PHAssetCollection) {
|
||||
init(id: String, collection: PHAssetCollection) {
|
||||
self.id = id
|
||||
self.collection = collection
|
||||
}
|
||||
|
||||
|
@ -289,7 +291,7 @@ class PhotoLibrary: NSObject, PHPhotoLibraryChangeObserver {
|
|||
subtype: .smartAlbumUserLibrary,
|
||||
options: fetchOptions
|
||||
).enumerateObjects { collection, _, stop in
|
||||
fetchedCollection = PhotoCollection(collection: collection)
|
||||
fetchedCollection = PhotoCollection(id: collection.localIdentifier, collection: collection)
|
||||
stop.pointee = true
|
||||
}
|
||||
|
||||
|
@ -310,17 +312,16 @@ class PhotoLibrary: NSObject, PHPhotoLibraryChangeObserver {
|
|||
let (collection, hideIfEmpty) = arg
|
||||
|
||||
// De-duplicate by id.
|
||||
let collectionId = collection.localIdentifier
|
||||
guard !collectionIds.contains(collectionId) else {
|
||||
return
|
||||
}
|
||||
let collectionId: String = collection.localIdentifier
|
||||
|
||||
guard !collectionIds.contains(collectionId) else { return }
|
||||
collectionIds.insert(collectionId)
|
||||
|
||||
guard let assetCollection = collection as? PHAssetCollection else {
|
||||
owsFailDebug("Asset collection has unexpected type: \(type(of: collection))")
|
||||
return
|
||||
}
|
||||
let photoCollection = PhotoCollection(collection: assetCollection)
|
||||
let photoCollection = PhotoCollection(id: collectionId, collection: assetCollection)
|
||||
guard !hideIfEmpty || photoCollection.contents().assetCount > 0 else {
|
||||
return
|
||||
}
|
||||
|
|
|
@ -5,8 +5,11 @@ import Photos
|
|||
import PromiseKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
class SendMediaNavigationController: OWSNavigationController {
|
||||
|
||||
class SendMediaNavigationController: UINavigationController {
|
||||
public override var preferredStatusBarStyle: UIStatusBarStyle {
|
||||
return ThemeManager.currentTheme.statusBarStyle
|
||||
}
|
||||
|
||||
// This is a sensitive constant, if you change it make sure to check
|
||||
// on iPhone5, 6, 6+, X, layouts.
|
||||
static let bottomButtonsCenterOffset: CGFloat = -50
|
||||
|
@ -18,7 +21,7 @@ class SendMediaNavigationController: OWSNavigationController {
|
|||
init(threadId: String) {
|
||||
self.threadId = threadId
|
||||
|
||||
super.init()
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
|
@ -26,9 +29,7 @@ class SendMediaNavigationController: OWSNavigationController {
|
|||
}
|
||||
|
||||
// MARK: - Overrides
|
||||
|
||||
override var prefersStatusBarHidden: Bool { return true }
|
||||
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
|
@ -39,7 +40,9 @@ class SendMediaNavigationController: OWSNavigationController {
|
|||
view.addSubview(batchModeButton)
|
||||
batchModeButton.setCompressionResistanceHigh()
|
||||
batchModeButton.centerYAnchor.constraint(equalTo: view.layoutMarginsGuide.bottomAnchor, constant: bottomButtonsCenterOffset).isActive = true
|
||||
batchModeButton.autoPinEdge(toSuperviewMargin: .trailing)
|
||||
batchModeButton.centerXAnchor
|
||||
.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor, constant: -20)
|
||||
.isActive = true
|
||||
|
||||
view.addSubview(doneButton)
|
||||
doneButton.setCompressionResistanceHigh()
|
||||
|
@ -48,13 +51,19 @@ class SendMediaNavigationController: OWSNavigationController {
|
|||
|
||||
view.addSubview(cameraModeButton)
|
||||
cameraModeButton.setCompressionResistanceHigh()
|
||||
cameraModeButton.centerYAnchor.constraint(equalTo: view.layoutMarginsGuide.bottomAnchor, constant: bottomButtonsCenterOffset).isActive = true
|
||||
cameraModeButton.autoPinEdge(toSuperviewMargin: .leading)
|
||||
cameraModeButton.centerYAnchor
|
||||
.constraint(equalTo: view.layoutMarginsGuide.bottomAnchor, constant: bottomButtonsCenterOffset)
|
||||
.isActive = true
|
||||
cameraModeButton.centerXAnchor
|
||||
.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor, constant: 20)
|
||||
.isActive = true
|
||||
|
||||
view.addSubview(mediaLibraryModeButton)
|
||||
mediaLibraryModeButton.setCompressionResistanceHigh()
|
||||
mediaLibraryModeButton.centerYAnchor.constraint(equalTo: view.layoutMarginsGuide.bottomAnchor, constant: bottomButtonsCenterOffset).isActive = true
|
||||
mediaLibraryModeButton.autoPinEdge(toSuperviewMargin: .leading)
|
||||
mediaLibraryModeButton.centerXAnchor
|
||||
.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor, constant: 20)
|
||||
.isActive = true
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
@ -81,9 +90,9 @@ class SendMediaNavigationController: OWSNavigationController {
|
|||
didSet {
|
||||
if oldValue != isInBatchSelectMode {
|
||||
mediaLibraryViewController.batchSelectModeDidChange()
|
||||
guard let topViewController = viewControllers.last else {
|
||||
return
|
||||
}
|
||||
|
||||
guard let topViewController = viewControllers.last else { return }
|
||||
|
||||
updateButtons(topViewController: topViewController)
|
||||
}
|
||||
}
|
||||
|
@ -91,23 +100,26 @@ class SendMediaNavigationController: OWSNavigationController {
|
|||
|
||||
func updateButtons(topViewController: UIViewController) {
|
||||
switch topViewController {
|
||||
case is AttachmentApprovalViewController:
|
||||
batchModeButton.isHidden = true
|
||||
doneButton.isHidden = true
|
||||
cameraModeButton.isHidden = true
|
||||
mediaLibraryModeButton.isHidden = true
|
||||
case is ImagePickerGridController:
|
||||
batchModeButton.isHidden = isInBatchSelectMode
|
||||
doneButton.isHidden = !isInBatchSelectMode || (attachmentDraftCollection.count == 0 && mediaLibrarySelections.count == 0)
|
||||
cameraModeButton.isHidden = false
|
||||
mediaLibraryModeButton.isHidden = true
|
||||
case is PhotoCaptureViewController:
|
||||
batchModeButton.isHidden = isInBatchSelectMode
|
||||
doneButton.isHidden = !isInBatchSelectMode || (attachmentDraftCollection.count == 0 && mediaLibrarySelections.count == 0)
|
||||
cameraModeButton.isHidden = true
|
||||
mediaLibraryModeButton.isHidden = false
|
||||
default:
|
||||
owsFailDebug("unexpected topViewController: \(topViewController)")
|
||||
case is AttachmentApprovalViewController:
|
||||
batchModeButton.isHidden = true
|
||||
doneButton.isHidden = true
|
||||
cameraModeButton.isHidden = true
|
||||
mediaLibraryModeButton.isHidden = true
|
||||
|
||||
case is ImagePickerGridController:
|
||||
batchModeButton.isHidden = isInBatchSelectMode
|
||||
doneButton.isHidden = !isInBatchSelectMode || (attachmentDraftCollection.count == 0 && mediaLibrarySelections.count == 0)
|
||||
cameraModeButton.isHidden = false
|
||||
mediaLibraryModeButton.isHidden = true
|
||||
|
||||
case is PhotoCaptureViewController:
|
||||
batchModeButton.isHidden = isInBatchSelectMode
|
||||
doneButton.isHidden = !isInBatchSelectMode || (attachmentDraftCollection.count == 0 && mediaLibrarySelections.count == 0)
|
||||
cameraModeButton.isHidden = true
|
||||
mediaLibraryModeButton.isHidden = false
|
||||
|
||||
default:
|
||||
owsFailDebug("unexpected topViewController: \(topViewController)")
|
||||
}
|
||||
|
||||
doneButton.updateCount()
|
||||
|
@ -129,19 +141,15 @@ class SendMediaNavigationController: OWSNavigationController {
|
|||
}
|
||||
|
||||
private func didTapCameraModeButton() {
|
||||
self.ows_ask(forCameraPermissions: { granted in
|
||||
if (granted) {
|
||||
self.fadeTo(viewControllers: [self.captureViewController])
|
||||
}
|
||||
})
|
||||
Permissions.requestCameraPermissionIfNeeded { [weak self] in
|
||||
self?.fadeTo(viewControllers: ((self?.captureViewController).map { [$0] } ?? []))
|
||||
}
|
||||
}
|
||||
|
||||
private func didTapMediaLibraryModeButton() {
|
||||
self.ows_ask(forMediaLibraryPermissions: { granted in
|
||||
if (granted) {
|
||||
self.fadeTo(viewControllers: [self.mediaLibraryViewController])
|
||||
}
|
||||
})
|
||||
Permissions.requestLibraryPermissionIfNeeded { [weak self] in
|
||||
self?.fadeTo(viewControllers: ((self?.mediaLibraryViewController).map { [$0] } ?? []))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Views
|
||||
|
@ -150,54 +158,35 @@ class SendMediaNavigationController: OWSNavigationController {
|
|||
private lazy var doneButton: DoneButton = {
|
||||
let button = DoneButton()
|
||||
button.delegate = self
|
||||
button.setShadow()
|
||||
|
||||
return button
|
||||
}()
|
||||
|
||||
private lazy var batchModeButton: UIButton = {
|
||||
let button = OWSButton(imageName: "media_send_batch_mode_disabled",
|
||||
tintColor: .ows_gray60,
|
||||
block: { [weak self] in self?.didTapBatchModeButton() })
|
||||
|
||||
let width: CGFloat = type(of: self).bottomButtonWidth
|
||||
button.autoSetDimensions(to: CGSize(width: width, height: width))
|
||||
button.layer.cornerRadius = width / 2
|
||||
button.imageEdgeInsets = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10)
|
||||
button.backgroundColor = .ows_white
|
||||
button.setShadow()
|
||||
|
||||
return button
|
||||
private lazy var batchModeButton: InputViewButton = {
|
||||
let result: InputViewButton = InputViewButton(
|
||||
icon: UIImage(named: "media_send_batch_mode_disabled")?
|
||||
.withRenderingMode(.alwaysTemplate)
|
||||
) { [weak self] in self?.didTapBatchModeButton() }
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var cameraModeButton: UIButton = {
|
||||
let button = OWSButton(imageName: "settings-avatar-camera-2",
|
||||
tintColor: .ows_gray60,
|
||||
block: { [weak self] in self?.didTapCameraModeButton() })
|
||||
private lazy var cameraModeButton: InputViewButton = {
|
||||
let result: InputViewButton = InputViewButton(
|
||||
icon: UIImage(named: "settings-avatar-camera-2")?
|
||||
.withRenderingMode(.alwaysTemplate)
|
||||
) { [weak self] in self?.didTapCameraModeButton() }
|
||||
|
||||
let width: CGFloat = type(of: self).bottomButtonWidth
|
||||
button.autoSetDimensions(to: CGSize(width: width, height: width))
|
||||
button.layer.cornerRadius = width / 2
|
||||
button.imageEdgeInsets = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10)
|
||||
button.backgroundColor = .ows_white
|
||||
button.setShadow()
|
||||
|
||||
return button
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var mediaLibraryModeButton: UIButton = {
|
||||
let button = OWSButton(imageName: "actionsheet_camera_roll_black",
|
||||
tintColor: .ows_gray60,
|
||||
block: { [weak self] in self?.didTapMediaLibraryModeButton() })
|
||||
private lazy var mediaLibraryModeButton: InputViewButton = {
|
||||
let result: InputViewButton = InputViewButton(
|
||||
icon: UIImage(named: "actionsheet_camera_roll_black")?
|
||||
.withRenderingMode(.alwaysTemplate)
|
||||
) { [weak self] in self?.didTapMediaLibraryModeButton() }
|
||||
|
||||
let width: CGFloat = type(of: self).bottomButtonWidth
|
||||
button.autoSetDimensions(to: CGSize(width: width, height: width))
|
||||
button.layer.cornerRadius = width / 2
|
||||
button.imageEdgeInsets = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10)
|
||||
button.backgroundColor = .ows_white
|
||||
button.setShadow()
|
||||
|
||||
return button
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: State
|
||||
|
@ -243,63 +232,45 @@ class SendMediaNavigationController: OWSNavigationController {
|
|||
pushViewController(approvalViewController, animated: true)
|
||||
}
|
||||
|
||||
private func didRequestExit(dontAbandonText: String) {
|
||||
if attachmentDraftCollection.count == 0 {
|
||||
private func didRequestExit() {
|
||||
guard attachmentDraftCollection.count > 0 else {
|
||||
self.sendMediaNavDelegate?.sendMediaNavDidCancel(self)
|
||||
} else {
|
||||
let alertTitle = NSLocalizedString("SEND_MEDIA_ABANDON_TITLE", comment: "alert title when user attempts to leave the send media flow when they have an in-progress album")
|
||||
|
||||
let alert = UIAlertController(title: alertTitle, message: nil, preferredStyle: .alert)
|
||||
|
||||
let confirmAbandonText = NSLocalizedString("SEND_MEDIA_CONFIRM_ABANDON_ALBUM", comment: "alert action, confirming the user wants to exit the media flow and abandon any photos they've taken")
|
||||
let confirmAbandonAction = UIAlertAction(title: confirmAbandonText,
|
||||
style: .destructive,
|
||||
handler: { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
self.sendMediaNavDelegate?.sendMediaNavDidCancel(self)
|
||||
})
|
||||
alert.addAction(confirmAbandonAction)
|
||||
let dontAbandonAction = UIAlertAction(title: dontAbandonText,
|
||||
style: .default,
|
||||
handler: { _ in })
|
||||
alert.addAction(dontAbandonAction)
|
||||
|
||||
self.presentAlert(alert)
|
||||
return
|
||||
}
|
||||
|
||||
let modal: ConfirmationModal = ConfirmationModal(
|
||||
info: ConfirmationModal.Info(
|
||||
title: "SEND_MEDIA_ABANDON_TITLE".localized(),
|
||||
confirmTitle: "SEND_MEDIA_CONFIRM_ABANDON_ALBUM".localized(),
|
||||
confirmStyle: .danger,
|
||||
cancelStyle: .alert_text,
|
||||
onConfirm: { [weak self] _ in
|
||||
self?.sendMediaNavDelegate?.sendMediaNavDidCancel(self)
|
||||
}
|
||||
)
|
||||
)
|
||||
self.present(modal, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension SendMediaNavigationController: UINavigationControllerDelegate {
|
||||
|
||||
private func setNavBarBackgroundColor(to color: UIColor) {
|
||||
guard let navBar = navigationBar as? OWSNavigationBar else { return }
|
||||
navBar.setBackgroundImage(UIImage(), for: UIBarMetrics.default)
|
||||
navBar.shadowImage = UIImage()
|
||||
navBar.isTranslucent = false
|
||||
navBar.barTintColor = color
|
||||
}
|
||||
|
||||
func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
|
||||
if viewController == captureViewController {
|
||||
setNavBarBackgroundColor(to: .black)
|
||||
} else {
|
||||
setNavBarBackgroundColor(to: Colors.navigationBarBackground)
|
||||
}
|
||||
|
||||
switch viewController {
|
||||
case is PhotoCaptureViewController:
|
||||
if attachmentDraftCollection.count == 1 && !isInBatchSelectMode {
|
||||
// User is navigating "back" to the previous view, indicating
|
||||
// they want to discard the previously captured item
|
||||
discardDraft()
|
||||
}
|
||||
case is ImagePickerGridController:
|
||||
if attachmentDraftCollection.count == 1 && !isInBatchSelectMode {
|
||||
isInBatchSelectMode = true
|
||||
self.mediaLibraryViewController.batchSelectModeDidChange()
|
||||
}
|
||||
default:
|
||||
break
|
||||
case is PhotoCaptureViewController:
|
||||
if attachmentDraftCollection.count == 1 && !isInBatchSelectMode {
|
||||
// User is navigating "back" to the previous view, indicating
|
||||
// they want to discard the previously captured item
|
||||
discardDraft()
|
||||
}
|
||||
|
||||
case is ImagePickerGridController:
|
||||
if attachmentDraftCollection.count == 1 && !isInBatchSelectMode {
|
||||
isInBatchSelectMode = true
|
||||
self.mediaLibraryViewController.batchSelectModeDidChange()
|
||||
}
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
self.updateButtons(topViewController: viewController)
|
||||
|
@ -307,30 +278,8 @@ extension SendMediaNavigationController: UINavigationControllerDelegate {
|
|||
|
||||
// In case back navigation was canceled, we re-apply whatever is showing.
|
||||
func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
|
||||
if viewController == captureViewController {
|
||||
setNavBarBackgroundColor(to: .black)
|
||||
} else {
|
||||
setNavBarBackgroundColor(to: Colors.navigationBarBackground)
|
||||
}
|
||||
|
||||
self.updateButtons(topViewController: viewController)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func preferredNavbarTheme(viewController: UIViewController) -> OWSNavigationBar.NavigationBarThemeOverride? {
|
||||
switch viewController {
|
||||
case is AttachmentApprovalViewController:
|
||||
return .clear
|
||||
case is ImagePickerGridController:
|
||||
return .alwaysDark
|
||||
case is PhotoCaptureViewController:
|
||||
return .clear
|
||||
default:
|
||||
owsFailDebug("unexpected viewController: \(viewController)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SendMediaNavigationController: PhotoCaptureViewControllerDelegate {
|
||||
|
@ -338,14 +287,14 @@ extension SendMediaNavigationController: PhotoCaptureViewControllerDelegate {
|
|||
attachmentDraftCollection.append(.camera(attachment: attachment))
|
||||
if isInBatchSelectMode {
|
||||
updateButtons(topViewController: photoCaptureViewController)
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
pushApprovalViewController()
|
||||
}
|
||||
}
|
||||
|
||||
func photoCaptureViewControllerDidCancel(_ photoCaptureViewController: PhotoCaptureViewController) {
|
||||
let dontAbandonText = NSLocalizedString("SEND_MEDIA_RETURN_TO_CAMERA", comment: "alert action when the user decides not to cancel the media flow after all.")
|
||||
didRequestExit(dontAbandonText: dontAbandonText)
|
||||
didRequestExit()
|
||||
}
|
||||
|
||||
func discardDraft() {
|
||||
|
@ -364,8 +313,7 @@ extension SendMediaNavigationController: ImagePickerGridControllerDelegate {
|
|||
}
|
||||
|
||||
func imagePickerDidCancel(_ imagePicker: ImagePickerGridController) {
|
||||
let dontAbandonText = NSLocalizedString("SEND_MEDIA_RETURN_TO_MEDIA_LIBRARY", comment: "alert action when the user decides not to cancel the media flow after all.")
|
||||
didRequestExit(dontAbandonText: dontAbandonText)
|
||||
didRequestExit()
|
||||
}
|
||||
|
||||
func showApprovalAfterProcessingAnyMediaLibrarySelections() {
|
||||
|
@ -374,23 +322,36 @@ extension SendMediaNavigationController: ImagePickerGridControllerDelegate {
|
|||
let backgroundBlock: (ModalActivityIndicatorViewController) -> Void = { modal in
|
||||
let attachmentPromises: [Promise<MediaLibraryAttachment>] = mediaLibrarySelections.map { $0.promise }
|
||||
|
||||
when(fulfilled: attachmentPromises).map { attachments in
|
||||
Logger.debug("built all attachments")
|
||||
modal.dismiss {
|
||||
self.attachmentDraftCollection.selectedFromPicker(attachments: attachments)
|
||||
self.pushApprovalViewController()
|
||||
when(fulfilled: attachmentPromises)
|
||||
.map { attachments in
|
||||
Logger.debug("built all attachments")
|
||||
modal.dismiss {
|
||||
self.attachmentDraftCollection.selectedFromPicker(attachments: attachments)
|
||||
self.pushApprovalViewController()
|
||||
}
|
||||
}
|
||||
}.catch { error in
|
||||
Logger.error("failed to prepare attachments. error: \(error)")
|
||||
modal.dismiss {
|
||||
OWSAlerts.showAlert(title: NSLocalizedString("IMAGE_PICKER_FAILED_TO_PROCESS_ATTACHMENTS", comment: "alert title"))
|
||||
.catch { error in
|
||||
Logger.error("failed to prepare attachments. error: \(error)")
|
||||
modal.dismiss { [weak self] in
|
||||
let modal: ConfirmationModal = ConfirmationModal(
|
||||
targetView: self?.view,
|
||||
info: ConfirmationModal.Info(
|
||||
title: "IMAGE_PICKER_FAILED_TO_PROCESS_ATTACHMENTS".localized(),
|
||||
cancelTitle: "BUTTON_OK".localized(),
|
||||
cancelStyle: .alert_text
|
||||
)
|
||||
)
|
||||
self?.present(modal, animated: true)
|
||||
}
|
||||
}
|
||||
}.retainUntilComplete()
|
||||
.retainUntilComplete()
|
||||
}
|
||||
|
||||
ModalActivityIndicatorViewController.present(fromViewController: self,
|
||||
canCancel: false,
|
||||
backgroundBlock: backgroundBlock)
|
||||
ModalActivityIndicatorViewController.present(
|
||||
fromViewController: self,
|
||||
canCancel: false,
|
||||
onAppear: backgroundBlock
|
||||
)
|
||||
}
|
||||
|
||||
func imagePicker(_ imagePicker: ImagePickerGridController, isAssetSelected asset: PHAsset) -> Bool {
|
||||
|
@ -398,22 +359,17 @@ extension SendMediaNavigationController: ImagePickerGridControllerDelegate {
|
|||
}
|
||||
|
||||
func imagePicker(_ imagePicker: ImagePickerGridController, didSelectAsset asset: PHAsset, attachmentPromise: Promise<SignalAttachment>) {
|
||||
guard !mediaLibrarySelections.hasValue(forKey: asset) else {
|
||||
return
|
||||
}
|
||||
guard !mediaLibrarySelections.hasValue(forKey: asset) else { return }
|
||||
|
||||
let libraryMedia = MediaLibrarySelection(asset: asset, signalAttachmentPromise: attachmentPromise)
|
||||
mediaLibrarySelections.append(key: asset, value: libraryMedia)
|
||||
|
||||
updateButtons(topViewController: imagePicker)
|
||||
}
|
||||
|
||||
func imagePicker(_ imagePicker: ImagePickerGridController, didDeselectAsset asset: PHAsset) {
|
||||
guard mediaLibrarySelections.hasValue(forKey: asset) else {
|
||||
return
|
||||
}
|
||||
guard mediaLibrarySelections.hasValue(forKey: asset) else { return }
|
||||
|
||||
mediaLibrarySelections.remove(key: asset)
|
||||
|
||||
updateButtons(topViewController: imagePicker)
|
||||
}
|
||||
|
||||
|
@ -599,94 +555,181 @@ private protocol DoneButtonDelegate: AnyObject {
|
|||
|
||||
private class DoneButton: UIView {
|
||||
weak var delegate: DoneButtonDelegate?
|
||||
let numberFormatter: NumberFormatter = NumberFormatter()
|
||||
|
||||
private var didTouchDownInside: Bool = false
|
||||
|
||||
// MARK: - UI
|
||||
|
||||
private let container: UIView = {
|
||||
let result: UIView = UIView()
|
||||
result.themeBackgroundColor = .inputButton_background
|
||||
result.layer.cornerRadius = 20
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var badge: CircleView = {
|
||||
let result: CircleView = CircleView()
|
||||
result.themeBackgroundColor = .primary
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var badgeLabel: UILabel = {
|
||||
let result: UILabel = UILabel()
|
||||
result.font = .ows_dynamicTypeSubheadline.ows_monospaced()
|
||||
result.themeTextColor = .black // Will render on the primary color so should always be black
|
||||
result.textAlignment = .center
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var chevron: UIView = {
|
||||
let image: UIImage = {
|
||||
guard CurrentAppContext().isRTL else { return #imageLiteral(resourceName: "small_chevron_right") }
|
||||
|
||||
return #imageLiteral(resourceName: "small_chevron_left")
|
||||
}()
|
||||
let result: UIImageView = UIImageView(image: image.withRenderingMode(.alwaysTemplate))
|
||||
result.contentMode = .scaleAspectFit
|
||||
result.themeTintColor = .textPrimary
|
||||
result.set(.width, to: 10)
|
||||
result.set(.height, to: 18)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init() {
|
||||
super.init(frame: .zero)
|
||||
|
||||
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(didTap(tapGesture:)))
|
||||
addGestureRecognizer(tapGesture)
|
||||
|
||||
let container = UIView()
|
||||
container.backgroundColor = .ows_white
|
||||
container.layer.cornerRadius = 20
|
||||
container.layoutMargins = UIEdgeInsets(top: 7, leading: 8, bottom: 7, trailing: 8)
|
||||
|
||||
|
||||
addSubview(container)
|
||||
container.autoPinEdgesToSuperviewMargins()
|
||||
|
||||
let stackView = UIStackView(arrangedSubviews: [badge, chevron])
|
||||
stackView.axis = .horizontal
|
||||
stackView.alignment = .center
|
||||
stackView.spacing = 9
|
||||
|
||||
container.addSubview(stackView)
|
||||
stackView.autoPinEdgesToSuperviewMargins()
|
||||
}
|
||||
|
||||
let numberFormatter: NumberFormatter = NumberFormatter()
|
||||
|
||||
func updateCount() {
|
||||
guard let delegate = delegate else {
|
||||
return
|
||||
}
|
||||
|
||||
badgeLabel.text = numberFormatter.string(for: delegate.doneButtonCount)
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
// MARK: - Subviews
|
||||
|
||||
private lazy var badge: UIView = {
|
||||
let badge = CircleView()
|
||||
badge.layoutMargins = UIEdgeInsets(top: 4, leading: 4, bottom: 4, trailing: 4)
|
||||
badge.backgroundColor = .ows_signalBlue
|
||||
container.pin(to: self)
|
||||
|
||||
badge.addSubview(badgeLabel)
|
||||
badgeLabel.autoPinEdgesToSuperviewMargins()
|
||||
|
||||
badgeLabel.pin(to: badge, withInset: 4)
|
||||
|
||||
// Constrain to be a pill that is at least a circle, and maybe wider.
|
||||
badgeLabel.autoPin(toAspectRatio: 1.0, relation: .greaterThanOrEqual)
|
||||
NSLayoutConstraint.autoSetPriority(.defaultLow) {
|
||||
badgeLabel.autoPinToSquareAspectRatio()
|
||||
}
|
||||
|
||||
return badge
|
||||
}()
|
||||
let stackView = UIStackView(arrangedSubviews: [badge, chevron])
|
||||
stackView.axis = .horizontal
|
||||
stackView.alignment = .center
|
||||
stackView.spacing = 9
|
||||
|
||||
private lazy var badgeLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.textColor = .ows_white
|
||||
label.font = UIFont.ows_dynamicTypeSubheadline.ows_monospaced()
|
||||
label.textAlignment = .center
|
||||
return label
|
||||
}()
|
||||
addSubview(stackView)
|
||||
stackView.pin(.top, to: .top, of: self, withInset: 7)
|
||||
stackView.pin(.leading, to: .leading, of: self, withInset: 8)
|
||||
stackView.pin(.trailing, to: .trailing, of: self, withInset: -8)
|
||||
stackView.pin(.bottom, to: .bottom, of: self, withInset: -7)
|
||||
}
|
||||
|
||||
private lazy var chevron: UIView = {
|
||||
let image: UIImage
|
||||
if CurrentAppContext().isRTL {
|
||||
image = #imageLiteral(resourceName: "small_chevron_left")
|
||||
} else {
|
||||
image = #imageLiteral(resourceName: "small_chevron_right")
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
// MARK: - Functions
|
||||
|
||||
func updateCount() {
|
||||
guard let delegate = delegate else { return }
|
||||
|
||||
badgeLabel.text = numberFormatter.string(for: delegate.doneButtonCount)
|
||||
}
|
||||
|
||||
// MARK: - Interaction
|
||||
|
||||
private func animate(
|
||||
to scale: CGFloat,
|
||||
themeBackgroundColor: ThemeValue,
|
||||
themeBadgeBackgroundColor: ThemeValue,
|
||||
themeTintColor: ThemeValue
|
||||
) {
|
||||
UIView.animate(withDuration: 0.25) {
|
||||
self.container.transform = CGAffineTransform.identity.scale(scale)
|
||||
self.badgeLabel.themeTextColor = themeTintColor
|
||||
self.badge.themeBackgroundColor = themeBadgeBackgroundColor
|
||||
self.container.themeBackgroundColor = themeBackgroundColor
|
||||
}
|
||||
let chevron = UIImageView(image: image.withRenderingMode(.alwaysTemplate))
|
||||
chevron.contentMode = .scaleAspectFit
|
||||
chevron.tintColor = .ows_gray60
|
||||
chevron.autoSetDimensions(to: CGSize(width: 10, height: 18))
|
||||
}
|
||||
|
||||
private func expand() {
|
||||
animate(
|
||||
to: (InputViewButton.expandedSize / InputViewButton.size),
|
||||
themeBackgroundColor: .primary,
|
||||
themeBadgeBackgroundColor: .inputButton_background,
|
||||
themeTintColor: .textPrimary
|
||||
)
|
||||
}
|
||||
|
||||
private func collapse() {
|
||||
animate(
|
||||
to: 1,
|
||||
themeBackgroundColor: .inputButton_background,
|
||||
themeBadgeBackgroundColor: .primary,
|
||||
themeTintColor: .black
|
||||
)
|
||||
}
|
||||
|
||||
return chevron
|
||||
}()
|
||||
|
||||
@objc
|
||||
func didTap(tapGesture: UITapGestureRecognizer) {
|
||||
@objc func didTap(tapGesture: UITapGestureRecognizer) {
|
||||
delegate?.doneButtonWasTapped(self)
|
||||
}
|
||||
|
||||
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
guard
|
||||
isUserInteractionEnabled,
|
||||
let location: CGPoint = touches.first?.location(in: self),
|
||||
bounds.contains(location)
|
||||
else { return }
|
||||
|
||||
didTouchDownInside = true
|
||||
expand()
|
||||
}
|
||||
|
||||
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
guard
|
||||
isUserInteractionEnabled,
|
||||
let location: CGPoint = touches.first?.location(in: self),
|
||||
bounds.contains(location),
|
||||
didTouchDownInside
|
||||
else {
|
||||
if didTouchDownInside {
|
||||
collapse()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
expand()
|
||||
}
|
||||
|
||||
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
if didTouchDownInside {
|
||||
collapse()
|
||||
}
|
||||
|
||||
didTouchDownInside = false
|
||||
}
|
||||
|
||||
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
if didTouchDownInside {
|
||||
collapse()
|
||||
}
|
||||
|
||||
didTouchDownInside = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SendMediaNavDelegate
|
||||
|
||||
protocol SendMediaNavDelegate: AnyObject {
|
||||
func sendMediaNavDidCancel(_ sendMediaNavigationController: SendMediaNavigationController)
|
||||
func sendMediaNavDidCancel(_ sendMediaNavigationController: SendMediaNavigationController?)
|
||||
func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didApproveAttachments attachments: [SignalAttachment], forThreadId threadId: String, messageText: String?)
|
||||
|
||||
func sendMediaNavInitialMessageText(_ sendMediaNavigationController: SendMediaNavigationController) -> String?
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue