// 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 YYImage
import DifferenceKit
import SessionUIKit
import SessionMessagingKit
import SessionUtilitiesKit
import SignalUtilitiesKit
class SettingsViewModel : SessionTableViewModel , NavigationItemSource , NavigatableStateHolder , EditableStateHolder , ObservableTableSource {
public let dependencies : Dependencies
public let navigatableState : NavigatableState = NavigatableState ( )
public let editableState : EditableState < TableItem > = EditableState ( )
public let state : TableDataState < Section , TableItem > = TableDataState ( )
public let observableState : ObservableTableSourceState < Section , TableItem > = ObservableTableSourceState ( )
private let userSessionId : String
private lazy var imagePickerHandler : ImagePickerHandler = ImagePickerHandler (
onTransition : { [ weak self ] in self ? . transitionToScreen ( $0 , transitionType : $1 ) } ,
onImageDataPicked : { [ weak self ] resultImageData in
guard let oldDisplayName : String = self ? . oldDisplayName else { return }
self ? . updatedProfilePictureSelected (
name : oldDisplayName ,
avatarUpdate : . uploadImageData ( resultImageData )
)
}
)
fileprivate var oldDisplayName : String
private var editedDisplayName : String ?
private var editProfilePictureModal : ConfirmationModal ?
private var editProfilePictureModalInfo : ConfirmationModal . Info ?
// MARK: - I n i t i a l i z a t i o n
init ( using dependencies : Dependencies = Dependencies ( ) ) {
self . dependencies = dependencies
self . userSessionId = getUserHexEncodedPublicKey ( using : dependencies )
self . oldDisplayName = Profile . fetchOrCreateCurrentUser ( using : dependencies ) . name
}
// MARK: - C o n f i g
enum NavState {
case standard
case editing
}
enum NavItem : Equatable {
case close
case qrCode
case cancel
case done
}
public enum Section : SessionTableSection {
case profileInfo
case sessionId
case menus
case footer
var title : String ? {
switch self {
case . sessionId : return " your_session_id " . localized ( )
default : return nil
}
}
var style : SessionTableSectionStyle {
switch self {
case . sessionId : return . titleSeparator
case . menus : return . padding
default : return . none
}
}
}
public enum TableItem : Differentiable {
case avatar
case profileName
case sessionId
case idActions
case path
case privacy
case notifications
case conversations
case messageRequests
case appearance
case inviteAFriend
case recoveryPhrase
case help
case clearData
}
// MARK: - N a v i g a t i o n
lazy var navState : AnyPublisher < NavState , Never > = {
Publishers
. CombineLatest (
isEditing ,
textChanged
. handleEvents (
receiveOutput : { [ weak self ] value , _ in
self ? . editedDisplayName = value
}
)
. filter { _ in false }
. prepend ( ( nil , . profileName ) )
)
. map { isEditing , _ -> NavState in ( isEditing ? . editing : . standard ) }
. removeDuplicates ( )
. prepend ( . standard ) // I n i t i a l v a l u e
. shareReplay ( 1 )
. eraseToAnyPublisher ( )
} ( )
lazy var leftNavItems : AnyPublisher < [ SessionNavItem < NavItem > ] , Never > = navState
. map { navState -> [ SessionNavItem < NavItem > ] in
switch navState {
case . standard :
return [
SessionNavItem (
id : . close ,
image : UIImage ( named : " X " ) ?
. withRenderingMode ( . alwaysTemplate ) ,
style : . plain ,
accessibilityIdentifier : " Close button "
) { [ weak self ] in self ? . dismissScreen ( ) }
]
case . editing :
return [
SessionNavItem (
id : . cancel ,
systemItem : . cancel ,
accessibilityIdentifier : " Cancel button "
) { [ weak self ] in
self ? . setIsEditing ( false )
self ? . editedDisplayName = self ? . oldDisplayName
}
]
}
}
. eraseToAnyPublisher ( )
lazy var rightNavItems : AnyPublisher < [ SessionNavItem < NavItem > ] , Never > = navState
. map { [ weak self ] navState -> [ SessionNavItem < NavItem > ] in
switch navState {
case . standard :
return [
SessionNavItem (
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 [
SessionNavItem (
id : . done ,
systemItem : . done ,
accessibilityIdentifier : " Done "
) { [ 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 ,
avatarUpdate : . none
)
}
]
}
}
. eraseToAnyPublisher ( )
// MARK: - C o n t e n t
let title : String = " vc_settings_title " . localized ( )
lazy var observation : TargetObservation = ObservationBuilder
. databaseObservation ( self ) { [ weak self , dependencies ] db -> Profile in
Profile . fetchOrCreateCurrentUser ( db , using : dependencies )
}
. map { [ weak self ] profile -> [ SectionModel ] in
return [
SectionModel (
model : . profileInfo ,
elements : [
SessionCell . Info (
id : . avatar ,
accessory : . profile (
id : profile . id ,
size : . hero ,
profile : profile
) ,
styling : SessionCell . StyleInfo (
alignment : . centerHugging ,
customPadding : SessionCell . Padding ( bottom : Values . smallSpacing ) ,
backgroundStyle : . noBackground
) ,
accessibility : Accessibility (
label : " Profile picture "
) ,
onTap : {
self ? . updateProfilePicture ( currentFileName : profile . profilePictureFileName )
}
) ,
SessionCell . Info (
id : . profileName ,
title : SessionCell . TextInfo (
profile . displayName ( ) ,
font : . titleLarge ,
alignment : . center ,
interaction : . editable
) ,
styling : SessionCell . StyleInfo (
alignment : . centerHugging ,
customPadding : SessionCell . Padding ( top : Values . smallSpacing ) ,
backgroundStyle : . noBackground
) ,
accessibility : Accessibility (
identifier : " Username " ,
label : profile . displayName ( )
) ,
onTap : { self ? . setIsEditing ( true ) }
)
]
) ,
SectionModel (
model : . sessionId ,
elements : [
SessionCell . Info (
id : . sessionId ,
title : SessionCell . TextInfo (
profile . id ,
font : . monoLarge ,
alignment : . center ,
interaction : . copy
) ,
styling : SessionCell . StyleInfo (
customPadding : SessionCell . Padding ( bottom : Values . smallSpacing ) ,
backgroundStyle : . noBackground
) ,
accessibility : Accessibility (
identifier : " Session ID " ,
label : profile . id
)
) ,
SessionCell . Info (
id : . idActions ,
leftAccessory : . button (
style : . bordered ,
title : " copy " . localized ( ) ,
run : { button in
self ? . copySessionId ( profile . id , button : button )
}
) ,
rightAccessory : . button (
style : . bordered ,
title : " share " . localized ( ) ,
run : { _ in
self ? . shareSessionId ( profile . id )
}
) ,
styling : SessionCell . StyleInfo (
customPadding : SessionCell . Padding (
top : Values . smallSpacing ,
leading : 0 ,
trailing : 0
) ,
backgroundStyle : . noBackground
)
)
]
) ,
SectionModel (
model : . menus ,
elements : [
SessionCell . Info (
id : . path ,
leftAccessory : . customView ( hashValue : " PathStatusView " ) { // s t r i n g l i n t : d i s a b l e
// 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 : { self ? . transitionToScreen ( PathVC ( ) ) }
) ,
SessionCell . Info (
id : . privacy ,
leftAccessory : . icon (
UIImage ( named : " icon_privacy " ) ?
. withRenderingMode ( . alwaysTemplate )
) ,
title : " vc_settings_privacy_button_title " . localized ( ) ,
onTap : {
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 : {
self ? . transitionToScreen (
SessionTableViewController ( viewModel : NotificationSettingsViewModel ( ) )
)
}
) ,
SessionCell . Info (
id : . conversations ,
leftAccessory : . icon (
UIImage ( named : " icon_msg " ) ?
. withRenderingMode ( . alwaysTemplate )
) ,
title : " CONVERSATION_SETTINGS_TITLE " . localized ( ) ,
onTap : {
self ? . transitionToScreen (
SessionTableViewController ( viewModel : ConversationSettingsViewModel ( ) )
)
}
) ,
SessionCell . Info (
id : . messageRequests ,
leftAccessory : . icon (
UIImage ( named : " icon_msg_req " ) ?
. withRenderingMode ( . alwaysTemplate )
) ,
title : " MESSAGE_REQUESTS_TITLE " . localized ( ) ,
onTap : {
self ? . transitionToScreen (
SessionTableViewController ( viewModel : MessageRequestsViewModel ( ) )
)
}
) ,
SessionCell . Info (
id : . appearance ,
leftAccessory : . icon (
UIImage ( named : " icon_apperance " ) ?
. withRenderingMode ( . alwaysTemplate )
) ,
title : " APPEARANCE_TITLE " . localized ( ) ,
onTap : {
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 : {
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 : {
let targetViewController : UIViewController = {
if let modal : SeedModal = try ? SeedModal ( ) {
return modal
}
return ConfirmationModal (
info : ConfirmationModal . Info (
title : " ALERT_ERROR_TITLE " . localized ( ) ,
body : . text ( " LOAD_RECOVERY_PASSWORD_ERROR " . localized ( ) ) ,
cancelTitle : " BUTTON_OK " . localized ( ) ,
cancelStyle : . alert_text
)
)
} ( )
self ? . transitionToScreen ( targetViewController , transitionType : . present )
}
) ,
SessionCell . Info (
id : . help ,
leftAccessory : . icon (
UIImage ( named : " icon_help " ) ?
. withRenderingMode ( . alwaysTemplate )
) ,
title : " HELP_TITLE " . localized ( ) ,
onTap : {
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 ( ) ,
styling : SessionCell . StyleInfo ( tintColor : . danger ) ,
onTap : {
self ? . transitionToScreen ( NukeDataModal ( ) , transitionType : . present )
}
)
]
)
]
}
public let footerView : AnyPublisher < UIView ? , Never > = Just ( VersionFooterView ( ) ) . eraseToAnyPublisher ( )
// MARK: - F u n c t i o n s
private func updateProfilePicture ( currentFileName : String ? ) {
let existingDisplayName : String = self . oldDisplayName
let existingImageData : Data ? = ProfileManager
. profileAvatar ( id : self . userSessionId )
let editProfilePictureModalInfo : ConfirmationModal . Info = ConfirmationModal . Info (
title : " update_profile_modal_title " . localized ( ) ,
body : . image (
placeholderData : UIImage ( named : " profile_placeholder " ) ? . pngData ( ) ,
valueData : existingImageData ,
icon : . rightPlus ,
style : . circular ,
accessibility : Accessibility (
identifier : " Image picker " ,
label : " Image picker "
) ,
onClick : { [ weak self ] in self ? . showPhotoLibraryForAvatar ( ) }
) ,
confirmTitle : " update_profile_modal_save " . localized ( ) ,
confirmEnabled : false ,
cancelTitle : " update_profile_modal_remove " . localized ( ) ,
cancelEnabled : ( existingImageData != nil ) ,
hasCloseButton : true ,
dismissOnConfirm : false ,
onConfirm : { modal in modal . close ( ) } ,
onCancel : { [ weak self ] modal in
self ? . updateProfile (
name : existingDisplayName ,
avatarUpdate : . remove ,
onComplete : { [ weak modal ] in modal ? . close ( ) }
)
} ,
afterClosed : { [ weak self ] in
self ? . editProfilePictureModal = nil
self ? . editProfilePictureModalInfo = nil
}
)
let modal : ConfirmationModal = ConfirmationModal ( info : editProfilePictureModalInfo )
self . editProfilePictureModalInfo = editProfilePictureModalInfo
self . editProfilePictureModal = modal
self . transitionToScreen ( modal , transitionType : . present )
}
fileprivate func updatedProfilePictureSelected ( name : String , avatarUpdate : ProfileManager . AvatarUpdate ) {
guard let info : ConfirmationModal . Info = self . editProfilePictureModalInfo else { return }
self . editProfilePictureModal ? . updateContent (
with : info . with (
body : . image (
placeholderData : UIImage ( named : " profile_placeholder " ) ? . pngData ( ) ,
valueData : {
switch avatarUpdate {
case . uploadImageData ( let imageData ) : return imageData
default : return nil
}
} ( ) ,
icon : . rightPlus ,
style : . circular ,
accessibility : Accessibility (
identifier : " Image picker " ,
label : " Image picker "
) ,
onClick : { [ weak self ] in self ? . showPhotoLibraryForAvatar ( ) }
) ,
confirmEnabled : true ,
onConfirm : { [ weak self ] modal in
self ? . updateProfile (
name : name ,
avatarUpdate : avatarUpdate ,
onComplete : { [ weak modal ] in modal ? . close ( ) }
)
}
)
)
}
private func showPhotoLibraryForAvatar ( ) {
Permissions . requestLibraryPermissionIfNeeded { [ weak self ] in
DispatchQueue . main . async {
let picker : UIImagePickerController = UIImagePickerController ( )
picker . sourceType = . photoLibrary
picker . mediaTypes = [ " public.image " ] // s t r i n g l i n t : d i s a b l e
picker . delegate = self ? . imagePickerHandler
self ? . transitionToScreen ( picker , transitionType : . present )
}
}
}
fileprivate func updateProfile (
name : String ,
avatarUpdate : ProfileManager . AvatarUpdate ,
onComplete : ( ( ) -> ( ) ) ? = nil
) {
let viewController = ModalActivityIndicatorViewController ( canCancel : false ) { [ weak self ] modalActivityIndicator in
ProfileManager . updateLocal (
queue : . global ( qos : . default ) ,
profileName : name ,
avatarUpdate : avatarUpdate ,
success : { db in
// 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 . afterNextTransactionNested { _ in
DispatchQueue . main . async {
modalActivityIndicator . dismiss ( completion : {
onComplete ? ( )
} )
}
}
} ,
failure : { [ weak self ] error in
DispatchQueue . main . async {
modalActivityIndicator . dismiss {
let title : String = {
switch ( avatarUpdate , error ) {
case ( . remove , _ ) : return " update_profile_modal_remove_error_title " . localized ( )
case ( _ , . avatarUploadMaxFileSizeExceeded ) :
return " update_profile_modal_max_size_error_title " . localized ( )
default : return " update_profile_modal_error_title " . localized ( )
}
} ( )
let message : String ? = {
switch ( avatarUpdate , error ) {
case ( . remove , _ ) : return nil
case ( _ , . avatarUploadMaxFileSizeExceeded ) :
return " update_profile_modal_max_size_error_message " . localized ( )
default : return " update_profile_modal_error_message " . localized ( )
}
} ( )
self ? . transitionToScreen (
ConfirmationModal (
info : ConfirmationModal . Info (
title : title ,
body : ( message . map { . text ( $0 ) } ? ? . none ) ,
cancelTitle : " BUTTON_OK " . localized ( ) ,
cancelStyle : . alert_text ,
dismissType : . single
)
) ,
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 )
}
}