Merge branch 'theming' into ipad-landscape-support

This commit is contained in:
ryanzhao 2022-10-04 16:48:42 +11:00
commit 3dfa3ac5ee
443 changed files with 24702 additions and 23341 deletions

15
Podfile
View file

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

View file

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

View file

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

View file

@ -4,17 +4,19 @@ extension CallVC : CameraManagerDelegate {
func handleVideoOutputCaptured(sampleBuffer: CMSampleBuffer) {
guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
let rtcPixelBuffer = RTCCVPixelBuffer(pixelBuffer: pixelBuffer)
let timestamp = CMTimeGetSeconds(CMSampleBufferGetPresentationTimeStamp(sampleBuffer))
let timestampNs = Int64(timestamp * 1000000000)
let rotation: RTCVideoRotation = {
switch UIDevice.current.orientation {
case .landscapeRight: return RTCVideoRotation._90
case .portraitUpsideDown: return RTCVideoRotation._180
case .landscapeLeft: return RTCVideoRotation._270
default: return RTCVideoRotation._0
case .landscapeRight: return RTCVideoRotation._90
case .portraitUpsideDown: return RTCVideoRotation._180
case .landscapeLeft: return RTCVideoRotation._270
default: return RTCVideoRotation._0
}
}()
let frame = RTCVideoFrame(buffer: rtcPixelBuffer, rotation: rotation, timeStampNs: timestampNs)
frame.timeStamp = Int32(timestamp)
call.webRTCSession.handleLocalFrameCaptured(frame)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,7 @@
// Copyright © 2021 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import AVFoundation
import CoreMedia
class RenderView: UIView {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -29,16 +29,25 @@ extension ConversationVC:
}
@objc func openSettings() {
let settingsVC: OWSConversationSettingsViewController = OWSConversationSettingsViewController()
settingsVC.configure(
withThreadId: viewModel.threadData.threadId,
threadName: viewModel.threadData.displayName,
isClosedGroup: (viewModel.threadData.threadVariant == .closedGroup),
isOpenGroup: (viewModel.threadData.threadVariant == .openGroup),
isNoteToSelf: viewModel.threadData.threadIsNoteToSelf
let viewController: 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)
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,13 +1,19 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
final class MediaLoaderView : UIView {
import UIKit
import SessionUIKit
final class MediaLoaderView: UIView {
private let bar = UIView()
private lazy var barLeftConstraint = bar.pin(.left, to: .left, of: self)
private lazy var barRightConstraint = bar.pin(.right, to: .right, of: self)
// MARK: Lifecycle
// MARK: - Lifecycle
override init(frame: CGRect) {
super.init(frame: frame)
setUpViewHierarchy()
}
@ -17,9 +23,10 @@ final class MediaLoaderView : UIView {
}
private func setUpViewHierarchy() {
bar.backgroundColor = Colors.accent
bar.themeBackgroundColor = .primary
bar.set(.height, to: 8)
addSubview(bar)
barLeftConstraint.isActive = true
bar.pin(.top, to: .top, of: self)
barRightConstraint.isActive = true
@ -27,7 +34,8 @@ final class MediaLoaderView : UIView {
step1()
}
// MARK: Animation
// MARK: - Animation
func step1() {
barRightConstraint.constant = -bounds.width
UIView.animate(withDuration: 0.5, animations: { [weak self] in

View file

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

View file

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

View file

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

View file

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

View file

@ -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 = []
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,24 +0,0 @@
//
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
#import "OWSConversationSettingsViewDelegate.h"
#import "OWSTableViewController.h"
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
@class TSThread;
@class YapDatabaseConnection;
@interface OWSConversationSettingsViewController : OWSTableViewController
@property (nonatomic, weak) id<OWSConversationSettingsViewDelegate> conversationSettingsViewDelegate;
@property (nonatomic) BOOL showVerificationOnAppear;
- (void)configureWithThreadId:(NSString *)threadId threadName:(NSString *)threadName isClosedGroup:(BOOL)isClosedGroup isOpenGroup:(BOOL)isOpenGroup isNoteToSelf:(BOOL)isNoteToSelf;
@end
NS_ASSUME_NONNULL_END

View file

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

View file

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

View file

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

View file

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

View file

@ -1,15 +1,63 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import YYImage
import SessionUIKit
/// Shown when the user taps a profile picture in the conversation settings.
@objc(SNProfilePictureVC)
final class ProfilePictureVC: BaseVC {
private let image: UIImage
private let image: UIImage?
private let animatedImage: YYImage?
private let snTitle: String
@objc init(image: UIImage, title: String) {
private var imageSize: CGFloat { (UIScreen.main.bounds.width - (2 * Values.largeSpacing)) }
// MARK: - UI
private lazy var fallbackView: UIView = {
let result: UIView = UIView()
result.clipsToBounds = true
result.themeBackgroundColor = .backgroundSecondary
result.layer.cornerRadius = (imageSize / 2)
result.isHidden = (
image != nil ||
animatedImage != nil
)
result.set(.width, to: imageSize)
result.set(.height, to: imageSize)
return result
}()
private lazy var imageView: UIImageView = {
let result: UIImageView = UIImageView(image: image)
result.clipsToBounds = true
result.contentMode = .scaleAspectFill
result.layer.cornerRadius = (imageSize / 2)
result.isHidden = (image == nil)
result.set(.width, to: imageSize)
result.set(.height, to: imageSize)
return result
}()
private lazy var animatedImageView: YYAnimatedImageView = {
let result: YYAnimatedImageView = YYAnimatedImageView(image: animatedImage)
result.clipsToBounds = true
result.contentMode = .scaleAspectFill
result.layer.cornerRadius = (imageSize / 2)
result.isHidden = (animatedImage == nil)
result.set(.width, to: imageSize)
result.set(.height, to: imageSize)
return result
}()
// MARK: - Initialization
init(image: UIImage?, animatedImage: YYImage?, title: String) {
self.image = image
self.animatedImage = animatedImage
self.snTitle = title
super.init(nibName: nil, bundle: nil)
@ -24,23 +72,28 @@ final class ProfilePictureVC: BaseVC {
}
override func viewDidLoad() {
view.backgroundColor = .clear
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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -8,7 +8,7 @@ import SessionMessagingKit
import SignalUtilitiesKit
class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDataSource {
private static let loadingHeaderHeight: CGFloat = 20
private static let loadingHeaderHeight: CGFloat = 40
private let viewModel: MessageRequestsViewModel = MessageRequestsViewModel()
private var dataChangeObservable: DatabaseCancellable?
@ -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
}
}

View file

@ -17,7 +17,7 @@ public class MessageRequestsViewModel {
// MARK: - Variables
public static let pageSize: Int = 20
public static let pageSize: Int = 15
// MARK: - Initialization

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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