diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 28331bfd4c..e3ed457158 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -179,11 +179,9 @@ 942BA9C42E55AB54007C4595 /* UILabel+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 942BA9C32E55AB51007C4595 /* UILabel+Utilities.swift */; }; 94363E5B2E6002750004EE43 /* SessionListScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94363E5A2E60026F0004EE43 /* SessionListScreen.swift */; }; 94363E5E2E6002960004EE43 /* SessionListScreen+Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94363E5D2E6002940004EE43 /* SessionListScreen+Models.swift */; }; - 94363E622E60148A0004EE43 /* SessionProSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94363E612E6014880004EE43 /* SessionProSettingsViewModel.swift */; }; 94363E642E6017F50004EE43 /* SessionListScreen+ListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94363E632E6017E70004EE43 /* SessionListScreen+ListItem.swift */; }; 94363E662E60186A0004EE43 /* SessionListScreen+Section.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94363E652E60185D0004EE43 /* SessionListScreen+Section.swift */; }; 94363E682E6024A40004EE43 /* SessionListScreen+AccessoryViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94363E672E6024960004EE43 /* SessionListScreen+AccessoryViews.swift */; }; - 94363E6A2E613AF80004EE43 /* SessionProSettingsViewModel+Database.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94363E692E613AEE0004EE43 /* SessionProSettingsViewModel+Database.swift */; }; 9438658F2EAB380700DB989A /* MutipleLinksModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9438658E2EAB37F600DB989A /* MutipleLinksModal.swift */; }; 9438D4862E67B015008C7FFE /* CocoaLumberjackSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 940978602E655C2F00925B36 /* CocoaLumberjackSwift */; }; 9438D4882E67B704008C7FFE /* SessionListHostingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9438D4872E67B6F5008C7FFE /* SessionListHostingViewController.swift */; }; @@ -202,13 +200,14 @@ 94519A952E851BF500F02723 /* SessionProPaymentScreen+UpdatePlan.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94519A942E851BF300F02723 /* SessionProPaymentScreen+UpdatePlan.swift */; }; 94519A972E851F1400F02723 /* SessionProPaymentScreen+RequestRefund.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94519A962E851F1400F02723 /* SessionProPaymentScreen+RequestRefund.swift */; }; 94519A992E8A1A4200F02723 /* SessionProPaymentScreen+CancelPlan.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94519A982E8A1A3200F02723 /* SessionProPaymentScreen+CancelPlan.swift */; }; - 945E89D22E95D54700D8D907 /* SessionProPaymentScreen+Renew.swift in Sources */ = {isa = PBXBuildFile; fileRef = 945E89D12E95D54000D8D907 /* SessionProPaymentScreen+Renew.swift */; }; + 945E89D22E95D54700D8D907 /* SessionProPaymentScreen+NoBillingAccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = 945E89D12E95D54000D8D907 /* SessionProPaymentScreen+NoBillingAccess.swift */; }; 945E89D42E95D97000D8D907 /* SessionProPaymentScreen+SharedViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 945E89D32E95D96100D8D907 /* SessionProPaymentScreen+SharedViews.swift */; }; 945E89D62E9602AB00D8D907 /* SessionProPaymentScreen+Purchase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 945E89D52E96028B00D8D907 /* SessionProPaymentScreen+Purchase.swift */; }; 9463794A2E7131070017A014 /* SessionProManagerType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 946379492E71308B0017A014 /* SessionProManagerType.swift */; }; 9463794C2E71371F0017A014 /* SessionProPaymentScreen+Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9463794B2E7137120017A014 /* SessionProPaymentScreen+Models.swift */; }; 946F5A732D5DA3AC00A5ADCE /* Punycode in Frameworks */ = {isa = PBXBuildFile; productRef = 946F5A722D5DA3AC00A5ADCE /* Punycode */; }; 9473386E2BDF5F3E00B9E169 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 9473386D2BDF5F3E00B9E169 /* InfoPlist.xcstrings */; }; + 9478E84B2EC6DDBB00BFDED0 /* ProCTAModal+Type.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9478E84A2EC6DDB300BFDED0 /* ProCTAModal+Type.swift */; }; 9479981C2DD44ADC008F5CD5 /* ThreadNotificationSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9479981B2DD44AC5008F5CD5 /* ThreadNotificationSettingsViewModel.swift */; }; 947D7FD62D509FC900E8E413 /* SessionNetworkAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 947D7FCF2D509FC900E8E413 /* SessionNetworkAPI.swift */; }; 947D7FD72D509FC900E8E413 /* HTTPClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 947D7FD22D509FC900E8E413 /* HTTPClient.swift */; }; @@ -217,6 +216,22 @@ 947D7FE72D51837200E8E413 /* ArrowCapsule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 947D7FE42D51837200E8E413 /* ArrowCapsule.swift */; }; 947D7FE82D51837200E8E413 /* PopoverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 947D7FE52D51837200E8E413 /* PopoverView.swift */; }; 947D7FE92D51837200E8E413 /* Text+CopyButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 947D7FE62D51837200E8E413 /* Text+CopyButton.swift */; }; + 94805EB22EB087FD0055EBBC /* BottomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94805EB12EB087F90055EBBC /* BottomSheet.swift */; }; + 94805EB92EB1E16D0055EBBC /* SessionProSettings+ProFeatures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94805EB82EB1E1650055EBBC /* SessionProSettings+ProFeatures.swift */; }; + 94805EBF2EB462C40055EBBC /* TransitionType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94805EBE2EB462C10055EBBC /* TransitionType.swift */; }; + 94805EC12EB48D910055EBBC /* SessionProState+Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94805EC02EB48D860055EBBC /* SessionProState+Models.swift */; }; + 94805EC32EB48ED50055EBBC /* SessionProPaymentScreenContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94805EC22EB48EC40055EBBC /* SessionProPaymentScreenContent.swift */; }; + 94805EC62EB823B80055EBBC /* DismissType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94805EC52EB823B00055EBBC /* DismissType.swift */; }; + 94805EC82EB834D40055EBBC /* UINavigationController+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94805EC72EB834CD0055EBBC /* UINavigationController+Utilities.swift */; }; + 948615BC2ED40D38000A5666 /* GenericCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 948615BB2ED40D38000A5666 /* GenericCTA.webp */; }; + 948615BD2ED40D38000A5666 /* GenericCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 948615BB2ED40D38000A5666 /* GenericCTA.webp */; }; + 948615BF2ED51F5F000A5666 /* ToolBarManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 948615BE2ED51F4D000A5666 /* ToolBarManager.swift */; }; + 948615C22ED7D39B000A5666 /* SessionProSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 948615C02ED7D39B000A5666 /* SessionProSettingsViewModel.swift */; }; + 948615C32ED7D39B000A5666 /* SessionProSettingsViewModel+Database.swift in Sources */ = {isa = PBXBuildFile; fileRef = 948615C12ED7D39B000A5666 /* SessionProSettingsViewModel+Database.swift */; }; + 948615C52ED7D4D4000A5666 /* ModalActivityIndicatorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 948615C42ED7D4D4000A5666 /* ModalActivityIndicatorViewController.swift */; }; + 948615C72ED7D516000A5666 /* OWSViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 948615C62ED7D516000A5666 /* OWSViewController.swift */; }; + 948615C92ED7D646000A5666 /* Publisher+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 948615C82ED7D63E000A5666 /* Publisher+Utilities.swift */; }; + 948615CB2ED7D6E5000A5666 /* NavigatableState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 948615CA2ED7D6E5000A5666 /* NavigatableState.swift */; }; 9499E6032DDD9BF900091434 /* ExpandableLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9499E6022DDD9BEE00091434 /* ExpandableLabel.swift */; }; 9499E68B2DF92F4E00091434 /* ThreadNotificationSettingsViewModelSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9499E68A2DF92F3B00091434 /* ThreadNotificationSettingsViewModelSpec.swift */; }; 94A6B9DB2DD6BF7C00DB4B44 /* Constants+Apple.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94A6B9DA2DD6BF6E00DB4B44 /* Constants+Apple.swift */; }; @@ -243,14 +258,11 @@ 94CD962D2E1B85920097754D /* InputViewButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94CD962B2E1B85920097754D /* InputViewButton.swift */; }; 94CD962E2E1B85920097754D /* InputTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94CD962A2E1B85920097754D /* InputTextView.swift */; }; 94CD96302E1B88430097754D /* CGRect+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94CD962F2E1B88430097754D /* CGRect+Utilities.swift */; }; - 94CD96402E1BABE90097754D /* GenericCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 94CD963C2E1BABE90097754D /* GenericCTA.webp */; }; 94CD96412E1BABE90097754D /* HigherCharLimitCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 94CD963E2E1BABE90097754D /* HigherCharLimitCTA.webp */; }; - 94CD96432E1BAC0F0097754D /* GenericCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 94CD963C2E1BABE90097754D /* GenericCTA.webp */; }; 94CD96452E1BAC0F0097754D /* HigherCharLimitCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 94CD963E2E1BABE90097754D /* HigherCharLimitCTA.webp */; }; 94D716802E8F6363008294EE /* HighlightMentionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94D7167F2E8F6362008294EE /* HighlightMentionView.swift */; }; 94D716822E8FA1A0008294EE /* AttributedLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94D716812E8FA19D008294EE /* AttributedLabel.swift */; }; 94D716862E933958008294EE /* SessionProBadge+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94D716852E93394B008294EE /* SessionProBadge+Utilities.swift */; }; - 94D955EB2E9CA5E000DEE66E /* SessionProPaymentScreen+ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94D955EA2E9CA5DA00DEE66E /* SessionProPaymentScreen+ViewModel.swift */; }; 94E9BC0D2C7BFBDA006984EA /* Localization+Style.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94E9BC0C2C7BFBDA006984EA /* Localization+Style.swift */; }; A11CD70D17FA230600A2D1B1 /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A11CD70C17FA230600A2D1B1 /* QuartzCore.framework */; }; A163E8AB16F3F6AA0094D68B /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A163E8AA16F3F6A90094D68B /* Security.framework */; }; @@ -359,7 +371,6 @@ C38EF24D255B6D67007E1867 /* UIView+OWS.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF240255B6D67007E1867 /* UIView+OWS.swift */; }; C38EF24E255B6D67007E1867 /* Collection+OWS.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF241255B6D67007E1867 /* Collection+OWS.swift */; }; C38EF2B3255B6D9C007E1867 /* UIViewController+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2B1255B6D9C007E1867 /* UIViewController+Utilities.swift */; }; - C38EF363255B6DCC007E1867 /* ModalActivityIndicatorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF349255B6DC7007E1867 /* ModalActivityIndicatorViewController.swift */; }; C38EF372255B6DCC007E1867 /* MediaMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF358255B6DCC007E1867 /* MediaMessageView.swift */; }; C38EF385255B6DD2007E1867 /* AttachmentTextToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF37C255B6DCF007E1867 /* AttachmentTextToolbar.swift */; }; C38EF387255B6DD2007E1867 /* AttachmentItemCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF37E255B6DD0007E1867 /* AttachmentItemCollection.swift */; }; @@ -484,7 +495,6 @@ FD11E22D2CA4D12C001BAF58 /* DifferenceKit in Frameworks */ = {isa = PBXBuildFile; productRef = FD2286782C38D4FF00BC06F7 /* DifferenceKit */; }; FD11E22E2CA4D12C001BAF58 /* WebRTC in Frameworks */ = {isa = PBXBuildFile; productRef = FDEF57292C3CF50B00131302 /* WebRTC */; }; FD12A83F2AD63BDF00EEBA0D /* Navigatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD12A83E2AD63BDF00EEBA0D /* Navigatable.swift */; }; - FD12A8412AD63BEA00EEBA0D /* NavigatableState.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD12A8402AD63BEA00EEBA0D /* NavigatableState.swift */; }; FD12A8432AD63BF600EEBA0D /* ObservableTableSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD12A8422AD63BF600EEBA0D /* ObservableTableSource.swift */; }; FD12A8452AD63C2200EEBA0D /* TableDataState.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD12A8442AD63C2200EEBA0D /* TableDataState.swift */; }; FD12A8472AD63C3400EEBA0D /* PagedObservationSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD12A8462AD63C3400EEBA0D /* PagedObservationSource.swift */; }; @@ -855,7 +865,6 @@ FD7115F428C71EB200B47552 /* ThreadDisappearingMessagesSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7115F328C71EB200B47552 /* ThreadDisappearingMessagesSettingsViewModel.swift */; }; FD7115F828C8151C00B47552 /* DisposableBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7115F728C8151C00B47552 /* DisposableBarButtonItem.swift */; }; FD7115FA28C8153400B47552 /* UIBarButtonItem+Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7115F928C8153400B47552 /* UIBarButtonItem+Combine.swift */; }; - FD7115FE28C8202D00B47552 /* ReplaySubject.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7115FD28C8202D00B47552 /* ReplaySubject.swift */; }; FD71160028C8253500B47552 /* UIView+Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7115FF28C8253500B47552 /* UIView+Combine.swift */; }; FD71160228C8255900B47552 /* UIControl+Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD71160128C8255900B47552 /* UIControl+Combine.swift */; }; FD71160428C95B5600B47552 /* PhotoCollectionPickerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD71160328C95B5600B47552 /* PhotoCollectionPickerViewModel.swift */; }; @@ -872,11 +881,9 @@ FD71163828E2C50700B47552 /* SessionTableViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37EA0828AA2D27003AE748 /* SessionTableViewModel.swift */; }; FD71163E28E2C82900B47552 /* SessionCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37EA0A28AB12E2003AE748 /* SessionCell.swift */; }; FD71163F28E2C82C00B47552 /* SessionHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7115EF28C5D7DE00B47552 /* SessionHeaderView.swift */; }; - FD71164228E2C85A00B47552 /* TransitionType.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD71163328E2C48400B47552 /* TransitionType.swift */; }; FD71164428E2CB8A00B47552 /* SessionCell+Accessory.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD71164328E2CB8A00B47552 /* SessionCell+Accessory.swift */; }; FD71164628E2CC1300B47552 /* SessionHighlightingBackgroundLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD71164528E2CC1300B47552 /* SessionHighlightingBackgroundLabel.swift */; }; FD71164828E2CE8700B47552 /* SessionCell+AccessoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD71164728E2CE8700B47552 /* SessionCell+AccessoryView.swift */; }; - FD71164A28E3EA5B00B47552 /* DismissType.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD71164928E3EA5B00B47552 /* DismissType.swift */; }; FD71164E28E3F8CC00B47552 /* SessionCell+Info.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD71164D28E3F8CC00B47552 /* SessionCell+Info.swift */; }; FD71165228E410BE00B47552 /* SessionTableSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD71165128E410BE00B47552 /* SessionTableSection.swift */; }; FD71165828E436E800B47552 /* Modal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B86BD08323399ACF000F5AE3 /* Modal.swift */; }; @@ -1153,7 +1160,6 @@ FDE754E02C9BAF8A002A2623 /* Hex.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE754DA2C9BAF8A002A2623 /* Hex.swift */; }; FDE754E32C9BAFF4002A2623 /* Crypto+SessionNetworkingKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE754E12C9BAFF4002A2623 /* Crypto+SessionNetworkingKit.swift */; }; FDE754E52C9BB012002A2623 /* BezierPathView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE754E42C9BB012002A2623 /* BezierPathView.swift */; }; - FDE754E72C9BB051002A2623 /* OWSViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE754E62C9BB051002A2623 /* OWSViewController.swift */; }; FDE754F02C9BB08B002A2623 /* Crypto+Attachments.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE754EC2C9BB08B002A2623 /* Crypto+Attachments.swift */; }; FDE754F12C9BB08B002A2623 /* Crypto+LibSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE754ED2C9BB08B002A2623 /* Crypto+LibSession.swift */; }; FDE754F22C9BB08B002A2623 /* Crypto+SessionMessagingKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE754EE2C9BB08B002A2623 /* Crypto+SessionMessagingKit.swift */; }; @@ -1168,7 +1174,6 @@ FDE755182C9BC169002A2623 /* UIAlertAction+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE755132C9BC169002A2623 /* UIAlertAction+Utilities.swift */; }; FDE755192C9BC169002A2623 /* UIImage+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE755142C9BC169002A2623 /* UIImage+Utilities.swift */; }; FDE7551A2C9BC169002A2623 /* UIApplicationState+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE755152C9BC169002A2623 /* UIApplicationState+Utilities.swift */; }; - FDE7551B2C9BC169002A2623 /* UINavigationController+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE755162C9BC169002A2623 /* UINavigationController+Utilities.swift */; }; FDE7551C2C9BC169002A2623 /* UIBezierPath+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE755172C9BC169002A2623 /* UIBezierPath+Utilities.swift */; }; FDE755202C9BC1A6002A2623 /* CacheConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE7551F2C9BC1A6002A2623 /* CacheConfig.swift */; }; FDE755222C9BC1BA002A2623 /* LibSessionError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE755212C9BC1BA002A2623 /* LibSessionError.swift */; }; @@ -1640,11 +1645,9 @@ 942BA9C32E55AB51007C4595 /* UILabel+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UILabel+Utilities.swift"; sourceTree = ""; }; 94363E5A2E60026F0004EE43 /* SessionListScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionListScreen.swift; sourceTree = ""; }; 94363E5D2E6002940004EE43 /* SessionListScreen+Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionListScreen+Models.swift"; sourceTree = ""; }; - 94363E612E6014880004EE43 /* SessionProSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProSettingsViewModel.swift; sourceTree = ""; }; 94363E632E6017E70004EE43 /* SessionListScreen+ListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionListScreen+ListItem.swift"; sourceTree = ""; }; 94363E652E60185D0004EE43 /* SessionListScreen+Section.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionListScreen+Section.swift"; sourceTree = ""; }; 94363E672E6024960004EE43 /* SessionListScreen+AccessoryViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionListScreen+AccessoryViews.swift"; sourceTree = ""; }; - 94363E692E613AEE0004EE43 /* SessionProSettingsViewModel+Database.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionProSettingsViewModel+Database.swift"; sourceTree = ""; }; 94367C422C6C828500814252 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; 9438658E2EAB37F600DB989A /* MutipleLinksModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MutipleLinksModal.swift; sourceTree = ""; }; 9438D4872E67B6F5008C7FFE /* SessionListHostingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionListHostingViewController.swift; sourceTree = ""; }; @@ -1664,13 +1667,14 @@ 94519A962E851F1400F02723 /* SessionProPaymentScreen+RequestRefund.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionProPaymentScreen+RequestRefund.swift"; sourceTree = ""; }; 94519A982E8A1A3200F02723 /* SessionProPaymentScreen+CancelPlan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionProPaymentScreen+CancelPlan.swift"; sourceTree = ""; }; 945D9C572D6FDBE7003C4C0C /* _020_AddJobUniqueHash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _020_AddJobUniqueHash.swift; sourceTree = ""; }; - 945E89D12E95D54000D8D907 /* SessionProPaymentScreen+Renew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionProPaymentScreen+Renew.swift"; sourceTree = ""; }; + 945E89D12E95D54000D8D907 /* SessionProPaymentScreen+NoBillingAccess.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionProPaymentScreen+NoBillingAccess.swift"; sourceTree = ""; }; 945E89D32E95D96100D8D907 /* SessionProPaymentScreen+SharedViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionProPaymentScreen+SharedViews.swift"; sourceTree = ""; }; 945E89D52E96028B00D8D907 /* SessionProPaymentScreen+Purchase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionProPaymentScreen+Purchase.swift"; sourceTree = ""; }; 946379492E71308B0017A014 /* SessionProManagerType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProManagerType.swift; sourceTree = ""; }; 9463794B2E7137120017A014 /* SessionProPaymentScreen+Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionProPaymentScreen+Models.swift"; sourceTree = ""; }; 9471CAA72CACFB4E00090FB7 /* GenerateLicenses.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenerateLicenses.swift; sourceTree = ""; }; 9473386D2BDF5F3E00B9E169 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; }; + 9478E84A2EC6DDB300BFDED0 /* ProCTAModal+Type.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProCTAModal+Type.swift"; sourceTree = ""; }; 9479981B2DD44AC5008F5CD5 /* ThreadNotificationSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadNotificationSettingsViewModel.swift; sourceTree = ""; }; 947AD68F2C8968FF000B2730 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; 947D7FCF2D509FC900E8E413 /* SessionNetworkAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionNetworkAPI.swift; sourceTree = ""; }; @@ -1683,6 +1687,21 @@ 947D7FE42D51837200E8E413 /* ArrowCapsule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArrowCapsule.swift; sourceTree = ""; }; 947D7FE52D51837200E8E413 /* PopoverView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopoverView.swift; sourceTree = ""; }; 947D7FE62D51837200E8E413 /* Text+CopyButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Text+CopyButton.swift"; sourceTree = ""; }; + 94805EB12EB087F90055EBBC /* BottomSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomSheet.swift; sourceTree = ""; }; + 94805EB82EB1E1650055EBBC /* SessionProSettings+ProFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionProSettings+ProFeatures.swift"; sourceTree = ""; }; + 94805EBE2EB462C10055EBBC /* TransitionType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransitionType.swift; sourceTree = ""; }; + 94805EC02EB48D860055EBBC /* SessionProState+Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionProState+Models.swift"; sourceTree = ""; }; + 94805EC22EB48EC40055EBBC /* SessionProPaymentScreenContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProPaymentScreenContent.swift; sourceTree = ""; }; + 94805EC52EB823B00055EBBC /* DismissType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DismissType.swift; sourceTree = ""; }; + 94805EC72EB834CD0055EBBC /* UINavigationController+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UINavigationController+Utilities.swift"; sourceTree = ""; }; + 948615BB2ED40D38000A5666 /* GenericCTA.webp */ = {isa = PBXFileReference; lastKnownFileType = file; path = GenericCTA.webp; sourceTree = ""; }; + 948615BE2ED51F4D000A5666 /* ToolBarManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolBarManager.swift; sourceTree = ""; }; + 948615C02ED7D39B000A5666 /* SessionProSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProSettingsViewModel.swift; sourceTree = ""; }; + 948615C12ED7D39B000A5666 /* SessionProSettingsViewModel+Database.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionProSettingsViewModel+Database.swift"; sourceTree = ""; }; + 948615C42ED7D4D4000A5666 /* ModalActivityIndicatorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModalActivityIndicatorViewController.swift; sourceTree = ""; }; + 948615C62ED7D516000A5666 /* OWSViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OWSViewController.swift; sourceTree = ""; }; + 948615C82ED7D63E000A5666 /* Publisher+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Publisher+Utilities.swift"; sourceTree = ""; }; + 948615CA2ED7D6E5000A5666 /* NavigatableState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigatableState.swift; sourceTree = ""; }; 9499E6022DDD9BEE00091434 /* ExpandableLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpandableLabel.swift; sourceTree = ""; }; 9499E68A2DF92F3B00091434 /* ThreadNotificationSettingsViewModelSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadNotificationSettingsViewModelSpec.swift; sourceTree = ""; }; 94A6B9DA2DD6BF6E00DB4B44 /* Constants+Apple.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Constants+Apple.swift"; sourceTree = ""; }; @@ -1707,12 +1726,10 @@ 94CD962A2E1B85920097754D /* InputTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputTextView.swift; sourceTree = ""; }; 94CD962B2E1B85920097754D /* InputViewButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputViewButton.swift; sourceTree = ""; }; 94CD962F2E1B88430097754D /* CGRect+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CGRect+Utilities.swift"; sourceTree = ""; }; - 94CD963C2E1BABE90097754D /* GenericCTA.webp */ = {isa = PBXFileReference; lastKnownFileType = file; path = GenericCTA.webp; sourceTree = ""; }; 94CD963E2E1BABE90097754D /* HigherCharLimitCTA.webp */ = {isa = PBXFileReference; lastKnownFileType = file; path = HigherCharLimitCTA.webp; sourceTree = ""; }; 94D7167F2E8F6362008294EE /* HighlightMentionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightMentionView.swift; sourceTree = ""; }; 94D716812E8FA19D008294EE /* AttributedLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedLabel.swift; sourceTree = ""; }; 94D716852E93394B008294EE /* SessionProBadge+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionProBadge+Utilities.swift"; sourceTree = ""; }; - 94D955EA2E9CA5DA00DEE66E /* SessionProPaymentScreen+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionProPaymentScreen+ViewModel.swift"; sourceTree = ""; }; 94E9BC0C2C7BFBDA006984EA /* Localization+Style.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Localization+Style.swift"; sourceTree = ""; }; A11CD70C17FA230600A2D1B1 /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; }; A163E8AA16F3F6A90094D68B /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; }; @@ -1828,7 +1845,6 @@ C38EF2FB255B6DBD007E1867 /* OWSWindowManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = OWSWindowManager.h; path = SessionMessagingKit/Utilities/OWSWindowManager.h; sourceTree = SOURCE_ROOT; }; C38EF306255B6DBE007E1867 /* OWSWindowManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = OWSWindowManager.m; path = SessionMessagingKit/Utilities/OWSWindowManager.m; sourceTree = SOURCE_ROOT; }; C38EF309255B6DBE007E1867 /* DeviceSleepManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = DeviceSleepManager.swift; path = SessionMessagingKit/Utilities/DeviceSleepManager.swift; sourceTree = SOURCE_ROOT; }; - C38EF349255B6DC7007E1867 /* ModalActivityIndicatorViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ModalActivityIndicatorViewController.swift; path = "SignalUtilitiesKit/Shared View Controllers/ModalActivityIndicatorViewController.swift"; sourceTree = SOURCE_ROOT; }; C38EF358255B6DCC007E1867 /* MediaMessageView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = MediaMessageView.swift; path = "SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift"; sourceTree = SOURCE_ROOT; }; C38EF37C255B6DCF007E1867 /* AttachmentTextToolbar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = AttachmentTextToolbar.swift; path = "SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift"; sourceTree = SOURCE_ROOT; }; C38EF37E255B6DD0007E1867 /* AttachmentItemCollection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = AttachmentItemCollection.swift; path = "SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentItemCollection.swift"; sourceTree = SOURCE_ROOT; }; @@ -1956,7 +1972,6 @@ FD10AF112AF85D11007709E5 /* Feature+ServiceNetwork.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Feature+ServiceNetwork.swift"; sourceTree = ""; }; FD11E22F2CA4F498001BAF58 /* DestinationSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DestinationSpec.swift; sourceTree = ""; }; FD12A83E2AD63BDF00EEBA0D /* Navigatable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Navigatable.swift; sourceTree = ""; }; - FD12A8402AD63BEA00EEBA0D /* NavigatableState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigatableState.swift; sourceTree = ""; }; FD12A8422AD63BF600EEBA0D /* ObservableTableSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservableTableSource.swift; sourceTree = ""; }; FD12A8442AD63C2200EEBA0D /* TableDataState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableDataState.swift; sourceTree = ""; }; FD12A8462AD63C3400EEBA0D /* PagedObservationSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagedObservationSource.swift; sourceTree = ""; }; @@ -2199,7 +2214,6 @@ FD7115F328C71EB200B47552 /* ThreadDisappearingMessagesSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadDisappearingMessagesSettingsViewModel.swift; sourceTree = ""; }; FD7115F728C8151C00B47552 /* DisposableBarButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisposableBarButtonItem.swift; sourceTree = ""; }; FD7115F928C8153400B47552 /* UIBarButtonItem+Combine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIBarButtonItem+Combine.swift"; sourceTree = ""; }; - FD7115FD28C8202D00B47552 /* ReplaySubject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplaySubject.swift; sourceTree = ""; }; FD7115FF28C8253500B47552 /* UIView+Combine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Combine.swift"; sourceTree = ""; }; FD71160128C8255900B47552 /* UIControl+Combine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIControl+Combine.swift"; sourceTree = ""; }; FD71160328C95B5600B47552 /* PhotoCollectionPickerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoCollectionPickerViewModel.swift; sourceTree = ""; }; @@ -2213,11 +2227,9 @@ FD71162B28E1451400B47552 /* Position.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Position.swift; sourceTree = ""; }; FD71162D28E168C700B47552 /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; FD71163128E2C42A00B47552 /* IconSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconSize.swift; sourceTree = ""; }; - FD71163328E2C48400B47552 /* TransitionType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransitionType.swift; sourceTree = ""; }; FD71164328E2CB8A00B47552 /* SessionCell+Accessory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionCell+Accessory.swift"; sourceTree = ""; }; FD71164528E2CC1300B47552 /* SessionHighlightingBackgroundLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionHighlightingBackgroundLabel.swift; sourceTree = ""; }; FD71164728E2CE8700B47552 /* SessionCell+AccessoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionCell+AccessoryView.swift"; sourceTree = ""; }; - FD71164928E3EA5B00B47552 /* DismissType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DismissType.swift; sourceTree = ""; }; FD71164D28E3F8CC00B47552 /* SessionCell+Info.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionCell+Info.swift"; sourceTree = ""; }; FD71165128E410BE00B47552 /* SessionTableSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionTableSection.swift; sourceTree = ""; }; FD71165A28E6DDBC00B47552 /* StyledNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StyledNavigationController.swift; sourceTree = ""; }; @@ -2477,7 +2489,6 @@ FDE754DA2C9BAF8A002A2623 /* Hex.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Hex.swift; sourceTree = ""; }; FDE754E12C9BAFF4002A2623 /* Crypto+SessionNetworkingKit.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Crypto+SessionNetworkingKit.swift"; sourceTree = ""; }; FDE754E42C9BB012002A2623 /* BezierPathView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BezierPathView.swift; sourceTree = ""; }; - FDE754E62C9BB051002A2623 /* OWSViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OWSViewController.swift; sourceTree = ""; }; FDE754EC2C9BB08B002A2623 /* Crypto+Attachments.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Crypto+Attachments.swift"; sourceTree = ""; }; FDE754ED2C9BB08B002A2623 /* Crypto+LibSession.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Crypto+LibSession.swift"; sourceTree = ""; }; FDE754EE2C9BB08B002A2623 /* Crypto+SessionMessagingKit.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Crypto+SessionMessagingKit.swift"; sourceTree = ""; }; @@ -2492,7 +2503,6 @@ FDE755132C9BC169002A2623 /* UIAlertAction+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIAlertAction+Utilities.swift"; sourceTree = ""; }; FDE755142C9BC169002A2623 /* UIImage+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIImage+Utilities.swift"; sourceTree = ""; }; FDE755152C9BC169002A2623 /* UIApplicationState+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIApplicationState+Utilities.swift"; sourceTree = ""; }; - FDE755162C9BC169002A2623 /* UINavigationController+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UINavigationController+Utilities.swift"; sourceTree = ""; }; FDE755172C9BC169002A2623 /* UIBezierPath+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIBezierPath+Utilities.swift"; sourceTree = ""; }; FDE7551F2C9BC1A6002A2623 /* CacheConfig.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CacheConfig.swift; sourceTree = ""; }; FDE755212C9BC1BA002A2623 /* LibSessionError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LibSessionError.swift; sourceTree = ""; }; @@ -3017,9 +3027,12 @@ 94AAB1502E1F752600A6FA18 /* CyclicGradientView.swift */, 94AAB14E2E1F6CB300A6FA18 /* SessionProBadge+SwiftUI.swift */, 94AAB14C2E1F39AB00A6FA18 /* ProCTAModal.swift */, + 9478E84A2EC6DDB300BFDED0 /* ProCTAModal+Type.swift */, 94B6BAFD2E39F50E00E718BB /* UserProfileModal.swift */, 9438658E2EAB37F600DB989A /* MutipleLinksModal.swift */, 94AAB14A2E1E197800A6FA18 /* Modal+SwiftUI.swift */, + 94805EB12EB087F90055EBBC /* BottomSheet.swift */, + 948615BE2ED51F4D000A5666 /* ToolBarManager.swift */, FD8A5B1F2DC03332004C689B /* AdaptiveText.swift */, FD8A5B212DC0489B004C689B /* AdaptiveHStack.swift */, 947D7FE42D51837200E8E413 /* ArrowCapsule.swift */, @@ -3052,28 +3065,19 @@ path = SessionListScreen; sourceTree = ""; }; - 94363E602E6014630004EE43 /* SessionProSettings */ = { - isa = PBXGroup; - children = ( - 94D955EA2E9CA5DA00DEE66E /* SessionProPaymentScreen+ViewModel.swift */, - 94363E612E6014880004EE43 /* SessionProSettingsViewModel.swift */, - 94363E692E613AEE0004EE43 /* SessionProSettingsViewModel+Database.swift */, - ); - path = SessionProSettings; - sourceTree = ""; - }; 9438D5542E6A6843008C7FFE /* SessionProSettings */ = { isa = PBXGroup; children = ( 9438D5562E6A6862008C7FFE /* SessionProPaymentScreen.swift */, 945E89D32E95D96100D8D907 /* SessionProPaymentScreen+SharedViews.swift */, 945E89D52E96028B00D8D907 /* SessionProPaymentScreen+Purchase.swift */, - 945E89D12E95D54000D8D907 /* SessionProPaymentScreen+Renew.swift */, + 945E89D12E95D54000D8D907 /* SessionProPaymentScreen+NoBillingAccess.swift */, 94519A942E851BF300F02723 /* SessionProPaymentScreen+UpdatePlan.swift */, 94519A962E851F1400F02723 /* SessionProPaymentScreen+RequestRefund.swift */, 94519A982E8A1A3200F02723 /* SessionProPaymentScreen+CancelPlan.swift */, 9463794B2E7137120017A014 /* SessionProPaymentScreen+Models.swift */, 9438D55C2E6FEF3B008C7FFE /* SessionProPlanUpdatedScreen.swift */, + 94805EB82EB1E1650055EBBC /* SessionProSettings+ProFeatures.swift */, ); path = SessionProSettings; sourceTree = ""; @@ -3131,6 +3135,18 @@ path = SessionNetworkScreen; sourceTree = ""; }; + 94805EC42EB8156A0055EBBC /* SessionPro */ = { + isa = PBXGroup; + children = ( + 948615C02ED7D39B000A5666 /* SessionProSettingsViewModel.swift */, + 948615C12ED7D39B000A5666 /* SessionProSettingsViewModel+Database.swift */, + 94805EC22EB48EC40055EBBC /* SessionProPaymentScreenContent.swift */, + 94B6BAF52E30A88800E718BB /* SessionProState.swift */, + 94805EC02EB48D860055EBBC /* SessionProState+Models.swift */, + ); + path = SessionPro; + sourceTree = ""; + }; 94CD96282E1B855E0097754D /* Input View */ = { isa = PBXGroup; children = ( @@ -3152,8 +3168,8 @@ 94AAB15B2E24C97400A6FA18 /* AnimatedProfileCTA.webp */, FD360ED92ED3E8BC0050CAF4 /* DonationsCTA.webp */, 94AAB1562E24BD3700A6FA18 /* PinnedConversationsCTA.webp */, - 94CD963C2E1BABE90097754D /* GenericCTA.webp */, 94CD963E2E1BABE90097754D /* HigherCharLimitCTA.webp */, + 948615BB2ED40D38000A5666 /* GenericCTA.webp */, ); path = WebPImages; sourceTree = ""; @@ -3551,6 +3567,8 @@ C331FFAE2558FA7700070591 /* Utilities */ = { isa = PBXGroup; children = ( + 948615C82ED7D63E000A5666 /* Publisher+Utilities.swift */, + 94805EC72EB834CD0055EBBC /* UINavigationController+Utilities.swift */, 94B6BB012E3AE85800E718BB /* QRCode.swift */, 94CD962F2E1B88430097754D /* CGRect+Utilities.swift */, FD848B9728422F1A000E298B /* Date+Utilities.swift */, @@ -3611,6 +3629,8 @@ FD0B77AF29B69A65009169BA /* TopBannerController.swift */, FDB348622BE3774000B716C2 /* BezierPathView.swift */, C328251E25CA3A900062D0A7 /* QuoteView.swift */, + 948615C62ED7D516000A5666 /* OWSViewController.swift */, + 948615C42ED7D4D4000A5666 /* ModalActivityIndicatorViewController.swift */, ); path = Components; sourceTree = ""; @@ -3621,7 +3641,6 @@ C33FD9B7255A54A300E217F9 /* Meta */, C36096ED25AD20FD008B62B2 /* Media Viewing & Editing */, C3851CD225624B060061EEB0 /* Shared Views */, - C360970125AD22D3008B62B2 /* Shared View Controllers */, C3CA3B11255CF17200F4C6D4 /* Utilities */, ); path = SignalUtilitiesKit; @@ -3694,7 +3713,6 @@ C360969125AD1765008B62B2 /* Settings */ = { isa = PBXGroup; children = ( - 94363E602E6014630004EE43 /* SessionProSettings */, FDE71B092E7934DC0023F5F9 /* DeveloperSettings */, FD8A5B002DBEFBF9004C689B /* SessionNetworkScreen */, FD37E9CD28A1E682003AE748 /* Views */, @@ -3804,15 +3822,6 @@ path = "Media Viewing & Editing"; sourceTree = ""; }; - C360970125AD22D3008B62B2 /* Shared View Controllers */ = { - isa = PBXGroup; - children = ( - C38EF349255B6DC7007E1867 /* ModalActivityIndicatorViewController.swift */, - FDE754E62C9BB051002A2623 /* OWSViewController.swift */, - ); - path = "Shared View Controllers"; - sourceTree = ""; - }; C379DC6825672B5E0002D4EB /* Notifications */ = { isa = PBXGroup; children = ( @@ -3882,7 +3891,6 @@ C3BBE0B32554F0D30050F1E3 /* Utilities */ = { isa = PBXGroup; children = ( - 94B6BAF52E30A88800E718BB /* SessionProState.swift */, FD428B1E2B4B758B006D0888 /* AppReadiness.swift */, FDE5218D2E03A06700061B8E /* AttachmentManager.swift */, FD47E0B02AA6A05800A55E41 /* Authentication+SessionMessagingKit.swift */, @@ -3988,6 +3996,7 @@ C3C2A6F125539DE700C340D1 /* SessionMessagingKit */ = { isa = PBXGroup; children = ( + 94805EC42EB8156A0055EBBC /* SessionPro */, C3C2A7802553AA6300C340D1 /* Protos */, C3C2A70A25539DF900C340D1 /* Meta */, B8DE1FB226C22F1F0079C9CE /* Calls */, @@ -4223,7 +4232,6 @@ FDE755152C9BC169002A2623 /* UIApplicationState+Utilities.swift */, FDE755172C9BC169002A2623 /* UIBezierPath+Utilities.swift */, FDE755142C9BC169002A2623 /* UIImage+Utilities.swift */, - FDE755162C9BC169002A2623 /* UINavigationController+Utilities.swift */, FD99D0862D0FA72E005D2E15 /* ThreadSafe.swift */, FD29598C2A43BC0B00888A17 /* Version.swift */, ); @@ -4782,7 +4790,6 @@ isa = PBXGroup; children = ( FD7115F628C8150D00B47552 /* Disposable Views */, - FD7115FD28C8202D00B47552 /* ReplaySubject.swift */, FDE755232C9BC1D1002A2623 /* Publisher+Utilities.swift */, FD71160128C8255900B47552 /* UIControl+Combine.swift */, FD7115F928C8153400B47552 /* UIBarButtonItem+Combine.swift */, @@ -4840,6 +4847,9 @@ FD71163028E2C41900B47552 /* Types */ = { isa = PBXGroup; children = ( + 948615CA2ED7D6E5000A5666 /* NavigatableState.swift */, + 94805EC52EB823B00055EBBC /* DismissType.swift */, + 94805EBE2EB462C10055EBBC /* TransitionType.swift */, FDE6E99729F8E63A00F93C5D /* Accessibility.swift */, FD71163128E2C42A00B47552 /* IconSize.swift */, FDB11A602DD5BDC900BEF49F /* ImageDataManager.swift */, @@ -4868,13 +4878,10 @@ FD71164128E2C83500B47552 /* Types */ = { isa = PBXGroup; children = ( - FD71164928E3EA5B00B47552 /* DismissType.swift */, FD12A83E2AD63BDF00EEBA0D /* Navigatable.swift */, - FD12A8402AD63BEA00EEBA0D /* NavigatableState.swift */, FD12A8422AD63BF600EEBA0D /* ObservableTableSource.swift */, FD12A8442AD63C2200EEBA0D /* TableDataState.swift */, FD12A8462AD63C3400EEBA0D /* PagedObservationSource.swift */, - FD71163328E2C48400B47552 /* TransitionType.swift */, FD71164D28E3F8CC00B47552 /* SessionCell+Info.swift */, FD71164328E2CB8A00B47552 /* SessionCell+Accessory.swift */, FDF848F429413EEC007DCAE5 /* SessionCell+Styling.swift */, @@ -6105,7 +6112,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 94CD96432E1BAC0F0097754D /* GenericCTA.webp in Resources */, + 948615BD2ED40D38000A5666 /* GenericCTA.webp in Resources */, 94AAB15E2E24C97400A6FA18 /* AnimatedProfileCTA.webp in Resources */, 94CD96452E1BAC0F0097754D /* HigherCharLimitCTA.webp in Resources */, 94AAB1582E24BD3700A6FA18 /* PinnedConversationsCTA.webp in Resources */, @@ -6184,6 +6191,7 @@ B67EBF5D19194AC60084CCFD /* Settings.bundle in Resources */, 34CF0787203E6B78005C4D61 /* busy_tone_ansi.caf in Resources */, 45A2F005204473A3002E978A /* NewMessage.aifc in Resources */, + 948615BC2ED40D38000A5666 /* GenericCTA.webp in Resources */, 45B74A882044AAB600CD42F8 /* aurora.aifc in Resources */, 45B74A742044AAB600CD42F8 /* aurora-quiet.aifc in Resources */, 9420CAC82E584B5800F738F6 /* GroupAdminCTA.webp in Resources */, @@ -6220,7 +6228,6 @@ 45B74A7D2044AAB600CD42F8 /* popcorn-quiet.aifc in Resources */, 45B74A822044AAB600CD42F8 /* pulse.aifc in Resources */, C3CA3AC8255CDB2900F4C6D4 /* spanish.txt in Resources */, - 94CD96402E1BABE90097754D /* GenericCTA.webp in Resources */, 94CD96412E1BABE90097754D /* HigherCharLimitCTA.webp in Resources */, FD360EDA2ED3E8BC0050CAF4 /* DonationsCTA.webp in Resources */, 45B74A802044AAB600CD42F8 /* pulse-quiet.aifc in Resources */, @@ -6570,6 +6577,7 @@ buildActionMask = 2147483647; files = ( 942BA9C22E53F694007C4595 /* SRCopyableLabel.swift in Sources */, + 948615C52ED7D4D4000A5666 /* ModalActivityIndicatorViewController.swift in Sources */, 9438D51A2E6951B3008C7FFE /* AnimatedToggle.swift in Sources */, FDAA36AC2EB2C5840040603E /* VoiceMessageRecordingView.swift in Sources */, FD9E26CE2EA72EFF00404C7F /* QuoteView_SwiftUI.swift in Sources */, @@ -6598,7 +6606,7 @@ 94CD95BB2E08D9E00097754D /* SessionProBadge.swift in Sources */, 942256972C23F8DD00C0FDBF /* SessionSearchBar.swift in Sources */, FD71165928E436E800B47552 /* ConfirmationModal.swift in Sources */, - 945E89D22E95D54700D8D907 /* SessionProPaymentScreen+Renew.swift in Sources */, + 945E89D22E95D54700D8D907 /* SessionProPaymentScreen+NoBillingAccess.swift in Sources */, FD981BD32DC9770E00564172 /* MentionUtilities.swift in Sources */, 7BBBDC44286EAD2D00747E59 /* LinkHighlightingLabel.swift in Sources */, FD8A5B252DC05B16004C689B /* Number+Utilities.swift in Sources */, @@ -6607,9 +6615,12 @@ FDE5219C2E08E76C00061B8E /* SessionAsyncImage.swift in Sources */, FD3FAB632AEB9A1500DC5421 /* ToastController.swift in Sources */, C331FFE72558FB0000070591 /* SNTextField.swift in Sources */, + 94805EB92EB1E16D0055EBBC /* SessionProSettings+ProFeatures.swift in Sources */, + 94805EC62EB823B80055EBBC /* DismissType.swift in Sources */, 942256962C23F8DD00C0FDBF /* CompatibleScrollingVStack.swift in Sources */, FD71165B28E6DDBC00B47552 /* StyledNavigationController.swift in Sources */, C331FFE32558FB0000070591 /* TabBar.swift in Sources */, + 94805EC82EB834D40055EBBC /* UINavigationController+Utilities.swift in Sources */, FD37E9D528A1FCE8003AE748 /* Theme+OceanLight.swift in Sources */, FDF848F129406A30007DCAE5 /* Format.swift in Sources */, 94519A952E851BF500F02723 /* SessionProPaymentScreen+UpdatePlan.swift in Sources */, @@ -6617,6 +6628,7 @@ FD8A5B112DBF34BD004C689B /* Date+Utilities.swift in Sources */, FDB348632BE3774000B716C2 /* BezierPathView.swift in Sources */, 9438658F2EAB380700DB989A /* MutipleLinksModal.swift in Sources */, + 948615C92ED7D646000A5666 /* Publisher+Utilities.swift in Sources */, 94363E5E2E6002960004EE43 /* SessionListScreen+Models.swift in Sources */, 94D716802E8F6363008294EE /* HighlightMentionView.swift in Sources */, FD8A5B292DC060E2004C689B /* Double+Utilities.swift in Sources */, @@ -6633,10 +6645,12 @@ FDB3DA882E24810C00148F8D /* SessionAsyncImage.swift in Sources */, 9499E6032DDD9BF900091434 /* ExpandableLabel.swift in Sources */, 94AAB14D2E1F39B500A6FA18 /* ProCTAModal.swift in Sources */, + 948615CB2ED7D6E5000A5666 /* NavigatableState.swift in Sources */, FDE754BA2C9B97B8002A2623 /* UIDevice+Utilities.swift in Sources */, C331FFB92558FA8D00070591 /* UIView+Constraints.swift in Sources */, FDAA36AB2EB2C45E0040603E /* UICollectionView+ReusableView.swift in Sources */, FD0B77B029B69A65009169BA /* TopBannerController.swift in Sources */, + 9478E84B2EC6DDBB00BFDED0 /* ProCTAModal+Type.swift in Sources */, 94E9BC0D2C7BFBDA006984EA /* Localization+Style.swift in Sources */, FDA335F52D91157A007E0EB6 /* SessionImageView.swift in Sources */, 94519A972E851F1400F02723 /* SessionProPaymentScreen+RequestRefund.swift in Sources */, @@ -6644,6 +6658,7 @@ FD8A5B0D2DBF2CA1004C689B /* Localization.swift in Sources */, 945E89D62E9602AB00D8D907 /* SessionProPaymentScreen+Purchase.swift in Sources */, FD37E9F628A5F106003AE748 /* Configuration.swift in Sources */, + 94805EBF2EB462C40055EBBC /* TransitionType.swift in Sources */, 943B43602EC3FCD6008ABC34 /* ListItemAccessory+Icon.swift in Sources */, 94AAB1512E1F753500A6FA18 /* CyclicGradientView.swift in Sources */, 943B43562EC2AFAC008ABC34 /* SessionListScreen+ListItemDataMatrix.swift in Sources */, @@ -6655,6 +6670,7 @@ 94363E662E60186A0004EE43 /* SessionListScreen+Section.swift in Sources */, FDB11A612DD5BDCC00BEF49F /* ImageDataManager.swift in Sources */, FD6673FD2D77F54600041530 /* ScreenLockViewController.swift in Sources */, + 94805EB22EB087FD0055EBBC /* BottomSheet.swift in Sources */, FDAA36A92EB2C3E50040603E /* UITableView+ReusableView.swift in Sources */, 94B6BB042E3B208C00E718BB /* Seperator+SwiftUI.swift in Sources */, FD8A5B222DC0489C004C689B /* AdaptiveHStack.swift in Sources */, @@ -6669,6 +6685,7 @@ FD42ECD22E3071DE002D03EA /* ThemeText.swift in Sources */, 94B6BAFE2E39F51800E718BB /* UserProfileModal.swift in Sources */, FDAA36AF2EB2C6EE0040603E /* LinkPreviewView.swift in Sources */, + 948615C72ED7D516000A5666 /* OWSViewController.swift in Sources */, FD52090328B4680F006098F6 /* RadioButton.swift in Sources */, 94B6BB022E3AE85C00E718BB /* QRCode.swift in Sources */, C331FFE82558FB0000070591 /* SNTextView.swift in Sources */, @@ -6691,6 +6708,7 @@ 94A6B9DB2DD6BF7C00DB4B44 /* Constants+Apple.swift in Sources */, FDAA36AA2EB2C4550040603E /* ReusableView.swift in Sources */, FD9E26D02EA73F4E00404C7F /* UTType+Localization.swift in Sources */, + 948615BF2ED51F5F000A5666 /* ToolBarManager.swift in Sources */, FDAA36BE2EB3FFB50040603E /* Task+Utilities.swift in Sources */, FD37E9C328A1C6F3003AE748 /* ThemeManager.swift in Sources */, FD8A5B0A2DBF246A004C689B /* Constants.swift in Sources */, @@ -6724,7 +6742,6 @@ C38EF389255B6DD2007E1867 /* AttachmentTextView.swift in Sources */, C38EF3FF255B6DF7007E1867 /* TappableView.swift in Sources */, C38EF3C2255B6DE7007E1867 /* ImageEditorPaletteView.swift in Sources */, - C38EF363255B6DCC007E1867 /* ModalActivityIndicatorViewController.swift in Sources */, C38EF3C1255B6DE7007E1867 /* ImageEditorBrushViewController.swift in Sources */, C38EF388255B6DD2007E1867 /* AttachmentApprovalViewController.swift in Sources */, C38EF38C255B6DD2007E1867 /* ApprovalRailCellView.swift in Sources */, @@ -6734,7 +6751,6 @@ C38EF24E255B6D67007E1867 /* Collection+OWS.swift in Sources */, FD2272DD2C34EFFA004D8A6C /* AppSetup.swift in Sources */, C38EF24D255B6D67007E1867 /* UIView+OWS.swift in Sources */, - FDE754E72C9BB051002A2623 /* OWSViewController.swift in Sources */, C38EF38B255B6DD2007E1867 /* AttachmentPrepViewController.swift in Sources */, C38EF405255B6DF7007E1867 /* OWSButton.swift in Sources */, C38EF3C4255B6DE7007E1867 /* ImageEditorContents.swift in Sources */, @@ -6925,7 +6941,6 @@ FD2272D12C34EBD6004D8A6C /* JSONDecoder+Utilities.swift in Sources */, FD6A38F12C2A66B100762359 /* KeychainStorage.swift in Sources */, FD2272EA2C351CA7004D8A6C /* Threading.swift in Sources */, - FD7115FE28C8202D00B47552 /* ReplaySubject.swift in Sources */, FD0E353C2AB9880B006A81F7 /* AppVersion.swift in Sources */, FD7F745F2BAAA3B4006DDFD8 /* TypeConversion+Utilities.swift in Sources */, FDE755062C9BB4EE002A2623 /* Bencode.swift in Sources */, @@ -7021,7 +7036,6 @@ FDF01FAD2A9ECC4200CAF969 /* SingletonConfig.swift in Sources */, FDE755182C9BC169002A2623 /* UIAlertAction+Utilities.swift in Sources */, FD7115F828C8151C00B47552 /* DisposableBarButtonItem.swift in Sources */, - FDE7551B2C9BC169002A2623 /* UINavigationController+Utilities.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -7144,6 +7158,7 @@ FD09798D27FD1D8900936362 /* DisappearingMessageConfiguration.swift in Sources */, FD2272732C32911C004D8A6C /* ConfigurationSyncJob.swift in Sources */, FDF0B75A2807F3A3004C14C5 /* MessageSenderError.swift in Sources */, + 94805EC12EB48D910055EBBC /* SessionProState+Models.swift in Sources */, FD245C692850666800B966DD /* ExpirationTimerUpdate.swift in Sources */, FD2272752C32911C004D8A6C /* RetrieveDefaultOpenGroupRoomsJob.swift in Sources */, FD2272712C32911C004D8A6C /* MessageReceiveJob.swift in Sources */, @@ -7177,6 +7192,7 @@ FDD23AE42E458C810057E853 /* _021_AddSnodeReveivedMessageInfoPrimaryKey.swift in Sources */, FD22727B2C32911C004D8A6C /* MessageSendJob.swift in Sources */, FD78E9EE2DD6D32500D55B50 /* ImageDataManager+Singleton.swift in Sources */, + 94805EC32EB48ED50055EBBC /* SessionProPaymentScreenContent.swift in Sources */, C3A71D1F25589AC30043A11F /* WebSocketResources.pb.swift in Sources */, FD981BC42DC304E600564172 /* MessageDeduplication.swift in Sources */, FDF0B74B28061F7A004C14C5 /* InteractionAttachment.swift in Sources */, @@ -7230,6 +7246,8 @@ FDD23AED2E4590A10057E853 /* _041_RenameTableSettingToKeyValueStore.swift in Sources */, FDE754FE2C9BB0D0002A2623 /* Threading+SessionMessagingKit.swift in Sources */, FDF0B75E280AAF35004C14C5 /* Preferences.swift in Sources */, + 948615C22ED7D39B000A5666 /* SessionProSettingsViewModel.swift in Sources */, + 948615C32ED7D39B000A5666 /* SessionProSettingsViewModel+Database.swift in Sources */, FDF2F0222DAE1AF500491E8A /* MessageReceiver+LegacyClosedGroups.swift in Sources */, FD05594E2E012D2700DC48CE /* _043_RenameAttachments.swift in Sources */, FD22726E2C32911C004D8A6C /* FailedMessageSendsJob.swift in Sources */, @@ -7279,7 +7297,6 @@ C328255225CA64470062D0A7 /* ContextMenuVC+ActionView.swift in Sources */, C3548F0824456AB6009433A8 /* UIView+Wrapping.swift in Sources */, B83524A525C3BA4B0089A44F /* InfoMessageCell.swift in Sources */, - 94363E6A2E613AF80004EE43 /* SessionProSettingsViewModel+Database.swift in Sources */, 7B9F71D82853100A006DFE7B /* EmojiWithSkinTones.swift in Sources */, FD71164E28E3F8CC00B47552 /* SessionCell+Info.swift in Sources */, B84A89BC25DE328A0040017D /* ProfilePictureVC.swift in Sources */, @@ -7318,7 +7335,6 @@ FDB7400D28EBEC240094D718 /* DateHeaderCell.swift in Sources */, B8D0A26925E4A2C200C1835E /* Onboarding.swift in Sources */, 4CC613362227A00400E21A3A /* ConversationSearch.swift in Sources */, - 94363E622E60148A0004EE43 /* SessionProSettingsViewModel.swift in Sources */, FD87DCFE28B7582C00AF0F98 /* BlockedContactsViewModel.swift in Sources */, FD37E9DD28A384EB003AE748 /* PrimaryColorSelectionView.swift in Sources */, 942256812C23F8BB00C0FDBF /* NewMessageScreen.swift in Sources */, @@ -7338,7 +7354,6 @@ 45C0DC1E1E69011F00E04C47 /* UIStoryboard+OWS.swift in Sources */, FDE754B02C9B96B4002A2623 /* WebRTCSession.swift in Sources */, B835246E25C38ABF0089A44F /* ConversationVC.swift in Sources */, - 94D955EB2E9CA5E000DEE66E /* SessionProPaymentScreen+ViewModel.swift in Sources */, 7B7037432834B81F000DCF35 /* ReactionContainerView.swift in Sources */, 7BBBDC462875600700747E59 /* DocumentTitleViewController.swift in Sources */, FD71163F28E2C82C00B47552 /* SessionHeaderView.swift in Sources */, @@ -7384,7 +7399,6 @@ 7BAADFCC27B0EF23007BCF92 /* CallVideoView.swift in Sources */, 942256892C23F8C800C0FDBF /* LandingScreen.swift in Sources */, B8CCF63F23975CFB0091D419 /* JoinOpenGroupVC.swift in Sources */, - FD71164228E2C85A00B47552 /* TransitionType.swift in Sources */, 942256882C23F8C800C0FDBF /* PNModeScreen.swift in Sources */, FD37E9DB28A244E9003AE748 /* ThemeMessagePreviewView.swift in Sources */, FD7443422D07A27E00862443 /* SyncPushTokensJob.swift in Sources */, @@ -7393,7 +7407,6 @@ 45B5360E206DD8BB00D61655 /* UIResponder+OWS.swift in Sources */, 942256802C23F8BB00C0FDBF /* StartConversationScreen.swift in Sources */, 7B9F71C928470667006DFE7B /* ReactionListSheet.swift in Sources */, - FD12A8412AD63BEA00EEBA0D /* NavigatableState.swift in Sources */, 7B7037452834BCC0000DCF35 /* ReactionView.swift in Sources */, FD7115F428C71EB200B47552 /* ThreadDisappearingMessagesSettingsViewModel.swift in Sources */, 7B7CB190270FB2150079FF93 /* MiniCallView.swift in Sources */, @@ -7468,7 +7481,6 @@ FD1C98E4282E3C5B00B76F9E /* UINavigationBar+Utilities.swift in Sources */, FDE754B62C9B96BB002A2623 /* WebRTCSession+UI.swift in Sources */, FD71163828E2C50700B47552 /* SessionTableViewModel.swift in Sources */, - FD71164A28E3EA5B00B47552 /* DismissType.swift in Sources */, 7B3A39322980D02B002FE4AC /* SessionCarouselView.swift in Sources */, B8EB20F02640F7F000773E52 /* OpenGroupInvitationView.swift in Sources */, C328254025CA55880062D0A7 /* ContextMenuVC.swift in Sources */, diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 6538abfeff..f389ac8a28 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -562,9 +562,16 @@ extension ConversationVC: @MainActor func handleCharacterLimitLabelTapped() { guard !viewModel.dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( - .longerMessages, - onConfirm: { [weak self] in - self?.snInputView.updateNumberOfCharactersLeft(self?.snInputView.text ?? "") + .longerMessages(renew: viewModel.dependencies[singleton: .sessionProState].isSessionProExpired), + onConfirm: { [weak self, dependencies = viewModel.dependencies] in + dependencies[singleton: .sessionProState].showSessionProBottomSheetIfNeeded( + afterClosed: { [weak self] in + self?.snInputView.updateNumberOfCharactersLeft(self?.snInputView.text ?? "") + }, + presenting: { bottomSheet in + self?.present(bottomSheet, animated: true) + } + ) }, onCancel: { [weak self] in self?.snInputView.updateNumberOfCharactersLeft(self?.snInputView.text ?? "") @@ -700,9 +707,16 @@ extension ConversationVC: @MainActor func showModalForMessagesExceedingCharacterLimit(_ isSessionPro: Bool) { guard !viewModel.dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( - .longerMessages, - onConfirm: { [weak self] in - self?.snInputView.updateNumberOfCharactersLeft(self?.snInputView.text ?? "") + .longerMessages(renew: viewModel.dependencies[singleton: .sessionProState].isSessionProExpired), + onConfirm: { [weak self, dependencies = viewModel.dependencies] in + dependencies[singleton: .sessionProState].showSessionProBottomSheetIfNeeded( + afterClosed: { [weak self] in + self?.snInputView.updateNumberOfCharactersLeft(self?.snInputView.text ?? "") + }, + presenting: { bottomSheet in + self?.present(bottomSheet, animated: true) + } + ) }, onCancel: { [weak self] in self?.snInputView.updateNumberOfCharactersLeft(self?.snInputView.text ?? "") @@ -1669,16 +1683,22 @@ extension ConversationVC: }, onProBadgeTapped: { [weak self, dependencies] in dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( - .generic, + .generic(renew: dependencies[singleton: .sessionProState].isSessionProExpired), dismissType: .single, - beforePresented: {}, - onConfirm: { [weak self] in - self?.snInputView.updateNumberOfCharactersLeft(self?.snInputView.text ?? "") + onConfirm: { + dependencies[singleton: .sessionProState].showSessionProBottomSheetIfNeeded( + afterClosed: { [weak self] in + self?.snInputView.updateNumberOfCharactersLeft(self?.snInputView.text ?? "") + }, + presenting: { bottomSheet in + dependencies[singleton: .appContext].frontMostViewController?.present(bottomSheet, animated: true) + } + ) }, onCancel: { [weak self] in self?.snInputView.updateNumberOfCharactersLeft(self?.snInputView.text ?? "") }, - afterClosed: {}, + afterClosed: nil, presenting: { modal in dependencies[singleton: .appContext].frontMostViewController?.present(modal, animated: true) } diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 2527da65d0..2738553b96 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -352,7 +352,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa ), imageDataManager: self.viewModel.dependencies[singleton: .imageDataManager], linkPreviewManager: self.viewModel.dependencies[singleton: .linkPreviewManager], - sessionProState: self.viewModel.dependencies[singleton: .sessionProState], + sessionProStatePublisher: self.viewModel.dependencies[singleton: .sessionProState].isSessionProActivePublisher, didLoadLinkPreview: nil ) diff --git a/Session/Conversations/Input View/InputView.swift b/Session/Conversations/Input View/InputView.swift new file mode 100644 index 0000000000..ea9248e386 --- /dev/null +++ b/Session/Conversations/Input View/InputView.swift @@ -0,0 +1,695 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import Combine +import SessionUIKit +import SessionMessagingKit +import SessionUtilitiesKit +import SignalUtilitiesKit + +final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, MentionSelectionViewDelegate { + // MARK: - Variables + + private static let linkPreviewViewInset: CGFloat = 6 + private static let thresholdForCharacterLimit: Int = 200 + + private var disposables: Set = Set() + private let dependencies: Dependencies + private let threadVariant: SessionThread.Variant + private weak var delegate: InputViewDelegate? + private var sessionProState: SessionProManagerType? + + var quoteDraftInfo: (model: QuotedReplyModel, isOutgoing: Bool)? { didSet { handleQuoteDraftChanged() } } + var linkPreviewInfo: (url: String, draft: LinkPreviewDraft?)? + private var linkPreviewLoadTask: Task? + private var voiceMessageRecordingView: VoiceMessageRecordingView? + private lazy var mentionsViewHeightConstraint = mentionsView.set(.height, to: 0) + + private lazy var linkPreviewView: LinkPreviewView = { + let maxWidth: CGFloat = (self.additionalContentContainer.bounds.width - InputView.linkPreviewViewInset) + + return LinkPreviewView(maxWidth: maxWidth, using: dependencies) { [weak self] in + self?.linkPreviewInfo = nil + self?.additionalContentContainer.subviews.forEach { $0.removeFromSuperview() } + } + }() + + var text: String { + get { inputTextView.text ?? "" } + set { inputTextView.text = newValue } + } + + var selectedRange: NSRange { + get { inputTextView.selectedRange } + set { inputTextView.selectedRange = newValue } + } + + var inputState: SessionThreadViewModel.MessageInputState = .all { + didSet { + setMessageInputState(inputState) + } + } + + override var intrinsicContentSize: CGSize { CGSize.zero } + var lastSearchedText: String? { nil } + + // MARK: - UI + + private lazy var tapGestureRecognizer: UITapGestureRecognizer = { + let result: UITapGestureRecognizer = UITapGestureRecognizer() + result.addTarget(self, action: #selector(disabledInputTapped)) + result.isEnabled = false + + return result + }() + + private lazy var swipeGestureRecognizer: UISwipeGestureRecognizer = { + let result: UISwipeGestureRecognizer = UISwipeGestureRecognizer() + result.direction = .down + result.addTarget(self, action: #selector(didSwipeDown)) + result.cancelsTouchesInView = false + + return result + }() + + private var bottomStackView: UIStackView? + private lazy var attachmentsButton: ExpandingAttachmentsButton = { + let result = ExpandingAttachmentsButton(delegate: delegate) + result.accessibilityLabel = "Attachments button" + result.accessibilityIdentifier = "Attachments button" + result.isAccessibilityElement = true + + return result + }() + + private lazy var voiceMessageButton: InputViewButton = { + let result = InputViewButton(icon: #imageLiteral(resourceName: "Microphone"), delegate: self) + result.accessibilityLabel = "New voice message" + result.accessibilityIdentifier = "New voice message" + result.isAccessibilityElement = true + + return result + }() + + private lazy var sendButton: InputViewButton = { + let result = InputViewButton(icon: #imageLiteral(resourceName: "ArrowUp"), isSendButton: true, delegate: self) + result.isHidden = true + result.accessibilityIdentifier = "Send message button" + result.accessibilityLabel = "Send message button" + result.isAccessibilityElement = true + + return result + }() + private lazy var voiceMessageButtonContainer = container(for: voiceMessageButton) + + private lazy var mentionsView: MentionSelectionView = { + let result: MentionSelectionView = MentionSelectionView(using: dependencies) + result.delegate = self + + return result + }() + + private lazy var mentionsViewContainer: UIView = { + let result: UIView = UIView() + result.accessibilityLabel = "Mentions list" + result.accessibilityIdentifier = "Mentions list" + result.alpha = 0 + + 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 + blurView?.effect = UIBlurEffect(style: theme.blurStyle) + } + + return result + }() + + private lazy var inputTextView: InputTextView = { + // HACK: When restoring a draft the input text view won't have a frame yet, and therefore it won't + // be able to calculate what size it should be to accommodate the draft text. As a workaround, we + // just calculate the max width that the input text view is allowed to be and pass it in. See + // setUpViewHierarchy() for why these values are the way they are. + let adjustment = (InputViewButton.expandedSize - InputViewButton.size) / 2 + let maxWidth = UIScreen.main.bounds.width - 2 * InputViewButton.expandedSize - 2 * Values.smallSpacing - 2 * (Values.mediumSpacing - adjustment) + let result = InputTextView(delegate: self, maxWidth: maxWidth) + result.accessibilityLabel = "contentDescriptionMessageComposition".localized() + result.accessibilityIdentifier = "Message input box" + result.isAccessibilityElement = true + + return result + }() + + private lazy var disabledInputLabel: UILabel = { + let label: UILabel = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = .systemFont(ofSize: Values.smallFontSize) + label.themeTextColor = .textPrimary + label.textAlignment = .center + label.alpha = 0 + label.numberOfLines = 0 + label.lineBreakMode = .byWordWrapping + + return label + }() + + private lazy var proStackView: UIStackView = { + let result = UIStackView(arrangedSubviews: [ characterLimitLabel, sessionProBadge ]) + result.axis = .vertical + result.spacing = Values.verySmallSpacing + result.alignment = .center + result.addGestureRecognizer(characterLimitLabelTapGestureRecognizer) + result.alpha = 0 + + return result + }() + private lazy var characterLimitLabelTapGestureRecognizer: UITapGestureRecognizer = { + let result: UITapGestureRecognizer = UITapGestureRecognizer() + result.addTarget(self, action: #selector(characterLimitLabelTapped)) + result.isEnabled = false + + return result + }() + + private lazy var characterLimitLabel: UILabel = { + let label: UILabel = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = .systemFont(ofSize: Values.smallFontSize) + label.themeTextColor = .textPrimary + label.textAlignment = .center + + return label + }() + + private lazy var sessionProBadge: SessionProBadge = { + let result: SessionProBadge = SessionProBadge(size: .medium) + result.isHidden = !dependencies[feature: .sessionProEnabled] || dependencies[cache: .libSession].isSessionPro + + return result + }() + + private lazy var additionalContentContainer = UIView() + + public var isInputFirstResponder: Bool { + inputTextView.isFirstResponder + } + + // MARK: - Initialization + + init(threadVariant: SessionThread.Variant, delegate: InputViewDelegate, using dependencies: Dependencies) { + self.dependencies = dependencies + self.threadVariant = threadVariant + self.delegate = delegate + self.sessionProState = dependencies[singleton: .sessionProState] + + super.init(frame: CGRect.zero) + + setUpViewHierarchy() + + self.sessionProState?.sessionProStatePublisher + .subscribe(on: DispatchQueue.main) + .receive(on: DispatchQueue.main) + .sink( + receiveValue: { [weak self] sessionProPlanState in + let isPro: Bool = { + switch sessionProPlanState { + case .active, .refunding : return true + case .none, .expired: return false + } + }() + self?.sessionProBadge.isHidden = isPro + self?.updateNumberOfCharactersLeft((self?.inputTextView.text ?? "")) + } + ) + .store(in: &disposables) + } + + override init(frame: CGRect) { + preconditionFailure("Use init(delegate:) instead.") + } + + required init?(coder: NSCoder) { + preconditionFailure("Use init(delegate:) instead.") + } + + deinit { + linkPreviewLoadTask?.cancel() + } + + private func setUpViewHierarchy() { + autoresizingMask = .flexibleHeight + + addGestureRecognizer(tapGestureRecognizer) + addGestureRecognizer(swipeGestureRecognizer) + + // Background & blur + let backgroundView = UIView() + backgroundView.themeBackgroundColor = .backgroundSecondary + backgroundView.alpha = Values.lowOpacity + addSubview(backgroundView) + backgroundView.pin(to: self) + + let blurView = UIVisualEffectView() + addSubview(blurView) + blurView.pin(to: self) + + ThemeManager.onThemeChange(observer: blurView) { [weak blurView] theme, _, _ in + blurView?.effect = UIBlurEffect(style: theme.blurStyle) + } + + // Separator + let separator = UIView() + separator.themeBackgroundColor = .borderSeparator + separator.set(.height, to: Values.separatorThickness) + addSubview(separator) + separator.pin([ UIView.HorizontalEdge.leading, UIView.VerticalEdge.top, UIView.HorizontalEdge.trailing ], to: self) + + // Bottom stack view + let bottomStackView = UIStackView(arrangedSubviews: [ attachmentsButton, inputTextView, container(for: sendButton) ]) + bottomStackView.axis = .horizontal + bottomStackView.spacing = Values.smallSpacing + bottomStackView.alignment = .center + self.bottomStackView = bottomStackView + + // Main stack view + let mainStackView = UIStackView(arrangedSubviews: [ additionalContentContainer, bottomStackView ]) + mainStackView.axis = .vertical + mainStackView.isLayoutMarginsRelativeArrangement = true + + let adjustment = (InputViewButton.expandedSize - InputViewButton.size) / 2 + mainStackView.layoutMargins = UIEdgeInsets(top: 2, leading: Values.mediumSpacing - adjustment, bottom: 2, trailing: Values.mediumSpacing - adjustment) + addSubview(mainStackView) + mainStackView.pin(.top, to: .bottom, of: separator) + mainStackView.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing ], to: self) + mainStackView.pin(.bottom, to: .bottom, of: self) + + // Pro stack view + addSubview(proStackView) + proStackView.pin(.bottom, to: .bottom, of: inputTextView) + proStackView.center(.horizontal, in: sendButton) + + addSubview(disabledInputLabel) + + disabledInputLabel.pin(.top, to: .top, of: attachmentsButton) + disabledInputLabel.pin(.leading, to: .leading, of: inputTextView) + disabledInputLabel.pin(.trailing, to: .trailing, of: inputTextView) + disabledInputLabel.set(.height, to: InputViewButton.expandedSize) + + // Mentions + insertSubview(mentionsViewContainer, belowSubview: mainStackView) + mentionsViewContainer.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing ], to: self) + mentionsViewContainer.pin(.bottom, to: .top, of: self) + mentionsViewContainer.addSubview(mentionsView) + mentionsView.pin(to: mentionsViewContainer) + mentionsViewHeightConstraint.isActive = true + + // Voice message button + addSubview(voiceMessageButtonContainer) + voiceMessageButtonContainer.center(in: sendButton) + } + + // MARK: - Updating + + @MainActor func inputTextViewDidChangeSize(_ inputTextView: InputTextView) { + invalidateIntrinsicContentSize() + self.bottomStackView?.alignment = (inputTextView.contentSize.height > inputTextView.minHeight) ? .top : .center + } + + @MainActor func inputTextViewDidChangeContent(_ inputTextView: InputTextView) { + let hasText = !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + sendButton.isHidden = !hasText + voiceMessageButtonContainer.isHidden = hasText + autoGenerateLinkPreviewIfPossible() + + delegate?.inputTextViewDidChangeContent(inputTextView) + } + + @MainActor func updateNumberOfCharactersLeft(_ text: String) { + let numberOfCharactersLeft: Int = LibSession.numberOfCharactersLeft( + for: text.trimmingCharacters(in: .whitespacesAndNewlines), + isSessionPro: dependencies[cache: .libSession].isSessionPro + ) + characterLimitLabel.text = "\(numberOfCharactersLeft.formatted(format: .abbreviated(decimalPlaces: 1)))" + characterLimitLabel.themeTextColor = (numberOfCharactersLeft < 0) ? .danger : .textPrimary + proStackView.alpha = (numberOfCharactersLeft <= Self.thresholdForCharacterLimit) ? 1 : 0 + characterLimitLabelTapGestureRecognizer.isEnabled = (numberOfCharactersLeft < Self.thresholdForCharacterLimit) + } + + @MainActor func didPasteImageDataFromPasteboard(_ inputTextView: InputTextView, imageData: Data) { + delegate?.didPasteImageDataFromPasteboard(imageData) + } + + // We want to show either a link preview or a quote draft, but never both at the same time. When trying to + // generate a link preview, wait until we're sure that we'll be able to build a link preview from the given + // URL before removing the quote draft. + + private func handleQuoteDraftChanged() { + additionalContentContainer.subviews.forEach { $0.removeFromSuperview() } + linkPreviewInfo = nil + + guard let quoteDraftInfo = quoteDraftInfo else { return } + + let hInset: CGFloat = 6 // Slight visual adjustment + + let quoteView: QuoteView = QuoteView( + for: .draft, + authorId: quoteDraftInfo.model.authorId, + quotedText: quoteDraftInfo.model.body, + threadVariant: threadVariant, + currentUserSessionIds: quoteDraftInfo.model.currentUserSessionIds, + direction: (quoteDraftInfo.isOutgoing ? .outgoing : .incoming), + attachment: quoteDraftInfo.model.attachment, + using: dependencies + ) { [weak self] in + self?.quoteDraftInfo = nil + } + + additionalContentContainer.addSubview(quoteView) + quoteView.pin(.leading, to: .leading, of: additionalContentContainer, withInset: hInset) + quoteView.pin(.top, to: .top, of: additionalContentContainer, withInset: 12) + quoteView.pin(.trailing, to: .trailing, of: additionalContentContainer, withInset: -hInset) + quoteView.pin(.bottom, to: .bottom, of: additionalContentContainer, withInset: -6) + } + + private func autoGenerateLinkPreviewIfPossible() { + // Don't allow link previews on 'none' or 'textOnly' input + guard inputState.allowedInputTypes == .all else { return } + + // Suggest that the user enable link previews if they haven't already and we haven't + // told them about link previews yet + let text = inputTextView.text! + DispatchQueue.global(qos: .userInitiated).async { [weak self, dependencies] in + let areLinkPreviewsEnabled: Bool = dependencies.mutate(cache: .libSession) { cache in + cache.get(.areLinkPreviewsEnabled) + } + + if + !LinkPreview.allPreviewUrls(forMessageBodyText: text).isEmpty && + !areLinkPreviewsEnabled && + !dependencies[defaults: .standard, key: .hasSeenLinkPreviewSuggestion] + { + DispatchQueue.main.async { + self?.delegate?.showLinkPreviewSuggestionModal() + } + dependencies[defaults: .standard, key: .hasSeenLinkPreviewSuggestion] = true + return + } + // Check that link previews are enabled + guard areLinkPreviewsEnabled else { return } + + // Proceed + DispatchQueue.main.async { + self?.autoGenerateLinkPreview() + } + } + } + + @MainActor func autoGenerateLinkPreview() { + // Check that a valid URL is present + guard let linkPreviewURL = LinkPreview.previewUrl(for: text, selectedRange: inputTextView.selectedRange, using: dependencies) else { + return + } + + // Guard against obsolete updates + guard linkPreviewURL != self.linkPreviewInfo?.url else { return } + + // Clear content container + additionalContentContainer.subviews.forEach { $0.removeFromSuperview() } + quoteDraftInfo = nil + + // Set the state to loading + linkPreviewInfo = (url: linkPreviewURL, draft: nil) + linkPreviewView.update(with: LinkPreview.LoadingState(), isOutgoing: false, using: dependencies) + + // Add the link preview view + additionalContentContainer.addSubview(linkPreviewView) + linkPreviewView.pin(.leading, to: .leading, of: additionalContentContainer, withInset: InputView.linkPreviewViewInset) + linkPreviewView.pin(.top, to: .top, of: additionalContentContainer, withInset: 10) + linkPreviewView.pin(.trailing, to: .trailing, of: additionalContentContainer) + linkPreviewView.pin(.bottom, to: .bottom, of: additionalContentContainer, withInset: -4) + + // Build the link preview + linkPreviewLoadTask?.cancel() + linkPreviewLoadTask = Task.detached(priority: .userInitiated) { [weak self, allowedInputTypes = inputState.allowedInputTypes, dependencies] in + do { + /// Load the draft + let draft: LinkPreviewDraft = try await LinkPreview.tryToBuildPreviewInfo( + previewUrl: linkPreviewURL, + skipImageDownload: (allowedInputTypes != .all), /// Disable if attachments are disabled + using: dependencies + ) + try Task.checkCancellation() + + await MainActor.run { [weak self] in + guard let self else { return } + guard linkPreviewInfo?.url == linkPreviewURL else { return } /// Obsolete + + linkPreviewInfo = (url: linkPreviewURL, draft: draft) + linkPreviewView.update( + with: LinkPreview.DraftState(linkPreviewDraft: draft), + isOutgoing: false, + using: dependencies + ) + setNeedsLayout() + layoutIfNeeded() + } + } + catch { + await MainActor.run { [weak self] in + guard let self else { return } + guard linkPreviewInfo?.url == linkPreviewURL else { return } /// Obsolete + + linkPreviewInfo = nil + additionalContentContainer.subviews.forEach { $0.removeFromSuperview() } + setNeedsLayout() + layoutIfNeeded() + } + } + } + } + + func setMessageInputState(_ updatedInputState: SessionThreadViewModel.MessageInputState) { + guard inputState != updatedInputState else { return } + + self.accessibilityIdentifier = updatedInputState.accessibility?.identifier + self.accessibilityLabel = updatedInputState.accessibility?.label + tapGestureRecognizer.isEnabled = (updatedInputState.allowedInputTypes == .none) + + inputState = updatedInputState + disabledInputLabel.text = (updatedInputState.message ?? "") + disabledInputLabel.accessibilityIdentifier = updatedInputState.messageAccessibility?.identifier + disabledInputLabel.accessibilityLabel = updatedInputState.messageAccessibility?.label + + attachmentsButton.isSoftDisabled = (updatedInputState.allowedInputTypes != .all) + voiceMessageButton.isSoftDisabled = (updatedInputState.allowedInputTypes != .all) + + UIView.animate(withDuration: 0.3) { [weak self] in + self?.bottomStackView?.arrangedSubviews.forEach { $0.alpha = (updatedInputState.allowedInputTypes != .none ? 1 : 0) } + + self?.attachmentsButton.alpha = (updatedInputState.allowedInputTypes == .all ? 1 : 0.4) + self?.attachmentsButton.mainButton.updateAppearance(isEnabled: updatedInputState.allowedInputTypes == .all) + + self?.voiceMessageButton.alpha = (updatedInputState.allowedInputTypes == .all ? 1 : 0.4) + self?.voiceMessageButton.updateAppearance(isEnabled: updatedInputState.allowedInputTypes == .all) + + self?.disabledInputLabel.alpha = (updatedInputState.allowedInputTypes != .none ? 0 : Values.mediumOpacity) + } + } + + // MARK: - Interaction + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + // Needed so that the user can tap the buttons when the expanding attachments button is expanded + let buttonContainers = [ attachmentsButton.mainButton, attachmentsButton.cameraButton, + attachmentsButton.libraryButton, attachmentsButton.documentButton, attachmentsButton.gifButton ] + + if let buttonContainer: InputViewButton = buttonContainers.first(where: { $0.superview?.convert($0.frame, to: self).contains(point) == true }) { + return buttonContainer + } + + return super.hitTest(point, with: event) + } + + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + let buttonContainers = [ attachmentsButton.gifButtonContainer, attachmentsButton.documentButtonContainer, + attachmentsButton.libraryButtonContainer, attachmentsButton.cameraButtonContainer, attachmentsButton.mainButtonContainer ] + let isPointInsideAttachmentsButton = buttonContainers + .contains { $0.superview!.convert($0.frame, to: self).contains(point) } + + if isPointInsideAttachmentsButton { + // Needed so that the user can tap the buttons when the expanding attachments button is expanded + return true + } + + if mentionsViewContainer.frame.contains(point) { + // Needed so that the user can tap mentions + return true + } + + return super.point(inside: point, with: event) + } + + @MainActor func handleInputViewButtonTapped(_ inputViewButton: InputViewButton) { + if inputViewButton == sendButton { delegate?.handleSendButtonTapped() } + if inputViewButton == voiceMessageButton && inputState.allowedInputTypes != .all { + delegate?.handleDisabledVoiceMessageButtonTapped() + } + } + + @MainActor func handleInputViewButtonLongPressBegan(_ inputViewButton: InputViewButton?) { + guard inputViewButton == voiceMessageButton else { return } + guard inputState.allowedInputTypes == .all else { return } + + // Note: The 'showVoiceMessageUI' call MUST come before triggering 'startVoiceMessageRecording' + // because if something goes wrong it'll trigger `hideVoiceMessageUI` and we don't want it to + // end up in a state with the input content hidden + showVoiceMessageUI() + delegate?.startVoiceMessageRecording() + } + + @MainActor 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) + } + + @MainActor 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) + } + + override func resignFirstResponder() -> Bool { + inputTextView.resignFirstResponder() + } + + @discardableResult + override func becomeFirstResponder() -> Bool { + inputTextView.becomeFirstResponder() + } + + func handleLongPress(_ gestureRecognizer: UITapGestureRecognizer) { + // Not relevant in this case + } + + @objc private func showVoiceMessageUI() { + guard let targetSuperview: UIView = voiceMessageButton.superview else { return } + + voiceMessageRecordingView?.removeFromSuperview() + let voiceMessageButtonFrame = targetSuperview.convert(voiceMessageButton.frame, to: self) + let voiceMessageRecordingView = VoiceMessageRecordingView( + voiceMessageButtonFrame: voiceMessageButtonFrame, + delegate: delegate + ) + voiceMessageRecordingView.alpha = 0 + addSubview(voiceMessageRecordingView) + + voiceMessageRecordingView.pin(to: self) + self.voiceMessageRecordingView = voiceMessageRecordingView + voiceMessageRecordingView.animate() + let allOtherViews = [ attachmentsButton, sendButton, inputTextView, additionalContentContainer ] + UIView.animate(withDuration: 0.25) { + allOtherViews.forEach { $0.alpha = 0 } + } + } + + func hideVoiceMessageUI() { + let allOtherViews = [ attachmentsButton, sendButton, inputTextView, additionalContentContainer ] + UIView.animate(withDuration: 0.25, animations: { + allOtherViews.forEach { $0.alpha = 1 } + self.voiceMessageRecordingView?.alpha = 0 + }, completion: { [weak self] _ in + self?.voiceMessageRecordingView?.removeFromSuperview() + self?.voiceMessageRecordingView = nil + }) + } + + func hideMentionsUI() { + UIView.animate( + withDuration: 0.25, + animations: { [weak self] in + self?.mentionsViewContainer.alpha = 0 + }, + completion: { [weak self] _ in + self?.mentionsViewHeightConstraint.constant = 0 + self?.mentionsView.contentOffset = CGPoint.zero + } + ) + } + + func showMentionsUI( + for candidates: [MentionInfo], + currentUserSessionIds: Set + ) { + mentionsView.currentUserSessionIds = currentUserSessionIds + mentionsView.candidates = candidates + + let mentionCellHeight = (ProfilePictureView.Size.message.viewSize + 2 * Values.smallSpacing) + mentionsViewHeightConstraint.constant = CGFloat(min(3, candidates.count)) * mentionCellHeight + layoutIfNeeded() + + UIView.animate(withDuration: 0.25) { + self.mentionsViewContainer.alpha = 1 + } + } + + @MainActor func handleMentionSelected(_ mentionInfo: MentionInfo, from view: MentionSelectionView) { + delegate?.handleMentionSelected(mentionInfo, from: view) + } + + func tapableLabel(_ label: TappableLabel, didTapUrl url: String, atRange range: NSRange) { + // Do nothing + } + + @objc private func disabledInputTapped() { + delegate?.handleDisabledInputTapped() + } + + @objc private func characterLimitLabelTapped() { + delegate?.handleCharacterLimitLabelTapped() + } + + @objc private func didSwipeDown() { + inputTextView.resignFirstResponder() + } + + // MARK: - Convenience + + private func container(for button: InputViewButton) -> UIView { + let result: UIView = UIView() + result.addSubview(button) + result.set(.width, to: InputViewButton.expandedSize) + result.set(.height, to: InputViewButton.expandedSize) + button.center(in: result) + + return result + } +} + +// MARK: - Delegate + +protocol InputViewDelegate: ExpandingAttachmentsButtonDelegate, VoiceMessageRecordingViewDelegate { + @MainActor func showLinkPreviewSuggestionModal() + @MainActor func handleSendButtonTapped() + @MainActor func handleDisabledInputTapped() + @MainActor func handleDisabledVoiceMessageButtonTapped() + @MainActor func handleCharacterLimitLabelTapped() + @MainActor func inputTextViewDidChangeContent(_ inputTextView: InputTextView) + @MainActor func handleMentionSelected(_ mentionInfo: MentionInfo, from view: MentionSelectionView) + @MainActor func didPasteImageDataFromPasteboard(_ imageData: Data) +} diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 19b2180a96..bb5ca2abba 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -363,13 +363,20 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi generator: { SessionProBadge(size: .mini) } ) ) - default: return .generic + default: + return .generic(renew: dependencies[singleton: .sessionProState].isSessionProExpired) } }() dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( proCTAModalVariant, - onConfirm: {}, + onConfirm: { + dependencies[singleton: .sessionProState].showSessionProBottomSheetIfNeeded( + presenting: { bottomSheet in + self?.transitionToScreen(bottomSheet, transitionType: .present) + } + ) + }, presenting: { modal in self?.transitionToScreen(modal, transitionType: .present) } @@ -1564,8 +1571,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi current: String?, displayName: String ) -> ConfirmationModal.Info { - /// Set `updatedName` to `current` so we can disable the "save" button when there are no changes and don't need to worry - /// about retrieving them in the confirmation closure + /// Set `updatedName` to `current` so we can disable the "save" button when there are no changes and don't need to worry about retrieving them in the confirmation closure self.updatedName = current return ConfirmationModal.Info( title: "nicknameSet".localized(), @@ -2044,15 +2050,18 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi let sessionProModal: ModalHostingViewController = ModalHostingViewController( modal: ProCTAModal( variant: .morePinnedConvos( - isGrandfathered: (numPinnedConversations > LibSession.PinnedConversationLimit) + isGrandfathered: (numPinnedConversations > LibSession.PinnedConversationLimit), + renew: dependencies[singleton: .sessionProState].isSessionProExpired ), dataManager: dependencies[singleton: .imageDataManager], onConfirm: { [dependencies] in - dependencies[singleton: .sessionProState].upgradeToPro( - plan: SessionProPlan(variant: .threeMonths), - originatingPlatform: .iOS, - completion: nil - ) + Task { + await dependencies[singleton: .sessionProState].upgradeToPro( + plan: SessionProPlan(variant: .threeMonths), + originatingPlatform: .iOS, + completion: nil + ) + } } ) ) diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift index f0867a9f0e..487c57b6c0 100644 --- a/Session/Home/HomeViewModel.swift +++ b/Session/Home/HomeViewModel.swift @@ -682,7 +682,8 @@ public class HomeViewModel: NavigatableStateHolder { dataModel: .init( flow: dependencies[singleton: .sessionProState].sessionProStateSubject.value.toPaymentFlow(using: dependencies), plans: dependencies[singleton: .sessionProState].sessionProPlans.map { $0.info() } - ) + ), + isFromBottomSheet: false ) ) ) diff --git a/Session/Media Viewing & Editing/MessageInfoScreen.swift b/Session/Media Viewing & Editing/MessageInfoScreen.swift index d9514cdee4..0ab8269364 100644 --- a/Session/Media Viewing & Editing/MessageInfoScreen.swift +++ b/Session/Media Viewing & Editing/MessageInfoScreen.swift @@ -25,7 +25,7 @@ struct MessageInfoScreen: View { let isCurrentUser: Bool let profileInfo: ProfilePictureView.Info? var proFeatures: [String] = [] - var proCTAVariant: ProCTAModal.Variant = .generic + var proCTAVariant: ProCTAModal.Variant = .generic(renew: false) public init( actions: [ContextMenuVC.Action], @@ -306,7 +306,19 @@ struct MessageInfoScreen: View { .foregroundColor(themeColor: .textPrimary) } .onTapGesture { - showSessionProCTAIfNeeded() + dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( + proCTAVariant, + onConfirm: { + dependencies[singleton: .sessionProState].showSessionProBottomSheetIfNeeded( + presenting: { bottomSheet in + self.host.controller?.present(bottomSheet, animated: true) + } + ) + }, + presenting: { modal in + self.host.controller?.present(modal, animated: true) + } + ) } Text( @@ -396,7 +408,19 @@ struct MessageInfoScreen: View { if (dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: messageViewModel.authorId)}) { SessionProBadge_SwiftUI(size: .small) .onTapGesture { - showSessionProCTAIfNeeded() + dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( + proCTAVariant, + onConfirm: { + dependencies[singleton: .sessionProState].showSessionProBottomSheetIfNeeded( + presenting: { bottomSheet in + self.host.controller?.present(bottomSheet, animated: true) + } + ) + }, + presenting: { modal in + self.host.controller?.present(modal, animated: true) + } + ) } } } @@ -509,7 +533,7 @@ struct MessageInfoScreen: View { private func getProFeaturesInfo() -> (proFeatures: [String], proCTAVariant: ProCTAModal.Variant) { var proFeatures: [String] = [] - var proCTAVariant: ProCTAModal.Variant = .generic + var proCTAVariant: ProCTAModal.Variant = .generic(renew: dependencies[singleton: .sessionProState].isSessionProExpired) guard dependencies[feature: .sessionProEnabled] else { return (proFeatures, proCTAVariant) } @@ -523,7 +547,11 @@ struct MessageInfoScreen: View { dependencies[feature: .messageFeatureLongMessage] ) { proFeatures.append("proIncreasedMessageLengthFeature".localized()) - proCTAVariant = (proFeatures.count > 1 ? .generic : .longerMessages) + proCTAVariant = ( + proFeatures.count > 1 ? + .generic(renew: dependencies[singleton: .sessionProState].isSessionProExpired) : + .longerMessages(renew: dependencies[singleton: .sessionProState].isSessionProExpired) + ) } if ( @@ -531,35 +559,19 @@ struct MessageInfoScreen: View { dependencies[feature: .messageFeatureAnimatedAvatar] ) { proFeatures.append("proAnimatedDisplayPictureFeature".localized()) - proCTAVariant = (proFeatures.count > 1 ? .generic : .animatedProfileImage(isSessionProActivated: false)) + proCTAVariant = ( + proFeatures.count > 1 ? + .generic(renew: dependencies[singleton: .sessionProState].isSessionProExpired) : + .animatedProfileImage( + isSessionProActivated: false, + renew: dependencies[singleton: .sessionProState].isSessionProExpired + ) + ) } return (proFeatures, proCTAVariant) } - private func showSessionProCTAIfNeeded() { - guard dependencies[feature: .sessionProEnabled] && (!dependencies[cache: .libSession].isSessionPro) else { - return - } - - DispatchQueue.main.async { - let sessionProModal: ModalHostingViewController = ModalHostingViewController( - modal: ProCTAModal( - variant: proCTAVariant, - dataManager: dependencies[singleton: .imageDataManager], - onConfirm: { [dependencies] in - dependencies[singleton: .sessionProState].upgradeToPro( - plan: SessionProPlan(variant: .threeMonths), - originatingPlatform: .iOS, - completion: nil - ) - } - ) - ) - self.host.controller?.present(sessionProModal, animated: true) - } - } - func showUserProfileModal() { guard threadCanWrite else { return } // FIXME: Add in support for starting a thread with a 'blinded25' id (disabled until we support this decoding) @@ -647,7 +659,21 @@ struct MessageInfoScreen: View { isProUser: dependencies.mutate(cache: .libSession, { $0.validateProProof(for: messageViewModel.profile) }), isMessageRequestsEnabled: isMessasgeRequestsEnabled, onStartThread: self.onStartThread, - onProBadgeTapped: self.showSessionProCTAIfNeeded + onProBadgeTapped: { + dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( + proCTAVariant, + onConfirm: { + dependencies[singleton: .sessionProState].showSessionProBottomSheetIfNeeded( + presenting: { bottomSheet in + self.host.controller?.present(bottomSheet, animated: true) + } + ) + }, + presenting: { modal in + self.host.controller?.present(modal, animated: true) + } + ) + } ), dataManager: dependencies[singleton: .imageDataManager] ) diff --git a/Session/Meta/MainAppContext.swift b/Session/Meta/MainAppContext.swift index 2451509b67..82b225e5e1 100644 --- a/Session/Meta/MainAppContext.swift +++ b/Session/Meta/MainAppContext.swift @@ -156,4 +156,8 @@ final class MainAppContext: AppContext { } UIApplication.shared.isIdleTimerDisabled = shouldBeBlocking } + + func openUrl(_ url: URL) { + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } } diff --git a/Session/Meta/WebPImages/AnimatedProfileCTA.webp b/Session/Meta/WebPImages/AnimatedProfileCTA.webp index 9d2ee88e15..d6571612a7 100644 Binary files a/Session/Meta/WebPImages/AnimatedProfileCTA.webp and b/Session/Meta/WebPImages/AnimatedProfileCTA.webp differ diff --git a/Session/Meta/WebPImages/GenericCTA.webp b/Session/Meta/WebPImages/GenericCTA.webp index f4d150ea6a..2780c953ce 100644 Binary files a/Session/Meta/WebPImages/GenericCTA.webp and b/Session/Meta/WebPImages/GenericCTA.webp differ diff --git a/Session/Meta/WebPImages/HigherCharLimitCTA.webp b/Session/Meta/WebPImages/HigherCharLimitCTA.webp index 87e7e3e56f..12e3118006 100644 Binary files a/Session/Meta/WebPImages/HigherCharLimitCTA.webp and b/Session/Meta/WebPImages/HigherCharLimitCTA.webp differ diff --git a/Session/Meta/WebPImages/PinnedConversationsCTA.webp b/Session/Meta/WebPImages/PinnedConversationsCTA.webp index 8a5070fb59..9fb34151e5 100644 Binary files a/Session/Meta/WebPImages/PinnedConversationsCTA.webp and b/Session/Meta/WebPImages/PinnedConversationsCTA.webp differ diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift index b12ceb8d78..4a9d5cb100 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift @@ -437,33 +437,30 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold ) } ), - ( - state.mockCurrentUserSessionPro == .none ? nil : - SessionCell.Info( - id: .loadingState, - title: "Loading State", - trailingAccessory: .dropDown { state.loadingState.title }, - onTap: { [weak viewModel, dependencies = viewModel.dependencies] in - viewModel?.transitionToScreen( - SessionTableViewController( - viewModel: SessionListViewModel( - title: "Session Pro Loading State", - options: SessionProLoadingState.allCases, - behaviour: .autoDismiss( - initialSelection: state.loadingState, - onOptionSelected: { [dependencies] selected in - dependencies.set( - feature: .mockCurrentUserSessionProLoadingState, - to: selected - ) - } - ), - using: dependencies - ) - ) + SessionCell.Info( + id: .loadingState, + title: "Loading State", + trailingAccessory: .dropDown { state.loadingState.title }, + onTap: { [weak viewModel, dependencies = viewModel.dependencies] in + viewModel?.transitionToScreen( + SessionTableViewController( + viewModel: SessionListViewModel( + title: "Session Pro Loading State", + options: SessionProLoadingState.allCases, + behaviour: .autoDismiss( + initialSelection: state.loadingState, + onOptionSelected: { [dependencies] selected in + dependencies.set( + feature: .mockCurrentUserSessionProLoadingState, + to: selected + ) + } + ), + using: dependencies ) - } + ) ) + } ), SessionCell.Info( id: .allUsersSessionPro, @@ -694,24 +691,33 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold dependencies.set(feature: .mockCurrentUserSessionProState, to: state) switch state { case .none: - dependencies[singleton: .sessionProState].cancelPro(completion: nil) + dependencies[singleton: .sessionProState].sessionProStateSubject.send(.none) + dependencies[singleton: .sessionProState].shouldAnimateImageSubject.send(false) case .active: - dependencies[singleton: .sessionProState].upgradeToPro( - plan: SessionProPlan(variant: .threeMonths), - originatingPlatform: dependencies[feature: .proPlanOriginatingPlatform], - completion: nil - ) + Task { + await dependencies[singleton: .sessionProState].upgradeToPro( + plan: SessionProPlan(variant: .threeMonths), + originatingPlatform: dependencies[feature: .proPlanOriginatingPlatform], + completion: nil + ) + } case .expiring: - dependencies[singleton: .sessionProState].upgradeToPro( - plan: SessionProPlan(variant: .threeMonths), - originatingPlatform: dependencies[feature: .proPlanOriginatingPlatform], - completion: nil - ) - dependencies[singleton: .sessionProState].cancelPro(completion: nil) + Task { + await dependencies[singleton: .sessionProState].upgradeToPro( + plan: SessionProPlan(variant: .threeMonths), + originatingPlatform: dependencies[feature: .proPlanOriginatingPlatform], + completion: nil + ) + await dependencies[singleton: .sessionProState].cancelPro(completion: nil) + } case .expired: - dependencies[singleton: .sessionProState].expirePro(completion: nil) + Task { + await dependencies[singleton: .sessionProState].expirePro(completion: nil) + } case .refunding: - dependencies[singleton: .sessionProState].requestRefund(completion: nil) + Task { + await dependencies[singleton: .sessionProState].requestRefund(completion: nil) + } } } diff --git a/Session/Settings/SettingsViewModel.swift b/Session/Settings/SettingsViewModel.swift index c844b12d7b..2fcb82a70b 100644 --- a/Session/Settings/SettingsViewModel.swift +++ b/Session/Settings/SettingsViewModel.swift @@ -458,7 +458,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl } }(), styling: SessionCell.StyleInfo( - tintColor: .primary + tintColor: .sessionButton_text ), onTap: { [weak viewModel, dependencies = viewModel.dependencies] in let viewController: SessionListHostingViewController = SessionListHostingViewController( @@ -868,9 +868,16 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl Task { @MainActor in dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( .animatedProfileImage( - isSessionProActivated: dependencies[cache: .libSession].isSessionPro + isSessionProActivated: dependencies[cache: .libSession].isSessionPro, + renew: dependencies[singleton: .sessionProState].isSessionProExpired ), - onConfirm: {}, + onConfirm: { + dependencies[singleton: .sessionProState].showSessionProBottomSheetIfNeeded( + presenting: { bottomSheet in + self?.transitionToScreen(bottomSheet, transitionType: .present) + } + ) + }, presenting: { modal in self?.transitionToScreen(modal, transitionType: .present) } @@ -924,9 +931,16 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl Task { @MainActor in dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( .animatedProfileImage( - isSessionProActivated: dependencies[cache: .libSession].isSessionPro + isSessionProActivated: dependencies[cache: .libSession].isSessionPro, + renew: dependencies[singleton: .sessionProState].isSessionProExpired ), - onConfirm: {}, + onConfirm: { + dependencies[singleton: .sessionProState].showSessionProBottomSheetIfNeeded( + presenting: { bottomSheet in + self?.transitionToScreen(bottomSheet, transitionType: .present) + } + ) + }, presenting: { modal in self?.transitionToScreen(modal, transitionType: .present) } diff --git a/Session/Shared/BaseVC.swift b/Session/Shared/BaseVC.swift index 028c735208..1e352951b4 100644 --- a/Session/Shared/BaseVC.swift +++ b/Session/Shared/BaseVC.swift @@ -96,17 +96,14 @@ public class BaseVC: UIViewController { let sessionProBadge: SessionProBadge = SessionProBadge(size: .medium) let isPro: Bool = { - if case .active = currentUserSessionProState.sessionProStateSubject.value { - return true - } else { - return false + switch currentUserSessionProState.sessionProStateSubject.value { + case .active, .refunding : return true + case .none, .expired: return false } }() sessionProBadge.isHidden = !isPro - let stackView: UIStackView = UIStackView( - arrangedSubviews: MainAppContext.determineDeviceRTL() ? [ sessionProBadge, headingImageView ] : [ headingImageView, sessionProBadge ] - ) + let stackView: UIStackView = UIStackView(arrangedSubviews: [ headingImageView, sessionProBadge ]) stackView.axis = .horizontal stackView.alignment = .center stackView.spacing = 0 @@ -117,10 +114,9 @@ public class BaseVC: UIViewController { .sink( receiveValue: { [weak sessionProBadge] sessionProPlanState in let isPro: Bool = { - if case .active = sessionProPlanState { - return true - } else { - return false + switch sessionProPlanState { + case .active, .refunding : return true + case .none, .expired: return false } }() sessionProBadge?.isHidden = !isPro diff --git a/Session/Shared/Views/SessionCell+AccessoryView.swift b/Session/Shared/Views/SessionCell+AccessoryView.swift index 9eeb7fb717..32a43ed863 100644 --- a/Session/Shared/Views/SessionCell+AccessoryView.swift +++ b/Session/Shared/Views/SessionCell+AccessoryView.swift @@ -383,10 +383,11 @@ extension SessionCell { private func layoutProBadgeView(_ view: UIView?, size: SessionProBadge.Size) { guard let badgeView: SessionProBadge = view as? SessionProBadge else { return } badgeView.size = size + let inset: CGFloat = (IconSize.medium.size - size.height) / 2 badgeView.pin(.leading, to: .leading, of: self, withInset: Values.smallSpacing) badgeView.pin(.trailing, to: .trailing, of: self, withInset: -Values.smallSpacing) - badgeView.pin(.top, to: .top, of: self) - badgeView.pin(.bottom, to: .bottom, of: self) + badgeView.pin(.top, to: .top, of: self, withInset: inset) + badgeView.pin(.bottom, to: .bottom, of: self, withInset: -inset) } private func configureProBadgeView(_ view: UIView?, tintColor: ThemeValue) { diff --git a/Session/Shared/Views/SessionProBadge+Utilities.swift b/Session/Shared/Views/SessionProBadge+Utilities.swift index 4ff22dcb9f..3b1aae707a 100644 --- a/Session/Shared/Views/SessionProBadge+Utilities.swift +++ b/Session/Shared/Views/SessionProBadge+Utilities.swift @@ -26,7 +26,7 @@ public extension SessionProBadge { return ( .themedKey(size.cacheKey, themeBackgroundColor: themeBackgroundColor), accessibilityLabel: SessionProBadge.accessibilityLabel, - { SessionProBadge(size: size) } + { SessionProBadge(size: size, themeBackgroundColor: themeBackgroundColor) } ) } } diff --git a/Session/Utilities/UIContextualAction+Utilities.swift b/Session/Utilities/UIContextualAction+Utilities.swift index f8843959b4..f03b9e8ee7 100644 --- a/Session/Utilities/UIContextualAction+Utilities.swift +++ b/Session/Utilities/UIContextualAction+Utilities.swift @@ -238,25 +238,24 @@ public extension UIContextualAction { }), pinnedConversationsNumber >= LibSession.PinnedConversationLimit { - let sessionProModal: ModalHostingViewController = ModalHostingViewController( - modal: ProCTAModal( - variant: .morePinnedConvos( - isGrandfathered: (pinnedConversationsNumber > LibSession.PinnedConversationLimit) - ), - dataManager: dependencies[singleton: .imageDataManager], - afterClosed: { [completionHandler] in - completionHandler(true) - }, - onConfirm: { [dependencies] in - dependencies[singleton: .sessionProState].upgradeToPro( - plan: SessionProPlan(variant: .threeMonths), - originatingPlatform: .iOS, - completion: nil - ) - } - ) + dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( + .morePinnedConvos( + isGrandfathered: (pinnedConversationsNumber > LibSession.PinnedConversationLimit), + renew: dependencies[singleton: .sessionProState].isSessionProExpired + ), + onConfirm: { [dependencies] in + dependencies[singleton: .sessionProState].showSessionProBottomSheetIfNeeded( + afterClosed: nil, + presenting: { bottomSheet in + viewController?.present(bottomSheet, animated: true) + } + ) + }, + presenting: { sessionProModal in + viewController?.present(sessionProModal, animated: true, completion: nil) + } ) - viewController?.present(sessionProModal, animated: true, completion: nil) + return } diff --git a/Session/Settings/SessionProSettings/SessionProPaymentScreen+ViewModel.swift b/SessionMessagingKit/SessionPro/SessionProPaymentScreenContent.swift similarity index 72% rename from Session/Settings/SessionProSettings/SessionProPaymentScreen+ViewModel.swift rename to SessionMessagingKit/SessionPro/SessionProPaymentScreenContent.swift index 622529711a..ce1f812adf 100644 --- a/Session/Settings/SessionProSettings/SessionProPaymentScreen+ViewModel.swift +++ b/SessionMessagingKit/SessionPro/SessionProPaymentScreenContent.swift @@ -10,17 +10,19 @@ extension SessionProPaymentScreenContent { public var dataModel: DataModel public var isRefreshing: Bool = false public var errorString: String? + public var isFromBottomSheet: Bool private var dependencies: Dependencies - init(dependencies: Dependencies, dataModel: DataModel) { + public init(dependencies: Dependencies, dataModel: DataModel, isFromBottomSheet: Bool) { self.dependencies = dependencies self.dataModel = dataModel + self.isFromBottomSheet = isFromBottomSheet } - public func purchase(planInfo: SessionProPlanInfo, success: (() -> Void)?, failure: (() -> Void)?) { + public func purchase(planInfo: SessionProPlanInfo, success: (() -> Void)?, failure: (() -> Void)?) async { let plan: SessionProPlan = SessionProPlan.from(planInfo) - dependencies[singleton: .sessionProState].upgradeToPro( + await dependencies[singleton: .sessionProState].upgradeToPro( plan: plan, originatingPlatform: .iOS ) { result in @@ -32,8 +34,8 @@ extension SessionProPaymentScreenContent { } } - public func cancelPro(success: (() -> Void)?, failure: (() -> Void)?) { - dependencies[singleton: .sessionProState].cancelPro { result in + public func cancelPro(success: (() -> Void)?, failure: (() -> Void)?) async { + await dependencies[singleton: .sessionProState].cancelPro { result in if result { success?() } else { @@ -42,8 +44,8 @@ extension SessionProPaymentScreenContent { } } - public func requestRefund(success: (() -> Void)?, failure: (() -> Void)?) { - dependencies[singleton: .sessionProState].requestRefund { result in + public func requestRefund(success: (() -> Void)?, failure: (() -> Void)?) async { + await dependencies[singleton: .sessionProState].requestRefund { result in if result { success?() } else { @@ -51,9 +53,5 @@ extension SessionProPaymentScreenContent { } } } - - public func openURL(_ url: URL) { - UIApplication.shared.open(url, options: [:], completionHandler: nil) - } } } diff --git a/Session/Settings/SessionProSettings/SessionProSettingsViewModel+Database.swift b/SessionMessagingKit/SessionPro/SessionProSettingsViewModel+Database.swift similarity index 100% rename from Session/Settings/SessionProSettings/SessionProSettingsViewModel+Database.swift rename to SessionMessagingKit/SessionPro/SessionProSettingsViewModel+Database.swift diff --git a/Session/Settings/SessionProSettings/SessionProSettingsViewModel.swift b/SessionMessagingKit/SessionPro/SessionProSettingsViewModel.swift similarity index 67% rename from Session/Settings/SessionProSettings/SessionProSettingsViewModel.swift rename to SessionMessagingKit/SessionPro/SessionProSettingsViewModel.swift index 26ed5e7124..6f272e3700 100644 --- a/Session/Settings/SessionProSettings/SessionProSettingsViewModel.swift +++ b/SessionMessagingKit/SessionPro/SessionProSettingsViewModel.swift @@ -3,19 +3,18 @@ import Foundation import Combine import SwiftUI -import Lucide import GRDB import DifferenceKit import SessionUIKit -import SignalUtilitiesKit -import SessionMessagingKit import SessionUtilitiesKit -public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType, NavigatableStateHolder { +public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType, NavigatableStateHolder, NavigatableStateHolder_SwiftUI { public let dependencies: Dependencies public let navigatableState: NavigatableState = NavigatableState() + public var navigatableStateSwiftUI: NavigatableState_SwiftUI = NavigatableState_SwiftUI() public let title: String = "" public let state: SessionListScreenContent.ListItemDataState = SessionListScreenContent.ListItemDataState() + public let isInBottomSheet: Bool /// This value is the current state of the view @MainActor @Published private(set) var internalState: ViewModelState @@ -23,10 +22,12 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType // MARK: - Initialization - @MainActor init( + @MainActor public init( + isInBottomSheet: Bool = false, using dependencies: Dependencies ) { self.dependencies = dependencies + self.isInBottomSheet = isInBottomSheet self.internalState = ViewModelState.initialState() self.observationTask = ObservationBuilder @@ -104,9 +105,9 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType case proBadge case longerMessages + case unlimitedPins case animatedDisplayPictures case badges - case unlimitedPins case plusLoadsMore case cancelPlan @@ -242,28 +243,20 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType id: .logoWithPro, variant: .logoWithPro( info: .init( - style:{ - switch state.currentProPlanState { - case .expired: .disabled + themeStyle:{ + switch (state.currentProPlanState, viewModel.isInBottomSheet) { + case (.expired, false): .disabled default: .normal } }(), + glowingBackgroundStyle: .base, state: { - guard state.currentProPlanState != .none else { - return .success( - description: "proFullestPotential" - .put(key: "app_name", value: Constants.app_name) - .put(key: "app_pro", value: Constants.app_pro) - .localizedFormatted() - ) - } - switch state.loadingState { case .loading: return .loading( message: { switch state.currentProPlanState { - case .expired: + case .expired, .none: "checkingProStatus" .put(key: "pro", value: Constants.pro) .localized() @@ -278,7 +271,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType return .error( message: { switch state.currentProPlanState { - case .expired: + case .expired, .none: "errorCheckingProStatus" .put(key: "pro", value: Constants.pro) .localized() @@ -290,8 +283,24 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType }() ) case .success: - return .success(description: nil) + return .success } + }(), + description: { + switch (state.currentProPlanState, viewModel.isInBottomSheet) { + case (.expired, true): + return "proAccessRenewStart" + .put(key: "pro", value: Constants.pro) + .put(key: "app_pro", value: Constants.app_pro) + .localizedFormatted() + case (.none, _): + return "proFullestPotential" + .put(key: "app_name", value: Constants.app_name) + .put(key: "app_pro", value: Constants.app_pro) + .localizedFormatted() + default: + return nil + } }() ) ), @@ -302,11 +311,11 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType from: .logoWithPro, title: { switch state.currentProPlanState { - case .active, .refunding, .none: + case .active, .refunding: "proStatusLoading" .put(key: "pro", value: Constants.pro) .localized() - case .expired: + case .expired, .none: "checkingProStatus" .put(key: "pro", value: Constants.pro) .localized() @@ -314,7 +323,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType }(), description: { switch state.currentProPlanState { - case .active, .refunding, .none: + case .active, .refunding: "proStatusLoadingDescription" .put(key: "pro", value: Constants.pro) .localized() @@ -322,6 +331,10 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType "checkingProStatusDescription" .put(key: "pro", value: Constants.pro) .localized() + case .none: + "checkingProStatusContinue" + .put(key: "pro", value: Constants.pro) + .localized() } }() ) @@ -331,9 +344,18 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType title: "proStatusError" .put(key: "pro", value: Constants.pro) .localized(), - description: "proStatusRefreshNetworkError" - .put(key: "pro", value: Constants.pro) - .localizedFormatted() + description: { + switch state.currentProPlanState { + case .none: + "proStatusNetworkErrorContinue" + .put(key: "pro", value: Constants.pro) + .localizedFormatted() + default: + "proStatusRefreshNetworkError" + .put(key: "pro", value: Constants.pro) + .localizedFormatted() + } + }() ) case .success: break @@ -341,112 +363,55 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType } ), ( - state.currentProPlanState != .none ? nil : + (state.currentProPlanState != .none && !viewModel.isInBottomSheet) ? nil : SessionListScreenContent.ListItemInfo( id: .continueButton, - variant: .button(title: "theContinue".localized()), - onTap: { [weak viewModel] in viewModel?.updateProPlan() } + variant: .button(title: "theContinue".localized(), enabled: (state.loadingState == .success)), + onTap: { [weak viewModel] in + switch state.loadingState { + case .loading: + viewModel?.showLoadingModal( + from: .logoWithPro, + title: "checkingProStatus" + .put(key: "pro", value: Constants.pro) + .localized(), + description: "checkingProStatusContinue" + .put(key: "pro", value: Constants.pro) + .localized() + ) + case .error: + viewModel?.showErrorModal( + from: .logoWithPro, + title: "proStatusError" + .put(key: "pro", value: Constants.pro) + .localized(), + description: "proStatusRefreshNetworkError" + .put(key: "pro", value: Constants.pro) + .localizedFormatted() + ) + case .success: + viewModel?.updateProPlan() + } + } ) ) ].compactMap { $0 } ) + let proFeatures: SectionModel = SectionModel( + model: .proFeatures, + elements: getProFeaturesElements(state: state, viewModel: viewModel) + ) + + // We can return the logo and proFeatures here since they are the only 2 sections that + // the bottom sheet needs + guard !viewModel.isInBottomSheet else { + return [ logo, proFeatures ] + } + let proStats: SectionModel = SectionModel( model: .proStats, - elements: [ - SessionListScreenContent.ListItemInfo( - id: .proStats, - variant: .dataMatrix( - info: [ - [ - .init( - leadingAccessory: .icon( - .messageSquare, - size: .large, - customTint: .primary - ), - title: .init( - "proLongerMessagesSent" - .putNumber(state.numberOfLongerMessagesSent) - .put(key: "total", value: state.loadingState == .loading ? "" : state.numberOfLongerMessagesSent) - .localized(), - font: .Headings.H9 - ), - isLoading: state.loadingState == .loading - ), - .init( - leadingAccessory: .icon( - .pin, - size: .large, - customTint: .primary - ), - title: .init( - "proPinnedConversations" - .putNumber(state.numberOfPinnedConversations) - .put(key: "total", value: state.loadingState == .loading ? "" : state.numberOfPinnedConversations) - .localized(), - font: .Headings.H9 - ), - isLoading: state.loadingState == .loading - ) - ], - [ - .init( - leadingAccessory: .icon( - .rectangleEllipsis, - size: .large, - customTint: .primary - ), - title: .init( - "proBadgesSent" - .putNumber(state.numberOfProBadgesSent) - .put(key: "total", value: state.loadingState == .loading ? "" : state.numberOfProBadgesSent) - .put(key: "pro", value: Constants.pro) - .localized(), - font: .Headings.H9 - ), - isLoading: state.loadingState == .loading - ), - .init( - leadingAccessory: .icon( - UIImage(named: "ic_user_group"), - size: .large, - customTint: .disabled - ), - title: .init( - "proGroupsUpgraded" - .putNumber(state.numberOfGroupsUpgraded) - .put(key: "total", value: state.loadingState == .loading ? "" : state.numberOfGroupsUpgraded) - .localized(), - font: .Headings.H9, - color: state.loadingState == .loading ? .textPrimary : .disabled - ), - tooltipInfo: .init( - id: "SessionListScreen.DataMatrix.UpgradedGroups.ToolTip", // stringlint:ignore - content: "proLargerGroupsTooltip" - .localizedFormatted(baseFont: .systemFont(ofSize: Values.smallFontSize)), - tintColor: .disabled, - position: .topLeft - ), - isLoading: state.loadingState == .loading - ) - ] - ] - ), - onTap: { [weak viewModel] in - guard state.loadingState == .loading else { return } - viewModel?.showLoadingModal( - from: .proStats, - title: "proStatsLoading" - .put(key: "pro", value: Constants.pro) - .localized(), - description: "proStatsLoadingDescription" - .put(key: "pro", value: Constants.pro) - .localized() - ) - } - ) - ] + elements: getProStatsElements(state: state, viewModel: viewModel) ) let proSettings: SectionModel = SectionModel( @@ -454,60 +419,6 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType elements: getProSettingsElements(state: state, previousState: previousState, viewModel: viewModel) ) - let proFeatures: SectionModel = SectionModel( - model: .proFeatures, - elements: ProFeaturesInfo.allCases(state.currentProPlanState).map { info in - SessionListScreenContent.ListItemInfo( - id: info.id, - variant: .cell( - info: .init( - leadingAccessory: .icon( - info.icon, - iconSize: .medium, - customTint: .black, - gradientBackgroundColors: info.backgroundColors, - backgroundSize: .veryLarge, - backgroundCornerRadius: 8 - ), - title: .init(info.title, font: .Headings.H9, accessory: info.accessory), - description: .init(info.description, font: .Body.smallRegular, color: .textSecondary) - ) - ) - ) - }.appending( - SessionListScreenContent.ListItemInfo( - id: .plusLoadsMore, - variant: .cell( - info: .init( - leadingAccessory: .icon( - Lucide.image(icon: .circlePlus, size: IconSize.medium.size), - iconSize: .medium, - customTint: .black, - gradientBackgroundColors: { - return switch state.currentProPlanState { - case .expired: [ThemeValue.disabled] - default: [.explicitPrimary(.orange), .explicitPrimary(.yellow)] - } - }(), - backgroundSize: .veryLarge, - backgroundCornerRadius: 8 - ), - title: .init("plusLoadsMore".localized(), font: .Headings.H9), - description: .init( - font: .Body.smallRegular, - attributedString: "plusLoadsMoreDescription" - .put(key: "pro", value: Constants.pro) - .put(key: "icon", value: Lucide.Icon.squareArrowUpRight) - .localizedFormatted(Fonts.Body.smallRegular), - color: .textSecondary - ) - ) - ), - onTap: { [weak viewModel] in viewModel?.openUrl(Constants.session_pro_roadmap) } - ) - ) - ) - let proManagement: SectionModel = SectionModel( model: .proManagement, elements: getProManagementElements(state: state, viewModel: viewModel) @@ -579,7 +490,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType return switch state.currentProPlanState { case .none: - [ logo, proFeatures, help ] + [ logo, proFeatures, proManagement, help ] case .active: [ logo, proStats, proSettings, proFeatures, proManagement, help ] case .expired: @@ -589,6 +500,175 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType } } + // MARK: - Pro Stats Elements + + private static func getProStatsElements( + state: ViewModelState, + viewModel: SessionProSettingsViewModel + ) -> [SessionListScreenContent.ListItemInfo] { + return [ + SessionListScreenContent.ListItemInfo( + id: .proStats, + variant: .dataMatrix( + info: [ + [ + .init( + leadingAccessory: .icon( + .messageSquare, + size: .large, + customTint: .primary + ), + title: .init( + "proLongerMessagesSent" + .putNumber(state.numberOfLongerMessagesSent) + .put(key: "total", value: state.loadingState == .loading ? "" : state.numberOfLongerMessagesSent) + .localized(), + font: .Headings.H9 + ), + isLoading: state.loadingState == .loading + ), + .init( + leadingAccessory: .icon( + .pin, + size: .large, + customTint: .primary + ), + title: .init( + "proPinnedConversations" + .putNumber(state.numberOfPinnedConversations) + .put(key: "total", value: state.loadingState == .loading ? "" : state.numberOfPinnedConversations) + .localized(), + font: .Headings.H9 + ), + isLoading: state.loadingState == .loading + ) + ], + [ + .init( + leadingAccessory: .icon( + .rectangleEllipsis, + size: .large, + customTint: .primary + ), + title: .init( + "proBadgesSent" + .putNumber(state.numberOfProBadgesSent) + .put(key: "total", value: state.loadingState == .loading ? "" : state.numberOfProBadgesSent) + .put(key: "pro", value: Constants.pro) + .localized(), + font: .Headings.H9 + ), + isLoading: state.loadingState == .loading + ), + .init( + leadingAccessory: .icon( + UIImage(named: "ic_user_group"), + size: .large, + customTint: .disabled + ), + title: .init( + "proGroupsUpgraded" + .putNumber(state.numberOfGroupsUpgraded) + .put(key: "total", value: state.loadingState == .loading ? "" : state.numberOfGroupsUpgraded) + .localized(), + font: .Headings.H9, + color: state.loadingState == .loading ? .textPrimary : .disabled + ), + tooltipInfo: .init( + id: "SessionListScreen.DataMatrix.UpgradedGroups.ToolTip", // stringlint:ignore + content: "proLargerGroupsTooltip" + .localizedFormatted(baseFont: .systemFont(ofSize: Values.smallFontSize)), + tintColor: .disabled, + position: .topLeft + ), + isLoading: state.loadingState == .loading + ) + ] + ] + ), + onTap: { [weak viewModel] in + guard state.loadingState == .loading else { return } + viewModel?.showLoadingModal( + from: .proStats, + title: "proStatsLoading" + .put(key: "pro", value: Constants.pro) + .localized(), + description: "proStatsLoadingDescription" + .put(key: "pro", value: Constants.pro) + .localized() + ) + } + ) + ] + } + + // MARK: - Pro Features Elements + + private static func getProFeaturesElements( + state: ViewModelState, + viewModel: SessionProSettingsViewModel + ) -> [SessionListScreenContent.ListItemInfo] { + let proFeaturesIds: [ListItem] = [ .longerMessages, .unlimitedPins, .animatedDisplayPictures, .badges ] + let proState: ProFeaturesInfo.ProState = { + guard !viewModel.isInBottomSheet else { return .none } + switch state.currentProPlanState { + case .none: return .none + case .expired: return .expired + default: return .active + } + }() + let proFeatureInfos: [ProFeaturesInfo] = ProFeaturesInfo.allCases(proState: proState) + let plusMoreFeatureInfo: ProFeaturesInfo = ProFeaturesInfo.plusMoreFeatureInfo(proState: proState) + + var result = zip(proFeaturesIds, proFeatureInfos).map { id, info in + SessionListScreenContent.ListItemInfo( + id: id, + variant: .cell( + info: .init( + leadingAccessory: .icon( + info.icon, + iconSize: .medium, + customTint: .black, + gradientBackgroundColors: info.backgroundColors, + backgroundSize: .veryLarge, + backgroundCornerRadius: 8 + ), + title: .init(info.title, font: .Headings.H9, accessory: info.accessory), + description: .init(font: .Body.smallRegular, attributedString: info.description, color: .textSecondary) + ) + ) + ) + } + result.append( + SessionListScreenContent.ListItemInfo( + id: .plusLoadsMore, + variant: .cell( + info: .init( + leadingAccessory: .icon( + plusMoreFeatureInfo.icon, + iconSize: .medium, + customTint: .black, + gradientBackgroundColors: plusMoreFeatureInfo.backgroundColors, + backgroundSize: .veryLarge, + backgroundCornerRadius: 8 + ), + title: .init(plusMoreFeatureInfo.title, font: .Headings.H9), + description: .init( + font: .Body.smallRegular, + attributedString: plusMoreFeatureInfo.description, + color: .textSecondary + ) + ) + ), + onTap: { [weak viewModel] in + viewModel?.openUrl(Constants.session_pro_roadmap) + } + ) + ) + + return result + } + // MARK: - Pro Settings Elements private static func getProSettingsElements( @@ -749,7 +829,39 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType viewModel: SessionProSettingsViewModel ) -> [SessionListScreenContent.ListItemInfo] { return switch state.currentProPlanState { - case .none: [] + case .none: + [ + SessionListScreenContent.ListItemInfo( + id: .recoverPlan, + variant: .cell( + info: .init( + title: .init( + "proAccessRecover" + .put(key: "pro", value: Constants.pro) + .localized(), + font: .Headings.H8, + color: .textPrimary + ), + trailingAccessory: .icon( + .refreshCcw, + size: .large, + customTint: .textPrimary + ) + ) + ), + onTap: { [weak viewModel] in + Task { + await viewModel? + .dependencies[singleton: .sessionProState] + .recoverPro { [weak viewModel] result in + DispatchQueue.main.async { + viewModel?.recoverProPlanCompletionHandler(result) + } + } + } + } + ) + ] case .active(_, _, let isAutoRenewing, _): [ !isAutoRenewing ? nil : @@ -791,7 +903,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType .put(key: "pro", value: Constants.pro) .localized(), font: .Headings.H8, - color: state.loadingState == .success ? .primary : .textPrimary + color: state.loadingState == .success ? .sessionButton_text : .textPrimary ), description: { switch state.loadingState { @@ -821,7 +933,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType .icon( .circlePlus, size: .large, - customTint: state.loadingState == .success ? .primary : .textPrimary + customTint: state.loadingState == .success ? .sessionButton_text : .textPrimary ) ) ) @@ -841,10 +953,10 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType case .error: viewModel?.showErrorModal( from: .updatePlan, - title: "proAccessError" + title: "proStatusError" .put(key: "pro", value: Constants.pro) .localized(), - description: "proAccessNetworkLoadError" + description: "proStatusRenewError" .put(key: "pro", value: Constants.pro) .put(key: "app_name", value: Constants.app_name) .localizedFormatted(baseFont: .systemFont(ofSize: Values.smallFontSize)) @@ -872,8 +984,18 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType ) ) ), - onTap: { [weak viewModel] in viewModel?.recoverProPlan() } - ), + onTap: { [weak viewModel] in + Task { + await viewModel? + .dependencies[singleton: .sessionProState] + .recoverPro { [weak viewModel] result in + DispatchQueue.main.async { + viewModel?.recoverProPlanCompletionHandler(result) + } + } + } + } + ) ] case .refunding: [] } @@ -883,7 +1005,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType // MARK: - Interactions extension SessionProSettingsViewModel { - func openUrl(_ urlString: String) { + @MainActor func openUrl(_ urlString: String) { guard let url: URL = URL(string: urlString) else { return } let modal: ConfirmationModal = ConfirmationModal( @@ -899,8 +1021,8 @@ extension SessionProSettingsViewModel { cancelTitle: "urlCopy".localized(), cancelStyle: .alert_text, hasCloseButton: true, - onConfirm: { modal in - UIApplication.shared.open(url, options: [:], completionHandler: nil) + onConfirm: { [dependencies] modal in + dependencies[singleton: .appContext].openUrl(url) modal.dismiss(animated: true) }, onCancel: { _ in @@ -912,7 +1034,7 @@ extension SessionProSettingsViewModel { self.transitionToScreen(modal, transitionType: .present) } - func showLoadingModal( + @MainActor func showLoadingModal( from item: ListItem, title: String, description: String @@ -931,7 +1053,7 @@ extension SessionProSettingsViewModel { self.transitionToScreen(modal, transitionType: .present) } - func showErrorModal( + @MainActor func showErrorModal( from item: ListItem, title: String, description: ThemedAttributedString @@ -962,78 +1084,66 @@ extension SessionProSettingsViewModel { } func updateProPlan() { - guard !dependencies[feature: .mockInstalledFromIPA] else { - DispatchQueue.main.async { - let viewController = ModalActivityIndicatorViewController() { [weak self] modalActivityIndicator in - Task { - sleep(5) - modalActivityIndicator.dismiss(animated: true) { - self?.showToast(text: "errorGeneric".localized()) - } - } - } - self.transitionToScreen(viewController, transitionType: .present) - } + let paymentScreen = SessionProPaymentScreen( + viewModel: SessionProPaymentScreenContent.ViewModel( + dependencies: dependencies, + dataModel: .init( + flow: dependencies[singleton: .sessionProState].sessionProStateSubject.value.toPaymentFlow(using: dependencies), + plans: dependencies[singleton: .sessionProState].sessionProPlans.map { $0.info() } + ), + isFromBottomSheet: isInBottomSheet + ) + ) + + guard !isInBottomSheet else { + self.transitionToScreen(paymentScreen, transitionType: .push) return } - let viewController: SessionHostingViewController = SessionHostingViewController( - rootView: SessionProPaymentScreen( - viewModel: SessionProPaymentScreenContent.ViewModel( - dependencies: dependencies, - dataModel: .init( - flow: dependencies[singleton: .sessionProState].sessionProStateSubject.value.toPaymentFlow(using: dependencies), - plans: dependencies[singleton: .sessionProState].sessionProPlans.map { $0.info() } - ) - ) - ) - ) - self.transitionToScreen(viewController) + self.transitionToScreen(SessionHostingViewController(rootView: paymentScreen)) } - func recoverProPlan() { - dependencies[singleton: .sessionProState].recoverPro { [weak self] result in - let modal: ConfirmationModal = ConfirmationModal( - info: ConfirmationModal.Info( - title: ( + @MainActor func recoverProPlanCompletionHandler(_ result: Bool) { + let modal: ConfirmationModal = ConfirmationModal( + info: ConfirmationModal.Info( + title: ( + result ? + "proAccessRestored" + .put(key: "pro", value: Constants.pro) + .localized() : + "proAccessNotFound" + .put(key: "pro", value: Constants.pro) + .localized() + ), + body: .text( + ( result ? - "proAccessRestored" + "proAccessRestoredDescription" + .put(key: "app_name", value: Constants.app_name) .put(key: "pro", value: Constants.pro) .localized() : - "proAccessNotFound" - .put(key: "pro", value: Constants.pro) - .localized() - ), - body: .text( - ( - result ? - "proAccessRestoredDescription" - .put(key: "app_name", value: Constants.app_name) - .put(key: "pro", value: Constants.pro) - .localized() : - "proAccessNotFoundDescription" - .put(key: "app_name", value: Constants.app_name) - .put(key: "pro", value: Constants.pro) - .localized() - ), - scrollMode: .never + "proAccessNotFoundDescription" + .put(key: "app_name", value: Constants.app_name) + .put(key: "pro", value: Constants.pro) + .localized() ), - confirmTitle: (result ? nil : "helpSupport".localized()), - cancelTitle: (result ? "okay".localized() : "close".localized()), - cancelStyle: (result ? .textPrimary : .danger), - dismissOnConfirm: false, - onConfirm: { [weak self] modal in - guard result == false else { - return modal.dismiss(animated: true) - } - - self?.openUrl(Constants.session_pro_recovery_support_url) + scrollMode: .never + ), + confirmTitle: (result ? nil : "helpSupport".localized()), + cancelTitle: (result ? "okay".localized() : "close".localized()), + cancelStyle: (result ? .textPrimary : .danger), + dismissOnConfirm: false, + onConfirm: { [weak self] modal in + guard result == false else { + return modal.dismiss(animated: true) } - ) + + self?.openUrl(Constants.session_pro_recovery_support_url) + } ) - - self?.transitionToScreen(modal, transitionType: .present) - } + ) + + self.transitionToScreen(modal, transitionType: .present) } func cancelPlan() { @@ -1045,13 +1155,14 @@ extension SessionProSettingsViewModel { flow: .cancel( originatingPlatform: { switch dependencies[singleton: .sessionProState].sessionProStateSubject.value.originatingPlatform { - case .iOS: return .iOS - case .Android: return .Android + case .iOS: return .iOS + case .Android: return .Android } }() ), plans: dependencies[singleton: .sessionProState].sessionProPlans.map { $0.info() } - ) + ), + isFromBottomSheet: false ) ) ) @@ -1067,197 +1178,19 @@ extension SessionProSettingsViewModel { flow: .refund( originatingPlatform: { switch dependencies[singleton: .sessionProState].sessionProStateSubject.value.originatingPlatform { - case .iOS: return .iOS - case .Android: return .Android + case .iOS: return .iOS + case .Android: return .Android } }(), isNonOriginatingAccount: dependencies[feature: .mockNonOriginatingAccount], // TODO: [PRO] Get the real state if not originator requestedAt: nil ), plans: dependencies[singleton: .sessionProState].sessionProPlans.map { $0.info() } - ) + ), + isFromBottomSheet: false ) ) ) self.transitionToScreen(viewController) } } - -// MARK: - Pro Features Info - -extension SessionProSettingsViewModel { - struct ProFeaturesInfo { - let id: ListItem - let icon: UIImage? - let backgroundColors: [ThemeValue] - let title: String - let description: String - let accessory: SessionListScreenContent.TextInfo.Accessory - - static func allCases(_ state: SessionProPlanState) -> [ProFeaturesInfo] { - return [ - ProFeaturesInfo( - id: .longerMessages, - icon: Lucide.image(icon: .messageSquare, size: IconSize.medium.size), - backgroundColors: { - return switch state { - case .expired: [ThemeValue.disabled] - default: [.explicitPrimary(.blue), .explicitPrimary(.purple)] - } - }(), - title: "proLongerMessages".localized(), - description: "proLongerMessagesDescription".localized(), - accessory: .none - ), - ProFeaturesInfo( - id: .unlimitedPins, - icon: Lucide.image(icon: .pin, size: IconSize.medium.size), - backgroundColors: { - return switch state { - case .expired: [ThemeValue.disabled] - default: [.explicitPrimary(.purple), .explicitPrimary(.pink)] - } - }(), - title: "proUnlimitedPins".localized(), - description: "proUnlimitedPinsDescription".localized(), - accessory: .none - ), - ProFeaturesInfo( - id: .animatedDisplayPictures, - icon: Lucide.image(icon: .squarePlay, size: IconSize.medium.size), - backgroundColors: { - return switch state { - case .expired: [ThemeValue.disabled] - default: [.explicitPrimary(.pink), .explicitPrimary(.red)] - } - }(), - title: "proAnimatedDisplayPictures".localized(), - description: "proAnimatedDisplayPicturesDescription".localized(), - accessory: .none - ), - ProFeaturesInfo( - id: .badges, - icon: Lucide.image(icon: .rectangleEllipsis, size: IconSize.medium.size), - backgroundColors: { - return switch state { - case .expired: [ThemeValue.disabled] - default: [.explicitPrimary(.red), .explicitPrimary(.orange)] - } - }(), - title: "proBadges".localized(), - description: "proBadgesDescription".put(key: "app_name", value: Constants.app_name).localized(), - accessory: .proBadgeLeading( - themeBackgroundColor: { - return switch state { - case .expired: .disabled - default: .primary - } - }() - ) - ) - ] - } - } -} - -// MARK: - Convenience - -extension SessionProPlan { - func info() -> SessionProPaymentScreenContent.SessionProPlanInfo { - let price: Double = self.variant.price - let pricePerMonth: Double = (self.variant.price / Double(self.variant.duration)) - return .init( - duration: self.variant.duration, - totalPrice: price, - pricePerMonth: pricePerMonth, - discountPercent: self.variant.discountPercent, - titleWithPrice: { - switch self.variant { - case .oneMonth: - return "proPriceOneMonth" - .put(key: "monthly_price", value: pricePerMonth.formatted(format: .currency(decimal: true, withLocalSymbol: true))) - .localized() - case .threeMonths: - return "proPriceThreeMonths" - .put(key: "monthly_price", value: pricePerMonth.formatted(format: .currency(decimal: true, withLocalSymbol: true))) - .localized() - case .twelveMonths: - return "proPriceTwelveMonths" - .put(key: "monthly_price", value: pricePerMonth.formatted(format: .currency(decimal: true, withLocalSymbol: true))) - .localized() - } - }(), - subtitleWithPrice: { - switch self.variant { - case .oneMonth: - return "proBilledMonthly" - .put(key: "price", value: price.formatted(format: .currency(decimal: true, withLocalSymbol: true))) - .localized() - case .threeMonths: - return "proBilledQuarterly" - .put(key: "price", value: price.formatted(format: .currency(decimal: true, withLocalSymbol: true))) - .localized() - case .twelveMonths: - return "proBilledAnnually" - .put(key: "price", value: price.formatted(format: .currency(decimal: true, withLocalSymbol: true))) - .localized() - } - }() - ) - } - - static func from(_ info: SessionProPaymentScreenContent.SessionProPlanInfo) -> SessionProPlan { - let variant: SessionProPlan.Variant = { - switch info.duration { - case 1: return .oneMonth - case 3: return .threeMonths - case 12: return .twelveMonths - default: fatalError("Unhandled SessionProPlan.Variant.Duration case") - } - }() - - return SessionProPlan(variant: variant) - } -} - -extension SessionProPlanState { - func toPaymentFlow(using dependencies: Dependencies) -> SessionProPaymentScreenContent.SessionProPlanPaymentFlow { - switch self { - case .none: - return .purchase - case .active(let currentPlan, let expiredOn, let isAutoRenewing, let originatingPlatform): - return .update( - currentPlan: currentPlan.info(), - expiredOn: expiredOn, - isAutoRenewing: isAutoRenewing, - originatingPlatform: { - switch originatingPlatform { - case .iOS: return .iOS - case .Android: return .Android - } - }() - ) - case .expired(_, let originatingPlatform): - return .renew( - originatingPlatform: { - switch originatingPlatform { - case .iOS: return .iOS - case .Android: return .Android - } - }() - ) - case .refunding(let originatingPlatform, let requestedAt): - return .refund( - originatingPlatform: { - switch originatingPlatform { - case .iOS: return .iOS - case .Android: return .Android - } - }(), - isNonOriginatingAccount: dependencies[feature: .mockNonOriginatingAccount], // TODO: [PRO] Get the real state if not originator, - requestedAt: requestedAt - ) - } - } -} - diff --git a/SessionMessagingKit/SessionPro/SessionProState+Models.swift b/SessionMessagingKit/SessionPro/SessionProState+Models.swift new file mode 100644 index 0000000000..97c03f4206 --- /dev/null +++ b/SessionMessagingKit/SessionPro/SessionProState+Models.swift @@ -0,0 +1,109 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import SessionUIKit +import SessionUtilitiesKit +import Combine + +public extension SessionProPlanState { + func toPaymentFlow(using dependencies: Dependencies) -> SessionProPaymentScreenContent.SessionProPlanPaymentFlow { + switch self { + case .none: + return .purchase( + billingAccess: !dependencies[feature: .mockInstalledFromIPA] + ) + case .active(let currentPlan, let expiredOn, let isAutoRenewing, let originatingPlatform): + return .update( + currentPlan: currentPlan.info(), + expiredOn: expiredOn, + isAutoRenewing: isAutoRenewing, + originatingPlatform: { + switch originatingPlatform { + case .iOS: return .iOS + case .Android: return .Android + } + }(), + isNonOriginatingAccount: dependencies[feature: .mockNonOriginatingAccount], + billingAccess: !dependencies[feature: .mockInstalledFromIPA] + ) + case .expired(_, let originatingPlatform): + return .renew( + originatingPlatform: { + switch originatingPlatform { + case .iOS: return .iOS + case .Android: return .Android + } + }(), + billingAccess: !dependencies[feature: .mockInstalledFromIPA] + ) + case .refunding(let originatingPlatform, let requestedAt): + return .refund( + originatingPlatform: { + switch originatingPlatform { + case .iOS: return .iOS + case .Android: return .Android + } + }(), + isNonOriginatingAccount: dependencies[feature: .mockNonOriginatingAccount], + requestedAt: requestedAt + ) + } + } +} + +public extension SessionProPlan { + func info() -> SessionProPaymentScreenContent.SessionProPlanInfo { + let price: Double = self.variant.price + let pricePerMonth: Double = (self.variant.price / Double(self.variant.duration)) + return .init( + duration: self.variant.duration, + totalPrice: price, + pricePerMonth: pricePerMonth, + discountPercent: self.variant.discountPercent, + titleWithPrice: { + switch self.variant { + case .oneMonth: + return "proPriceOneMonth" + .put(key: "monthly_price", value: pricePerMonth.formatted(format: .currency(decimal: true, withLocalSymbol: true))) + .localized() + case .threeMonths: + return "proPriceThreeMonths" + .put(key: "monthly_price", value: pricePerMonth.formatted(format: .currency(decimal: true, withLocalSymbol: true))) + .localized() + case .twelveMonths: + return "proPriceTwelveMonths" + .put(key: "monthly_price", value: pricePerMonth.formatted(format: .currency(decimal: true, withLocalSymbol: true))) + .localized() + } + }(), + subtitleWithPrice: { + switch self.variant { + case .oneMonth: + return "proBilledMonthly" + .put(key: "price", value: price.formatted(format: .currency(decimal: true, withLocalSymbol: true))) + .localized() + case .threeMonths: + return "proBilledQuarterly" + .put(key: "price", value: price.formatted(format: .currency(decimal: true, withLocalSymbol: true))) + .localized() + case .twelveMonths: + return "proBilledAnnually" + .put(key: "price", value: price.formatted(format: .currency(decimal: true, withLocalSymbol: true))) + .localized() + } + }() + ) + } + + static func from(_ info: SessionProPaymentScreenContent.SessionProPlanInfo) -> SessionProPlan { + let variant: SessionProPlan.Variant = { + switch info.duration { + case 1: return .oneMonth + case 3: return .threeMonths + case 12: return .twelveMonths + default: fatalError("Unhandled SessionProPlan.Variant.Duration case") + } + }() + + return SessionProPlan(variant: variant) + } +} diff --git a/SessionMessagingKit/Utilities/SessionProState.swift b/SessionMessagingKit/SessionPro/SessionProState.swift similarity index 73% rename from SessionMessagingKit/Utilities/SessionProState.swift rename to SessionMessagingKit/SessionPro/SessionProState.swift index ed5423ea40..dfe63eb671 100644 --- a/SessionMessagingKit/Utilities/SessionProState.swift +++ b/SessionMessagingKit/SessionPro/SessionProState.swift @@ -15,7 +15,7 @@ public extension Singleton { // MARK: - SessionProState -public class SessionProState: SessionProManagerType, ProfilePictureAnimationManagerType, SessionProCTAManagerType { +public class SessionProState: SessionProManagerType, ProfilePictureAnimationManagerType { public let dependencies: Dependencies public var sessionProStateSubject: CurrentValueSubject public var sessionProStatePublisher: AnyPublisher { @@ -23,13 +23,24 @@ public class SessionProState: SessionProManagerType, ProfilePictureAnimationMana .compactMap { $0 } .eraseToAnyPublisher() } + public var isSessionProActivePublisher: AnyPublisher { + sessionProStateSubject + .compactMap { + switch $0 { + case .active, .refunding: return true + case .none, .expired: return false + } + } + .eraseToAnyPublisher() + } + public var isSessionProExpired: Bool { + switch sessionProStateSubject.value { + case .expired: return true + default: return false + } + } public var sessionProPlans: [SessionProPlan] { - ( - dependencies[feature: .mockInstalledFromIPA] || - dependencies[feature: .mockNonOriginatingAccount] - ) ? - [] : - SessionProPlan.Variant.allCases.map { SessionProPlan(variant: $0) } + dependencies[feature: .mockInstalledFromIPA] ? [] : SessionProPlan.Variant.allCases.map { SessionProPlan(variant: $0) } } public var shouldAnimateImageSubject: CurrentValueSubject @@ -41,6 +52,7 @@ public class SessionProState: SessionProManagerType, ProfilePictureAnimationMana public init(using dependencies: Dependencies) { self.dependencies = dependencies + // TODO: [PRO] Get the pro state of current user let originatingPlatform: ClientPlatform = dependencies[feature: .proPlanOriginatingPlatform] let expiryInSeconds = dependencies[feature: .mockCurrentUserSessionProExpiry].durationInSeconds ?? 3 * 30 * 24 * 60 * 60 switch dependencies[feature: .mockCurrentUserSessionProState] { @@ -85,25 +97,30 @@ public class SessionProState: SessionProManagerType, ProfilePictureAnimationMana ) } - public func upgradeToPro(plan: SessionProPlan, originatingPlatform: ClientPlatform, completion: ((_ result: Bool) -> Void)?) { - dependencies.set(feature: .mockCurrentUserSessionProState, to: .active) - dependencies[defaults: .standard, key: .hasShownProExpiringCTA] = false - dependencies[defaults: .standard, key: .hasShownProExpiredCTA] = false - let expiryInSeconds = dependencies[feature: .mockCurrentUserSessionProExpiry].durationInSeconds ?? TimeInterval(plan.variant.duration) * 30 * 24 * 60 * 60 - self.sessionProStateSubject.send( - SessionProPlanState.active( - currentPlan: plan, - expiredOn: Calendar.current.date(byAdding: .second, value: Int(expiryInSeconds), to: Date())!, - isAutoRenewing: true, - originatingPlatform: originatingPlatform + public func upgradeToPro(plan: SessionProPlan, originatingPlatform: ClientPlatform, completion: ((_ result: Bool) -> Void)?) async { + // TODO: [PRO] Upgrade to Pro + Task { + try await Task.sleep(for: .seconds(5)) + dependencies[defaults: .standard, key: .hasShownProExpiringCTA] = false + dependencies[defaults: .standard, key: .hasShownProExpiredCTA] = false + dependencies.set(feature: .mockCurrentUserSessionProState, to: .active) + let expiryInSeconds = dependencies[feature: .mockCurrentUserSessionProExpiry].durationInSeconds ?? TimeInterval(plan.variant.duration) * 30 * 24 * 60 * 60 + self.sessionProStateSubject.send( + SessionProPlanState.active( + currentPlan: plan, + expiredOn: Calendar.current.date(byAdding: .second, value: Int(expiryInSeconds), to: Date())!, + isAutoRenewing: true, + originatingPlatform: originatingPlatform + ) ) - ) - self.shouldAnimateImageSubject.send(true) - dependencies.setAsync(.isProBadgeEnabled, true) - completion?(true) + self.shouldAnimateImageSubject.send(true) + dependencies.setAsync(.isProBadgeEnabled, true) + completion?(true) + } } - public func cancelPro(completion: ((_ result: Bool) -> Void)?) { + public func cancelPro(completion: ((_ result: Bool) -> Void)?) async { + // TODO: [PRO] Cancel Pro: This is more like just cancel subscription guard case .active(let currentPlan, let expiredOn, _, let originatingPlatform) = self.sessionProStateSubject.value else { return } @@ -120,7 +137,8 @@ public class SessionProState: SessionProManagerType, ProfilePictureAnimationMana completion?(true) } - public func requestRefund(completion: ((_ result: Bool) -> Void)?) { + public func requestRefund(completion: ((_ result: Bool) -> Void)?) async { + // TODO: [PRO] Request refund dependencies.set(feature: .mockCurrentUserSessionProState, to: .refunding) self.sessionProStateSubject.send( SessionProPlanState.refunding( @@ -132,7 +150,8 @@ public class SessionProState: SessionProManagerType, ProfilePictureAnimationMana completion?(true) } - public func expirePro(completion: ((_ result: Bool) -> Void)?) { + public func expirePro(completion: ((_ result: Bool) -> Void)?) async { + // TODO: [PRO] Mannualy expire pro state, maybe just for QA as we have backend to determine if pro is expired dependencies.set(feature: .mockCurrentUserSessionProState, to: .expired) self.sessionProStateSubject.send( SessionProPlanState.expired( @@ -144,22 +163,50 @@ public class SessionProState: SessionProManagerType, ProfilePictureAnimationMana completion?(true) } - public func recoverPro(completion: ((_ result: Bool) -> Void)?) { + public func recoverPro(completion: ((_ result: Bool) -> Void)?) async { + // TODO: [PRO] Recover from an existing pro plan guard dependencies[feature: .proPlanToRecover] == true && dependencies[feature: .mockCurrentUserSessionProLoadingState] == .success else { completion?(false) return } - upgradeToPro( + await upgradeToPro( plan: SessionProPlan(variant: .threeMonths), originatingPlatform: dependencies[feature: .proPlanOriginatingPlatform], completion: completion ) } + // These functions are only for QA purpose + public func updateOriginatingPlatform(_ newValue: ClientPlatform) { + self.sessionProStateSubject.send( + self.sessionProStateSubject.value + .with(originatingPlatform: newValue) + ) + } + + public func updateProExpiry(_ expiryInSeconds: TimeInterval?) { + guard case .active(let currentPlan, _, let isAutoRenewing, let originatingPlatform) = self.sessionProStateSubject.value else { + return + } + let expiryInSeconds = expiryInSeconds ?? TimeInterval(currentPlan.variant.duration * 30 * 24 * 60 * 60) + + self.sessionProStateSubject.send( + SessionProPlanState.active( + currentPlan: currentPlan, + expiredOn: Calendar.current.date(byAdding: .second, value: Int(expiryInSeconds), to: Date())!, + isAutoRenewing: isAutoRenewing, + originatingPlatform: originatingPlatform + ) + ) + } +} + +// MARK: - SessionProCTAManagerType + +extension SessionProState: SessionProCTAManagerType { @discardableResult @MainActor public func showSessionProCTAIfNeeded( _ variant: ProCTAModal.Variant, dismissType: Modal.DismissType, - beforePresented: (() -> Void)?, onConfirm: (() -> Void)?, onCancel: (() -> Void)?, afterClosed: (() -> Void)?, @@ -187,7 +234,8 @@ public class SessionProState: SessionProManagerType, ProfilePictureAnimationMana dataManager: dependencies[singleton: .imageDataManager], dismissType: dismissType, afterClosed: afterClosed, - onConfirm: onConfirm + onConfirm: onConfirm, + onCancel: onCancel ) ) presenting?(sessionProModal) @@ -195,27 +243,19 @@ public class SessionProState: SessionProManagerType, ProfilePictureAnimationMana return true } - // These functions are only for QA purpose - public func updateOriginatingPlatform(_ newValue: ClientPlatform) { - self.sessionProStateSubject.send( - self.sessionProStateSubject.value - .with(originatingPlatform: newValue) - ) - } - - public func updateProExpiry(_ expiryInSeconds: TimeInterval?) { - guard case .active(let currentPlan, _, let isAutoRenewing, let originatingPlatform) = self.sessionProStateSubject.value else { - return - } - let expiryInSeconds = expiryInSeconds ?? TimeInterval(currentPlan.variant.duration * 30 * 24 * 60 * 60) - - self.sessionProStateSubject.send( - SessionProPlanState.active( - currentPlan: currentPlan, - expiredOn: Calendar.current.date(byAdding: .second, value: Int(expiryInSeconds), to: Date())!, - isAutoRenewing: isAutoRenewing, - originatingPlatform: originatingPlatform - ) + @MainActor public func showSessionProBottomSheetIfNeeded( + afterClosed: (() -> Void)?, + presenting: ((UIViewController) -> Void)? + ) { + let viewModel = SessionProSettingsViewModel(isInBottomSheet: true, using: dependencies) + let sessionProBottomSheet: BottomSheetHostingViewController = BottomSheetHostingViewController( + bottomSheet: BottomSheet( + hasCloseButton: true, + afterClosed: afterClosed + ) { + SessionListScreen(viewModel: viewModel) + } ) + presenting?(sessionProBottomSheet) } } diff --git a/SessionUIKit/Components/Input View/InputView.swift b/SessionUIKit/Components/Input View/InputView.swift index 6f449eab6c..aa7dff9329 100644 --- a/SessionUIKit/Components/Input View/InputView.swift +++ b/SessionUIKit/Components/Input View/InputView.swift @@ -63,7 +63,7 @@ public final class InputView: UIView, InputViewButtonDelegate, InputTextViewDele private let displayNameRetriever: (String, Bool) -> String? private let onQuoteCancelled: (() -> Void)? private weak var delegate: InputViewDelegate? - private var sessionProState: SessionProCTAManagerType? + private var sessionProStatePublisher: AnyPublisher public var quoteViewModel: QuoteViewModel? { didSet { handleQuoteDraftChanged() } } public var linkPreviewViewModel: LinkPreviewViewModel? @@ -359,7 +359,7 @@ public final class InputView: UIView, InputViewButtonDelegate, InputTextViewDele displayNameRetriever: @escaping (String, Bool) -> String?, imageDataManager: ImageDataManagerType, linkPreviewManager: LinkPreviewManagerType, - sessionProState: SessionProCTAManagerType?, + sessionProStatePublisher: AnyPublisher, onQuoteCancelled: (() -> Void)? = nil, didLoadLinkPreview: (@MainActor (LinkPreviewViewModel.LoadResult) -> Void)? ) { @@ -367,7 +367,7 @@ public final class InputView: UIView, InputViewButtonDelegate, InputTextViewDele self.linkPreviewManager = linkPreviewManager self.delegate = delegate self.displayNameRetriever = displayNameRetriever - self.sessionProState = sessionProState + self.sessionProStatePublisher = sessionProStatePublisher self.didLoadLinkPreview = didLoadLinkPreview self.onQuoteCancelled = onQuoteCancelled @@ -375,7 +375,7 @@ public final class InputView: UIView, InputViewButtonDelegate, InputTextViewDele setUpViewHierarchy() - self.sessionProState?.isSessionProPublisher + self.sessionProStatePublisher .subscribe(on: DispatchQueue.main) .receive(on: DispatchQueue.main) .sink( diff --git a/SignalUtilitiesKit/Shared View Controllers/ModalActivityIndicatorViewController.swift b/SessionUIKit/Components/ModalActivityIndicatorViewController.swift similarity index 98% rename from SignalUtilitiesKit/Shared View Controllers/ModalActivityIndicatorViewController.swift rename to SessionUIKit/Components/ModalActivityIndicatorViewController.swift index 6bdf1b2086..898933704c 100644 --- a/SignalUtilitiesKit/Shared View Controllers/ModalActivityIndicatorViewController.swift +++ b/SessionUIKit/Components/ModalActivityIndicatorViewController.swift @@ -3,9 +3,7 @@ import Foundation import Combine import MediaPlayer -import SessionUIKit import NVActivityIndicatorView -import SessionUtilitiesKit // A modal view that be used during blocking interactions (e.g. waiting on response from // service or on the completion of a long-running local operation). @@ -180,8 +178,6 @@ public class ModalActivityIndicatorViewController: OWSViewController { } @objc func cancelPressed() { - Log.assertOnMainThread() - wasCancelled = true dismiss { } diff --git a/SignalUtilitiesKit/Shared View Controllers/OWSViewController.swift b/SessionUIKit/Components/OWSViewController.swift similarity index 99% rename from SignalUtilitiesKit/Shared View Controllers/OWSViewController.swift rename to SessionUIKit/Components/OWSViewController.swift index e584bcf6fc..c95cabe596 100644 --- a/SignalUtilitiesKit/Shared View Controllers/OWSViewController.swift +++ b/SessionUIKit/Components/OWSViewController.swift @@ -1,7 +1,6 @@ // Copyright © 2024 Rangeproof Pty Ltd. All rights reserved. import UIKit -import SessionUIKit open class OWSViewController: UIViewController { public var shouldUseTheme: Bool = true diff --git a/SessionUIKit/Components/SwiftUI/BottomSheet.swift b/SessionUIKit/Components/SwiftUI/BottomSheet.swift new file mode 100644 index 0000000000..bc6cc30d7c --- /dev/null +++ b/SessionUIKit/Components/SwiftUI/BottomSheet.swift @@ -0,0 +1,209 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import SwiftUI +import Lucide +import Combine + +private struct SizePreferenceKey: PreferenceKey { + static var defaultValue: CGSize = .zero + static func reduce(value: inout CGSize, nextValue: () -> CGSize) { + let next = nextValue() + if next != .zero { value = next } + } +} + +public struct BottomSheet: View where Content: View { + @EnvironmentObject var host: HostWrapper + @State private var disposables: Set = Set() + @StateObject private var toolbarManager: ToolbarManager + + let hasCloseButton: Bool + let afterClosed: (() -> Void)? + let contentPrefferedHeight: CGFloat + let content: () -> Content + + let cornerRadius: CGFloat = 11 + let shadowRadius: CGFloat = 10 + let shadowOpacity: Double = 0.4 + + @State private var show: Bool = false + @State private var topPadding: CGFloat = 80 + @State private var contentSize: CGSize = .zero + @State private var dragOffset: CGFloat = 0 + + public init( + hasCloseButton: Bool, + afterClosed: (() -> Void)? = nil, + contentPrefferedHeight: CGFloat? = nil, + content: @escaping () -> Content) + { + self.hasCloseButton = hasCloseButton + _toolbarManager = StateObject(wrappedValue: ToolbarManager(hasCloseButton: hasCloseButton)) + self.afterClosed = afterClosed + self.content = content + self.contentPrefferedHeight = contentPrefferedHeight ?? .infinity + } + + public var body: some View { + ZStack(alignment: .bottom) { + // Background + Rectangle() + .fill(.ultraThinMaterial) + .opacity(backgroundOpacity) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ignoresSafeArea() + .onTapGesture { + close() + } + + if show { + VStack(spacing: Values.verySmallSpacing) { + Capsule() + .fill(themeColor: .value(.textPrimary, alpha: 0.8)) + .frame(width: 35, height: 3) + + ZStack(alignment: .topTrailing) { + NavigationView { + ZStack { + Rectangle() + .fill(themeColor: .backgroundPrimary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ignoresSafeArea() + + content() + .navigationTitle("") + .persistentCloseToolbar() + .environmentObject(toolbarManager) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .navigationViewStyle(.stack) + .frame(maxHeight: contentPrefferedHeight) + } + .cornerRadius(cornerRadius, corners: [.topLeft, .topRight]) + .frame(maxWidth: .infinity, alignment: .topTrailing) + } + .background( + GeometryReader { proxy in + Color.clear + .preference(key: SizePreferenceKey.self, value: proxy.size) + } + ) + .offset(y: max(0, dragOffset)) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .onPreferenceChange(SizePreferenceKey.self) { size in + contentSize = size + recomputeTopPadding() + } + .onAppear { recomputeTopPadding() } + .padding(.top, topPadding) + } + } + .ignoresSafeArea(edges: .bottom) + .onAppear { + toolbarManager.setCloseAction { + close() + } + withAnimation { + show.toggle() + } + } + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .bottom + ) + .gesture( + DragGesture(minimumDistance: 10, coordinateSpace: .global) + .onChanged { value in + // Only allow downward movement; clamp upward drags to 0 + let translation = value.translation.height + withAnimation { + dragOffset = max(0, translation) + } + } + .onEnded { value in + let translation = max(0, value.translation.height) + let velocity = value.velocity.height + let threshold: CGFloat = max(120, contentSize.height * 0.25) + if translation > threshold || velocity > 1200 { + close() + } else { + withAnimation { + dragOffset = 0 + } + } + } + ) + } + + private var backgroundOpacity: Double { + let fade = min(1.0, Double(dragOffset / 300)) + return max(0.2, 1.0 - fade * 0.8) + } + + // MARK: - Dismiss Logic + + private func close() { + withAnimation { + show.toggle() + } + host.controller?.presentingViewController?.dismiss(animated: true) + afterClosed?() + } + + // MARK: - Layout helpers + + private func recomputeTopPadding() { + let screenHeight = UIScreen.main.bounds.height + let bottomSafeInset = host.controller?.view.safeAreaInsets.bottom ?? 0 + + let handleHeight: CGFloat = 3 + let handleSpacing: CGFloat = Values.verySmallSpacing + let headerHeight: CGFloat = handleHeight + handleSpacing + let totalSheetHeight = headerHeight + contentSize.height + Values.veryLargeSpacing + + let computed = screenHeight - bottomSafeInset - totalSheetHeight + topPadding = max(bottomSafeInset, computed) + } +} + +// MARK: - BottomSheetIdentifiable + +protocol BottomSheetIdentifiable {} + +// MARK: - BottomSheetHostingViewController + +open class BottomSheetHostingViewController: UIHostingController>>, BottomSheetIdentifiable where Content: View { + public init(bottomSheet: Content) { + let container = HostWrapper() + let modified = bottomSheet.environmentObject(container) as! ModifiedContent> + super.init(rootView: modified) + container.controller = self + self.modalTransitionStyle = .crossDissolve + self.modalPresentationStyle = .overFullScreen + } + + required public init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func viewDidLoad() { + super.viewDidLoad() + + navigationItem.backButtonTitle = "" + view.themeBackgroundColor = .clear + ThemeManager.applyNavigationStylingIfNeeded(to: self) + + setNeedsStatusBarAppearanceUpdate() + } +} + +private extension DragGesture.Value { + var velocity: CGSize { + let dt: CGFloat = 0.016 + let dx = (predictedEndLocation.x - location.x) / dt + let dy = (predictedEndLocation.y - location.y) / dt + return CGSize(width: dx, height: dy) + } +} diff --git a/SessionUIKit/Components/SwiftUI/Modal+SwiftUI.swift b/SessionUIKit/Components/SwiftUI/Modal+SwiftUI.swift index 86801f7891..230f336625 100644 --- a/SessionUIKit/Components/SwiftUI/Modal+SwiftUI.swift +++ b/SessionUIKit/Components/SwiftUI/Modal+SwiftUI.swift @@ -12,7 +12,7 @@ public struct Modal_SwiftUI: View where Content: View { let shadowRadius: CGFloat = 10 let shadowOpacity: Double = 0.4 - @State private var show: Bool = true + @State private var show: Bool = false public var body: some View { ZStack { @@ -23,33 +23,38 @@ public struct Modal_SwiftUI: View where Content: View { .ignoresSafeArea() .onTapGesture { close() } - // Modal - VStack { - Spacer() - - VStack(spacing: 0) { - content { internalAfterClosed in - close(internalAfterClosed) + if show { + // Modal + VStack { + Spacer() + + VStack(spacing: 0) { + content { internalAfterClosed in + close(internalAfterClosed) + } } + .backgroundColor(themeColor: .alert_background) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + .shadow(color: Color.black.opacity(shadowOpacity), radius: shadowRadius) + .frame( + maxWidth: UIDevice.current.isIPad ? Values.iPadModalWidth : .infinity + ) + .padding(.horizontal, UIDevice.current.isIPad ? 0 : Values.veryLargeSpacing) + + Spacer() } - .backgroundColor(themeColor: .alert_background) - .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) - .shadow(color: Color.black.opacity(shadowOpacity), radius: shadowRadius) - .frame( - maxWidth: UIDevice.current.isIPad ? Values.iPadModalWidth : .infinity - ) - .padding(.horizontal, UIDevice.current.isIPad ? 0 : Values.veryLargeSpacing) - - Spacer() + .transition(.move(edge: .bottom).combined(with: .opacity)) } - .transition(.move(edge: .bottom).combined(with: .opacity)) - .animation(.spring(), value: show) - } .frame( maxWidth: .infinity, maxHeight: .infinity ) + .onAppear { + withAnimation { + show.toggle() + } + } .gesture( DragGesture(minimumDistance: 20, coordinateSpace: .global) .onEnded { value in @@ -75,6 +80,10 @@ public struct Modal_SwiftUI: View where Content: View { } } + withAnimation { + show.toggle() + } + targetViewController?.presentingViewController?.dismiss( animated: true, completion: { diff --git a/SessionUIKit/Components/SwiftUI/ProCTAModal+Type.swift b/SessionUIKit/Components/SwiftUI/ProCTAModal+Type.swift new file mode 100644 index 0000000000..89d7f820fd --- /dev/null +++ b/SessionUIKit/Components/SwiftUI/ProCTAModal+Type.swift @@ -0,0 +1,81 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import SwiftUI + +// MARK: - SessionProCTAManagerType + +public protocol SessionProCTAManagerType: AnyObject { + @discardableResult @MainActor func showSessionProCTAIfNeeded( + _ variant: ProCTAModal.Variant, + dismissType: Modal.DismissType, + onConfirm: (() -> Void)?, + onCancel: (() -> Void)?, + afterClosed: (() -> Void)?, + presenting: ((UIViewController) -> Void)? + ) -> Bool + + @MainActor func showSessionProBottomSheetIfNeeded( + afterClosed: (() -> Void)?, + presenting: ((UIViewController) -> Void)? + ) +} + +// MARK: - Convenience + +public extension SessionProCTAManagerType { + @discardableResult @MainActor func showSessionProCTAIfNeeded( + _ variant: ProCTAModal.Variant, + onConfirm: (() -> Void)?, + onCancel: (() -> Void)?, + afterClosed: (() -> Void)?, + presenting: ((UIViewController) -> Void)? + ) -> Bool { + showSessionProCTAIfNeeded( + variant, + dismissType: .recursive, + onConfirm: onConfirm, + onCancel: onCancel, + afterClosed: afterClosed, + presenting: presenting + ) + } + + @discardableResult @MainActor func showSessionProCTAIfNeeded( + _ variant: ProCTAModal.Variant, + onConfirm: (() -> Void)?, + onCancel: (() -> Void)?, + presenting: ((UIViewController) -> Void)? + ) -> Bool { + showSessionProCTAIfNeeded( + variant, + dismissType: .recursive, + onConfirm: onConfirm, + onCancel: onCancel, + afterClosed: nil, + presenting: presenting + ) + } + + @discardableResult @MainActor func showSessionProCTAIfNeeded( + _ variant: ProCTAModal.Variant, + onConfirm: (() -> Void)?, + presenting: ((UIViewController) -> Void)? + ) -> Bool { + showSessionProCTAIfNeeded( + variant, + dismissType: .recursive, + onConfirm: onConfirm, + onCancel: nil, + afterClosed: nil, + presenting: presenting + ) + } + + @MainActor func showSessionProBottomSheetIfNeeded(presenting: ((UIViewController) -> Void)?) { + showSessionProBottomSheetIfNeeded( + afterClosed: nil, + presenting: presenting + ) + } +} diff --git a/SessionUIKit/Components/SwiftUI/ProCTAModal.swift b/SessionUIKit/Components/SwiftUI/ProCTAModal.swift index 61099b2679..bd7b0ae151 100644 --- a/SessionUIKit/Components/SwiftUI/ProCTAModal.swift +++ b/SessionUIKit/Components/SwiftUI/ProCTAModal.swift @@ -14,20 +14,22 @@ public struct ProCTAModal: View { let dismissType: Modal.DismissType let afterClosed: (() -> Void)? let onConfirm: (() -> Void)? + let onCancel: (() -> Void)? public init( variant: ProCTAModal.Variant, dataManager: ImageDataManagerType, dismissType: Modal.DismissType = .recursive, afterClosed: (() -> Void)? = nil, - onConfirm: (() -> Void)? = nil - + onConfirm: (() -> Void)? = nil, + onCancel: (() -> Void)? = nil ) { self.variant = variant self.dataManager = dataManager self.dismissType = dismissType self.afterClosed = afterClosed self.onConfirm = onConfirm + self.onCancel = onCancel } public var body: some View { @@ -41,11 +43,13 @@ public struct ProCTAModal: View { ZStack { if let animatedAvatarImageURL = variant.animatedAvatarImageURL { GeometryReader { geometry in - let size: CGFloat = geometry.size.width / 1522.0 * 135 + let size: CGFloat = geometry.size.width / 1522.0 * variant.animatedAvatarImageSize let scale: CGFloat = geometry.size.width / 1522.0 SessionAsyncImage( source: .url(animatedAvatarImageURL), dataManager: dataManager, + shouldAnimateImage: true, + grayscale: variant.grayscale, content: { image in image .resizable() @@ -57,6 +61,7 @@ public struct ProCTAModal: View { Image(uiImage: UIImage(data: data) ?? UIImage()) .resizable() .aspectRatio(1, contentMode: .fit) + .grayscale(variant.grayscale) .frame(width: size, height: size) } else { EmptyView() @@ -99,51 +104,61 @@ public struct ProCTAModal: View { // Content VStack(spacing: Values.largeSpacing) { // Title - switch variant { - case .animatedProfileImage(let isSessionProActivated) where isSessionProActivated: - HStack(spacing: Values.smallSpacing) { - SessionProBadge_SwiftUI(size: .large) + if variant.isRenewing { + HStack(spacing: Values.smallSpacing) { + Text("renew".localized()) + .font(.Headings.H4) + .foregroundColor(themeColor: .textPrimary) + + SessionProBadge_SwiftUI(size: .large) + } + } else { + switch variant { + case .animatedProfileImage(let isSessionProActivated, _) where isSessionProActivated: + HStack(spacing: Values.smallSpacing) { + SessionProBadge_SwiftUI(size: .large) - Text("proActivated".localized()) - .font(.Headings.H4) - .foregroundColor(themeColor: .textPrimary) - } + Text("proActivated".localized()) + .font(.Headings.H4) + .foregroundColor(themeColor: .textPrimary) + } - case .groupLimit(_, let isSessionProActivated, _) where isSessionProActivated: - HStack(spacing: Values.smallSpacing) { - SessionProBadge_SwiftUI(size: .large) - - Text("proGroupActivated".localized()) - .font(.Headings.H4) - .foregroundColor(themeColor: .textPrimary) - } + case .groupLimit(_, let isSessionProActivated, _) where isSessionProActivated: + HStack(spacing: Values.smallSpacing) { + SessionProBadge_SwiftUI(size: .large) + + Text("proGroupActivated".localized()) + .font(.Headings.H4) + .foregroundColor(themeColor: .textPrimary) + } - case .expiring(let timeLeft): - let isExpired: Bool = (timeLeft?.isEmpty != false) - HStack(spacing: Values.smallSpacing) { - SessionProBadge_SwiftUI( - size: .large, - themeBackgroundColor: variant.themeColor - ) - - Text(isExpired ? "proExpired".localized() : "proExpiringSoon".localized()) - .font(.Headings.H4) - .foregroundColor(themeColor: isExpired ? .disabled : .textPrimary) - } + case .expiring(let timeLeft): + let isExpired: Bool = (timeLeft?.isEmpty != false) + HStack(spacing: Values.smallSpacing) { + SessionProBadge_SwiftUI( + size: .large, + themeBackgroundColor: variant.themeColor + ) + + Text(isExpired ? "proExpired".localized() : "proExpiringSoon".localized()) + .font(.Headings.H4) + .foregroundColor(themeColor: isExpired ? .disabled : .textPrimary) + } - default: - HStack(spacing: Values.smallSpacing) { - Text("upgradeTo".localized()) - .font(.Headings.H4) - .foregroundColor(themeColor: .textPrimary) - - SessionProBadge_SwiftUI(size: .large) - } + default: + HStack(spacing: Values.smallSpacing) { + Text("upgradeTo".localized()) + .font(.Headings.H4) + .foregroundColor(themeColor: .textPrimary) + + SessionProBadge_SwiftUI(size: .large) + } + } } // Description, Subtitle VStack(spacing: 0) { - if case .animatedProfileImage(let isSessionProActivated) = variant, isSessionProActivated { + if case .animatedProfileImage(let isSessionProActivated, _) = variant, isSessionProActivated { HStack(spacing: Values.verySmallSpacing) { Text("proAlreadyPurchased".localized()) .font(.Body.largeRegular) @@ -193,7 +208,10 @@ public struct ProCTAModal: View { Text(variant.benefits[index].description) .font(.Body.largeRegular) .foregroundColor(themeColor: .textPrimary) - .fixedSize(horizontal: false, vertical: true) + .frame( + maxWidth: .infinity, + alignment: .leading + ) } } } @@ -230,8 +248,7 @@ public struct ProCTAModal: View { HStack(spacing: Values.smallSpacing) { // Upgrade Button ShineButton { - onConfirm?() - close(nil) + close(onConfirm) } label: { Text(variant.confirmButtonTitle) .font(.Body.baseRegular) @@ -250,6 +267,7 @@ public struct ProCTAModal: View { // Cancel Button Button { close(nil) + onCancel?() } label: { Text(variant.cancelButtonTitle) .font(.Body.baseRegular) @@ -276,12 +294,21 @@ public struct ProCTAModal: View { public extension ProCTAModal { enum Variant { - case generic - case longerMessages - case animatedProfileImage(isSessionProActivated: Bool) - case morePinnedConvos(isGrandfathered: Bool) + case generic(renew: Bool) + case longerMessages(renew: Bool) + case animatedProfileImage(isSessionProActivated: Bool, renew: Bool) + case morePinnedConvos(isGrandfathered: Bool, renew: Bool) case groupLimit(isAdmin: Bool, isSessionProActivated: Bool, proBadgeImage: UIImage) case expiring(timeLeft: String?) + + public var isRenewing: Bool { + switch self { + case .generic(let renew), .longerMessages(let renew), .animatedProfileImage(_, let renew), .morePinnedConvos(_, let renew): + return renew + case .groupLimit, .expiring: + return false + } + } // stringlint:ignore_contents public var backgroundImageName: String { @@ -311,10 +338,17 @@ public extension ProCTAModal { } } + public var grayscale: Double { + switch self { + case .expiring(let timeLeft): return (timeLeft?.isEmpty == false) ? 0.0 : 1.0 + default: return 0.0 + } + } + // stringlint:ignore_contents public var animatedAvatarImageURL: URL? { switch self { - case .generic, .animatedProfileImage: + case .generic, .animatedProfileImage, .expiring: return Bundle.main.url(forResource: "AnimatedProfileCTAAnimationCropped", withExtension: "webp") default: return nil } @@ -324,51 +358,85 @@ public extension ProCTAModal { /// of the modal. public var animatedAvatarImagePadding: (leading: CGFloat, top: CGFloat) { switch self { - case .generic: return (1293, 743) - case .animatedProfileImage: return (690, 363) + case .generic, .expiring: return (1303, 743) + case .animatedProfileImage: return (680, 363) default: return (0, 0) } } + + public var animatedAvatarImageSize: CGFloat { + switch self { + case .generic, .expiring: return 115 + case .animatedProfileImage: return 200 + default: return 0 + } + } public var subtitle: ThemedAttributedString { switch self { - case .generic: - return "proUserProfileModalCallToAction" - .put(key: "app_pro", value: Constants.app_pro) - .put(key: "app_name", value: Constants.app_name) - .localizedFormatted() - case .longerMessages: - return "proCallToActionLongerMessages" - .put(key: "app_pro", value: Constants.app_pro) - .localizedFormatted() - case .animatedProfileImage(let isSessionProActivated): - return isSessionProActivated ? - "proAnimatedDisplayPicture" - .localizedFormatted(baseFont: .systemFont(ofSize: 14)) : - "proAnimatedDisplayPictureCallToActionDescription" - .put(key: "app_pro", value: Constants.app_pro) - .localizedFormatted() - case .morePinnedConvos(let isGrandfathered): - if isGrandfathered { - return "proCallToActionPinnedConversations" - .put(key: "app_pro", value: Constants.app_pro) - .localizedFormatted() + case .generic(let renew): + return renew ? + "proRenewMaxPotential" + .put(key: "pro", value: Constants.pro) + .put(key: "app_name", value: Constants.app_name) + .localizedFormatted(baseFont: Fonts.Body.largeRegular) : + "proUserProfileModalCallToAction" + .put(key: "app_pro", value: Constants.app_pro) + .put(key: "app_name", value: Constants.app_name) + .localizedFormatted(baseFont: Fonts.Body.largeRegular) + case .longerMessages(let renew): + return renew ? + "proRenewLongerMessages" + .put(key: "pro", value: Constants.pro) + .localizedFormatted(baseFont: Fonts.Body.largeRegular) : + "proCallToActionLongerMessages" + .put(key: "app_pro", value: Constants.app_pro) + .localizedFormatted(baseFont: Fonts.Body.largeRegular) + case .animatedProfileImage(let isSessionProActivated, let renew): + switch (isSessionProActivated, renew) { + case (true, _): + return "proAnimatedDisplayPicture" + .localizedFormatted(baseFont: Fonts.Body.largeRegular) + case (false, true): + return "proRenewAnimatedDisplayPicture" + .put(key: "pro", value: Constants.pro) + .localizedFormatted(baseFont: Fonts.Body.largeRegular) + case (false, false): + return "proAnimatedDisplayPictureCallToActionDescription" + .put(key: "app_pro", value: Constants.app_pro) + .localizedFormatted(baseFont: Fonts.Body.largeRegular) + } + case .morePinnedConvos(let isGrandfathered, let renew): + switch (isGrandfathered, renew) { + case (true, false): + return "proCallToActionPinnedConversations" + .put(key: "app_pro", value: Constants.app_pro) + .localizedFormatted(baseFont: Fonts.Body.largeRegular) + case (false, false): + return "proCallToActionPinnedConversationsMoreThan" + .put(key: "app_pro", value: Constants.app_pro) + .put(key: "limit", value: 5) // TODO: [PRO] Get from SessionProUIManager + .localizedFormatted(baseFont: Fonts.Body.largeRegular) + case (true, true): + return "proRenewPinMoreConversations" + .put(key: "pro", value: Constants.pro) + .localizedFormatted(baseFont: Fonts.Body.largeRegular) + case (false, true): + return "proRenewPinFiveConversations" + .put(key: "pro", value: Constants.pro) + .put(key: "limit", value: 5) // TODO: [PRO] Get from SessionProUIManager + .localizedFormatted(baseFont: Fonts.Body.largeRegular) } - return "proCallToActionPinnedConversationsMoreThan" - .put(key: "app_pro", value: Constants.app_pro) - .put(key: "limit", value: 5) // TODO: [PRO] Get from SessionProUIManager - .localizedFormatted() - case .groupLimit(let isAdmin, let isSessionProActivated, _): switch (isAdmin, isSessionProActivated) { case (_, true): return "proGroupActivatedDescription" - .localizedFormatted(baseFont: .systemFont(ofSize: 14)) + .localizedFormatted(baseFont: Fonts.Body.largeRegular) case (true, false): return "proUserProfileModalCallToAction" .put(key: "app_pro", value: Constants.app_pro) .put(key: "app_name", value: Constants.app_name) - .localizedFormatted() + .localizedFormatted(baseFont: Fonts.Body.largeRegular) case (false, false): // TODO: Localised return ThemedAttributedString( @@ -381,12 +449,12 @@ public extension ProCTAModal { .put(key: "pro", value: Constants.pro) .put(key: "time", value: timeLeft) .put(key: "app_pro", value: Constants.app_pro) - .localizedFormatted() + .localizedFormatted(baseFont: Fonts.Body.largeRegular) } else { return "proExpiredDescription" .put(key: "pro", value: Constants.pro) .put(key: "app_pro", value: Constants.app_pro) - .localizedFormatted() + .localizedFormatted(baseFont: Fonts.Body.largeRegular) } } } @@ -411,10 +479,10 @@ public extension ProCTAModal { public var benefits: [Benefits] { return switch self { - case .generic: [ .largerGroups, .longerMessages, .loadsMore ] + case .generic: [ .longerMessages, .morePinnedConvos, .loadsMore ] case .longerMessages: [ .longerMessages, .morePinnedConvos, .loadsMore ] - case .animatedProfileImage: [ .animatedProfileImage, .largerGroups, .loadsMore ] - case .morePinnedConvos: [ .morePinnedConvos, .largerGroups, .loadsMore ] + case .animatedProfileImage: [ .animatedProfileImage, .longerMessages, .loadsMore ] + case .morePinnedConvos: [ .morePinnedConvos, .longerMessages, .loadsMore ] case .groupLimit(let isAdmin, let isSessionProActivated, _): switch (isAdmin, isSessionProActivated) { case (true, false): [ .largerGroups, .longerMessages, .loadsMore ] @@ -446,7 +514,7 @@ public extension ProCTAModal { public var onlyShowCloseButton: Bool { switch self { - case .animatedProfileImage(let isSessionProActivated): + case .animatedProfileImage(let isSessionProActivated, _): return isSessionProActivated case .groupLimit(let isAdmin, let isSessionProActivated, _): return (!isAdmin || isSessionProActivated) @@ -457,96 +525,6 @@ public extension ProCTAModal { } } -// MARK: - SessionProCTAManagerType - -public protocol SessionProCTAManagerType: AnyObject { - var isSessionProPublisher: AnyPublisher { get } - - @discardableResult @MainActor func showSessionProCTAIfNeeded( - _ variant: ProCTAModal.Variant, - dismissType: Modal.DismissType, - beforePresented: (() -> Void)?, - onConfirm: (() -> Void)?, - onCancel: (() -> Void)?, - afterClosed: (() -> Void)?, - presenting: ((UIViewController) -> Void)? - ) -> Bool -} - -// MARK: - Convenience - -public extension SessionProCTAManagerType { - @discardableResult @MainActor func showSessionProCTAIfNeeded( - _ variant: ProCTAModal.Variant, - beforePresented: (() -> Void)?, - onConfirm: (() -> Void)?, - onCancel: (() -> Void)?, - afterClosed: (() -> Void)?, - presenting: ((UIViewController) -> Void)? - ) -> Bool { - showSessionProCTAIfNeeded( - variant, - dismissType: .recursive, - beforePresented: beforePresented, - onConfirm: onConfirm, - onCancel: onCancel, - afterClosed: afterClosed, - presenting: presenting - ) - } - - @discardableResult @MainActor func showSessionProCTAIfNeeded( - _ variant: ProCTAModal.Variant, - beforePresented: (() -> Void)?, - onConfirm: (() -> Void)?, - onCancel: (() -> Void)?, - presenting: ((UIViewController) -> Void)? - ) -> Bool { - showSessionProCTAIfNeeded( - variant, - dismissType: .recursive, - beforePresented: beforePresented, - onConfirm: onConfirm, - onCancel: onCancel, - afterClosed: nil, - presenting: presenting - ) - } - - @discardableResult @MainActor func showSessionProCTAIfNeeded( - _ variant: ProCTAModal.Variant, - onConfirm: (() -> Void)?, - presenting: ((UIViewController) -> Void)? - ) -> Bool { - showSessionProCTAIfNeeded( - variant, - dismissType: .recursive, - beforePresented: nil, - onConfirm: onConfirm, - onCancel: nil, - afterClosed: nil, - presenting: presenting - ) - } - - @discardableResult @MainActor func showSessionProCTAIfNeeded( - _ variant: ProCTAModal.Variant, - onConfirm: (() -> Void)?, - onCancel: (() -> Void)?, - presenting: ((UIViewController) -> Void)? - ) -> Bool { - showSessionProCTAIfNeeded( - variant, - dismissType: .recursive, - beforePresented: nil, - onConfirm: onConfirm, - onCancel: onCancel, - afterClosed: nil, - presenting: presenting - ) - } -} - // MARK: - Previews struct ProCTAModal_Previews: PreviewProvider { @@ -554,7 +532,7 @@ struct ProCTAModal_Previews: PreviewProvider { Group { PreviewThemeWrapper(theme: .classicDark) { ProCTAModal( - variant: .generic, + variant: .generic(renew: false), dataManager: ImageDataManager(), dismissType: .single, afterClosed: nil @@ -565,7 +543,7 @@ struct ProCTAModal_Previews: PreviewProvider { PreviewThemeWrapper(theme: .classicLight) { ProCTAModal( - variant: .generic, + variant: .generic(renew: false), dataManager: ImageDataManager(), dismissType: .single, afterClosed: nil @@ -576,7 +554,7 @@ struct ProCTAModal_Previews: PreviewProvider { PreviewThemeWrapper(theme: .oceanDark) { ProCTAModal( - variant: .generic, + variant: .generic(renew: false), dataManager: ImageDataManager(), dismissType: .single, afterClosed: nil @@ -587,7 +565,7 @@ struct ProCTAModal_Previews: PreviewProvider { PreviewThemeWrapper(theme: .oceanLight) { ProCTAModal( - variant: .generic, + variant: .generic(renew: false), dataManager: ImageDataManager(), dismissType: .single, afterClosed: nil diff --git a/SessionUIKit/Components/SwiftUI/SessionAsyncImage.swift b/SessionUIKit/Components/SwiftUI/SessionAsyncImage.swift index 41121946e8..b99f37e82a 100644 --- a/SessionUIKit/Components/SwiftUI/SessionAsyncImage.swift +++ b/SessionUIKit/Components/SwiftUI/SessionAsyncImage.swift @@ -17,6 +17,7 @@ public struct SessionAsyncImage: View { private let dataManager: ImageDataManagerType private let shouldAnimateImage: Bool + private let grayscale: Double private let content: (Image) -> Content private let placeholder: () -> Placeholder @@ -24,12 +25,14 @@ public struct SessionAsyncImage: View { source: ImageDataManager.DataSource, dataManager: ImageDataManagerType, shouldAnimateImage: Bool = true, + grayscale: Double = 0.0, @ViewBuilder content: @escaping (Image) -> Content, @ViewBuilder placeholder: @escaping () -> Placeholder ) { self.source = source self.dataManager = dataManager self.shouldAnimateImage = shouldAnimateImage + self.grayscale = grayscale self.content = content self.placeholder = placeholder } @@ -42,6 +45,7 @@ public struct SessionAsyncImage: View { if isAnimating { TimelineView(.animation) { context in imageView + .grayscale(grayscale) .onChange(of: context.date) { newDate in updateAnimationFrame(at: newDate) } @@ -97,8 +101,8 @@ public struct SessionAsyncImage: View { await MainActor.run { switch event { - case .frameLoaded, .completed: break - case .readyToAnimate: + case .frameLoaded: break + case .readyToAnimate, .completed: guard self.shouldAnimateImage else { return } self.isAnimating = true diff --git a/SessionUIKit/Components/SwiftUI/ShineButton.swift b/SessionUIKit/Components/SwiftUI/ShineButton.swift index 479cfeae69..3ded9162e5 100644 --- a/SessionUIKit/Components/SwiftUI/ShineButton.swift +++ b/SessionUIKit/Components/SwiftUI/ShineButton.swift @@ -64,7 +64,7 @@ struct ShineOverlay: View { endPoint: .trailing ) ) - .frame(width: width * 0.4, height: height) // 0.4 让光带不是整块 + .frame(width: width * 0.4, height: height) .offset( x: shineX * width, y: 0 diff --git a/SessionUIKit/Components/SwiftUI/ToolBarManager.swift b/SessionUIKit/Components/SwiftUI/ToolBarManager.swift new file mode 100644 index 0000000000..cc84b85488 --- /dev/null +++ b/SessionUIKit/Components/SwiftUI/ToolBarManager.swift @@ -0,0 +1,53 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import SwiftUI +import Lucide + +// MARK: - Toolbar Manager + +class ToolbarManager: ObservableObject { + @Published var hasCloseButton: Bool + var closeAction: () -> Void + + init(hasCloseButton: Bool = true, closeAction: @escaping () -> Void = {}) { + self.hasCloseButton = hasCloseButton + self.closeAction = closeAction + } + + func close() { + closeAction() + } + + func setCloseAction(_ action: @escaping () -> Void) { + closeAction = action + } +} + +// MARK: - Reusable Toolbar Modifier + +struct PersistentCloseToolbarModifier: ViewModifier { + @EnvironmentObject var toolbarManager: ToolbarManager + + func body(content: Content) -> some View { + content + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + toolbarManager.close() + } label: { + AttributedText(Lucide.Icon.x.attributedString(size: 28)) + .font(.system(size: 28, weight: .bold)) + .foregroundColor(themeColor: .textPrimary) + } + } + } + } +} + +// MARK: - View Extension + +public extension View { + func persistentCloseToolbar() -> some View { + modifier(PersistentCloseToolbarModifier()) + } +} diff --git a/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+ListItem.swift b/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+ListItem.swift index 365d7c1cba..f60f897fea 100644 --- a/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+ListItem.swift +++ b/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+ListItem.swift @@ -27,7 +27,7 @@ public extension SessionListScreenContent { case cell(info: ListItemCell.Info) case logoWithPro(info: ListItemLogoWithPro.Info) case dataMatrix(info: [[ListItemDataMatrix.Info]]) - case button(title: String) + case button(title: String, enabled: Bool) } let id: ID diff --git a/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+Models.swift b/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+Models.swift index 19a209bee7..7d4e90b1d2 100644 --- a/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+Models.swift +++ b/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+Models.swift @@ -1,10 +1,14 @@ // Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. import Foundation +import UIKit import SwiftUI +import Combine public enum SessionListScreenContent {} +// MARK: - ViewModelType + public extension SessionListScreenContent { protocol ViewModelType: ObservableObject, SectionedListItemData { var title: String { get } diff --git a/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemButton.swift b/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemButton.swift index 44c5abf48e..86838a1555 100644 --- a/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemButton.swift +++ b/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemButton.swift @@ -7,7 +7,8 @@ import DifferenceKit struct ListItemButton: View { let title: String - + let enabled: Bool + var body: some View { Text(title) .font(.Body.largeRegular) @@ -19,8 +20,8 @@ struct ListItemButton: View { ) .background( RoundedRectangle(cornerRadius: 7) - .fill(themeColor: .sessionButton_primaryFilledBackground) + .fill(themeColor: enabled ? .sessionButton_primaryFilledBackground : .disabled) ) - .padding(.vertical, Values.smallSpacing) } } + diff --git a/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemLogoWithPro.swift b/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemLogoWithPro.swift index 0f244e6789..d2b5530b03 100644 --- a/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemLogoWithPro.swift +++ b/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemLogoWithPro.swift @@ -6,6 +6,54 @@ import DifferenceKit // MARK: - ListItemLogoWithPro public struct ListItemLogoWithPro: View { + public enum GlowingBackgroundStyle { + case base + case large + case largeNoPaddings + + var blurSize: CGSize { + switch self { + case .base: + return CGSize( + width: UIScreen.main.bounds.width - 2 * Values.mediumSpacing - 20 * 2, + height: 96 + ) + case .large, .largeNoPaddings: + return CGSize( + width: UIScreen.main.bounds.width - 2 * Values.mediumSpacing, + height: UIScreen.main.bounds.width - 2 * Values.mediumSpacing + ) + } + } + + var shadowRadius: CGFloat { + switch self { + case .base: + return 15 + case .large, .largeNoPaddings: + return 20 + } + } + + var blurRadius: CGFloat { + switch self { + case .base: + return 20 + case .large, .largeNoPaddings: + return 30 + } + } + + var verticalPaddings: CGFloat { + switch self { + case .base, .large: + return (blurSize.height - 96) / 2 + case .largeNoPaddings: + return 0 + } + } + } + public enum ThemeStyle { case normal case disabled @@ -17,7 +65,7 @@ public struct ListItemLogoWithPro: View { } } - var growingBackgroundColor: ThemeValue { + var glowingBackgroundColor: ThemeValue { switch self { case .normal: return .primary case .disabled: return .disabled @@ -28,93 +76,101 @@ public struct ListItemLogoWithPro: View { public enum State: Equatable, Hashable { case loading(message: String) case error(message: String) - case success(description: ThemedAttributedString?) + case success } public struct Info: Equatable, Hashable, Differentiable { - public let style: ThemeStyle + public let themeStyle: ThemeStyle + public let glowingBackgroundStyle: GlowingBackgroundStyle public let state: State + public let description: ThemedAttributedString? - public init(style: ThemeStyle, state: State) { - self.style = style + public init( + themeStyle: ThemeStyle, + glowingBackgroundStyle: GlowingBackgroundStyle, + state: State, + description: ThemedAttributedString? = nil + ) { + self.themeStyle = themeStyle + self.glowingBackgroundStyle = glowingBackgroundStyle self.state = state + self.description = description } } let info: Info public var body: some View { - VStack(spacing: 0) { - ZStack { - Ellipse() - .fill(themeColor: info.style.growingBackgroundColor) - .frame( - width: UIScreen.main.bounds.width - 2 * Values.mediumSpacing - 20 * 2, - height: 96 - ) - .opacity(0.15) - .shadow(radius: 15) - .blur(radius: 20) - + ZStack(alignment: .top) { + Ellipse() + .fill(themeColor: info.themeStyle.glowingBackgroundColor) + .frame( + width: info.glowingBackgroundStyle.blurSize.width, + height: info.glowingBackgroundStyle.blurSize.height + ) + .opacity(0.17) + .shadow(radius: info.glowingBackgroundStyle.shadowRadius) + .blur(radius: info.glowingBackgroundStyle.blurRadius) + .padding(.top, info.glowingBackgroundStyle.blurRadius / 2) + + VStack(spacing: 0) { Image("SessionGreen64") .resizable() .renderingMode(.template) - .foregroundColor(themeColor: info.style.themeColor) + .foregroundColor(themeColor: info.themeStyle.themeColor) .scaledToFit() .frame(width: 100, height: 111) - } - .framing( - maxWidth: .infinity, - height: 133, - alignment: .center - ) - .padding(.top, Values.smallSpacing) - - HStack(spacing: Values.smallSpacing) { - Image("SessionHeading") - .resizable() - .renderingMode(.template) - .foregroundColor(themeColor: .textPrimary) - .scaledToFit() - .frame(width: 131, height: 18) - SessionProBadge_SwiftUI(size: .medium, themeBackgroundColor: info.style.themeColor) - } - .environment(\.layoutDirection, .leftToRight) - - if case .success(let description) = info.state, let description { - AttributedText(description) + HStack(spacing: Values.smallSpacing) { + Image("SessionHeading") + .resizable() + .renderingMode(.template) + .foregroundColor(themeColor: .textPrimary) + .scaledToFit() + .frame(width: 131, height: 18) + + SessionProBadge_SwiftUI(size: .medium, themeBackgroundColor: info.themeStyle.themeColor) + } + .padding(.top, Values.mediumSpacing) + .environment(\.layoutDirection, .leftToRight) + + if case .error(let message) = info.state { + HStack(spacing: Values.verySmallSpacing) { + Text(message) + Image(systemName: "exclamationmark.triangle") + } + .font(.Body.baseRegular) + .foregroundColor(themeColor: .warning) + .padding(.top, Values.mediumSpacing) + } + + if case .loading(let message) = info.state { + HStack(spacing: Values.verySmallSpacing) { + Text(message) + ProgressView() + .tint(themeColor: .textPrimary) + .controlSize(.regular) + .scaleEffect(0.8) + .frame(width: 16, height: 16) + } .font(.Body.baseRegular) .foregroundColor(themeColor: .textPrimary) - .multilineTextAlignment(.center) - .padding(.vertical, Values.largeSpacing) - } - - if case .error(let message) = info.state { - HStack(spacing: Values.verySmallSpacing) { - Text(message) - Image(systemName: "exclamationmark.triangle") + .padding(.top, Values.mediumSpacing) } - .font(.Body.baseRegular) - .foregroundColor(themeColor: .warning) - .padding(.top, Values.mediumSpacing) - } - - if case .loading(let message) = info.state { - HStack(spacing: Values.verySmallSpacing) { - Text(message) - ProgressView() - .tint(themeColor: .textPrimary) - .controlSize(.regular) - .scaleEffect(0.8) - .frame(width: 16, height: 16) + + if let description = info.description { + AttributedText(description) + .font(.Body.baseRegular) + .foregroundColor(themeColor: .textPrimary) + .multilineTextAlignment(.center) + .padding(.top, Values.mediumSpacing) + .padding(.bottom, Values.largeSpacing) } - .font(.Body.baseRegular) - .foregroundColor(themeColor: .textPrimary) - .padding(.top, Values.mediumSpacing) } + .padding(.vertical, info.glowingBackgroundStyle.verticalPaddings) } - .frame(maxWidth: .infinity, alignment: .center) + .padding(.top, Values.smallSpacing) + .frame(maxWidth: .infinity, alignment: .top) .contentShape(Rectangle()) } } diff --git a/SessionUIKit/Screens/SessionListScreen/SessionListScreen.swift b/SessionUIKit/Screens/SessionListScreen/SessionListScreen.swift index 5aecac0e4c..53d040a6de 100644 --- a/SessionUIKit/Screens/SessionListScreen/SessionListScreen.swift +++ b/SessionUIKit/Screens/SessionListScreen/SessionListScreen.swift @@ -1,9 +1,11 @@ // Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. import SwiftUI +import Combine public struct SessionListScreen: View { @EnvironmentObject var host: HostWrapper + @EnvironmentObject var toolbarManager: ToolbarManager @StateObject private var viewModel: ViewModel @ObservedObject private var state: SessionListScreenContent.ListItemDataState @State var isShowingTooltip: Bool = false @@ -18,14 +20,82 @@ public struct SessionListScreen = [] + @State private var navigationDestination: NavigationDestination? = nil + @State private var isNavigationActive: Bool = false + private let navigatableState: NavigatableState_SwiftUI? + private var navigationPublisher: AnyPublisher<(NavigationDestination, TransitionType), Never> { + navigatableState?.transitionToScreen ?? Empty().eraseToAnyPublisher() } + @ViewBuilder + private var destinationView: some View { + if let destination = navigationDestination { + destination + .view + .backgroundColor(themeColor: .backgroundPrimary) + .persistentCloseToolbar() + .environmentObject(toolbarManager) + } else { + EmptyView() + } + } + + // MARK: - Body + public var body: some View { + ZStack { + listContent + + // Hidden NavigationLink for publisher-driven navigation + NavigationLink( + destination: destinationView, + isActive: $isNavigationActive + ) { + EmptyView() + } + .hidden() + } + .onReceive(navigationPublisher) { destination, transitionType in + // Only handle push transitions in SwiftUI + // Present transitions are handled by UIKit in setupBindings + if transitionType == .push { + navigationDestination = destination + isNavigationActive = true + } + } + .onAppear { + if + let navigatableStateHolder = viewModel as? NavigatableStateHolder, + let viewController: UIViewController = self.host.controller, + viewController is BottomSheetIdentifiable + { + navigatableStateHolder.navigatableState.setupBindings( + viewController: viewController, + disposables: &disposables + ) + } + } + } + + private var listContent: some View { List { - ForEach(state.listItemData, id: \.model) { section in + ForEach(state.listItemData, id: \.model) { section in Section { // MARK: - Header @@ -114,8 +184,8 @@ public struct SessionListScreen Void)?, failure: (() -> Void)?) - func cancelPro(success: (() -> Void)?, failure: (() -> Void)?) - func requestRefund(success: (() -> Void)?, failure: (() -> Void)?) - func openURL(_ url: URL) + func purchase(planInfo: SessionProPlanInfo, success: (() -> Void)?, failure: (() -> Void)?) async + func cancelPro(success: (() -> Void)?, failure: (() -> Void)?) async + func requestRefund(success: (() -> Void)?, failure: (() -> Void)?) async } } diff --git a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+NoBillingAccess.swift b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+NoBillingAccess.swift new file mode 100644 index 0000000000..4df46963d3 --- /dev/null +++ b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+NoBillingAccess.swift @@ -0,0 +1,177 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import SwiftUI +import Lucide + +// MARK: - No Billing Access Content + +struct NoBillingAccessContent: View { + let isRenewingPro: Bool + let originatingPlatform: SessionProPaymentScreenContent.ClientPlatform + let openProRoadmapAction: (() -> Void)? + let openPlatformStoreWebsiteAction: (() -> Void)? + + public init( + isRenewingPro: Bool, + originatingPlatform: SessionProPaymentScreenContent.ClientPlatform, + openProRoadmapAction: (() -> Void)?, + openPlatformStoreWebsiteAction: (() -> Void)? = nil + ) { + self.isRenewingPro = isRenewingPro + self.originatingPlatform = originatingPlatform + self.openProRoadmapAction = openProRoadmapAction + self.openPlatformStoreWebsiteAction = openPlatformStoreWebsiteAction + } + + var approaches: [ApproachCell.Info] { + isRenewingPro ? + [ + ApproachCell.Info( + title: "onLinkedDevice".localized(), + description: "proRenewDesktopLinked" + .put(key: "app_name", value: Constants.app_name) + .put(key: "app_pro", value: Constants.app_pro) + .put(key: "platform_store", value: Constants.platform_store) + .put(key: "platform_store_other", value: Constants.android_platform_store) + .put(key: "pro", value: Constants.pro) + .localizedFormatted(), + variant: .link + ), + ApproachCell.Info( + title: "proNewInstallation".localized(), + description: "proNewInstallationDescription" + .put(key: "app_name", value: Constants.app_name) + .put(key: "platform_store", value: Constants.platform_store) + .put(key: "app_pro", value: Constants.app_pro) + .put(key: "pro", value: Constants.pro) + .localizedFormatted(), + variant: .device + ), + ApproachCell.Info( + title: "onPlatformWebsite" + .put(key: "platform", value: originatingPlatform.store) + .localized(), + description: "proAccessRenewPlatformWebsite" + .put(key: "platform_account", value: originatingPlatform.account) + .put(key: "platform", value: originatingPlatform.name) + .put(key: "pro", value: Constants.pro) + .localizedFormatted(Fonts.Body.baseRegular), + variant: .website + ) + ] : + [ + ApproachCell.Info( + title: "onLinkedDevice".localized(), + description: "proUpgradeDesktopLinked" + .put(key: "app_name", value: Constants.app_name) + .put(key: "app_pro", value: Constants.app_pro) + .put(key: "platform_store", value: Constants.platform_store) + .put(key: "platform_store_other", value: Constants.android_platform_store) + .put(key: "pro", value: Constants.pro) + .localizedFormatted(), + variant: .link + ), + ApproachCell.Info( + title: "proNewInstallation".localized(), + description: isRenewingPro ? + "proNewInstallationDescription" + .put(key: "app_name", value: Constants.app_name) + .put(key: "platform_store", value: Constants.platform_store) + .put(key: "app_pro", value: Constants.app_pro) + .put(key: "pro", value: Constants.pro) + .localizedFormatted() : + "proNewInstallationUpgrade" + .put(key: "app_name", value: Constants.app_name) + .put(key: "platform_store", value: Constants.platform_store) + .put(key: "app_pro", value: Constants.app_pro) + .put(key: "pro", value: Constants.pro) + .localizedFormatted(), + variant: .device + ) + ] + } + + var body: some View { + VStack(spacing: Values.mediumSpacing) { + VStack( + alignment: .leading, + spacing: Values.mediumSpacing + ) { + VStack( + alignment: .leading, + spacing: Values.verySmallSpacing + ) { + Text( + isRenewingPro ? + "renewingPro" + .put(key: "pro", value: Constants.pro) + .localized() : + "proUpgradingTo" + .put(key: "pro", value: Constants.pro) + .localized() + ) + .font(.Headings.H7) + .foregroundColor(themeColor: .textPrimary) + + AttributedText( + isRenewingPro ? + "proRenewingNoAccessBilling" + .put(key: "pro", value: Constants.pro) + .put(key: "platform_store", value: Constants.platform_store) + .put(key: "platform_store_other", value: Constants.android_platform_store) + .put(key: "app_name", value: Constants.app_name) + .put(key: "build_variant", value: Constants.IPA) + .put(key: "icon", value: Lucide.Icon.squareArrowUpRight) + .localizedFormatted(Fonts.Body.baseRegular) : + "proUpgradeNoAccessBilling" + .put(key: "pro", value: Constants.pro) + .put(key: "platform_store", value: Constants.platform_store) + .put(key: "platform_store_other", value: Constants.android_platform_store) + .put(key: "app_name", value: Constants.app_name) + .put(key: "build_variant", value: Constants.IPA) + .put(key: "icon", value: Lucide.Icon.squareArrowUpRight) + .localizedFormatted(Fonts.Body.baseRegular) + ) + .font(.Body.baseRegular) + .foregroundColor(themeColor: .textPrimary) + .onTapGesture { + self.openProRoadmapAction?() + } + } + + Text(isRenewingPro ? "proOptionsRenewalSubtitle".localized() : "proUpgradeOptionsTwo".localized()) + .font(.Body.baseRegular) + .foregroundColor(themeColor: .textSecondary) + + ForEach(approaches.indices, id: \.self) { index in + ApproachCell(info: approaches[index]) + } + } + .padding(Values.mediumSpacing) + .background( + RoundedRectangle(cornerRadius: 11) + .fill(themeColor: .backgroundSecondary) + ) + + if isRenewingPro { + Button { + openPlatformStoreWebsiteAction?() + } label: { + Text("openPlatformStoreWebsite".put(key: "platform_store", value: originatingPlatform.store).localized()) + .font(.Body.largeRegular) + .foregroundColor(themeColor: .sessionButton_primaryFilledText) + .framing( + maxWidth: .infinity, + height: 50, + alignment: .center + ) + .background( + RoundedRectangle(cornerRadius: 7) + .fill(themeColor: .sessionButton_primaryFilledBackground) + ) + .padding(.vertical, Values.smallSpacing) + } + } + } + } +} diff --git a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+Purchase.swift b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+Purchase.swift index 8b9fe60866..d0aa593d33 100644 --- a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+Purchase.swift +++ b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+Purchase.swift @@ -9,6 +9,7 @@ struct SessionProPlanPurchaseContent: View { @Binding var currentSelection: Int @Binding var isShowingTooltip: Bool @Binding var suppressUntil: Date + @Binding var isPendingPurchase: Bool let currentPlan: SessionProPaymentScreenContent.SessionProPlanInfo? let sessionProPlans: [SessionProPaymentScreenContent.SessionProPlanInfo] @@ -29,28 +30,38 @@ struct SessionProPlanPurchaseContent: View { index: index, isCurrentPlan: (sessionProPlans[index] == currentPlan) ) + .disabled(isPendingPurchase) } Button { - purchaseAction() + if !isPendingPurchase { + purchaseAction() + } } label: { - Text(actionButtonTitle) - .font(.Body.largeRegular) - .foregroundColor(themeColor: .sessionButton_primaryFilledText) - .framing( - maxWidth: .infinity, - height: 50, - alignment: .center - ) - .background( - RoundedRectangle(cornerRadius: 7) - .fill( - themeColor: (sessionProPlans[currentSelection] == currentPlan) ? - .disabled : - .sessionButton_primaryFilledBackground - ) - ) - .padding(.vertical, Values.smallSpacing) + ZStack { + if isPendingPurchase { + ProgressView() + .tint(themeColor: .sessionButton_primaryFilledText) + } else { + Text(actionButtonTitle) + .font(.Body.largeRegular) + .foregroundColor(themeColor: .sessionButton_primaryFilledText) + } + } + .framing( + maxWidth: .infinity, + height: 50, + alignment: .center + ) + .background( + RoundedRectangle(cornerRadius: 7) + .fill( + themeColor: (sessionProPlans[currentSelection] == currentPlan) ? + .disabled : + .sessionButton_primaryFilledBackground + ) + ) + .padding(.vertical, Values.smallSpacing) } .disabled((sessionProPlans[currentSelection] == currentPlan)) diff --git a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+Renew.swift b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+Renew.swift deleted file mode 100644 index 76b4ac91e9..0000000000 --- a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+Renew.swift +++ /dev/null @@ -1,108 +0,0 @@ -// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. - -import SwiftUI -import Lucide - -// MARK: - Renew Plan No Billing Access Content - -struct RenewPlanNoBillingAccessContent: View { - let originatingPlatform: SessionProPaymentScreenContent.ClientPlatform - let openPlatformStoreWebsiteAction: () -> Void - - var body: some View { - VStack(spacing: Values.mediumSpacing) { - VStack( - alignment: .leading, - spacing: Values.mediumSpacing - ) { - VStack( - alignment: .leading, - spacing: Values.verySmallSpacing - ) { - Text( - "renewingPro" - .put(key: "pro", value: Constants.pro) - .localized() - ) - .font(.Headings.H7) - .foregroundColor(themeColor: .textPrimary) - - AttributedText( - "proRenewingNoAccessBilling" - .put(key: "pro", value: Constants.pro) - .put(key: "platform_store", value: Constants.platform_store) - .put(key: "platform_store_other", value: Constants.android_platform_store) - .put(key: "app_name", value: Constants.app_name) - .put(key: "build_variant", value: Constants.IPA) - .put(key: "icon", value: Lucide.Icon.squareArrowUpRight) - .localizedFormatted(Fonts.Body.baseRegular) - ) - .font(.Body.baseRegular) - .foregroundColor(themeColor: .textPrimary) - } - - Text("proOptionsRenewalSubtitle".localized()) - .font(.Body.baseRegular) - .foregroundColor(themeColor: .textSecondary) - - ApproachCell( - title: "onLinkedDevice".localized(), - description: "proRenewDesktopLinked" - .put(key: "app_name", value: Constants.app_name) - .put(key: "app_pro", value: Constants.app_pro) - .put(key: "platform_store", value: Constants.platform_store) - .put(key: "platform_store_other", value: Constants.android_platform_store) - .put(key: "pro", value: Constants.pro) - .localizedFormatted(), - variant: .link - ) - - ApproachCell( - title: "proNewInstallation".localized(), - description: "proNewInstallationDescription" - .put(key: "app_name", value: Constants.app_name) - .put(key: "platform_store", value: Constants.platform_store) - .put(key: "app_pro", value: Constants.app_pro) - .put(key: "pro", value: Constants.pro) - .localizedFormatted(), - variant: .device - ) - - ApproachCell( - title: "onPlatformWebsite" - .put(key: "platform", value: originatingPlatform.store) - .localized(), - description: "proAccessRenewPlatformWebsite" - .put(key: "platform_account", value: originatingPlatform.account) - .put(key: "platform", value: originatingPlatform.name) - .put(key: "pro", value: Constants.pro) - .localizedFormatted(Fonts.Body.baseRegular), - variant: .website - ) - } - .padding(Values.mediumSpacing) - .background( - RoundedRectangle(cornerRadius: 11) - .fill(themeColor: .backgroundSecondary) - ) - - Button { - openPlatformStoreWebsiteAction() - } label: { - Text("openPlatformStoreWebsite".put(key: "platform_store", value: originatingPlatform.store).localized()) - .font(.Body.largeRegular) - .foregroundColor(themeColor: .sessionButton_primaryFilledText) - .framing( - maxWidth: .infinity, - height: 50, - alignment: .center - ) - .background( - RoundedRectangle(cornerRadius: 7) - .fill(themeColor: .sessionButton_primaryFilledBackground) - ) - .padding(.vertical, Values.smallSpacing) - } - } - } -} diff --git a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+RequestRefund.swift b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+RequestRefund.swift index 9bb0ecdd6d..be50d816f5 100644 --- a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+RequestRefund.swift +++ b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+RequestRefund.swift @@ -208,29 +208,33 @@ struct RequestRefundNonOriginatorContent: View { .foregroundColor(themeColor: .textSecondary) ApproachCell( - title: "onDevice" - .put(key: "device_type", value: originatingPlatform.deviceType) - .localized(), - description: "onDeviceDescription" - .put(key: "app_name", value: Constants.app_name) - .put(key: "device_type", value: originatingPlatform.deviceType) - .put(key: "platform_account", value: originatingPlatform.account) - .put(key: "app_pro", value: Constants.app_pro) - .put(key: "pro", value: Constants.pro) - .localizedFormatted(), - variant: .device + info: .init( + title: "onDevice" + .put(key: "device_type", value: originatingPlatform.deviceType) + .localized(), + description: "onDeviceDescription" + .put(key: "app_name", value: Constants.app_name) + .put(key: "device_type", value: originatingPlatform.deviceType) + .put(key: "platform_account", value: originatingPlatform.account) + .put(key: "app_pro", value: Constants.app_pro) + .put(key: "pro", value: Constants.pro) + .localizedFormatted(), + variant: .device + ) ) ApproachCell( - title: "onPlatformWebsite" - .put(key: "platform", value: originatingPlatform.name) - .localized(), - description: "viaStoreWebsiteDescription" - .put(key: "platform_account", value: originatingPlatform.account) - .put(key: "platform_store", value: originatingPlatform.store) - .put(key: "pro", value: Constants.pro) - .localizedFormatted(Fonts.Body.baseRegular), - variant: .website + info: .init( + title: "onPlatformWebsite" + .put(key: "platform", value: originatingPlatform.name) + .localized(), + description: "viaStoreWebsiteDescription" + .put(key: "platform_account", value: originatingPlatform.account) + .put(key: "platform_store", value: originatingPlatform.store) + .put(key: "pro", value: Constants.pro) + .localizedFormatted(Fonts.Body.baseRegular), + variant: .website + ) ) } else { VStack( diff --git a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+SharedViews.swift b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+SharedViews.swift index d254337df9..2d3de212ae 100644 --- a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+SharedViews.swift +++ b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+SharedViews.swift @@ -74,10 +74,16 @@ struct PlanCell: View { .background( RoundedRectangle(cornerRadius: 12) .fill(themeColor: .backgroundSecondary) + .shadow( + color: .black.opacity(0.4), + radius: 4, + x: 2, + y: 3 + ) ) .overlay( RoundedRectangle(cornerRadius: 12) - .stroke(themeColor: isSelected ? .sessionButton_primaryFilledBackground : .borderSeparator) + .stroke(themeColor: isSelected ? .sessionButton_text : .borderSeparator) ) .padding(.top, Values.smallSpacing) .contentShape(Rectangle()) @@ -156,16 +162,24 @@ struct ApproachCell: View { } } - let title: String - let description: ThemedAttributedString - let variant: Variant - let action: (() -> Void)? + struct Info { + let title: String + let description: ThemedAttributedString + let variant: Variant + let action: (() -> Void)? + + public init(title: String, description: ThemedAttributedString, variant: Variant, action: (() -> Void)? = nil) { + self.title = title + self.description = description + self.variant = variant + self.action = action + } + } + + let info: Info - init(title: String, description: ThemedAttributedString, variant: Variant, action: (() -> Void)? = nil) { - self.title = title - self.description = description - self.variant = variant - self.action = action + init(info: Info) { + self.info = info } var body: some View { @@ -177,7 +191,7 @@ struct ApproachCell: View { RoundedRectangle(cornerRadius: 4) .fill(themeColor: .value(.primary, alpha: 0.1)) - AttributedText(variant.icon.attributedString(size: 24)) + AttributedText(info.variant.icon.attributedString(size: 24)) .foregroundColor(themeColor: .primary) } .frame(width: 34, height: 34) @@ -186,17 +200,21 @@ struct ApproachCell: View { alignment: .leading, spacing: Values.verySmallSpacing ) { - Text(title) + Text(info.title) .font(.Body.baseBold) .foregroundColor(themeColor: .textPrimary) - AttributedText(description) + AttributedText(info.description) .font(.Body.baseRegular) .foregroundColor(themeColor: .textPrimary) .multilineTextAlignment(.leading) } } .padding(Values.mediumSpacing) + .frame( + maxWidth: .infinity, + alignment: .leading + ) .background( RoundedRectangle(cornerRadius: 11) .fill(themeColor: .inputButton_background) @@ -205,6 +223,6 @@ struct ApproachCell: View { RoundedRectangle(cornerRadius: 11) .stroke(themeColor: .borderSeparator) ) - .onTapGesture { action?() } + .onTapGesture { info.action?() } } } diff --git a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+UpdatePlan.swift b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+UpdatePlan.swift index 68e2dcdfde..889704922b 100644 --- a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+UpdatePlan.swift +++ b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+UpdatePlan.swift @@ -52,29 +52,33 @@ struct UpdatePlanNonOriginatingPlatformContent: View { .foregroundColor(themeColor: .textSecondary) ApproachCell( - title: "onDevice" - .put(key: "device_type", value: originatingPlatform.deviceType) - .localized(), - description: "onDeviceDescription" - .put(key: "app_name", value: Constants.app_name) - .put(key: "device_type", value: originatingPlatform.deviceType) - .put(key: "platform_account", value: originatingPlatform.account) - .put(key: "app_pro", value: Constants.app_pro) - .put(key: "pro", value: Constants.pro) - .localizedFormatted(Fonts.Body.baseRegular), - variant: .device + info: .init( + title: "onDevice" + .put(key: "device_type", value: originatingPlatform.deviceType) + .localized(), + description: "onDeviceDescription" + .put(key: "app_name", value: Constants.app_name) + .put(key: "device_type", value: originatingPlatform.deviceType) + .put(key: "platform_account", value: originatingPlatform.account) + .put(key: "app_pro", value: Constants.app_pro) + .put(key: "pro", value: Constants.pro) + .localizedFormatted(Fonts.Body.baseRegular), + variant: .device + ) ) ApproachCell( - title: "viaStoreWebsite" - .put(key: "platform", value: originatingPlatform.name) - .localized(), - description: "viaStoreWebsiteDescription" - .put(key: "platform_account", value: originatingPlatform.account) - .put(key: "platform_store", value: originatingPlatform.store) - .put(key: "pro", value: Constants.pro) - .localizedFormatted(Fonts.Body.baseRegular), - variant: .website + info: .init( + title: "viaStoreWebsite" + .put(key: "platform", value: originatingPlatform.name) + .localized(), + description: "viaStoreWebsiteDescription" + .put(key: "platform_account", value: originatingPlatform.account) + .put(key: "platform_store", value: originatingPlatform.store) + .put(key: "pro", value: Constants.pro) + .localizedFormatted(Fonts.Body.baseRegular), + variant: .website + ) ) } .padding(Values.mediumSpacing) diff --git a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen.swift b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen.swift index 159f91e47c..cef3b01067 100644 --- a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen.swift +++ b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen.swift @@ -5,8 +5,11 @@ import Lucide public struct SessionProPaymentScreen: View { @EnvironmentObject var host: HostWrapper + @EnvironmentObject var toolbarManager: ToolbarManager + @State private var isNavigationActive: Bool = false @State var currentSelection: Int @State private var isShowingTooltip: Bool = false + @State var isPendingPurchase: Bool = false /// There is an issue on `.onAnyInteraction` of the List and `.onTapGuesture` of the TooltipsIcon. The `.onAnyInteraction` will be called first when tapping the TooltipsIcon to dismiss a tooltip. /// This will result in the tooltip will show again right after it dismissed when tapping the TooltipsIcon. This `suppressUntil` is a workaround to fix this issue. @@ -20,7 +23,7 @@ public struct SessionProPaymentScreen: View { public init(viewModel: SessionProPaymentScreenContent.ViewModelType) { self.viewModel = viewModel if - case .update(let currentPlan, _, _, _) = viewModel.dataModel.flow, + case .update(let currentPlan, _, _, _, _, _) = viewModel.dataModel.flow, let indexOfCurrentPlan = viewModel.dataModel.plans.firstIndex(of: currentPlan) { self.currentSelection = indexOfCurrentPlan @@ -32,144 +35,27 @@ public struct SessionProPaymentScreen: View { public var body: some View { GeometryReader { geometry in ScrollView(.vertical, showsIndicators: false) { - VStack(spacing: Values.mediumSmallSpacing) { - ListItemLogoWithPro( - info: .init( - style: { - switch viewModel.dataModel.flow { - case .refund, .cancel: return .disabled - default: return .normal - } - }(), - state: .success(description: viewModel.dataModel.flow.description) + ZStack(alignment: .topLeading) { + content + .padding(.horizontal, Values.largeSpacing) + .frame( + maxWidth: .infinity, + minHeight: geometry.size.height ) - ) - - // Content - switch viewModel.dataModel.flow { - case .purchase: - SessionProPlanPurchaseContent( - currentSelection: $currentSelection, - isShowingTooltip: $isShowingTooltip, - suppressUntil: $suppressUntil, - currentPlan: nil, - sessionProPlans: viewModel.dataModel.plans, - actionButtonTitle: "upgrade".localized(), - actionType: "proUpgradingAction".localized(), - activationType: "proActivatingActivation".localized(), - purchaseAction: { updatePlan() }, - openTosPrivacyAction: { openTosPrivacy() } - ) - - case .renew(let originatingPlatform): - if viewModel.dataModel.plans.isEmpty { - RenewPlanNoBillingAccessContent( - originatingPlatform: originatingPlatform, - openPlatformStoreWebsiteAction: { openPlatformStoreWebsite() } - ) - } else { - SessionProPlanPurchaseContent( - currentSelection: $currentSelection, - isShowingTooltip: $isShowingTooltip, - suppressUntil: $suppressUntil, - currentPlan: nil, - sessionProPlans: viewModel.dataModel.plans, - actionButtonTitle: "renew".localized(), - actionType: "proRenewingAction".localized(), - activationType: "proReactivatingActivation".localized(), - purchaseAction: { updatePlan() }, - openTosPrivacyAction: { openTosPrivacy() } - ) + .onAnyInteraction(scrollCoordinateSpaceName: coordinateSpaceName) { + guard self.isShowingTooltip else { return } + suppressUntil = Date().addingTimeInterval(0.2) + withAnimation(.spring()) { + self.isShowingTooltip = false } - - case .update(let currentPlan, let expiredOn, let isAutoRenewing, let originatingPlatform): - if viewModel.dataModel.plans.isEmpty || originatingPlatform != .iOS { - UpdatePlanNonOriginatingPlatformContent( - currentPlan: currentPlan, - currentPlanExpiredOn: expiredOn, - isAutoRenewing: isAutoRenewing, - originatingPlatform: originatingPlatform, - openPlatformStoreWebsiteAction: { openPlatformStoreWebsite() } - ) - } else { - SessionProPlanPurchaseContent( - currentSelection: $currentSelection, - isShowingTooltip: $isShowingTooltip, - suppressUntil: $suppressUntil, - currentPlan: currentPlan, - sessionProPlans: viewModel.dataModel.plans, - actionButtonTitle: "updateAccess".put(key: "pro", value: Constants.pro).localized(), - actionType: "proUpdatingAction".localized(), - activationType: "", - purchaseAction: { updatePlan() }, - openTosPrivacyAction: { openTosPrivacy() } - ) - } - case .refund(let originatingPlatform, let isNonOriginatingAccount, let requestedAt): - if originatingPlatform == .iOS && isNonOriginatingAccount != true { - RequestRefundOriginatingPlatformContent( - requestRefundAction: { - viewModel.requestRefund( - success: { - host.controller?.navigationController?.popViewController(animated: true) - }, - failure: { - // TODO: [PRO] Request refund failure behaviour - } - ) - } - ) - } else { - RequestRefundNonOriginatorContent( - originatingPlatform: originatingPlatform, - isNonOriginatingAccount: isNonOriginatingAccount, - requestedAt: requestedAt, - openPlatformStoreWebsiteAction: { openPlatformStoreWebsite() } - ) - } - - case .cancel(let originatingPlatform): - if originatingPlatform == .iOS { - CancelPlanOriginatingPlatformContent( - cancelPlanAction: { - viewModel.cancelPro( - success: { - host.controller?.navigationController?.popViewController(animated: true) - }, - failure: { - // TODO: [PRO] Payment failure behaviour - } - ) - } - ) - } else { - CancelPlanNonOriginatingPlatformContent( - originatingPlatform: originatingPlatform, - openPlatformStoreWebsiteAction: { openPlatformStoreWebsite() } - ) - } - } - - Spacer() - } - .padding(.horizontal, Values.largeSpacing) - .frame( - maxWidth: .infinity, - minHeight: geometry.size.height - ) - .onAnyInteraction(scrollCoordinateSpaceName: coordinateSpaceName) { - guard self.isShowingTooltip else { return } - suppressUntil = Date().addingTimeInterval(0.2) - withAnimation(.spring()) { - self.isShowingTooltip = false - } + } } } .coordinateSpace(name: coordinateSpaceName) .popoverView( content: { ZStack { - if case .update(let currentPlan, _, _, _) = viewModel.dataModel.flow, let discountPercent = currentPlan.discountPercent { + if case .update(let currentPlan, _, _, _, _, _) = viewModel.dataModel.flow, let discountPercent = currentPlan.discountPercent { Text( "proDiscountTooltip" .put(key: "percent", value: discountPercent) @@ -195,56 +81,226 @@ public struct SessionProPaymentScreen: View { } } - private func updatePlan() { - let updatedPlan = viewModel.dataModel.plans[currentSelection] - if - case .update(let currentPlan, let expiredOn, let isAutoRenewing, _) = viewModel.dataModel.flow, - let updatedPlanExpiredOn = Calendar.current.date(byAdding: .month, value: updatedPlan.duration, to: expiredOn) - { - let confirmationModal = ConfirmationModal( + private var content: some View { + VStack(spacing: Values.mediumSmallSpacing) { + ListItemLogoWithPro( info: .init( - title: "updateAccess" - .put(key: "pro", value: Constants.pro) - .localized(), - body: .attributedText( - isAutoRenewing ? - "proUpdateAccessDescription" - .put(key: "current_plan_length", value: currentPlan.durationString) - .put(key: "selected_plan_length", value: updatedPlan.durationString) - .put(key: "selected_plan_length_singular", value: updatedPlan.durationStringSingular) - .put(key: "date", value: expiredOn.formatted("MMM dd, yyyy")) - .put(key: "pro", value: Constants.pro) - .localizedFormatted(Fonts.Body.largeRegular) : - "proUpdateAccessExpireDescription" - .put(key: "date", value: expiredOn.formatted("MMM dd, yyyy")) - .put(key: "selected_plan_length", value: updatedPlan.durationString) - .put(key: "pro", value: Constants.pro) - .localizedFormatted(Fonts.Body.largeRegular), - scrollMode: .never - ), - confirmTitle: "update".localized(), - onConfirm: { _ in - self.viewModel.purchase( - planInfo: updatedPlan, - success: { onPaymentSuccess(expiredOn: updatedPlanExpiredOn) }, - failure: { - // TODO: Payment failure behaviour + themeStyle: { + switch viewModel.dataModel.flow { + case .refund, .cancel: return .disabled + default: return .normal + } + }(), + glowingBackgroundStyle: .base, + state: .success, + description: viewModel.dataModel.flow.description + ) + ) + + switch viewModel.dataModel.flow { + case .purchase(let billingAccess): + if billingAccess { + SessionProPlanPurchaseContent( + currentSelection: $currentSelection, + isShowingTooltip: $isShowingTooltip, + suppressUntil: $suppressUntil, + isPendingPurchase: $isPendingPurchase, + currentPlan: nil, + sessionProPlans: viewModel.dataModel.plans, + actionButtonTitle: "upgrade".localized(), + actionType: "proUpgradingAction".localized(), + activationType: "proActivatingActivation".localized(), + purchaseAction: { updatePlan() }, + openTosPrivacyAction: { openTosPrivacy() } + ) + } else { + NoBillingAccessContent( + isRenewingPro: false, + originatingPlatform: .iOS, + openProRoadmapAction: { openUrl(Constants.session_pro_roadmap) } + ) + } + + case .renew(let originatingPlatform, let billingAccess): + if billingAccess { + SessionProPlanPurchaseContent( + currentSelection: $currentSelection, + isShowingTooltip: $isShowingTooltip, + suppressUntil: $suppressUntil, + isPendingPurchase: $isPendingPurchase, + currentPlan: nil, + sessionProPlans: viewModel.dataModel.plans, + actionButtonTitle: "renew".localized(), + actionType: "proRenewingAction".localized(), + activationType: "proReactivatingActivation".localized(), + purchaseAction: { updatePlan() }, + openTosPrivacyAction: { openTosPrivacy() } + ) + } else { + NoBillingAccessContent( + isRenewingPro: true, + originatingPlatform: originatingPlatform, + openProRoadmapAction: { openUrl(Constants.session_pro_roadmap) }, + openPlatformStoreWebsiteAction: { openUrl(Constants.apple_store_subscriptions_url) } + ) + } + + case .update(let currentPlan, let expiredOn, let isAutoRenewing, let originatingPlatform, let isNonOriginatingAccount, let billingAccess): + if originatingPlatform != .iOS || isNonOriginatingAccount == true { + UpdatePlanNonOriginatingPlatformContent( + currentPlan: currentPlan, + currentPlanExpiredOn: expiredOn, + isAutoRenewing: isAutoRenewing, + originatingPlatform: originatingPlatform, + openPlatformStoreWebsiteAction: { openUrl(Constants.google_play_store_subscriptions_url) } + ) + } else { + if billingAccess { + SessionProPlanPurchaseContent( + currentSelection: $currentSelection, + isShowingTooltip: $isShowingTooltip, + suppressUntil: $suppressUntil, + isPendingPurchase: $isPendingPurchase, + currentPlan: currentPlan, + sessionProPlans: viewModel.dataModel.plans, + actionButtonTitle: "updateAccess".put(key: "pro", value: Constants.pro).localized(), + actionType: "proUpdatingAction".localized(), + activationType: "", + purchaseAction: { updatePlan() }, + openTosPrivacyAction: { openTosPrivacy() } + ) + } else { + NoBillingAccessContent( + isRenewingPro: false, + originatingPlatform: originatingPlatform, + openProRoadmapAction: { openUrl(Constants.session_pro_roadmap) } + ) + } + } + + case .refund(let originatingPlatform, let isNonOriginatingAccount, let requestedAt): + if originatingPlatform == .iOS && isNonOriginatingAccount != true { + RequestRefundOriginatingPlatformContent( + requestRefundAction: { + Task { + await viewModel.requestRefund( + success: { + DispatchQueue.main.async { + host.controller?.navigationController?.popViewController(animated: true) + } + }, + failure: { + // TODO: [PRO] Request refund failure behaviour + } + ) + } + } + ) + } else { + RequestRefundNonOriginatorContent( + originatingPlatform: originatingPlatform, + isNonOriginatingAccount: isNonOriginatingAccount, + requestedAt: requestedAt, + openPlatformStoreWebsiteAction: { + openUrl( + isNonOriginatingAccount == true ? + Constants.app_store_refund_support : + Constants.google_play_store_subscriptions_url + ) } ) } - ) - ) - self.host.controller?.present(confirmationModal, animated: true) + + case .cancel(let originatingPlatform): + if originatingPlatform == .iOS { + CancelPlanOriginatingPlatformContent( + cancelPlanAction: { + Task { + await viewModel.cancelPro( + success: { + DispatchQueue.main.async { + host.controller?.navigationController?.popViewController(animated: true) + } + }, + failure: { + // TODO: [PRO] Failed to cancel plan + } + ) + } + } + ) + } else { + CancelPlanNonOriginatingPlatformContent( + originatingPlatform: originatingPlatform, + openPlatformStoreWebsiteAction: { openUrl(Constants.google_play_store_subscriptions_url) } + ) + } + } } - + } + + private func updatePlan() { + isPendingPurchase = true + let updatedPlan = viewModel.dataModel.plans[currentSelection] switch viewModel.dataModel.flow { + case .update(let currentPlan, let expiredOn, let isAutoRenewing, _, _, _): + if let updatedPlanExpiredOn = Calendar.current.date(byAdding: .month, value: updatedPlan.duration, to: expiredOn) { + let confirmationModal = ConfirmationModal( + info: .init( + title: "updateAccess" + .put(key: "pro", value: Constants.pro) + .localized(), + body: .attributedText( + isAutoRenewing ? + "proUpdateAccessDescription" + .put(key: "current_plan_length", value: currentPlan.durationString) + .put(key: "selected_plan_length", value: updatedPlan.durationString) + .put(key: "selected_plan_length_singular", value: updatedPlan.durationStringSingular) + .put(key: "date", value: expiredOn.formatted("MMM dd, yyyy")) + .put(key: "pro", value: Constants.pro) + .localizedFormatted(Fonts.Body.largeRegular) : + "proUpdateAccessExpireDescription" + .put(key: "date", value: expiredOn.formatted("MMM dd, yyyy")) + .put(key: "selected_plan_length", value: updatedPlan.durationString) + .put(key: "pro", value: Constants.pro) + .localizedFormatted(Fonts.Body.largeRegular), + scrollMode: .never + ), + confirmTitle: "update".localized(), + onConfirm: { _ in + Task { + await viewModel.purchase( + planInfo: updatedPlan, + success: { + Task { @MainActor in + onPaymentSuccess(expiredOn: updatedPlanExpiredOn) + } + }, + failure: { + Task { @MainActor in + onPaymentFailed() + } + } + ) + } + } + ) + ) + self.host.controller?.present(confirmationModal, animated: true) + } case .purchase, .renew: - if let updatedPlanExpiredOn = Calendar.current.date(byAdding: .month, value: updatedPlan.duration, to: Date()) { - self.viewModel.purchase( + Task { + await viewModel.purchase( planInfo: updatedPlan, - success: { onPaymentSuccess(expiredOn: updatedPlanExpiredOn) }, + success: { + Task { @MainActor in + onPaymentSuccess(expiredOn: nil) + } + }, failure: { - // TODO: Payment failure behaviour + Task { @MainActor in + onPaymentFailed() + } } ) } @@ -252,16 +308,68 @@ public struct SessionProPaymentScreen: View { } } - private func onPaymentSuccess(expiredOn: Date) { + @MainActor private func onPaymentSuccess(expiredOn: Date?) { + isPendingPurchase = false + guard !self.viewModel.isFromBottomSheet else { + let sessionProBottomSheet: BottomSheetHostingViewController = BottomSheetHostingViewController( + bottomSheet: BottomSheet( + hasCloseButton: true, + contentPrefferedHeight: 480 + ) { + SessionProPlanUpdatedScreen( + flow: self.viewModel.dataModel.flow, + expiredOn: expiredOn, + isFromBottomSheet: true + ) + .backgroundColor(themeColor: .backgroundPrimary) + } + ) + self.host.controller?.dismiss(animated: false) + self.host.controller?.presentingViewController?.present(sessionProBottomSheet, animated: true) + return + } + let viewController: SessionHostingViewController = SessionHostingViewController( rootView: SessionProPlanUpdatedScreen( flow: self.viewModel.dataModel.flow, - expiredOn: expiredOn + expiredOn: expiredOn, + isFromBottomSheet: false ) ) viewController.modalTransitionStyle = .crossDissolve viewController.modalPresentationStyle = .overFullScreen self.host.controller?.present(viewController, animated: true) + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250)) { + self.host.controller?.navigationController?.popViewController(animated: false) + } + } + + @MainActor private func onPaymentFailed() { + isPendingPurchase = false + let modal: ConfirmationModal = ConfirmationModal( + info: ConfirmationModal.Info( + title: "urlOpen".localized(), + body: .attributedText( + "paymentProError" + .put(key: "action_type", value: "proUpdatingAction".localized()) + .put(key: "pro", value: Constants.pro) + .localizedFormatted(baseFont: .systemFont(ofSize: Values.smallFontSize)), + scrollMode: .automatic + ), + confirmTitle: "retry".localized(), + confirmStyle: .alert_text, + cancelTitle: "helpSupport".localized(), + cancelStyle: .alert_text, + onConfirm: { _ in + // TODO: [PRO] Retry connecting to Pro backend + }, + onCancel: { _ in + self.openUrl(Constants.session_pro_support_url) + } + ) + ) + + self.host.controller?.present(modal, animated: true) } private func openTosPrivacy() { @@ -272,15 +380,17 @@ public struct SessionProPaymentScreen: View { Constants.session_pro_privacy_url ], openURL: { url in - viewModel.openURL(url) + if let extensionContext = self.host.controller?.extensionContext { + extensionContext.open(url, completionHandler: nil) + } } ) ) self.host.controller?.present(modal, animated: true) } - private func openPlatformStoreWebsite() { - guard let url: URL = URL(string: Constants.google_play_store_subscriptions_url) else { return } + private func openUrl(_ urlString: String) { + guard let url: URL = URL(string: urlString) else { return } let modal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( @@ -295,7 +405,11 @@ public struct SessionProPaymentScreen: View { confirmStyle: .danger, cancelTitle: "urlCopy".localized(), cancelStyle: .alert_text, - onConfirm: { _ in viewModel.openURL(url) }, + onConfirm: { _ in + if let extensionContext = self.host.controller?.extensionContext { + extensionContext.open(url, completionHandler: nil) + } + }, onCancel: { modal in UIPasteboard.general.string = url.absoluteString modal.close() diff --git a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPlanUpdatedScreen.swift b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPlanUpdatedScreen.swift index 6e8ce5dc83..ac94bdcb42 100644 --- a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPlanUpdatedScreen.swift +++ b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPlanUpdatedScreen.swift @@ -6,12 +6,14 @@ import Lucide public struct SessionProPlanUpdatedScreen: View { @EnvironmentObject var host: HostWrapper let flow: SessionProPaymentScreenContent.SessionProPlanPaymentFlow - let expiredOn: Date - var blurSize: CGFloat { UIScreen.main.bounds.width - 2 * Values.mediumSpacing } + let expiredOn: Date? + let isFromBottomSheet: Bool + var blurSizeWidth: CGFloat { UIScreen.main.bounds.width - 2 * Values.mediumSpacing } + var blurSizeHeight: CGFloat { isFromBottomSheet ? 111 : blurSizeWidth } var dismissButtonTitle: String { switch flow { case .purchase, .renew: - "Start Using Pro" + "proStartUsing".put(key: "pro", value: Constants.pro).localized() case .update: "theReturn".localized() default: @@ -20,14 +22,20 @@ public struct SessionProPlanUpdatedScreen: View { } var desription: ThemedAttributedString { switch flow { - case .update(let currentPlan, let expiredOn, let isAutoRenewing, let originatingPlatform): - "proAllSetDescription" + case .update: + guard let expiredOn else { fallthrough } + return "proAllSetDescription" .put(key: "app_pro", value: Constants.app_pro) .put(key: "pro", value: Constants.pro) .put(key: "date", value: expiredOn.formatted("MMM dd, yyyy")) .localizedFormatted(Fonts.Body.baseRegular) case .renew: - "proPlanRenewSupport" + return "proPlanRenewSupport" + .put(key: "app_pro", value: Constants.app_pro) + .put(key: "network_name", value: Constants.network_name) + .localizedFormatted(Fonts.Body.baseRegular) + case .purchase: + return "proUpgraded" .put(key: "app_pro", value: Constants.app_pro) .put(key: "network_name", value: Constants.network_name) .localizedFormatted(Fonts.Body.baseRegular) @@ -39,9 +47,9 @@ public struct SessionProPlanUpdatedScreen: View { ZStack(alignment: .top) { Ellipse() .fill(themeColor: .settings_glowingBackground) - .frame(width: blurSize, height: blurSize) + .frame(width: blurSizeWidth, height: blurSizeHeight) .shadow(radius: 20) - .opacity(0.17) + .opacity(0.15) .blur(radius: 30) VStack(spacing: Values.mediumSpacing) { @@ -93,7 +101,7 @@ public struct SessionProPlanUpdatedScreen: View { .padding(.vertical, Values.smallSpacing) } .padding(.horizontal, Values.mediumSpacing) - .padding(.vertical, (blurSize - 111) / 2) + .padding(.vertical, (blurSizeHeight - 111) / 2) } } } diff --git a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProSettings+ProFeatures.swift b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProSettings+ProFeatures.swift new file mode 100644 index 0000000000..8a63d714ab --- /dev/null +++ b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProSettings+ProFeatures.swift @@ -0,0 +1,71 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import DifferenceKit +import Lucide + +// MARK: - Pro Features Info + +public struct ProFeaturesInfo { + public enum ProState { + case none + case expired + case active + } + + public let icon: UIImage? + public let backgroundColors: [ThemeValue] + public let title: String + public let description: ThemedAttributedString + public let accessory: SessionListScreenContent.TextInfo.Accessory + + public static func allCases(proState: ProState) -> [ProFeaturesInfo] { + return [ + ProFeaturesInfo( + icon: Lucide.image(icon: .messageSquare, size: IconSize.medium.size), + backgroundColors: (proState == .expired) ? [ThemeValue.disabled] : [.explicitPrimary(.blue), .explicitPrimary(.purple)], + title: "proLongerMessages".localized(), + description: ( + proState == .none ? + "nonProLongerMessagesDescription".localizedFormatted(baseFont: Fonts.Body.smallRegular) : + "proLongerMessagesDescription".localizedFormatted(baseFont: Fonts.Body.smallRegular) + ), + accessory: .none + ), + ProFeaturesInfo( + icon: Lucide.image(icon: .pin, size: IconSize.medium.size), + backgroundColors: (proState == .expired) ? [ThemeValue.disabled] : [.explicitPrimary(.purple), .explicitPrimary(.pink)], + title: "proUnlimitedPins".localized(), + description: "proUnlimitedPinsDescription".localizedFormatted(baseFont: Fonts.Body.smallRegular), + accessory: .none + ), + ProFeaturesInfo( + icon: Lucide.image(icon: .squarePlay, size: IconSize.medium.size), + backgroundColors: (proState == .expired) ? [ThemeValue.disabled] : [.explicitPrimary(.pink), .explicitPrimary(.red)], + title: "proAnimatedDisplayPictures".localized(), + description: "proAnimatedDisplayPicturesDescription".localizedFormatted(baseFont: Fonts.Body.smallRegular), + accessory: .none + ), + ProFeaturesInfo( + icon: Lucide.image(icon: .rectangleEllipsis, size: IconSize.medium.size), + backgroundColors: (proState == .expired) ? [ThemeValue.disabled] : [.explicitPrimary(.red), .explicitPrimary(.orange)], + title: "proBadges".localized(), + description: "proBadgesDescription".put(key: "app_name", value: Constants.app_name).localizedFormatted(Fonts.Body.smallRegular), + accessory: .proBadgeLeading(themeBackgroundColor: (proState == .expired) ? .disabled : .primary) + ) + ] + } + + public static func plusMoreFeatureInfo(proState: ProState) -> ProFeaturesInfo { + ProFeaturesInfo( + icon: Lucide.image(icon: .circlePlus, size: IconSize.medium.size), + backgroundColors: (proState == .expired) ? [ThemeValue.disabled] : [.explicitPrimary(.orange), .explicitPrimary(.yellow)], + title: "plusLoadsMore".localized(), + description: "plusLoadsMoreDescription" + .put(key: "pro", value: Constants.pro) + .put(key: "icon", value: Lucide.Icon.squareArrowUpRight) + .localizedFormatted(Fonts.Body.smallRegular), + accessory: .proBadgeLeading(themeBackgroundColor: (proState == .expired) ? .disabled : .primary) + ) + } +} diff --git a/SessionUIKit/Style Guide/Constants+Apple.swift b/SessionUIKit/Style Guide/Constants+Apple.swift index 7bde18fc4d..37ad7a68f4 100644 --- a/SessionUIKit/Style Guide/Constants+Apple.swift +++ b/SessionUIKit/Style Guide/Constants+Apple.swift @@ -16,6 +16,7 @@ public extension Constants { static let session_feedback_url = "https://getsession.org/feedback" static let app_store_refund_support = "https://support.apple.com/118224" static let google_play_store_subscriptions_url = "https://play.google.com/store/account/subscriptions?package=network.loki.messenger" + static let apple_store_subscriptions_url = "https://apps.apple.com/account/subscriptions" static let session_pro_terms_url = "https://getsession.org/pro/terms" static let session_pro_privacy_url = "https://getsession.org/pro/privacy" diff --git a/Session/Shared/Types/DismissType.swift b/SessionUIKit/Types/DismissType.swift similarity index 100% rename from Session/Shared/Types/DismissType.swift rename to SessionUIKit/Types/DismissType.swift diff --git a/Session/Shared/Types/NavigatableState.swift b/SessionUIKit/Types/NavigatableState.swift similarity index 81% rename from Session/Shared/Types/NavigatableState.swift rename to SessionUIKit/Types/NavigatableState.swift index 97aed06d64..707d9c9aa0 100644 --- a/Session/Shared/Types/NavigatableState.swift +++ b/SessionUIKit/Types/NavigatableState.swift @@ -1,10 +1,48 @@ // Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. import UIKit +import SwiftUI import Combine -import SessionUIKit -import SessionUtilitiesKit -import SignalUtilitiesKit + +// MARK: - SwiftUI NavigationDestination + +struct NavigationDestination: Identifiable { + public let id = UUID() + public let view: AnyView + + public init(_ view: V) { + self.view = AnyView(view) + } +} + +// MARK: - SwiftUI NavigatableStateHolder + +public protocol NavigatableStateHolder_SwiftUI { + var navigatableStateSwiftUI: NavigatableState_SwiftUI { get } +} + +public extension NavigatableStateHolder_SwiftUI { + func transitionToScreen(_ view: V, transitionType: TransitionType = .push) { + navigatableStateSwiftUI._transitionToScreen.send((NavigationDestination(view), transitionType)) + } +} + + +// MARK: - SwiftUI NavigatableState + +public struct NavigatableState_SwiftUI { + let transitionToScreen: AnyPublisher<(NavigationDestination, TransitionType), Never> + + // MARK: - Internal Variables + + fileprivate let _transitionToScreen: PassthroughSubject<(NavigationDestination, TransitionType), Never> = PassthroughSubject() + + // MARK: - Initialization + + public init() { + self.transitionToScreen = _transitionToScreen.shareReplay(0) + } +} // MARK: - NavigatableStateHolder @@ -33,9 +71,9 @@ public extension NavigatableStateHolder { // MARK: - NavigatableState public struct NavigatableState { - let showToast: AnyPublisher<(ThemedAttributedString, ThemeValue, CGFloat), Never> - let transitionToScreen: AnyPublisher<(UIViewController, TransitionType), Never> - let dismissScreen: AnyPublisher<(DismissType, (() -> Void)?), Never> + public let showToast: AnyPublisher<(ThemedAttributedString, ThemeValue, CGFloat), Never> + public let transitionToScreen: AnyPublisher<(UIViewController, TransitionType), Never> + public let dismissScreen: AnyPublisher<(DismissType, (() -> Void)?), Never> // MARK: - Internal Variables @@ -45,7 +83,7 @@ public struct NavigatableState { // MARK: - Initialization - init() { + public init() { self.showToast = _showToast.shareReplay(0) self.transitionToScreen = _transitionToScreen.shareReplay(0) self.dismissScreen = _dismissScreen.shareReplay(0) @@ -97,9 +135,7 @@ public struct NavigatableState { case .auto: guard let viewController: UIViewController = viewController, - (viewController.navigationController?.viewControllers - .firstIndex(of: viewController)) - .defaulting(to: 0) > 0 + (viewController.navigationController?.viewControllers.firstIndex(of: viewController) ?? 0) > 0 else { viewController?.dismiss(animated: true, completion: completion) return diff --git a/Session/Shared/Types/TransitionType.swift b/SessionUIKit/Types/TransitionType.swift similarity index 56% rename from Session/Shared/Types/TransitionType.swift rename to SessionUIKit/Types/TransitionType.swift index c4915aaacc..d5523e0cee 100644 --- a/Session/Shared/Types/TransitionType.swift +++ b/SessionUIKit/Types/TransitionType.swift @@ -1,4 +1,4 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. import Foundation diff --git a/SessionUtilitiesKit/Combine/ReplaySubject.swift b/SessionUIKit/Utilities/Publisher+Utilities.swift similarity index 52% rename from SessionUtilitiesKit/Combine/ReplaySubject.swift rename to SessionUIKit/Utilities/Publisher+Utilities.swift index 56f4368a06..4984209b1b 100644 --- a/SessionUtilitiesKit/Combine/ReplaySubject.swift +++ b/SessionUIKit/Utilities/Publisher+Utilities.swift @@ -1,8 +1,27 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. -import Foundation import Combine +public extension Publisher { + /// Converts the publisher to output a Result instead of throwing an error, can be used to ensure a subscription never + /// closes due to a failure + func asResult() -> AnyPublisher, Never> { + self + .map { Result.success($0) } + .catch { Just(Result.failure($0)).eraseToAnyPublisher() } + .eraseToAnyPublisher() + } + + /// Provides a subject that shares a single subscription to the upstream publisher and replays at most + /// `bufferSize` items emitted by that publisher + /// - Parameter bufferSize: limits the number of items that can be replayed + func shareReplay(_ bufferSize: Int) -> AnyPublisher { + return multicast(subject: ReplaySubject(bufferSize)) + .autoconnect() + .eraseToAnyPublisher() + } +} + /// A subject that stores the last `bufferSize` emissions and emits them for every new subscriber /// /// Note: This implementation was found here: https://github.com/sgl0v/OnSwiftWings @@ -11,7 +30,7 @@ public final class ReplaySubject: Subject { private let bufferSize: Int private let lock: NSRecursiveLock = NSRecursiveLock() private var completion: Subscribers.Completion? - @ThreadSafeObject private var subscriptions: [ReplaySubjectSubscription] = [] + private var subscriptions: [ReplaySubjectSubscription] = [] // MARK: - Initialization @@ -49,29 +68,10 @@ public final class ReplaySubject: Subject { public func receive(subscriber: S) where S: Subscriber, Failure == S.Failure, Output == S.Input { lock.lock(); defer { lock.unlock() } - /// According to the below comment the `subscriber.receive(subscription: subscription)` code runs asynchronously - /// which aligns with testing (resulting in the `request(_ newDemand: Subscribers.Demand)` function getting called after this - /// function returns - /// - /// Later in the thread it's mentioned that as of `iOS 13.3` this behaviour changed to be synchronous but as of writing the minimum - /// deployment version is set to `iOS 13.0` which I assume is why we are seeing the async behaviour which results in `receiveValue` - /// not being called in some cases - /// - /// When the project is eventually updated to have a minimum version higher than `iOS 13.3` we should re-test this behaviour to see if - /// we can revert this change - /// - /// https://forums.swift.org/t/combine-receive-on-runloop-main-loses-sent-value-how-can-i-make-it-work/28631/20 - let subscription: ReplaySubjectSubscription = ReplaySubjectSubscription(downstream: AnySubscriber(subscriber)) { [weak self, buffer = buffer, completion = completion] subscription in - self?._subscriptions.performUpdate { $0.appending(subscription) } - subscription.replay(buffer, completion: completion) - } - subscription.onCancel = { [weak self] in - self?._subscriptions.performUpdate { subscriptions in - /// Intentionally use `===` here to compare the object identifier since it could be deallocated at this point - subscriptions.filter { $0 === subscription } - } - } + let subscription = ReplaySubjectSubscription(downstream: AnySubscriber(subscriber)) subscriber.receive(subscription: subscription) + subscriptions.append(subscription) + subscription.replay(buffer, completion: completion) } } @@ -79,35 +79,22 @@ public final class ReplaySubject: Subject { public final class ReplaySubjectSubscription: Subscription { private let downstream: AnySubscriber - private var isCompleted: Bool = false + private var isCompleted = false private var demand: Subscribers.Demand = .none - private var onInitialDemand: ((ReplaySubjectSubscription) -> ())? - - fileprivate var onCancel: (() -> Void)? - - // MARK: - Initialization - init(downstream: AnySubscriber, onInitialDemand: @escaping (ReplaySubjectSubscription) -> ()) { + public init(downstream: AnySubscriber) { self.downstream = downstream - self.onInitialDemand = onInitialDemand } - - // MARK: - Subscription + // Tells a publisher that it may send more values to the subscriber. public func request(_ newDemand: Subscribers.Demand) { demand += newDemand - onInitialDemand?(self) - onInitialDemand = nil } public func cancel() { guard !isCompleted else { return } isCompleted = true - onCancel?() - onCancel = nil } - - // MARK: - Functions public func receive(_ value: Output) { guard !isCompleted, demand > 0 else { return } @@ -118,18 +105,14 @@ public final class ReplaySubjectSubscription: Subscripti public func receive(completion: Subscribers.Completion) { guard !isCompleted else { return } - isCompleted = true downstream.receive(completion: completion) } public func replay(_ values: [Output], completion: Subscribers.Completion?) { guard !isCompleted else { return } - values.forEach { value in receive(value) } - - if let completion = completion { - receive(completion: completion) - } + if let completion = completion { receive(completion: completion) } } } + diff --git a/SessionUIKit/Utilities/SwiftUI+Utilities.swift b/SessionUIKit/Utilities/SwiftUI+Utilities.swift index 43aa9e263a..2b7c930981 100644 --- a/SessionUIKit/Utilities/SwiftUI+Utilities.swift +++ b/SessionUIKit/Utilities/SwiftUI+Utilities.swift @@ -310,3 +310,26 @@ struct HideScrollIndicators: ViewModifier { } } } + +// MARK: Rounded Corner +// FIXME: Remove this and use clipShape(.rect()) when we only support iOS 16+ + +struct RoundedCorner: Shape { + var radius: CGFloat = .infinity + var corners: UIRectCorner = .allCorners + + func path(in rect: CGRect) -> Path { + let path = UIBezierPath( + roundedRect: rect, + byRoundingCorners: corners, + cornerRadii: CGSize(width: radius, height: radius) + ) + return Path(path.cgPath) + } +} + +extension View { + func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View { + clipShape(RoundedCorner(radius: radius, corners: corners)) + } +} diff --git a/SessionUtilitiesKit/Utilities/UINavigationController+Utilities.swift b/SessionUIKit/Utilities/UINavigationController+Utilities.swift similarity index 100% rename from SessionUtilitiesKit/Utilities/UINavigationController+Utilities.swift rename to SessionUIKit/Utilities/UINavigationController+Utilities.swift diff --git a/SessionUtilitiesKit/Combine/Publisher+Utilities.swift b/SessionUtilitiesKit/Combine/Publisher+Utilities.swift index ca7cea95c0..b951355d89 100644 --- a/SessionUtilitiesKit/Combine/Publisher+Utilities.swift +++ b/SessionUtilitiesKit/Combine/Publisher+Utilities.swift @@ -21,15 +21,6 @@ public enum PublisherError: Error, CustomStringConvertible { } public extension Publisher { - /// Provides a subject that shares a single subscription to the upstream publisher and replays at most - /// `bufferSize` items emitted by that publisher - /// - Parameter bufferSize: limits the number of items that can be replayed - func shareReplay(_ bufferSize: Int) -> AnyPublisher { - return multicast(subject: ReplaySubject(bufferSize)) - .autoconnect() - .eraseToAnyPublisher() - } - func sink(into subject: PassthroughSubject, includeCompletions: Bool = false) -> AnyCancellable { return sink( receiveCompletion: { completion in @@ -188,17 +179,6 @@ public extension Publisher { } } -public extension Publisher { - /// Converts the publisher to output a Result instead of throwing an error, can be used to ensure a subscription never - /// closes due to a failure - func asResult() -> AnyPublisher, Never> { - self - .map { Result.success($0) } - .catch { Just(Result.failure($0)).eraseToAnyPublisher() } - .eraseToAnyPublisher() - } -} - extension AnyPublisher: @retroactive ExpressibleByArrayLiteral where Output: RangeReplaceableCollection { public init(arrayLiteral elements: Output.Element...) { self = Just(Output(elements)).setFailureType(to: Failure.self).eraseToAnyPublisher() diff --git a/SessionUtilitiesKit/General/AppContext.swift b/SessionUtilitiesKit/General/AppContext.swift index cf47d1f0c3..f98e287ebb 100644 --- a/SessionUtilitiesKit/General/AppContext.swift +++ b/SessionUtilitiesKit/General/AppContext.swift @@ -28,6 +28,7 @@ public protocol AppContext: AnyObject { func ensureSleepBlocking(_ shouldBeBlocking: Bool, blockingObjects: [Any]) func beginBackgroundTask(expirationHandler: @escaping () -> ()) -> UIBackgroundTaskIdentifier func endBackgroundTask(_ backgroundTaskIdentifier: UIBackgroundTaskIdentifier) + func openUrl(_ url: URL) } // MARK: - Defaults @@ -52,6 +53,7 @@ public extension AppContext { func ensureSleepBlocking(_ shouldBeBlocking: Bool, blockingObjects: [Any]) {} func beginBackgroundTask(expirationHandler: @escaping () -> ()) -> UIBackgroundTaskIdentifier { return .invalid } func endBackgroundTask(_ backgroundTaskIdentifier: UIBackgroundTaskIdentifier) {} + func openUrl(_ url: URL) {} } private final class NoopAppContext: AppContext, NoopDependency { @@ -74,4 +76,5 @@ private final class NoopAppContext: AppContext, NoopDependency { func ensureSleepBlocking(_ shouldBeBlocking: Bool, blockingObjects: [Any]) {} func beginBackgroundTask(expirationHandler: @escaping () -> ()) -> UIBackgroundTaskIdentifier { return .invalid } func endBackgroundTask(_ backgroundTaskIdentifier: UIBackgroundTaskIdentifier) {} + func openUrl(_ url: URL) {} } diff --git a/SessionUtilitiesKit/Types/SessionProManagerType.swift b/SessionUtilitiesKit/Types/SessionProManagerType.swift index f45539b13a..3c6ead80c5 100644 --- a/SessionUtilitiesKit/Types/SessionProManagerType.swift +++ b/SessionUtilitiesKit/Types/SessionProManagerType.swift @@ -6,30 +6,19 @@ import Combine public protocol SessionProManagerType: AnyObject { var sessionProStateSubject: CurrentValueSubject { get } var sessionProStatePublisher: AnyPublisher { get } + var isSessionProActivePublisher: AnyPublisher { get } + var isSessionProExpired: Bool { get } var sessionProPlans: [SessionProPlan] { get } - func upgradeToPro(plan: SessionProPlan, originatingPlatform: ClientPlatform, completion: ((_ result: Bool) -> Void)?) - func cancelPro(completion: ((_ result: Bool) -> Void)?) - func requestRefund(completion: ((_ result: Bool) -> Void)?) - func expirePro(completion: ((_ result: Bool) -> Void)?) - func recoverPro(completion: ((_ result: Bool) -> Void)?) + func upgradeToPro(plan: SessionProPlan, originatingPlatform: ClientPlatform, completion: ((_ result: Bool) -> Void)?) async + func cancelPro(completion: ((_ result: Bool) -> Void)?) async + func requestRefund(completion: ((_ result: Bool) -> Void)?) async + func expirePro(completion: ((_ result: Bool) -> Void)?) async + func recoverPro(completion: ((_ result: Bool) -> Void)?) async // These functions are only for QA purpose func updateOriginatingPlatform(_ newValue: ClientPlatform) func updateProExpiry(_ expiryInSeconds: TimeInterval?) } -public extension SessionProManagerType { - var isSessionProPublisher: AnyPublisher { - sessionProStatePublisher - .map { - switch $0 { - case .active: return true - default: return false - } - } - .eraseToAnyPublisher() - } -} - public enum SessionProPlanState: Equatable, Sendable { case none case active( diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift index a3357502c0..3b88d32d93 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift @@ -233,7 +233,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC ), imageDataManager: dependencies[singleton: .imageDataManager], linkPreviewManager: dependencies[singleton: .linkPreviewManager], - sessionProState: dependencies[singleton: .sessionProState], + sessionProStatePublisher: dependencies[singleton: .sessionProState].isSessionProActivePublisher, onQuoteCancelled: onQuoteCancelled, didLoadLinkPreview: { [weak self] result in self?.didLoadLinkPreview?(result) @@ -702,9 +702,16 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC @MainActor func showModalForMessagesExceedingCharacterLimit(isSessionPro: Bool) { guard dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( - .longerMessages, - onConfirm: { [weak self] in - self?.snInputView.updateNumberOfCharactersLeft(self?.snInputView.text ?? "") + .longerMessages(renew: dependencies[singleton: .sessionProState].isSessionProExpired), + onConfirm: { [weak self, dependencies] in + dependencies[singleton: .sessionProState].showSessionProBottomSheetIfNeeded( + afterClosed: { + self?.snInputView.updateNumberOfCharactersLeft(self?.snInputView.text ?? "") + }, + presenting: { bottomSheet in + self?.present(bottomSheet, animated: true) + } + ) }, onCancel: { [weak self] in self?.snInputView.updateNumberOfCharactersLeft(self?.snInputView.text ?? "") @@ -749,9 +756,16 @@ extension AttachmentApprovalViewController: InputViewDelegate { public func handleCharacterLimitLabelTapped() { guard dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( - .longerMessages, - onConfirm: { [weak self] in - self?.snInputView.updateNumberOfCharactersLeft(self?.snInputView.text ?? "") + .longerMessages(renew: dependencies[singleton: .sessionProState].isSessionProExpired), + onConfirm: { [weak self, dependencies] in + dependencies[singleton: .sessionProState].showSessionProBottomSheetIfNeeded( + afterClosed: { + self?.snInputView.updateNumberOfCharactersLeft(self?.snInputView.text ?? "") + }, + presenting: { bottomSheet in + self?.present(bottomSheet, animated: true) + } + ) }, onCancel: { [weak self] in self?.snInputView.updateNumberOfCharactersLeft(self?.snInputView.text ?? "") diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift index bad71a030d..8ae5090e97 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift @@ -113,10 +113,9 @@ class AttachmentTextToolbar: UIView, UITextViewDelegate { .sink( receiveValue: { [weak self] sessionProPlanState in let isPro: Bool = { - if case .active = sessionProPlanState { - return true - } else { - return false + switch sessionProPlanState { + case .active, .refunding : return true + case .none, .expired: return false } }() self?.sessionProBadge.isHidden = isPro