// C o p y r i g h t © 2 0 2 2 R a n g e p r o o f P t y L t d . A l l r i g h t s r e s e r v e d .
import Foundation
import Combine
import GRDB
import DifferenceKit
import SessionUIKit
import SessionMessagingKit
import SessionUtilitiesKit
import SignalUtilitiesKit
class SettingsViewModel : SessionTableViewModel < SettingsViewModel . NavButton , SettingsViewModel . Section , SettingsViewModel . Item > {
// MARK: - C o n f i g
enum NavState {
case standard
case editing
}
enum NavButton : Equatable {
case close
case qrCode
case cancel
case done
}
public enum Section : SessionTableSection {
case profileInfo
case menus
case footer
}
public enum Item : Differentiable {
case profileInfo
case path
case privacy
case notifications
case conversations
case messageRequests
case appearance
case inviteAFriend
case recoveryPhrase
case help
case clearData
}
// MARK: - V a r i a b l e s
private let userSessionId : String
private lazy var imagePickerHandler : ImagePickerHandler = ImagePickerHandler ( viewModel : self )
fileprivate var oldDisplayName : String
private var editedDisplayName : String ?
// MARK: - I n i t i a l i z a t i o n
override init ( ) {
self . userSessionId = getUserHexEncodedPublicKey ( )
self . oldDisplayName = Profile . fetchOrCreateCurrentUser ( ) . name
super . init ( )
}
// MARK: - N a v i g a t i o n
lazy var navState : AnyPublisher < NavState , Never > = {
isEditing
. map { isEditing in ( isEditing ? . editing : . standard ) }
. removeDuplicates ( )
. prepend ( . standard ) // I n i t i a l v a l u e
. eraseToAnyPublisher ( )
} ( )
override var leftNavItems : AnyPublisher < [ NavItem ] ? , Never > {
navState
. map { navState -> [ NavItem ] in
switch navState {
case . standard :
return [
NavItem (
id : . close ,
image : UIImage ( named : " X " ) ?
. withRenderingMode ( . alwaysTemplate ) ,
style : . plain ,
accessibilityIdentifier : " Close button "
) { [ weak self ] in self ? . dismissScreen ( ) }
]
case . editing :
return [
NavItem (
id : . cancel ,
systemItem : . cancel ,
accessibilityIdentifier : " Cancel button "
) { [ weak self ] in
self ? . setIsEditing ( false )
self ? . editedDisplayName = self ? . oldDisplayName
}
]
}
}
. eraseToAnyPublisher ( )
}
override var rightNavItems : AnyPublisher < [ NavItem ] ? , Never > {
navState
. map { [ weak self ] navState -> [ NavItem ] in
switch navState {
case . standard :
return [
NavItem (
id : . qrCode ,
image : UIImage ( named : " QRCode " ) ?
. withRenderingMode ( . alwaysTemplate ) ,
style : . plain ,
accessibilityIdentifier : " Show QR code button " ,
action : { [ weak self ] in
self ? . transitionToScreen ( QRCodeVC ( ) )
}
)
]
case . editing :
return [
NavItem (
id : . done ,
systemItem : . done ,
accessibilityIdentifier : " Done button "
) { [ weak self ] in
let updatedNickname : String = ( self ? . editedDisplayName ? ? " " )
. trimmingCharacters ( in : . whitespacesAndNewlines )
guard ! updatedNickname . isEmpty else {
self ? . transitionToScreen (
ConfirmationModal (
info : ConfirmationModal . Info (
title : " vc_settings_display_name_missing_error " . localized ( ) ,
cancelTitle : " BUTTON_OK " . localized ( ) ,
cancelStyle : . alert_text
)
) ,
transitionType : . present
)
return
}
guard ! ProfileManager . isToLong ( profileName : updatedNickname ) else {
self ? . transitionToScreen (
ConfirmationModal (
info : ConfirmationModal . Info (
title : " vc_settings_display_name_too_long_error " . localized ( ) ,
cancelTitle : " BUTTON_OK " . localized ( ) ,
cancelStyle : . alert_text
)
) ,
transitionType : . present
)
return
}
self ? . setIsEditing ( false )
self ? . oldDisplayName = updatedNickname
self ? . updateProfile (
name : updatedNickname ,
profilePicture : nil ,
profilePictureFilePath : nil ,
isUpdatingDisplayName : true ,
isUpdatingProfilePicture : false
)
}
]
}
}
. eraseToAnyPublisher ( )
}
// MARK: - C o n t e n t
override var title : String { " vc_settings_title " . localized ( ) }
private var _settingsData : [ SectionModel ] = [ ]
public override var settingsData : [ SectionModel ] { _settingsData }
public override var observableSettingsData : ObservableData { _observableSettingsData }
// / T h i s i s a l l t h e d a t a t h e s c r e e n n e e d s t o p o p u l a t e i t s e l f , p l e a s e s e e t h e f o l l o w i n g l i n k f o r t i p s t o h e l p o p t i m i s e
// / p e r f o r m a n c e h t t p s : / / g i t h u b . c o m / g r o u e / G R D B . s w i f t # v a l u e o b s e r v a t i o n - p e r f o r m a n c e
// /
// / * * N o t e : * * T h i s o b s e r v a t i o n w i l l b e t r i g g e r e d t w i c e i m m e d i a t e l y ( a n d b e d e - d u p e d b y t h e ` r e m o v e D u p l i c a t e s ` )
// / t h i s i s d u e t o t h e b e h a v i o u r o f ` V a l u e C o n c u r r e n t O b s e r v e r . a s y n c S t a r t O b s e r v a t i o n ` w h i c h t r i g g e r s i t ' s o w n
// / f e t c h ( a f t e r t h e o n e s i n ` V a l u e C o n c u r r e n t O b s e r v e r . a s y n c S t a r t ` / ` V a l u e C o n c u r r e n t O b s e r v e r . s y n c S t a r t ` )
// / j u s t i n c a s e t h e d a t a b a s e h a s c h a n g e d b e t w e e n t h e t w o r e a d s - u n f o r t u n a t e l y i t d o e s n ' t l o o k l i k e t h e r e i s a w a y t o p r e v e n t t h i s
private lazy var _observableSettingsData : ObservableData = ValueObservation
. trackingConstantRegion { db -> [ SectionModel ] in
let userPublicKey : String = getUserHexEncodedPublicKey ( db )
let profile : Profile = Profile . fetchOrCreateCurrentUser ( db )
return [
SectionModel (
model : . profileInfo ,
elements : [
SessionCell . Info (
id : . profileInfo ,
leftAccessory : . threadInfo (
threadViewModel : SessionThreadViewModel (
threadId : profile . id ,
threadIsNoteToSelf : true ,
contactProfile : profile
) ,
style : SessionCell . Accessory . ThreadInfoStyle (
separatorTitle : " your_session_id " . localized ( ) ,
descriptionStyle : . monoLarge ,
descriptionActions : [
SessionCell . Accessory . ThreadInfoStyle . Action (
title : " copy " . localized ( ) ,
run : { [ weak self ] button in
self ? . copySessionId ( profile . id , button : button )
}
) ,
SessionCell . Accessory . ThreadInfoStyle . Action (
title : " share " . localized ( ) ,
run : { [ weak self ] _ in
self ? . shareSessionId ( profile . id )
}
)
]
) ,
avatarTapped : { [ weak self ] in self ? . updateProfilePicture ( ) } ,
titleTapped : { [ weak self ] in self ? . setIsEditing ( true ) } ,
titleChanged : { [ weak self ] text in self ? . editedDisplayName = text }
) ,
title : profile . displayName ( ) ,
shouldHaveBackground : false
)
]
) ,
SectionModel (
model : . menus ,
elements : [
SessionCell . Info (
id : . path ,
leftAccessory : . customView {
// N e e d t o e n s u r e t h i s v i e w i s t h e s a m e s i z e a s t h e i c o n s s o
// w r a p i t i n a l a r g e r v i e w
let result : UIView = UIView ( )
let pathView : PathStatusView = PathStatusView ( size : . large )
result . addSubview ( pathView )
result . set ( . width , to : IconSize . medium . size )
result . set ( . height , to : IconSize . medium . size )
pathView . center ( in : result )
return result
} ,
title : " vc_path_title " . localized ( ) ,
onTap : { [ weak self ] in self ? . transitionToScreen ( PathVC ( ) ) }
) ,
SessionCell . Info (
id : . privacy ,
leftAccessory : . icon (
UIImage ( named : " icon_privacy " ) ?
. withRenderingMode ( . alwaysTemplate )
) ,
title : " vc_settings_privacy_button_title " . localized ( ) ,
onTap : { [ weak self ] in
self ? . transitionToScreen (
SessionTableViewController ( viewModel : PrivacySettingsViewModel ( ) )
)
}
) ,
SessionCell . Info (
id : . notifications ,
leftAccessory : . icon (
UIImage ( named : " icon_speaker " ) ?
. withRenderingMode ( . alwaysTemplate )
) ,
title : " vc_settings_notifications_button_title " . localized ( ) ,
onTap : { [ weak self ] in
self ? . transitionToScreen (
SessionTableViewController ( viewModel : NotificationSettingsViewModel ( ) )
)
}
) ,
SessionCell . Info (
id : . conversations ,
leftAccessory : . icon (
UIImage ( named : " icon_msg " ) ?
. withRenderingMode ( . alwaysTemplate )
) ,
title : " CONVERSATION_SETTINGS_TITLE " . localized ( ) ,
onTap : { [ weak self ] in
self ? . transitionToScreen (
SessionTableViewController ( viewModel : ConversationSettingsViewModel ( ) )
)
}
) ,
SessionCell . Info (
id : . messageRequests ,
leftAccessory : . icon (
UIImage ( named : " icon_msg_req " ) ?
. withRenderingMode ( . alwaysTemplate )
) ,
title : " MESSAGE_REQUESTS_TITLE " . localized ( ) ,
onTap : { [ weak self ] in
self ? . transitionToScreen ( MessageRequestsViewController ( ) )
}
) ,
SessionCell . Info (
id : . appearance ,
leftAccessory : . icon (
UIImage ( named : " icon_apperance " ) ?
. withRenderingMode ( . alwaysTemplate )
) ,
title : " APPEARANCE_TITLE " . localized ( ) ,
onTap : { [ weak self ] in
self ? . transitionToScreen ( AppearanceViewController ( ) )
}
) ,
SessionCell . Info (
id : . inviteAFriend ,
leftAccessory : . icon (
UIImage ( named : " icon_invite " ) ?
. withRenderingMode ( . alwaysTemplate )
) ,
title : " vc_settings_invite_a_friend_button_title " . localized ( ) ,
onTap : { [ weak self ] in
let invitation : String = " Hey, I've been using Session to chat with complete privacy and security. Come join me! Download it at https://getsession.org/. My Session ID is \( profile . id ) ! "
self ? . transitionToScreen (
UIActivityViewController (
activityItems : [ invitation ] ,
applicationActivities : nil
) ,
transitionType : . present
)
}
) ,
SessionCell . Info (
id : . recoveryPhrase ,
leftAccessory : . icon (
UIImage ( named : " icon_recovery " ) ?
. withRenderingMode ( . alwaysTemplate )
) ,
title : " vc_settings_recovery_phrase_button_title " . localized ( ) ,
onTap : { [ weak self ] in
self ? . transitionToScreen ( SeedModal ( ) , transitionType : . present )
}
) ,
SessionCell . Info (
id : . help ,
leftAccessory : . icon (
UIImage ( named : " icon_help " ) ?
. withRenderingMode ( . alwaysTemplate )
) ,
title : " HELP_TITLE " . localized ( ) ,
onTap : { [ weak self ] in
self ? . transitionToScreen (
SessionTableViewController ( viewModel : HelpViewModel ( ) )
)
}
) ,
SessionCell . Info (
id : . clearData ,
leftAccessory : . icon (
UIImage ( named : " icon_bin " ) ?
. withRenderingMode ( . alwaysTemplate )
) ,
title : " vc_settings_clear_all_data_button_title " . localized ( ) ,
tintColor : . danger ,
onTap : { [ weak self ] in
self ? . transitionToScreen ( NukeDataModal ( ) , transitionType : . present )
}
)
]
)
]
}
. removeDuplicates ( )
. publisher ( in : Storage . shared )
public override var footerView : AnyPublisher < UIView ? , Never > {
Just ( VersionFooterView ( ) )
. eraseToAnyPublisher ( )
}
// MARK: - F u n c t i o n s
public override func updateSettings ( _ updatedSettings : [ SectionModel ] ) {
self . _settingsData = updatedSettings
}
private func updateProfilePicture ( ) {
let actionSheet : UIAlertController = UIAlertController (
title : " Update Profile Picture " ,
message : nil ,
preferredStyle : . actionSheet
)
actionSheet . addAction ( UIAlertAction (
title : " MEDIA_FROM_LIBRARY_BUTTON " . localized ( ) ,
style : . default ,
handler : { [ weak self ] _ in
self ? . showPhotoLibraryForAvatar ( )
}
) )
actionSheet . addAction ( UIAlertAction ( title : " cancel " . localized ( ) , style : . cancel , handler : nil ) )
self . transitionToScreen ( actionSheet , transitionType : . present )
}
private func showPhotoLibraryForAvatar ( ) {
Permissions . requestLibraryPermissionIfNeeded { [ weak self ] in
DispatchQueue . main . async {
let picker : UIImagePickerController = UIImagePickerController ( )
picker . sourceType = . photoLibrary
picker . mediaTypes = [ " public.image " ]
picker . delegate = self ? . imagePickerHandler
self ? . transitionToScreen ( picker , transitionType : . present )
}
}
}
fileprivate func updateProfile (
name : String ,
profilePicture : UIImage ? ,
profilePictureFilePath : String ? ,
isUpdatingDisplayName : Bool ,
isUpdatingProfilePicture : Bool
) {
let imageFilePath : String ? = (
profilePictureFilePath ? ?
ProfileManager . profileAvatarFilepath ( id : self . userSessionId )
)
let viewController = ModalActivityIndicatorViewController ( canCancel : false ) { [ weak self ] modalActivityIndicator in
ProfileManager . updateLocal (
queue : DispatchQueue . global ( qos : . default ) ,
profileName : name ,
image : profilePicture ,
imageFilePath : imageFilePath ,
success : { db , updatedProfile in
if isUpdatingDisplayName {
UserDefaults . standard [ . lastDisplayNameUpdate ] = Date ( )
}
if isUpdatingProfilePicture {
UserDefaults . standard [ . lastProfilePictureUpdate ] = Date ( )
}
try MessageSender . syncConfiguration ( db , forceSyncNow : true ) . retainUntilComplete ( )
// W a i t f o r t h e d a t a b a s e t r a n s a c t i o n t o c o m p l e t e b e f o r e u p d a t i n g t h e U I
db . afterNextTransaction { _ in
DispatchQueue . main . async {
modalActivityIndicator . dismiss ( completion : { } )
}
}
} ,
failure : { [ weak self ] error in
DispatchQueue . main . async {
modalActivityIndicator . dismiss {
let isMaxFileSizeExceeded : Bool = ( error = = . avatarUploadMaxFileSizeExceeded )
self ? . transitionToScreen (
ConfirmationModal (
info : ConfirmationModal . Info (
title : ( isMaxFileSizeExceeded ?
" Maximum File Size Exceeded " :
" Couldn't Update Profile "
) ,
explanation : ( isMaxFileSizeExceeded ?
" Please select a smaller photo and try again " :
" Please check your internet connection and try again "
) ,
cancelTitle : " BUTTON_OK " . localized ( ) ,
cancelStyle : . alert_text
)
) ,
transitionType : . present
)
}
}
}
)
}
self . transitionToScreen ( viewController , transitionType : . present )
}
private func copySessionId ( _ sessionId : String , button : SessionButton ? ) {
UIPasteboard . general . string = sessionId
guard let button : SessionButton = button else { return }
// E n s u r e w e a r e o n t h e m a i n t h r e a d j u s t i n c a s e
DispatchQueue . main . async {
button . isUserInteractionEnabled = false
UIView . transition (
with : button ,
duration : 0.25 ,
options : . transitionCrossDissolve ,
animations : {
button . setTitle ( " copied " . localized ( ) , for : . normal )
} ,
completion : { _ in
DispatchQueue . main . asyncAfter ( deadline : . now ( ) + . seconds ( 4 ) ) {
button . isUserInteractionEnabled = true
UIView . transition (
with : button ,
duration : 0.25 ,
options : . transitionCrossDissolve ,
animations : {
button . setTitle ( " copy " . localized ( ) , for : . normal )
} ,
completion : nil
)
}
}
)
}
}
private func shareSessionId ( _ sessionId : String ) {
let shareVC = UIActivityViewController (
activityItems : [ sessionId ] ,
applicationActivities : nil
)
self . transitionToScreen ( shareVC , transitionType : . present )
}
}
// MARK: - I m a g e P i c k e r H a n d l e r
class ImagePickerHandler : NSObject , UIImagePickerControllerDelegate & UINavigationControllerDelegate {
private let viewModel : SettingsViewModel
// MARK: - I n i t i a l i z a t i o n
init ( viewModel : SettingsViewModel ) {
self . viewModel = viewModel
}
// MARK: - U I I m a g e P i c k e r C o n t r o l l e r D e l e g a t e
func imagePickerControllerDidCancel ( _ picker : UIImagePickerController ) {
picker . dismiss ( animated : true )
}
func imagePickerController ( _ picker : UIImagePickerController , didFinishPickingMediaWithInfo info : [ UIImagePickerController . InfoKey : Any ] ) {
guard
let imageUrl : URL = info [ . imageURL ] as ? URL ,
let rawAvatar : UIImage = info [ . originalImage ] as ? UIImage
else {
picker . presentingViewController ? . dismiss ( animated : true )
return
}
let name : String = self . viewModel . oldDisplayName
picker . presentingViewController ? . dismiss ( animated : true ) { [ weak self ] in
// C h e c k i f t h e u s e r s e l e c t e d a n a n i m a t e d i m a g e ( i f s o t h e n d o n ' t c r o p , j u s t
// s e t t h e a v a t a r d i r e c t l y
guard
let type : Any = try ? imageUrl . resourceValues ( forKeys : [ . typeIdentifierKey ] )
. allValues
. first ,
let typeString : String = type as ? String ,
MIMETypeUtil . supportedAnimatedImageUTITypes ( ) . contains ( typeString )
else {
let viewController : CropScaleImageViewController = CropScaleImageViewController (
srcImage : rawAvatar ,
successCompletion : { resultImage in
self ? . viewModel . updateProfile (
name : name ,
profilePicture : resultImage ,
profilePictureFilePath : nil ,
isUpdatingDisplayName : false ,
isUpdatingProfilePicture : true
)
}
)
self ? . viewModel . transitionToScreen ( viewController , transitionType : . present )
return
}
self ? . viewModel . updateProfile (
name : name ,
profilePicture : nil ,
profilePictureFilePath : imageUrl . path ,
isUpdatingDisplayName : false ,
isUpdatingProfilePicture : true
)
}
}
}