diff --git a/Scripts/LintLocalizableStrings.swift b/Scripts/LintLocalizableStrings.swift index b95f9574a5..f027587bec 100755 --- a/Scripts/LintLocalizableStrings.swift +++ b/Scripts/LintLocalizableStrings.swift @@ -70,6 +70,10 @@ extension ProjectState { .prefix("@available(", caseSensitive: false), .prefix("print(", caseSensitive: false), .prefix("Log.Category =", caseSensitive: false), + .previousLine( + numEarlier: 1, + .suffix("-> Log.Category {", caseSensitive: false) + ), .contains("fatalError(", caseSensitive: false), .contains("precondition(", caseSensitive: false), .contains("preconditionFailure(", caseSensitive: false), diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 9ace1f0645..94bec55f15 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -153,6 +153,8 @@ 7BFD1A972747689000FB91B9 /* Session-Turn-Server in Resources */ = {isa = PBXBuildFile; fileRef = 7BFD1A962747689000FB91B9 /* Session-Turn-Server */; }; 9409433E2C7EB81800D9D2E0 /* WebRTCSession+Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9409433D2C7EB81800D9D2E0 /* WebRTCSession+Constants.swift */; }; 940943402C7ED62300D9D2E0 /* StartupError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9409433F2C7ED62300D9D2E0 /* StartupError.swift */; }; + 941375BB2D5184C20058F244 /* HTTPHeader+SessionNetwork.swift in Sources */ = {isa = PBXBuildFile; fileRef = 941375BA2D5184B60058F244 /* HTTPHeader+SessionNetwork.swift */; }; + 941375BD2D5195F30058F244 /* KeyValueStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 941375BC2D5195F30058F244 /* KeyValueStore.swift */; }; 942256802C23F8BB00C0FDBF /* StartConversationScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9422567D2C23F8BB00C0FDBF /* StartConversationScreen.swift */; }; 942256812C23F8BB00C0FDBF /* NewMessageScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9422567E2C23F8BB00C0FDBF /* NewMessageScreen.swift */; }; 942256822C23F8BB00C0FDBF /* InviteAFriendScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9422567F2C23F8BB00C0FDBF /* InviteAFriendScreen.swift */; }; @@ -172,12 +174,21 @@ 9422569F2C23F8FE00C0FDBF /* ScanQRCodeScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9422569E2C23F8FE00C0FDBF /* ScanQRCodeScreen.swift */; }; 942256A12C23F90700C0FDBF /* CustomTopTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 942256A02C23F90700C0FDBF /* CustomTopTabBar.swift */; }; 9422EE2B2B8C3A97004C740D /* String+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9422EE2A2B8C3A97004C740D /* String+Utilities.swift */; }; - 94367C432C6C828500814252 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 94367C422C6C828500814252 /* Localizable.xcstrings */; }; - 94367C442C6C828500814252 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 94367C422C6C828500814252 /* Localizable.xcstrings */; }; - 94367C452C6C828500814252 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 94367C422C6C828500814252 /* Localizable.xcstrings */; }; + 942ADDD42D9F9613006E0BB0 /* NewTagView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 942ADDD32D9F960C006E0BB0 /* NewTagView.swift */; }; 943C6D822B75E061004ACE64 /* Message+DisappearingMessages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 943C6D812B75E061004ACE64 /* Message+DisappearingMessages.swift */; }; + 945D9C582D6FDBE7003C4C0C /* _005_AddJobUniqueHash.swift in Sources */ = {isa = PBXBuildFile; fileRef = 945D9C572D6FDBE7003C4C0C /* _005_AddJobUniqueHash.swift */; }; 946F5A732D5DA3AC00A5ADCE /* Punycode in Frameworks */ = {isa = PBXBuildFile; productRef = 946F5A722D5DA3AC00A5ADCE /* Punycode */; }; 9473386E2BDF5F3E00B9E169 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 9473386D2BDF5F3E00B9E169 /* InfoPlist.xcstrings */; }; + 947D7FD42D509FC900E8E413 /* SessionNetworkAPI+Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 947D7FD12D509FC900E8E413 /* SessionNetworkAPI+Models.swift */; }; + 947D7FD62D509FC900E8E413 /* SessionNetworkAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 947D7FCF2D509FC900E8E413 /* SessionNetworkAPI.swift */; }; + 947D7FD72D509FC900E8E413 /* SessionNetworkAPI+Network.swift in Sources */ = {isa = PBXBuildFile; fileRef = 947D7FD22D509FC900E8E413 /* SessionNetworkAPI+Network.swift */; }; + 947D7FD82D509FC900E8E413 /* SessionNetworkAPI+Database.swift in Sources */ = {isa = PBXBuildFile; fileRef = 947D7FD02D509FC900E8E413 /* SessionNetworkAPI+Database.swift */; }; + 947D7FDE2D5180F200E8E413 /* SessionNetworkScreen+ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 947D7FDB2D5180F200E8E413 /* SessionNetworkScreen+ViewModel.swift */; }; + 947D7FE32D5181F400E8E413 /* _006_RenameTableSettingToKeyValueStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 947D7FE22D5181F400E8E413 /* _006_RenameTableSettingToKeyValueStore.swift */; }; + 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 */; }; + 94A6B9DB2DD6BF7C00DB4B44 /* Constants+URLs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94A6B9DA2DD6BF6E00DB4B44 /* Constants+URLs.swift */; }; 94B3DC172AF8592200C88531 /* QuoteView_SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B3DC162AF8592200C88531 /* QuoteView_SwiftUI.swift */; }; 94C58AC92D2E037200609195 /* Permissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94C58AC82D2E036E00609195 /* Permissions.swift */; }; 94E9BC0D2C7BFBDA006984EA /* Localization+Style.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94E9BC0C2C7BFBDA006984EA /* Localization+Style.swift */; }; @@ -666,7 +677,7 @@ FD49E2492B05C1D500FFBBB5 /* MockKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD49E2452B05C1D500FFBBB5 /* MockKeychain.swift */; }; FD4B200E283492210034334B /* InsetLockableTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD4B200D283492210034334B /* InsetLockableTableView.swift */; }; FD4BB22B2D63F20700D0DC3D /* MigrationHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD4BB22A2D63F20600D0DC3D /* MigrationHelper.swift */; }; - FD4BB22C2D63FA8600D0DC3D /* _005_AddJobUniqueHash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1936462ACE752F004BCF0F /* _005_AddJobUniqueHash.swift */; }; + FD4BB22C2D63FA8600D0DC3D /* (null) in Sources */ = {isa = PBXBuildFile; }; FD4C4E9C2B02E2A300C72199 /* DisplayPictureError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD4C4E9B2B02E2A300C72199 /* DisplayPictureError.swift */; }; FD4C53AF2CC1D62E003B10F4 /* _021_ReworkRecipientState.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD4C53AE2CC1D61E003B10F4 /* _021_ReworkRecipientState.swift */; }; FD52090328B4680F006098F6 /* RadioButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD52090228B4680F006098F6 /* RadioButton.swift */; }; @@ -807,13 +818,20 @@ FD86FDA52BC51C5500EC251B /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = FD86FDA22BC5020600EC251B /* PrivacyInfo.xcprivacy */; }; FD87DCFA28B74DB300AF0F98 /* ConversationSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD87DCF928B74DB300AF0F98 /* ConversationSettingsViewModel.swift */; }; FD87DCFE28B7582C00AF0F98 /* BlockedContactsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD87DCFD28B7582C00AF0F98 /* BlockedContactsViewModel.swift */; }; + FD8A5B012DBEFF6D004C689B /* SessionNetworkScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 947D7FD92D5180F200E8E413 /* SessionNetworkScreen.swift */; }; + FD8A5B022DBEFF73004C689B /* SessionNetworkScreen+Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 947D7FDA2D5180F200E8E413 /* SessionNetworkScreen+Models.swift */; }; FD8A5B0A2DBF246A004C689B /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 947AD68F2C8968FF000B2730 /* Constants.swift */; }; FD8A5B0D2DBF2CA1004C689B /* Localization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 943C6D832B86B5F1004ACE64 /* Localization.swift */; }; FD8A5B0E2DBF2DB1004C689B /* SessionHostingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B8914762A7CAAE200A4C627 /* SessionHostingViewController.swift */; }; FD8A5B102DBF2F17004C689B /* NavBarSessionIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD8A5B0F2DBF2F14004C689B /* NavBarSessionIcon.swift */; }; FD8A5B112DBF34BD004C689B /* Date+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B9728422F1A000E298B /* Date+Utilities.swift */; }; + FD8A5B172DBF3D2D004C689B /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 94367C422C6C828500814252 /* Localizable.xcstrings */; }; FD8A5B182DBF47E9004C689B /* SessionUIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C331FF1B2558F9D300070591 /* SessionUIKit.framework */; }; FD8A5B1E2DBF4BBC004C689B /* ScreenLock+Errors.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD8A5B1D2DBF4BB8004C689B /* ScreenLock+Errors.swift */; }; + FD8A5B202DC03337004C689B /* AdaptiveText.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD8A5B1F2DC03332004C689B /* AdaptiveText.swift */; }; + FD8A5B222DC0489C004C689B /* AdaptiveHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD8A5B212DC0489B004C689B /* AdaptiveHStack.swift */; }; + FD8A5B252DC05B16004C689B /* Number+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD8A5B242DC05B16004C689B /* Number+Utilities.swift */; }; + FD8A5B292DC060E2004C689B /* Double+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD8A5B282DC060DD004C689B /* Double+Utilities.swift */; }; FD8A5B322DC191B4004C689B /* _025_DropLegacyClosedGroupKeyPairTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD8A5B312DC191AB004C689B /* _025_DropLegacyClosedGroupKeyPairTable.swift */; }; FD8A5B342DC1A732004C689B /* _008_ResetUserConfigLastHashes.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD8A5B332DC1A726004C689B /* _008_ResetUserConfigLastHashes.swift */; }; FD8ECF7B29340FFD00C0D1BB /* LibSession+SessionMessagingKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD8ECF7A29340FFD00C0D1BB /* LibSession+SessionMessagingKit.swift */; }; @@ -1432,6 +1450,9 @@ 7BFD1A962747689000FB91B9 /* Session-Turn-Server */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "Session-Turn-Server"; sourceTree = ""; }; 9409433D2C7EB81800D9D2E0 /* WebRTCSession+Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WebRTCSession+Constants.swift"; sourceTree = ""; }; 9409433F2C7ED62300D9D2E0 /* StartupError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupError.swift; sourceTree = ""; }; + 941375BA2D5184B60058F244 /* HTTPHeader+SessionNetwork.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HTTPHeader+SessionNetwork.swift"; sourceTree = ""; }; + 941375BC2D5195F30058F244 /* KeyValueStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyValueStore.swift; sourceTree = ""; }; + 941375BE2D5196D10058F244 /* Number+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Number+Utilities.swift"; sourceTree = ""; }; 9422567D2C23F8BB00C0FDBF /* StartConversationScreen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StartConversationScreen.swift; sourceTree = ""; }; 9422567E2C23F8BB00C0FDBF /* NewMessageScreen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewMessageScreen.swift; sourceTree = ""; }; 9422567F2C23F8BB00C0FDBF /* InviteAFriendScreen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InviteAFriendScreen.swift; sourceTree = ""; }; @@ -1451,12 +1472,26 @@ 9422569E2C23F8FE00C0FDBF /* ScanQRCodeScreen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScanQRCodeScreen.swift; sourceTree = ""; }; 942256A02C23F90700C0FDBF /* CustomTopTabBar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomTopTabBar.swift; sourceTree = ""; }; 9422EE2A2B8C3A97004C740D /* String+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Utilities.swift"; sourceTree = ""; }; + 942ADDD32D9F960C006E0BB0 /* NewTagView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTagView.swift; sourceTree = ""; }; 94367C422C6C828500814252 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; 943C6D812B75E061004ACE64 /* Message+DisappearingMessages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Message+DisappearingMessages.swift"; sourceTree = ""; }; 943C6D832B86B5F1004ACE64 /* Localization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Localization.swift; sourceTree = ""; }; + 945D9C572D6FDBE7003C4C0C /* _005_AddJobUniqueHash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _005_AddJobUniqueHash.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 = ""; }; 947AD68F2C8968FF000B2730 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; + 947D7FCF2D509FC900E8E413 /* SessionNetworkAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionNetworkAPI.swift; sourceTree = ""; }; + 947D7FD02D509FC900E8E413 /* SessionNetworkAPI+Database.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionNetworkAPI+Database.swift"; sourceTree = ""; }; + 947D7FD12D509FC900E8E413 /* SessionNetworkAPI+Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionNetworkAPI+Models.swift"; sourceTree = ""; }; + 947D7FD22D509FC900E8E413 /* SessionNetworkAPI+Network.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionNetworkAPI+Network.swift"; sourceTree = ""; }; + 947D7FD92D5180F200E8E413 /* SessionNetworkScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionNetworkScreen.swift; sourceTree = ""; }; + 947D7FDA2D5180F200E8E413 /* SessionNetworkScreen+Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionNetworkScreen+Models.swift"; sourceTree = ""; }; + 947D7FDB2D5180F200E8E413 /* SessionNetworkScreen+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionNetworkScreen+ViewModel.swift"; sourceTree = ""; }; + 947D7FE22D5181F400E8E413 /* _006_RenameTableSettingToKeyValueStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _006_RenameTableSettingToKeyValueStore.swift; sourceTree = ""; }; + 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 = ""; }; + 94A6B9DA2DD6BF6E00DB4B44 /* Constants+URLs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Constants+URLs.swift"; sourceTree = ""; }; 94B3DC162AF8592200C88531 /* QuoteView_SwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuoteView_SwiftUI.swift; sourceTree = ""; }; 94C58AC82D2E036E00609195 /* Permissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Permissions.swift; sourceTree = ""; }; 94E9BC0C2C7BFBDA006984EA /* Localization+Style.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Localization+Style.swift"; sourceTree = ""; }; @@ -1741,7 +1776,6 @@ FD17D7E627F6A16700122BE0 /* _003_YDBToGRDBMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _003_YDBToGRDBMigration.swift; sourceTree = ""; }; FD19363B2ACA3134004BCF0F /* ResponseInfo+SnodeAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ResponseInfo+SnodeAPI.swift"; sourceTree = ""; }; FD19363E2ACA66DE004BCF0F /* DatabaseSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseSpec.swift; sourceTree = ""; }; - FD1936462ACE752F004BCF0F /* _005_AddJobUniqueHash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _005_AddJobUniqueHash.swift; sourceTree = ""; }; FD1A94FA2900D1C2000D73D3 /* PersistableRecord+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PersistableRecord+Utilities.swift"; sourceTree = ""; }; FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UINavigationBar+Utilities.swift"; sourceTree = ""; }; FD1D73292A85AA2000E3F410 /* Setting+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Setting+Utilities.swift"; sourceTree = ""; }; @@ -2007,6 +2041,10 @@ FD87DCFD28B7582C00AF0F98 /* BlockedContactsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockedContactsViewModel.swift; sourceTree = ""; }; FD8A5B0F2DBF2F14004C689B /* NavBarSessionIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavBarSessionIcon.swift; sourceTree = ""; }; FD8A5B1D2DBF4BB8004C689B /* ScreenLock+Errors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ScreenLock+Errors.swift"; sourceTree = ""; }; + FD8A5B1F2DC03332004C689B /* AdaptiveText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveText.swift; sourceTree = ""; }; + FD8A5B212DC0489B004C689B /* AdaptiveHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveHStack.swift; sourceTree = ""; }; + FD8A5B242DC05B16004C689B /* Number+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Number+Utilities.swift"; sourceTree = ""; }; + FD8A5B282DC060DD004C689B /* Double+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Double+Utilities.swift"; sourceTree = ""; }; FD8A5B312DC191AB004C689B /* _025_DropLegacyClosedGroupKeyPairTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _025_DropLegacyClosedGroupKeyPairTable.swift; sourceTree = ""; }; FD8A5B332DC1A726004C689B /* _008_ResetUserConfigLastHashes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _008_ResetUserConfigLastHashes.swift; sourceTree = ""; }; FD8ECF7A29340FFD00C0D1BB /* LibSession+SessionMessagingKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LibSession+SessionMessagingKit.swift"; sourceTree = ""; }; @@ -2657,6 +2695,11 @@ 942256932C23F8DD00C0FDBF /* SwiftUI */ = { isa = PBXGroup; children = ( + FD8A5B1F2DC03332004C689B /* AdaptiveText.swift */, + FD8A5B212DC0489B004C689B /* AdaptiveHStack.swift */, + 947D7FE42D51837200E8E413 /* ArrowCapsule.swift */, + 947D7FE52D51837200E8E413 /* PopoverView.swift */, + 947D7FE62D51837200E8E413 /* Text+CopyButton.swift */, 9422568D2C23F8DD00C0FDBF /* ActivityView.swift */, 9422568E2C23F8DD00C0FDBF /* AttributedText.swift */, 9422568F2C23F8DD00C0FDBF /* CompatibleScrollingVStack.swift */, @@ -2667,6 +2710,27 @@ path = SwiftUI; sourceTree = ""; }; + 947D7FD32D509FC900E8E413 /* SessionNetworkAPI */ = { + isa = PBXGroup; + children = ( + 941375BA2D5184B60058F244 /* HTTPHeader+SessionNetwork.swift */, + 947D7FCF2D509FC900E8E413 /* SessionNetworkAPI.swift */, + 947D7FD02D509FC900E8E413 /* SessionNetworkAPI+Database.swift */, + 947D7FD12D509FC900E8E413 /* SessionNetworkAPI+Models.swift */, + 947D7FD22D509FC900E8E413 /* SessionNetworkAPI+Network.swift */, + ); + path = SessionNetworkAPI; + sourceTree = ""; + }; + 947D7FDC2D5180F200E8E413 /* SessionNetworkScreen */ = { + isa = PBXGroup; + children = ( + 947D7FD92D5180F200E8E413 /* SessionNetworkScreen.swift */, + 947D7FDA2D5180F200E8E413 /* SessionNetworkScreen+Models.swift */, + ); + path = SessionNetworkScreen; + sourceTree = ""; + }; B8041A7325C8F758003C2166 /* Content Views */ = { isa = PBXGroup; children = ( @@ -3043,6 +3107,7 @@ children = ( C331FF422558F9E400070591 /* Meta */, C331FFCC2558FAF300070591 /* Components */, + FD8A5AFE2DBEFB19004C689B /* Screens */, C331FF5E2558FA0F00070591 /* Style Guide */, FD71163028E2C41900B47552 /* Types */, C331FFAE2558FA7700070591 /* Utilities */, @@ -3054,8 +3119,8 @@ C331FF422558F9E400070591 /* Meta */ = { isa = PBXGroup; children = ( - C331FF1D2558F9D300070591 /* SessionUIKit.h */, C331FF1E2558F9D300070591 /* Info.plist */, + C331FF1D2558F9D300070591 /* SessionUIKit.h */, ); path = Meta; sourceTree = ""; @@ -3065,6 +3130,7 @@ children = ( FD37E9C428A1C701003AE748 /* Themes */, 947AD68F2C8968FF000B2730 /* Constants.swift */, + 94A6B9DA2DD6BF6E00DB4B44 /* Constants+URLs.swift */, B8BB82BD2394D4CE00BA5194 /* Fonts.swift */, FDF848F029406A30007DCAE5 /* Format.swift */, FD37E9C228A1C6F3003AE748 /* ThemeManager.swift */, @@ -3078,17 +3144,19 @@ children = ( FD2272E32C35134B004D8A6C /* Data+Utilities.swift */, FD848B9728422F1A000E298B /* Date+Utilities.swift */, + FD8A5B282DC060DD004C689B /* Double+Utilities.swift */, 94E9BC0C2C7BFBDA006984EA /* Localization+Style.swift */, + FD8A5B242DC05B16004C689B /* Number+Utilities.swift */, FD8A5B1D2DBF4BB8004C689B /* ScreenLock+Errors.swift */, 7BA1E0E72A8087DB00123D0D /* SwiftUI+Utilities.swift */, 9422EE2A2B8C3A97004C740D /* String+Utilities.swift */, FDE754BD2C9BA16C002A2623 /* UIButtonConfiguration+Utilities.swift */, FD37E9D628A20B5D003AE748 /* UIColor+Utilities.swift */, - B885D5F52334A32100EE0D8E /* UIView+Constraints.swift */, - C33100272559000A00070591 /* UIView+Utilities.swift */, - FD71161F28D97ABC00B47552 /* UIImage+Utilities.swift */, FDBB25E62988BBBD00F1508E /* UIContextualAction+Theming.swift */, FDE754B92C9B97B8002A2623 /* UIDevice+Utilities.swift */, + FD71161F28D97ABC00B47552 /* UIImage+Utilities.swift */, + B885D5F52334A32100EE0D8E /* UIView+Constraints.swift */, + C33100272559000A00070591 /* UIView+Utilities.swift */, ); path = Utilities; sourceTree = ""; @@ -3202,6 +3270,7 @@ C360969125AD1765008B62B2 /* Settings */ = { isa = PBXGroup; children = ( + FD8A5B002DBEFBF9004C689B /* SessionNetworkScreen */, FD37E9CD28A1E682003AE748 /* Views */, 9422569A2C23F8F000C0FDBF /* QRCodeScreen.swift */, 9422569B2C23F8F000C0FDBF /* RecoveryPasswordScreen.swift */, @@ -3425,6 +3494,7 @@ C3C2A5A0255385C100C340D1 /* SessionSnodeKit */ = { isa = PBXGroup; children = ( + 947D7FD32D509FC900E8E413 /* SessionNetworkAPI */, C3C2A5B0255385C700C340D1 /* Meta */, FDE754E22C9BAFF4002A2623 /* Crypto */, FD17D79D27F40CAA00122BE0 /* Database */, @@ -3622,6 +3692,7 @@ FDE7214E287E50D50093DF33 /* Scripts */, D221A08C169C9E5E00537ABF /* Frameworks */, D221A08A169C9E5E00537ABF /* Products */, + FD8A5B232DC05A0E004C689B /* Recovered References */, ); sourceTree = ""; }; @@ -3861,7 +3932,8 @@ FD9004132818AD0B00ABAAF6 /* _002_SetupStandardJobs.swift */, FD17D7E627F6A16700122BE0 /* _003_YDBToGRDBMigration.swift */, FDBB25E22988B13800F1508E /* _004_AddJobPriority.swift */, - FD1936462ACE752F004BCF0F /* _005_AddJobUniqueHash.swift */, + 945D9C572D6FDBE7003C4C0C /* _005_AddJobUniqueHash.swift */, + 947D7FE22D5181F400E8E413 /* _006_RenameTableSettingToKeyValueStore.swift */, ); path = Migrations; sourceTree = ""; @@ -3869,6 +3941,7 @@ FD17D7CB27F546F500122BE0 /* Models */ = { isa = PBXGroup; children = ( + 941375BC2D5195F30058F244 /* KeyValueStore.swift */, FD17D7E427F6A09900122BE0 /* Identity.swift */, FDF0B73F280402C4004C14C5 /* Job.swift */, FDE754D12C9BAF53002A2623 /* JobDependencies.swift */, @@ -3983,6 +4056,7 @@ FD37E9CD28A1E682003AE748 /* Views */ = { isa = PBXGroup; children = ( + 942ADDD32D9F960C006E0BB0 /* NewTagView.swift */, FD37E9D028A1F2EB003AE748 /* ThemeSelectionView.swift */, FD860CB52D66913B00BBE29C /* ThemePreviewView.swift */, FD37E9DA28A244E9003AE748 /* ThemeMessagePreviewView.swift */, @@ -4303,6 +4377,38 @@ path = Models; sourceTree = ""; }; + FD8A5AFE2DBEFB19004C689B /* Screens */ = { + isa = PBXGroup; + children = ( + FD8A5AFF2DBEFB21004C689B /* Settings */, + ); + path = Screens; + sourceTree = ""; + }; + FD8A5AFF2DBEFB21004C689B /* Settings */ = { + isa = PBXGroup; + children = ( + 947D7FDC2D5180F200E8E413 /* SessionNetworkScreen */, + ); + path = Settings; + sourceTree = ""; + }; + FD8A5B002DBEFBF9004C689B /* SessionNetworkScreen */ = { + isa = PBXGroup; + children = ( + 947D7FDB2D5180F200E8E413 /* SessionNetworkScreen+ViewModel.swift */, + ); + path = SessionNetworkScreen; + sourceTree = ""; + }; + FD8A5B232DC05A0E004C689B /* Recovered References */ = { + isa = PBXGroup; + children = ( + 941375BE2D5196D10058F244 /* Number+Utilities.swift */, + ); + name = "Recovered References"; + sourceTree = ""; + }; FD8ECF7529340F4800C0D1BB /* LibSession */ = { isa = PBXGroup; children = ( @@ -4354,8 +4460,8 @@ isa = PBXGroup; children = ( FD9401CE2ABD04AC003A4834 /* TRANSLATIONS.md */, - 94367C422C6C828500814252 /* Localizable.xcstrings */, 9473386D2BDF5F3E00B9E169 /* InfoPlist.xcstrings */, + 94367C422C6C828500814252 /* Localizable.xcstrings */, ); path = Translations; sourceTree = ""; @@ -4816,7 +4922,6 @@ 453518661FC635DD00210559 /* Resources */, FD5E93D42C12D3CB0038C25A /* Add Commit Hash To Build Info Plist */, FD5E93D62C12D8270038C25A /* Add App Group To Build Info Plist */, - FDC498C02AC1774500EDD897 /* Ensure Localizable.strings included */, ); buildRules = ( ); @@ -4844,7 +4949,6 @@ 7BC01A39241F40AB00BC7C55 /* Resources */, FD5E93D52C12D8120038C25A /* Add Commit Hash To Build Info Plist */, FD5E93D72C12DA400038C25A /* Add App Group To Build Info Plist */, - FDC498C12AC1775400EDD897 /* Ensure Localizable.strings included */, ); buildRules = ( ); @@ -4868,6 +4972,7 @@ C331FF172558F9D300070591 /* Sources */, C331FF182558F9D300070591 /* Frameworks */, C331FF192558F9D300070591 /* Resources */, + FD8A5B2C2DC1865E004C689B /* Ensure Localizable.strings included */, ); buildRules = ( ); @@ -4990,10 +5095,9 @@ D221A087169C9E5E00537ABF /* Resources */, 453518771FC635DD00210559 /* Embed Foundation Extensions */, 4535189F1FC63DBF00210559 /* Embed Frameworks */, - FDC289452C88113300020BC2 /* Copy GRDB framework */, + FD981BD02DC8545C00564172 /* Copy GRDB framework */, FDD82C422A2085B900425F05 /* Add Commit Hash To Build Info Plist */, FD5E93D32C12D3990038C25A /* Add App Group To Build Info Plist */, - FDC498BF2AC1747900EDD897 /* Ensure Localizable.strings included */, FD0B1FA92CA3805C00F60F46 /* Ensure InfoPlist.xcstrings updated */, 9471CAA62CACFB0600090FB7 /* Generate Licenses Plist */, ); @@ -5267,7 +5371,6 @@ buildActionMask = 2147483647; files = ( 4535186E1FC635DD00210559 /* MainInterface.storyboard in Resources */, - 94367C442C6C828500814252 /* Localizable.xcstrings in Resources */, B8D07406265C683A00F77E07 /* ElegantIcons.ttf in Resources */, FD86FDA42BC51C5400EC251B /* PrivacyInfo.xcprivacy in Resources */, 3478504C1FD7496D007B8332 /* Images.xcassets in Resources */, @@ -5279,7 +5382,6 @@ buildActionMask = 2147483647; files = ( FD86FDA52BC51C5500EC251B /* PrivacyInfo.xcprivacy in Resources */, - 94367C452C6C828500814252 /* Localizable.xcstrings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -5287,6 +5389,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + FD8A5B172DBF3D2D004C689B /* Localizable.xcstrings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -5354,7 +5457,6 @@ 4503F1BE20470A5B00CEE724 /* classic-quiet.aifc in Resources */, 45B74A7E2044AAB600CD42F8 /* complete.aifc in Resources */, 9473386E2BDF5F3E00B9E169 /* InfoPlist.xcstrings in Resources */, - 94367C432C6C828500814252 /* Localizable.xcstrings in Resources */, B8CCF6352396005F0091D419 /* SpaceMono-Regular.ttf in Resources */, 45B74A872044AAB600CD42F8 /* complete-quiet.aifc in Resources */, 45B74A772044AAB600CD42F8 /* hello.aifc in Resources */, @@ -5554,27 +5656,7 @@ shellScript = "INFO_PLIST=\"${TARGET_BUILD_DIR}\"/\"${INFOPLIST_PATH}\"\nENTITLEMENTS_FILE=\"${SRCROOT}/${CODE_SIGN_ENTITLEMENTS}\"\n\n# Wait for Info.plist to be processed\nwhile [[ ! -f \"${INFO_PLIST}\" ]] && [[ ! -f \"${ENTITLEMENTS_FILE}\" ]]; do\n echo \"Waiting for plist and entitlements to be copied\"\n sleep 1\ndone\n\n# Query and save the value; suppress any error message, if key not found.\nvalue=$(/usr/libexec/PlistBuddy -c 'print :AppGroupsId' \"${INFO_PLIST}\" 2>/dev/null)\n\n# Check if value is empty\nif [ -z \"$value\" ] \nthen\n /usr/libexec/PlistBuddy -c \"Add :AppGroupsId string\" \"${INFO_PLIST}\"\nfi\n\napp_group_id=$(xmllint --xpath 'string(//key[.=\"com.apple.security.application-groups\"]/following-sibling::array/string)' \"$ENTITLEMENTS_FILE\")\n\n/usr/libexec/PlistBuddy -c \"Set :AppGroupsId ${app_group_id}\" \"${INFO_PLIST}\"\n"; showEnvVarsInLog = 0; }; - FDC289452C88113300020BC2 /* Copy GRDB framework */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - ); - name = "Copy GRDB framework"; - outputFileListPaths = ( - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "# This script copies GRDB.framework to the bundle and signs it\n# It's required because GRDB is not an explicit app dependency\n# and as such it can't be selected in \"Copy Frameworks\" build phase.\ngrdb_source_dir=\"${BUILT_PRODUCTS_DIR}/GRDB.framework\"\ngrdb_install_dir=\"${BUILT_PRODUCTS_DIR}/${FRAMEWORKS_FOLDER_PATH}/GRDB.framework\"\n\n# Remove any existing files in the destination\nrm -rf \"${grdb_install_dir}\"\nmkdir -p \"${grdb_install_dir}\"\n\n# Copy the framework and the Info.plist\ncp -f \"${grdb_source_dir}/GRDB\" \"${grdb_source_dir}/Info.plist\" \"${grdb_install_dir}\"\n\n# Sign the framework directory contents\n/usr/bin/codesign \\\n --force \\\n --sign \"${EXPANDED_CODE_SIGN_IDENTITY}\" \\\n --timestamp=none \\\n --preserve-metadata=identifier,entitlements,flags \\\n --generate-entitlement-der \"${grdb_install_dir}\"\n"; - showEnvVarsInLog = 0; - }; - FDC498BF2AC1747900EDD897 /* Ensure Localizable.strings included */ = { + FD8A5B2C2DC1865E004C689B /* Ensure Localizable.strings included */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; buildActionMask = 2147483647; @@ -5594,7 +5676,7 @@ shellScript = "\"${SRCROOT}/Scripts/LintLocalizableStrings.swift\" validate\n"; showEnvVarsInLog = 0; }; - FDC498C02AC1774500EDD897 /* Ensure Localizable.strings included */ = { + FD981BD02DC8545C00564172 /* Copy GRDB framework */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; buildActionMask = 2147483647; @@ -5604,34 +5686,14 @@ ); inputPaths = ( ); - name = "Ensure Localizable.strings included"; - outputFileListPaths = ( - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${SRCROOT}/Scripts/LintLocalizableStrings.swift\" validate\n"; - showEnvVarsInLog = 0; - }; - FDC498C12AC1775400EDD897 /* Ensure Localizable.strings included */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - ); - name = "Ensure Localizable.strings included"; + name = "Copy GRDB framework"; outputFileListPaths = ( ); outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${SRCROOT}/Scripts/LintLocalizableStrings.swift\" validate\n"; + shellScript = "# This script copies GRDB.framework to the bundle and signs it\n# It's required because GRDB is not an explicit app dependency\n# and as such it can't be selected in \"Copy Frameworks\" build phase.\ngrdb_source_dir=\"${BUILT_PRODUCTS_DIR}/GRDB.framework\"\ngrdb_install_dir=\"${BUILT_PRODUCTS_DIR}/${FRAMEWORKS_FOLDER_PATH}/GRDB.framework\"\n\n# Remove any existing files in the destination\nrm -rf \"${grdb_install_dir}\"\nmkdir -p \"${grdb_install_dir}\"\n\n# Copy the framework and the Info.plist\ncp -f \"${grdb_source_dir}/GRDB\" \"${grdb_source_dir}/Info.plist\" \"${grdb_install_dir}\"\n\n# Sign the framework directory contents\n/usr/bin/codesign \\\n --force \\\n --sign \"${EXPANDED_CODE_SIGN_IDENTITY}\" \\\n --timestamp=none \\\n --preserve-metadata=identifier,entitlements,flags \\\n --generate-entitlement-der \"${grdb_install_dir}\"\n"; showEnvVarsInLog = 0; }; FDC6056F2C7167F7009B3D45 /* Build LibSession if Needed */ = { @@ -5771,6 +5833,7 @@ 942256952C23F8DD00C0FDBF /* AttributedText.swift in Sources */, 942256992C23F8DD00C0FDBF /* Toast.swift in Sources */, C331FF972558FA6B00070591 /* Fonts.swift in Sources */, + FD8A5B022DBEFF73004C689B /* SessionNetworkScreen+Models.swift in Sources */, FD71165828E436E800B47552 /* Modal.swift in Sources */, FD0150582CA27DF3005B08A1 /* ScrollableLabel.swift in Sources */, FD37E9D328A1FCDB003AE748 /* Theme+OceanDark.swift in Sources */, @@ -5778,6 +5841,7 @@ 942256972C23F8DD00C0FDBF /* SessionSearchBar.swift in Sources */, FD71165928E436E800B47552 /* ConfirmationModal.swift in Sources */, 7BBBDC44286EAD2D00747E59 /* TappableLabel.swift in Sources */, + FD8A5B252DC05B16004C689B /* Number+Utilities.swift in Sources */, FD09B7E328865FDA00ED0B66 /* HighlightMentionBackgroundView.swift in Sources */, FD3FAB632AEB9A1500DC5421 /* ToastController.swift in Sources */, FD2272E42C35134B004D8A6C /* Data+Utilities.swift in Sources */, @@ -5789,6 +5853,7 @@ FDF848F129406A30007DCAE5 /* Format.swift in Sources */, FD8A5B112DBF34BD004C689B /* Date+Utilities.swift in Sources */, FDB348632BE3774000B716C2 /* BezierPathView.swift in Sources */, + FD8A5B292DC060E2004C689B /* Double+Utilities.swift in Sources */, FD8A5B0E2DBF2DB1004C689B /* SessionHostingViewController.swift in Sources */, 7BA1E0E82A8087DB00123D0D /* SwiftUI+Utilities.swift in Sources */, FD37E9C828A1D73F003AE748 /* Theme+Colors.swift in Sources */, @@ -5808,6 +5873,7 @@ 7BF8D1FB2A70AF57005F1D6E /* SwiftUI+Theme.swift in Sources */, FDB11A612DD5BDCC00BEF49F /* ImageDataManager.swift in Sources */, FD6673FD2D77F54600041530 /* ScreenLockViewController.swift in Sources */, + FD8A5B222DC0489C004C689B /* AdaptiveHStack.swift in Sources */, FDE754BE2C9BA16C002A2623 /* UIButtonConfiguration+Utilities.swift in Sources */, FDBB25E72988BBBE00F1508E /* UIContextualAction+Theming.swift in Sources */, C331FFE02558FB0000070591 /* SearchBar.swift in Sources */, @@ -5816,11 +5882,17 @@ FD52090328B4680F006098F6 /* RadioButton.swift in Sources */, C331FFE82558FB0000070591 /* SNTextView.swift in Sources */, FD71162028D97ABC00B47552 /* UIImage+Utilities.swift in Sources */, + 947D7FE72D51837200E8E413 /* ArrowCapsule.swift in Sources */, + 947D7FE82D51837200E8E413 /* PopoverView.swift in Sources */, + FD8A5B202DC03337004C689B /* AdaptiveText.swift in Sources */, + 947D7FE92D51837200E8E413 /* Text+CopyButton.swift in Sources */, FD71162A28DA83DF00B47552 /* GradientView.swift in Sources */, FD37E9D728A20B5D003AE748 /* UIColor+Utilities.swift in Sources */, + 94A6B9DB2DD6BF7C00DB4B44 /* Constants+URLs.swift in Sources */, FD37E9C328A1C6F3003AE748 /* ThemeManager.swift in Sources */, FD8A5B0A2DBF246A004C689B /* Constants.swift in Sources */, C331FF9A2558FA6B00070591 /* Values.swift in Sources */, + FD8A5B012DBEFF6D004C689B /* SessionNetworkScreen.swift in Sources */, FD37E9C628A1D4EC003AE748 /* Theme+ClassicDark.swift in Sources */, FD16AB5F2A1DD98F0083D849 /* ProfilePictureView.swift in Sources */, C331FFE42558FB0000070591 /* SessionButton.swift in Sources */, @@ -5921,6 +5993,10 @@ FDF848BD29405C5A007DCAE5 /* GetMessagesRequest.swift in Sources */, FD2272B02C33E337004D8A6C /* NetworkError.swift in Sources */, FD19363C2ACA3134004BCF0F /* ResponseInfo+SnodeAPI.swift in Sources */, + 947D7FD42D509FC900E8E413 /* SessionNetworkAPI+Models.swift in Sources */, + 947D7FD62D509FC900E8E413 /* SessionNetworkAPI.swift in Sources */, + 947D7FD72D509FC900E8E413 /* SessionNetworkAPI+Network.swift in Sources */, + 947D7FD82D509FC900E8E413 /* SessionNetworkAPI+Database.swift in Sources */, FDF848DB29405C5B007DCAE5 /* DeleteMessagesResponse.swift in Sources */, FDF848E629405D6E007DCAE5 /* Destination.swift in Sources */, FDF848CC29405C5B007DCAE5 /* SnodeReceivedMessage.swift in Sources */, @@ -5962,6 +6038,7 @@ FD3765E92ADE1AAE00DC1489 /* UnrevokeSubaccountResponse.swift in Sources */, FD5E93D22C12B0580038C25A /* AppVersionResponse.swift in Sources */, FD2272B42C33E337004D8A6C /* SwarmDrainBehaviour.swift in Sources */, + 941375BB2D5184C20058F244 /* HTTPHeader+SessionNetwork.swift in Sources */, FD2272AE2C33E337004D8A6C /* HTTPQueryParam.swift in Sources */, FD17D7AE27F41C4300122BE0 /* SnodeReceivedMessageInfo.swift in Sources */, FD2272C42C34E9AA004D8A6C /* BencodeResponse.swift in Sources */, @@ -5974,6 +6051,7 @@ files = ( FD2272C82C34EB0A004D8A6C /* Job.swift in Sources */, FD2272C72C34EAF5004D8A6C /* ColumnExpressible.swift in Sources */, + 945D9C582D6FDBE7003C4C0C /* _005_AddJobUniqueHash.swift in Sources */, FDB3486E2BE8457F00B716C2 /* BackgroundTaskManager.swift in Sources */, 7B1D74B027C365960030B423 /* Timer+MainThread.swift in Sources */, FD428B192B4B576F006D0888 /* AppContext.swift in Sources */, @@ -6003,6 +6081,7 @@ FD7F745F2BAAA3B4006DDFD8 /* TypeConversion+Utilities.swift in Sources */, FDE755062C9BB4EE002A2623 /* Bencode.swift in Sources */, C32C5DD2256DD9E5003C73A2 /* LRUCache.swift in Sources */, + 941375BD2D5195F30058F244 /* KeyValueStore.swift in Sources */, FD71160228C8255900B47552 /* UIControl+Combine.swift in Sources */, FD2272E02C3502BE004D8A6C /* Setting+Theme.swift in Sources */, FDE519F72AB7CDC700450C53 /* Result+Utilities.swift in Sources */, @@ -6011,7 +6090,7 @@ FD3765EA2ADE37B400DC1489 /* Authentication.swift in Sources */, FD17D7CA27F546D900122BE0 /* _001_InitialSetupMigration.swift in Sources */, FDE755202C9BC1A6002A2623 /* CacheConfig.swift in Sources */, - FD4BB22C2D63FA8600D0DC3D /* _005_AddJobUniqueHash.swift in Sources */, + FD4BB22C2D63FA8600D0DC3D /* (null) in Sources */, FDE755192C9BC169002A2623 /* UIImage+Utilities.swift in Sources */, C3BBE0AA2554D4DE0050F1E3 /* Dictionary+Utilities.swift in Sources */, FD97B2402A3FEB050027DD57 /* ARC4RandomNumberGenerator.swift in Sources */, @@ -6037,6 +6116,7 @@ FD17D7C327F5204C00122BE0 /* Database+Utilities.swift in Sources */, FDE754DD2C9BAF8A002A2623 /* Mnemonic.swift in Sources */, FDBEE52E2B6A18B900C143A0 /* UserDefaultsConfig.swift in Sources */, + 947D7FE32D5181F400E8E413 /* _006_RenameTableSettingToKeyValueStore.swift in Sources */, FDC438CD27BC641200C60D73 /* Set+Utilities.swift in Sources */, B8856DE6256F15F2001CE70E /* String+SSK.swift in Sources */, FDE754E02C9BAF8A002A2623 /* Hex.swift in Sources */, @@ -6543,10 +6623,12 @@ 7B1B52D828580C6D006069F2 /* EmojiPickerSheet.swift in Sources */, 940943402C7ED62300D9D2E0 /* StartupError.swift in Sources */, FD368A6A29DE9E30000DBF1E /* UIContextualAction+Utilities.swift in Sources */, + 947D7FDE2D5180F200E8E413 /* SessionNetworkScreen+ViewModel.swift in Sources */, FDEF57252C3CF04C00131302 /* (null) in Sources */, 7B4C75CD26BB92060000AC89 /* DeletedMessageView.swift in Sources */, FDD250722837234B00198BDA /* MediaGalleryNavigationController.swift in Sources */, FDD2506E283711D600198BDA /* DifferenceKit+Utilities.swift in Sources */, + 942ADDD42D9F9613006E0BB0 /* NewTagView.swift in Sources */, B897621C25D201F7004F83B2 /* RoundIconButton.swift in Sources */, 346B66311F4E29B200E5122F /* CropScaleImageViewController.swift in Sources */, FD1C98E4282E3C5B00B76F9E /* UINavigationBar+Utilities.swift in Sources */, @@ -7873,7 +7955,7 @@ HEADER_SEARCH_PATHS = "$(BUILT_PRODUCTS_DIR)/include/**"; IPHONEOS_DEPLOYMENT_TARGET = 15.6; LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util"; - MARKETING_VERSION = 2.10.6; + MARKETING_VERSION = 2.11.0; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "-Werror=protocol"; OTHER_SWIFT_FLAGS = "-D DEBUG -Xfrontend -warn-long-expression-type-checking=100"; @@ -7948,7 +8030,7 @@ HEADER_SEARCH_PATHS = "$(BUILT_PRODUCTS_DIR)/include/**"; IPHONEOS_DEPLOYMENT_TARGET = 15.6; LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util"; - MARKETING_VERSION = 2.10.6; + MARKETING_VERSION = 2.11.0; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = ( "-DNS_BLOCK_ASSERTIONS=1", @@ -8013,6 +8095,7 @@ "$(SRCROOT)", ); LLVM_LTO = NO; + OTHER_LDFLAGS = "$(inherited)"; OTHER_SWIFT_FLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger"; PRODUCT_NAME = Session; @@ -8077,6 +8160,7 @@ "$(SRCROOT)", ); LLVM_LTO = NO; + OTHER_LDFLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger"; PRODUCT_NAME = Session; PROVISIONING_PROFILE = ""; @@ -8486,7 +8570,7 @@ ); IPHONEOS_DEPLOYMENT_TARGET = 15.6; LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util"; - MARKETING_VERSION = 2.10.6; + MARKETING_VERSION = 2.11.0; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = ( "-fobjc-arc-exceptions", @@ -9118,7 +9202,7 @@ ); IPHONEOS_DEPLOYMENT_TARGET = 15.6; LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util"; - MARKETING_VERSION = 2.10.6; + MARKETING_VERSION = 2.11.0; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = ( "-DNS_BLOCK_ASSERTIONS=1", diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index 43f40ad9ea..78826b73f4 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -374,6 +374,8 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi public override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) + viewModel.dependencies[singleton: .notificationsManager].scheduleSessionNetworkPageLocalNotifcation(force: false) + self.viewHasAppeared = true self.autoLoadNextPageIfNeeded() } diff --git a/Session/Home/New Conversation/NewMessageScreen.swift b/Session/Home/New Conversation/NewMessageScreen.swift index 4717950006..932333508c 100644 --- a/Session/Home/New Conversation/NewMessageScreen.swift +++ b/Session/Home/New Conversation/NewMessageScreen.swift @@ -133,7 +133,6 @@ struct NewMessageScreen: View { struct EnterAccountIdScreen: View { @Binding var accountIdOrONS: String @Binding var error: String? - @State var isTextFieldInErrorMode: Bool = false var continueAction: () -> () var body: some View { diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index 2d279e11d0..fcf7a3c55c 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -670,6 +670,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD self?.startPollersIfNeeded() + SessionNetworkAPI.client.initialize(using: dependencies) + if dependencies[singleton: .appContext].isMainApp { DispatchQueue.main.async { self?.handleAppActivatedWithOngoingCallIfNeeded() diff --git a/Session/Meta/Images.xcassets/Settings/icon_session_network.imageset/Contents.json b/Session/Meta/Images.xcassets/Settings/icon_session_network.imageset/Contents.json new file mode 100644 index 0000000000..c6f97a05d9 --- /dev/null +++ b/Session/Meta/Images.xcassets/Settings/icon_session_network.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Vector.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Session/Meta/Images.xcassets/Settings/icon_session_network.imageset/Vector.pdf b/Session/Meta/Images.xcassets/Settings/icon_session_network.imageset/Vector.pdf new file mode 100644 index 0000000000..406b7288d0 Binary files /dev/null and b/Session/Meta/Images.xcassets/Settings/icon_session_network.imageset/Vector.pdf differ diff --git a/Session/Meta/Images.xcassets/Swarm/Contents.json b/Session/Meta/Images.xcassets/Swarm/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/Session/Meta/Images.xcassets/Swarm/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Session/Meta/Images.xcassets/Swarm/connection_1.imageset/Contents.json b/Session/Meta/Images.xcassets/Swarm/connection_1.imageset/Contents.json new file mode 100644 index 0000000000..a882fb173a --- /dev/null +++ b/Session/Meta/Images.xcassets/Swarm/connection_1.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "session_node_lines_1.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Session/Meta/Images.xcassets/Swarm/connection_1.imageset/session_node_lines_1.svg b/Session/Meta/Images.xcassets/Swarm/connection_1.imageset/session_node_lines_1.svg new file mode 100644 index 0000000000..d34b8df5e0 --- /dev/null +++ b/Session/Meta/Images.xcassets/Swarm/connection_1.imageset/session_node_lines_1.svg @@ -0,0 +1,136 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Session/Meta/Images.xcassets/Swarm/connection_10.imageset/Contents.json b/Session/Meta/Images.xcassets/Swarm/connection_10.imageset/Contents.json new file mode 100644 index 0000000000..686a7d6e78 --- /dev/null +++ b/Session/Meta/Images.xcassets/Swarm/connection_10.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "session_node_lines_10.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Session/Meta/Images.xcassets/Swarm/connection_10.imageset/session_node_lines_10.svg b/Session/Meta/Images.xcassets/Swarm/connection_10.imageset/session_node_lines_10.svg new file mode 100644 index 0000000000..9157f5b034 --- /dev/null +++ b/Session/Meta/Images.xcassets/Swarm/connection_10.imageset/session_node_lines_10.svg @@ -0,0 +1,40 @@ + + + + + + diff --git a/Session/Meta/Images.xcassets/Swarm/connection_2.imageset/Contents.json b/Session/Meta/Images.xcassets/Swarm/connection_2.imageset/Contents.json new file mode 100644 index 0000000000..53f1696366 --- /dev/null +++ b/Session/Meta/Images.xcassets/Swarm/connection_2.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "session_node_lines_2.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Session/Meta/Images.xcassets/Swarm/connection_2.imageset/session_node_lines_2.svg b/Session/Meta/Images.xcassets/Swarm/connection_2.imageset/session_node_lines_2.svg new file mode 100644 index 0000000000..2140958549 --- /dev/null +++ b/Session/Meta/Images.xcassets/Swarm/connection_2.imageset/session_node_lines_2.svg @@ -0,0 +1,39 @@ + + + + + + diff --git a/Session/Meta/Images.xcassets/Swarm/connection_3.imageset/Contents.json b/Session/Meta/Images.xcassets/Swarm/connection_3.imageset/Contents.json new file mode 100644 index 0000000000..bf43819f4b --- /dev/null +++ b/Session/Meta/Images.xcassets/Swarm/connection_3.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "session_node_lines_3.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Session/Meta/Images.xcassets/Swarm/connection_3.imageset/session_node_lines_3.svg b/Session/Meta/Images.xcassets/Swarm/connection_3.imageset/session_node_lines_3.svg new file mode 100644 index 0000000000..39d5159ed6 --- /dev/null +++ b/Session/Meta/Images.xcassets/Swarm/connection_3.imageset/session_node_lines_3.svg @@ -0,0 +1,41 @@ + + + + + + diff --git a/Session/Meta/Images.xcassets/Swarm/connection_4.imageset/Contents.json b/Session/Meta/Images.xcassets/Swarm/connection_4.imageset/Contents.json new file mode 100644 index 0000000000..d5a152f272 --- /dev/null +++ b/Session/Meta/Images.xcassets/Swarm/connection_4.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "session_node_lines_4.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Session/Meta/Images.xcassets/Swarm/connection_4.imageset/session_node_lines_4.svg b/Session/Meta/Images.xcassets/Swarm/connection_4.imageset/session_node_lines_4.svg new file mode 100644 index 0000000000..d97600c765 --- /dev/null +++ b/Session/Meta/Images.xcassets/Swarm/connection_4.imageset/session_node_lines_4.svg @@ -0,0 +1,41 @@ + + + + + + diff --git a/Session/Meta/Images.xcassets/Swarm/connection_5.imageset/Contents.json b/Session/Meta/Images.xcassets/Swarm/connection_5.imageset/Contents.json new file mode 100644 index 0000000000..2c0560a945 --- /dev/null +++ b/Session/Meta/Images.xcassets/Swarm/connection_5.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "session_node_lines_5.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Session/Meta/Images.xcassets/Swarm/connection_5.imageset/session_node_lines_5.svg b/Session/Meta/Images.xcassets/Swarm/connection_5.imageset/session_node_lines_5.svg new file mode 100644 index 0000000000..2ff1ecc81c --- /dev/null +++ b/Session/Meta/Images.xcassets/Swarm/connection_5.imageset/session_node_lines_5.svg @@ -0,0 +1,48 @@ + + + + + + + + diff --git a/Session/Meta/Images.xcassets/Swarm/connection_6.imageset/Contents.json b/Session/Meta/Images.xcassets/Swarm/connection_6.imageset/Contents.json new file mode 100644 index 0000000000..d6b8bf66e7 --- /dev/null +++ b/Session/Meta/Images.xcassets/Swarm/connection_6.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "session_node_lines_6.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Session/Meta/Images.xcassets/Swarm/connection_6.imageset/session_node_lines_6.svg b/Session/Meta/Images.xcassets/Swarm/connection_6.imageset/session_node_lines_6.svg new file mode 100644 index 0000000000..b85b280f0a --- /dev/null +++ b/Session/Meta/Images.xcassets/Swarm/connection_6.imageset/session_node_lines_6.svg @@ -0,0 +1,40 @@ + + + + + + diff --git a/Session/Meta/Images.xcassets/Swarm/connection_7.imageset/Contents.json b/Session/Meta/Images.xcassets/Swarm/connection_7.imageset/Contents.json new file mode 100644 index 0000000000..2fed5b907d --- /dev/null +++ b/Session/Meta/Images.xcassets/Swarm/connection_7.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "session_node_lines_7.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Session/Meta/Images.xcassets/Swarm/connection_7.imageset/session_node_lines_7.svg b/Session/Meta/Images.xcassets/Swarm/connection_7.imageset/session_node_lines_7.svg new file mode 100644 index 0000000000..a9d7c487df --- /dev/null +++ b/Session/Meta/Images.xcassets/Swarm/connection_7.imageset/session_node_lines_7.svg @@ -0,0 +1,40 @@ + + + + + + diff --git a/Session/Meta/Images.xcassets/Swarm/connection_8.imageset/Contents.json b/Session/Meta/Images.xcassets/Swarm/connection_8.imageset/Contents.json new file mode 100644 index 0000000000..b1503f0794 --- /dev/null +++ b/Session/Meta/Images.xcassets/Swarm/connection_8.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "session_node_lines_8.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Session/Meta/Images.xcassets/Swarm/connection_8.imageset/session_node_lines_8.svg b/Session/Meta/Images.xcassets/Swarm/connection_8.imageset/session_node_lines_8.svg new file mode 100644 index 0000000000..f7440f8ed2 --- /dev/null +++ b/Session/Meta/Images.xcassets/Swarm/connection_8.imageset/session_node_lines_8.svg @@ -0,0 +1,40 @@ + + + + + + diff --git a/Session/Meta/Images.xcassets/Swarm/connection_9.imageset/Contents.json b/Session/Meta/Images.xcassets/Swarm/connection_9.imageset/Contents.json new file mode 100644 index 0000000000..14a095fb9e --- /dev/null +++ b/Session/Meta/Images.xcassets/Swarm/connection_9.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "session_node_lines_9.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Session/Meta/Images.xcassets/Swarm/connection_9.imageset/session_node_lines_9.svg b/Session/Meta/Images.xcassets/Swarm/connection_9.imageset/session_node_lines_9.svg new file mode 100644 index 0000000000..7cd4977d44 --- /dev/null +++ b/Session/Meta/Images.xcassets/Swarm/connection_9.imageset/session_node_lines_9.svg @@ -0,0 +1,44 @@ + + + + + + + + diff --git a/Session/Meta/Images.xcassets/Swarm/snodes_1.imageset/Contents.json b/Session/Meta/Images.xcassets/Swarm/snodes_1.imageset/Contents.json new file mode 100644 index 0000000000..2414c350de --- /dev/null +++ b/Session/Meta/Images.xcassets/Swarm/snodes_1.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "session_nodes_1.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Session/Meta/Images.xcassets/Swarm/snodes_1.imageset/session_nodes_1.svg b/Session/Meta/Images.xcassets/Swarm/snodes_1.imageset/session_nodes_1.svg new file mode 100644 index 0000000000..0d373714ed --- /dev/null +++ b/Session/Meta/Images.xcassets/Swarm/snodes_1.imageset/session_nodes_1.svg @@ -0,0 +1,148 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Session/Meta/Images.xcassets/Swarm/snodes_10.imageset/Contents.json b/Session/Meta/Images.xcassets/Swarm/snodes_10.imageset/Contents.json new file mode 100644 index 0000000000..be9618e8f5 --- /dev/null +++ b/Session/Meta/Images.xcassets/Swarm/snodes_10.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "session_nodes_10.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Session/Meta/Images.xcassets/Swarm/snodes_10.imageset/session_nodes_10.svg b/Session/Meta/Images.xcassets/Swarm/snodes_10.imageset/session_nodes_10.svg new file mode 100644 index 0000000000..f02ddebac6 --- /dev/null +++ b/Session/Meta/Images.xcassets/Swarm/snodes_10.imageset/session_nodes_10.svg @@ -0,0 +1,1221 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Session/Meta/Images.xcassets/Swarm/snodes_2.imageset/Contents.json b/Session/Meta/Images.xcassets/Swarm/snodes_2.imageset/Contents.json new file mode 100644 index 0000000000..2e42cea1a8 --- /dev/null +++ b/Session/Meta/Images.xcassets/Swarm/snodes_2.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "session_nodes_2.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Session/Meta/Images.xcassets/Swarm/snodes_2.imageset/session_nodes_2.svg b/Session/Meta/Images.xcassets/Swarm/snodes_2.imageset/session_nodes_2.svg new file mode 100644 index 0000000000..542c961edf --- /dev/null +++ b/Session/Meta/Images.xcassets/Swarm/snodes_2.imageset/session_nodes_2.svg @@ -0,0 +1,261 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Session/Meta/Images.xcassets/Swarm/snodes_3.imageset/Contents.json b/Session/Meta/Images.xcassets/Swarm/snodes_3.imageset/Contents.json new file mode 100644 index 0000000000..33ed549bba --- /dev/null +++ b/Session/Meta/Images.xcassets/Swarm/snodes_3.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "session_nodes_3.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Session/Meta/Images.xcassets/Swarm/snodes_3.imageset/session_nodes_3.svg b/Session/Meta/Images.xcassets/Swarm/snodes_3.imageset/session_nodes_3.svg new file mode 100644 index 0000000000..f4943b5685 --- /dev/null +++ b/Session/Meta/Images.xcassets/Swarm/snodes_3.imageset/session_nodes_3.svg @@ -0,0 +1,389 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Session/Meta/Images.xcassets/Swarm/snodes_4.imageset/Contents.json b/Session/Meta/Images.xcassets/Swarm/snodes_4.imageset/Contents.json new file mode 100644 index 0000000000..d0d47b4a93 --- /dev/null +++ b/Session/Meta/Images.xcassets/Swarm/snodes_4.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "session_nodes_4.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Session/Meta/Images.xcassets/Swarm/snodes_4.imageset/session_nodes_4.svg b/Session/Meta/Images.xcassets/Swarm/snodes_4.imageset/session_nodes_4.svg new file mode 100644 index 0000000000..cd60119443 --- /dev/null +++ b/Session/Meta/Images.xcassets/Swarm/snodes_4.imageset/session_nodes_4.svg @@ -0,0 +1,505 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Session/Meta/Images.xcassets/Swarm/snodes_5.imageset/Contents.json b/Session/Meta/Images.xcassets/Swarm/snodes_5.imageset/Contents.json new file mode 100644 index 0000000000..6bdacc6288 --- /dev/null +++ b/Session/Meta/Images.xcassets/Swarm/snodes_5.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "session_nodes_5.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Session/Meta/Images.xcassets/Swarm/snodes_5.imageset/session_nodes_5.svg b/Session/Meta/Images.xcassets/Swarm/snodes_5.imageset/session_nodes_5.svg new file mode 100644 index 0000000000..aabace8d0a --- /dev/null +++ b/Session/Meta/Images.xcassets/Swarm/snodes_5.imageset/session_nodes_5.svg @@ -0,0 +1,472 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Session/Meta/Images.xcassets/Swarm/snodes_6.imageset/Contents.json b/Session/Meta/Images.xcassets/Swarm/snodes_6.imageset/Contents.json new file mode 100644 index 0000000000..a5f811415e --- /dev/null +++ b/Session/Meta/Images.xcassets/Swarm/snodes_6.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "session_nodes_6.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Session/Meta/Images.xcassets/Swarm/snodes_6.imageset/session_nodes_6.svg b/Session/Meta/Images.xcassets/Swarm/snodes_6.imageset/session_nodes_6.svg new file mode 100644 index 0000000000..50bbf3fbb9 --- /dev/null +++ b/Session/Meta/Images.xcassets/Swarm/snodes_6.imageset/session_nodes_6.svg @@ -0,0 +1,749 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Session/Meta/Images.xcassets/Swarm/snodes_7.imageset/Contents.json b/Session/Meta/Images.xcassets/Swarm/snodes_7.imageset/Contents.json new file mode 100644 index 0000000000..5c90a4258c --- /dev/null +++ b/Session/Meta/Images.xcassets/Swarm/snodes_7.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "session_nodes_7.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Session/Meta/Images.xcassets/Swarm/snodes_7.imageset/session_nodes_7.svg b/Session/Meta/Images.xcassets/Swarm/snodes_7.imageset/session_nodes_7.svg new file mode 100644 index 0000000000..73af97c9ae --- /dev/null +++ b/Session/Meta/Images.xcassets/Swarm/snodes_7.imageset/session_nodes_7.svg @@ -0,0 +1,868 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Session/Meta/Images.xcassets/Swarm/snodes_8.imageset/Contents.json b/Session/Meta/Images.xcassets/Swarm/snodes_8.imageset/Contents.json new file mode 100644 index 0000000000..bb35d9d3c0 --- /dev/null +++ b/Session/Meta/Images.xcassets/Swarm/snodes_8.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "session_nodes_8.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Session/Meta/Images.xcassets/Swarm/snodes_8.imageset/session_nodes_8.svg b/Session/Meta/Images.xcassets/Swarm/snodes_8.imageset/session_nodes_8.svg new file mode 100644 index 0000000000..f72819bd2b --- /dev/null +++ b/Session/Meta/Images.xcassets/Swarm/snodes_8.imageset/session_nodes_8.svg @@ -0,0 +1,987 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Session/Meta/Images.xcassets/Swarm/snodes_9.imageset/Contents.json b/Session/Meta/Images.xcassets/Swarm/snodes_9.imageset/Contents.json new file mode 100644 index 0000000000..58c8abb193 --- /dev/null +++ b/Session/Meta/Images.xcassets/Swarm/snodes_9.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "session_nodes_9.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Session/Meta/Images.xcassets/Swarm/snodes_9.imageset/session_nodes_9.svg b/Session/Meta/Images.xcassets/Swarm/snodes_9.imageset/session_nodes_9.svg new file mode 100644 index 0000000000..31a75796f9 --- /dev/null +++ b/Session/Meta/Images.xcassets/Swarm/snodes_9.imageset/session_nodes_9.svg @@ -0,0 +1,1106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Session/Meta/SessionApp.swift b/Session/Meta/SessionApp.swift index bd7808a1a0..d4be294dbe 100644 --- a/Session/Meta/SessionApp.swift +++ b/Session/Meta/SessionApp.swift @@ -162,6 +162,24 @@ public class SessionApp: SessionAppType { } } + /// Show Session Network Page for this release. We'll be able to extend this fuction to show other screens that is new + /// or we want to promote in the future. + public func showPromotedScreen() { + guard let homeViewController: HomeVC = self.homeViewController else { return } + + let viewController: SessionHostingViewController = SessionHostingViewController( + rootView: SessionNetworkScreen( + viewModel: SessionNetworkScreenContent.ViewModel(dependencies: dependencies) + ) + ) + viewController.setNavBarTitle(Constants.network_name) + viewController.setUpDismissingButton(on: .left) + + let navigationController = StyledNavigationController(rootViewController: viewController) + navigationController.modalPresentationStyle = .fullScreen + homeViewController.present(navigationController, animated: true, completion: nil) + } + // MARK: - Internal Functions private func creatingThreadIfNeededThenRunOnMain( @@ -249,6 +267,7 @@ public protocol SessionAppType { ) func createNewConversation() func resetData(onReset: (() -> ())) + func showPromotedScreen() } public extension SessionAppType { diff --git a/Session/Meta/Translations/Localizable.xcstrings b/Session/Meta/Translations/Localizable.xcstrings index 84227af9ac..280314118e 100644 --- a/Session/Meta/Translations/Localizable.xcstrings +++ b/Session/Meta/Translations/Localizable.xcstrings @@ -1,6 +1,12 @@ { "sourceLanguage" : "en", "strings" : { + "" : { + + }, + "%@" : { + + }, "about" : { "extractionState" : "manual", "localizations" : { diff --git a/Session/Notifications/NotificationActionHandler.swift b/Session/Notifications/NotificationActionHandler.swift index a952882bd5..cc6260310c 100644 --- a/Session/Notifications/NotificationActionHandler.swift +++ b/Session/Notifications/NotificationActionHandler.swift @@ -57,13 +57,21 @@ public class NotificationActionHandler { let userInfo: [AnyHashable: Any] = response.notification.request.content.userInfo let applicationState: UIApplication.State = UIApplication.shared.applicationState + let categoryIdentifier = response.notification.request.content.categoryIdentifier switch response.actionIdentifier { case UNNotificationDefaultActionIdentifier: Log.debug("[NotificationActionHandler] Default action") - return showThread(userInfo: userInfo) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() + switch categoryIdentifier { + case AppNotificationCategory.info.identifier: + return showPromotedScreen() + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + default: + return showThread(userInfo: userInfo) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } case UNNotificationDismissActionIdentifier: // TODO - mark as read? @@ -84,13 +92,26 @@ public class NotificationActionHandler { switch action { case .markAsRead: return markAsRead(userInfo: userInfo) - case .reply: guard let textInputResponse = response as? UNTextInputNotificationResponse else { return Fail(error: NotificationError.failDebug("response had unexpected type: \(response)")) .eraseToAnyPublisher() } + return reply( + userInfo: userInfo, + replyText: textInputResponse.userText, + applicationState: applicationState + ) + + // TODO: Remove in future release + case .deprecatedMarkAsRead: return markAsRead(userInfo: userInfo) + case .deprecatedReply: + guard let textInputResponse = response as? UNTextInputNotificationResponse else { + return Fail(error: NotificationError.failDebug("response had unexpected type: \(response)")) + .eraseToAnyPublisher() + } + return reply( userInfo: userInfo, replyText: textInputResponse.userText, @@ -222,6 +243,11 @@ public class NotificationActionHandler { return Just(()).eraseToAnyPublisher() } + func showPromotedScreen() -> AnyPublisher { + dependencies[singleton: .app].showPromotedScreen() + return Just(()).eraseToAnyPublisher() + } + private func markAsRead(threadId: String) -> AnyPublisher { return dependencies[singleton: .storage] .writePublisher { [dependencies] db in diff --git a/Session/Notifications/NotificationPresenter.swift b/Session/Notifications/NotificationPresenter.swift index c6d2b3ee0d..fcf7d5850a 100644 --- a/Session/Notifications/NotificationPresenter.swift +++ b/Session/Notifications/NotificationPresenter.swift @@ -417,6 +417,31 @@ public class NotificationPresenter: NSObject, UNUserNotificationCenterDelegate, notificationCenter.removeAllPendingNotificationRequests() notificationCenter.removeAllDeliveredNotifications() } + + // MARK: - Schedule New Session Network Page local notifcation + + public func scheduleSessionNetworkPageLocalNotifcation(force: Bool) { + guard dependencies[defaults: .standard, key: .isSessionNetworkPageNotificationScheduled] != true || force else { return } + + let identifier: String = "sessionNetworkPageLocalNotifcation_\(UUID().uuidString)" // stringlint:disable + + // Schedule the notification after 1 hour + scheduleNotification( + category: AppNotificationCategory.info, + title: Constants.app_name, + body: "sessionNetworkNotificationLive" + .put(key: "token_name_long", value: Constants.token_name_long) + .put(key: "network_name", value: Constants.network_name) + .localized(), + after: (force ? 10 : 3600), + userInfo: [:], + sound: Preferences.Sound.defaultNotificationSound, + applicationState: dependencies[singleton: .appContext].reportedApplicationState, + identifier: identifier + ) + + dependencies[defaults: .standard, key: .isSessionNetworkPageNotificationScheduled] = true + } } // MARK: - Convenience @@ -562,6 +587,84 @@ private extension NotificationPresenter { _mostRecentNotifications.performUpdate { $0.appending(nowMs) } return true } + + private func scheduleNotification( + category: AppNotificationCategory, + title: String?, + body: String, + date: DateComponents, + userInfo: [AnyHashable : Any], + sound: Preferences.Sound?, + applicationState: UIApplication.State, + identifier: String? + ) { + let content = UNMutableNotificationContent() + content.categoryIdentifier = category.identifier + content.userInfo = userInfo + content.title = title ?? Constants.app_name + content.body = body + + if let sound = sound, sound != .none { + content.sound = sound.notificationSound(isQuiet: (applicationState == .active)) + } + + let notificationIdentifier: String = (identifier ?? UUID().uuidString) + + let trigger = UNCalendarNotificationTrigger(dateMatching: date, repeats: false) + + let request = UNNotificationRequest( + identifier: notificationIdentifier, + content: content, + trigger: trigger + ) + + notificationCenter.add(request) { error in + if let error = error { + Log.debug("Failed to schedule notification: \(error.localizedDescription)") + } else { + Log.debug("Schedule notification successful with id: \(notificationIdentifier)") + } + } + } + + private func scheduleNotification( + category: AppNotificationCategory, + title: String?, + body: String, + after timeInterval: TimeInterval, + userInfo: [AnyHashable: Any], + sound: Preferences.Sound?, + applicationState: UIApplication.State, + identifier: String? + ) { + let content = UNMutableNotificationContent() + content.categoryIdentifier = category.identifier + content.userInfo = userInfo + content.title = title ?? Constants.app_name + content.body = body + + if let sound = sound, sound != .none { + content.sound = sound.notificationSound(isQuiet: (applicationState == .active)) + } + + let notificationIdentifier: String = (identifier ?? UUID().uuidString) + + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: timeInterval, repeats: false) + + let request = UNNotificationRequest( + identifier: notificationIdentifier, + content: content, + trigger: trigger + ) + + notificationCenter.add(request) { error in + if let error = error { + Log.debug("Failed to schedule notification: \(error.localizedDescription)") + } else { + Log.debug("Schedule notification successful with id: \(notificationIdentifier)") + } + } + } } enum NotificationError: Error { diff --git a/Session/Notifications/Types/AppNotificationAction.swift b/Session/Notifications/Types/AppNotificationAction.swift index 5bf32db8c0..ed7c168e9b 100644 --- a/Session/Notifications/Types/AppNotificationAction.swift +++ b/Session/Notifications/Types/AppNotificationAction.swift @@ -7,13 +7,22 @@ import Foundation enum AppNotificationAction: CaseIterable { case markAsRead case reply + + // TODO: Remove in future release + case deprecatedMarkAsRead + case deprecatedReply } extension AppNotificationAction { var identifier: String { switch self { - case .markAsRead: return "Signal.AppNotifications.Action.markAsRead" - case .reply: return "Signal.AppNotifications.Action.reply" + case .markAsRead: return "Session.AppNotifications.Action.markAsRead" + case .reply: return "Session.AppNotifications.Action.reply" + + // TODO: Remove in future release + case .deprecatedMarkAsRead: return "Signal.AppNotifications.Action.markAsRead" + case .deprecatedReply: return "Signal.AppNotifications.Action.reply" + } } } diff --git a/Session/Notifications/Types/AppNotificationCategory.swift b/Session/Notifications/Types/AppNotificationCategory.swift index 4e9ca02046..b8ad84528c 100644 --- a/Session/Notifications/Types/AppNotificationCategory.swift +++ b/Session/Notifications/Types/AppNotificationCategory.swift @@ -8,22 +8,31 @@ enum AppNotificationCategory: CaseIterable { case incomingMessage case errorMessage case threadlessErrorMessage + case info + + // TODO: Remove in future release + case deprecatedIncomingMessage } extension AppNotificationCategory { var identifier: String { switch self { - case .incomingMessage: return "Signal.AppNotificationCategory.incomingMessage" - case .errorMessage: return "Signal.AppNotificationCategory.errorMessage" - case .threadlessErrorMessage: return "Signal.AppNotificationCategory.threadlessErrorMessage" + case .incomingMessage: return "Session.AppNotificationCategory.incomingMessage" + case .errorMessage: return "Session.AppNotificationCategory.errorMessage" + case .threadlessErrorMessage: return "Session.AppNotificationCategory.threadlessErrorMessage" + case .info: return " Session.AppNotificationCategory.info" + + // TODO: Remove in future release + case .deprecatedIncomingMessage: return "Signal.AppNotificationCategory.incomingMessage" } } var actions: [AppNotificationAction] { switch self { case .incomingMessage: return [.markAsRead, .reply] - case .errorMessage: return [] - case .threadlessErrorMessage: return [] + // TODO: Remove in future release + case .deprecatedIncomingMessage: return [.markAsRead, .reply] + default: return [] } } } diff --git a/Session/Notifications/UserNotificationConfig.swift b/Session/Notifications/UserNotificationConfig.swift index 970e1c007a..33814e9923 100644 --- a/Session/Notifications/UserNotificationConfig.swift +++ b/Session/Notifications/UserNotificationConfig.swift @@ -43,6 +43,22 @@ class UserNotificationConfig { textInputButtonTitle: "send".localized(), textInputPlaceholder: "" ) + + // TODO: Remove in future release + case .deprecatedMarkAsRead: + return UNNotificationAction( + identifier: action.identifier, + title: "messageMarkRead".localized(), + options: [] + ) + case .deprecatedReply: + return UNTextInputNotificationAction( + identifier: action.identifier, + title: "reply".localized(), + options: [], + textInputButtonTitle: "send".localized(), + textInputPlaceholder: "" + ) } } diff --git a/Session/Settings/DeveloperSettingsViewModel.swift b/Session/Settings/DeveloperSettingsViewModel.swift index c347d9e474..86f8536df1 100644 --- a/Session/Settings/DeveloperSettingsViewModel.swift +++ b/Session/Settings/DeveloperSettingsViewModel.swift @@ -35,6 +35,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, public enum Section: SessionTableSection { case developerMode + case sessionNetwork case general case logging case network @@ -45,6 +46,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, var title: String? { switch self { case .developerMode: return nil + case .sessionNetwork: return "Session Network" case .general: return "General" case .logging: return "Logging" case .network: return "Network" @@ -65,6 +67,9 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, public enum TableItem: Hashable, Differentiable, CaseIterable { case developerMode + case versionBlindedID + case scheduleLocalNotification + case animationsEnabled case showStringKeys @@ -126,6 +131,9 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, case .updatedGroupsDeleteBeforeNow: return "updatedGroupsDeleteBeforeNow" case .updatedGroupsDeleteAttachmentsBeforeNow: return "updatedGroupsDeleteAttachmentsBeforeNow" + case .versionBlindedID: return "versionBlindedID" + case .scheduleLocalNotification: return "scheduleLocalNotification" + case .createMockContacts: return "createMockContacts" case .copyDatabasePath: return "copyDatabasePath" case .forceSlowDatabaseQueries: return "forceSlowDatabaseQueries" @@ -167,6 +175,9 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, case .updatedGroupsDeleteBeforeNow: result.append(.updatedGroupsDeleteBeforeNow); fallthrough case .updatedGroupsDeleteAttachmentsBeforeNow: result.append(.updatedGroupsDeleteAttachmentsBeforeNow); fallthrough + case .versionBlindedID: result.append(.versionBlindedID); fallthrough + case .scheduleLocalNotification: result.append(.scheduleLocalNotification); fallthrough + case .createMockContacts: result.append(.createMockContacts); fallthrough case .copyDatabasePath: result.append(.copyDatabasePath); fallthrough case .forceSlowDatabaseQueries: result.append(.forceSlowDatabaseQueries); fallthrough @@ -182,6 +193,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, private struct State: Equatable { let developerMode: Bool + let versionBlindedID: String? let animationsEnabled: Bool let showStringKeys: Bool @@ -213,8 +225,22 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, lazy var observation: TargetObservation = ObservationBuilder .refreshableData(self) { [weak self, dependencies] () -> State in - State( + let versionBlindedID: String? = { + guard + let userEdKeyPair: KeyPair = dependencies[singleton: .storage].read({ Identity.fetchUserEd25519KeyPair($0) }), + let blinded07KeyPair: KeyPair = dependencies[singleton: .crypto].generate( + .versionBlinded07KeyPair(ed25519SecretKey: userEdKeyPair.secretKey) + ) + else { + return nil + } + return SessionId(.versionBlinded07, publicKey: blinded07KeyPair.publicKey).hexString + }() + + + return State( developerMode: dependencies[singleton: .storage, key: .developerModeEnabled], + versionBlindedID: versionBlindedID, animationsEnabled: dependencies[feature: .animationsEnabled], showStringKeys: dependencies[feature: .showStringKeys], @@ -737,6 +763,41 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, ) ] ) + let sessionNetwork: SectionModel = SectionModel( + model: .sessionNetwork, + elements: [ + (current.versionBlindedID == nil ? nil : + SessionCell.Info( + id: .versionBlindedID, + title: "Version Blinded ID", + subtitle: current.versionBlindedID!, + trailingAccessory: .button( + style: .bordered, + title: "copy".localized(), + run: { [weak self] button in + self?.copyVersionBlindedID(current.versionBlindedID!, button: button) + } + ) + ) + ), + SessionCell.Info( + id: .scheduleLocalNotification, + title: "Schedule Local Notification", + subtitle: """ + Schedule a local notifcation in 10 seconds from click + + Note: local scheduled notifcations are not reliable on Simulators + """, + trailingAccessory: .button( + style: .bordered, + title: "Fire", + run: { [weak self] button in + self?.scheduleLocalNotification(button: button) + } + ) + ) + ].compactMap { $0 } + ) return [ developerMode, @@ -745,6 +806,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, network, disappearingMessages, groups, + sessionNetwork, database ] } @@ -757,6 +819,9 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, TableItem.allCases.forEach { item in switch item { case .developerMode: break // Not a feature + case .versionBlindedID: break // Not a feature + case .scheduleLocalNotification: break // Not a feature + case .animationsEnabled: guard dependencies.hasSet(feature: .animationsEnabled) else { return } @@ -860,7 +925,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, self.dismissScreen(type: .pop) } - + private func updateDefaulLogLevel(to updatedDefaultLogLevel: Log.Level?) { dependencies.set(feature: .logLevel(cat: .default), to: updatedDefaultLogLevel) forceRefresh(type: .databaseQuery) @@ -1182,6 +1247,78 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, ) } + // MARK: - SESH + + private func scheduleLocalNotification(button: SessionButton?) { + dependencies[singleton: .notificationsManager].scheduleSessionNetworkPageLocalNotifcation(force: true) + + guard let button: SessionButton = button else { return } + + // Ensure we are on the main thread just in case + DispatchQueue.main.async { + button.isUserInteractionEnabled = false + + UIView.transition( + with: button, + duration: 0.25, + options: .transitionCrossDissolve, + animations: { + button.setTitle("Fired", for: .normal) + }, + completion: { _ in + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(12)) { + button.isUserInteractionEnabled = true + + UIView.transition( + with: button, + duration: 0.25, + options: .transitionCrossDissolve, + animations: { + button.setTitle("Fire", for: .normal) + }, + completion: nil + ) + } + } + ) + } + } + + private func copyVersionBlindedID(_ versionBlindedID: String, button: SessionButton?) { + UIPasteboard.general.string = versionBlindedID + + guard let button: SessionButton = button else { return } + + // Ensure we are on the main thread just in case + DispatchQueue.main.async { + button.isUserInteractionEnabled = false + + UIView.transition( + with: button, + duration: 0.25, + options: .transitionCrossDissolve, + animations: { + button.setTitle("copied".localized(), for: .normal) + }, + completion: { _ in + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(4)) { + button.isUserInteractionEnabled = true + + UIView.transition( + with: button, + duration: 0.25, + options: .transitionCrossDissolve, + animations: { + button.setTitle("copy".localized(), for: .normal) + }, + completion: nil + ) + } + } + ) + } + } + // MARK: - Export and Import private func exportDatabase(_ targetView: UIView?) { diff --git a/Session/Settings/SessionNetworkScreen/SessionNetworkScreen+ViewModel.swift b/Session/Settings/SessionNetworkScreen/SessionNetworkScreen+ViewModel.swift new file mode 100644 index 0000000000..dd4db28cfa --- /dev/null +++ b/Session/Settings/SessionNetworkScreen/SessionNetworkScreen+ViewModel.swift @@ -0,0 +1,119 @@ +// Copyright © 2024 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SwiftUI +import Combine +import GRDB +import SessionUIKit +import SessionSnodeKit +import SessionUtilitiesKit +import SessionMessagingKit + +extension SessionNetworkScreenContent { + public class ViewModel: ObservableObject, ViewModelType { + @Published public var dataModel: DataModel + @Published public var isRefreshing: Bool = false + @Published public var lastRefreshWasSuccessful: Bool = false + @Published public var errorString: String? = nil + @Published public var lastUpdatedTimeString: String? = nil + + private var observationCancellable: AnyCancellable? + private var dependencies: Dependencies + + private var disposables = Set() + + private var timer: Timer? = nil + + init(dependencies: Dependencies) { + self.dependencies = dependencies + self.dataModel = DataModel() + + let userSessionId: SessionId = dependencies[cache: .general].sessionId + self.observationCancellable = ValueObservation + .tracking { [dependencies] db in + let swarmNodesCount: Int = dependencies[cache: .libSessionNetwork].snodeNumber[userSessionId.hexString] ?? 0 + let snodeInTotal: Int = { + let pathsCount: Int = dependencies[cache: .libSessionNetwork].currentPaths.count + let validThreadVariants: [SessionThread.Variant] = [.contact, .group, .legacyGroup] + let convosInTotal: Int = ( + try? SessionThread + .filter(validThreadVariants.contains(SessionThread.Columns.variant)) + .fetchAll(db) + ) + .defaulting(to: []) + .count + let calculatedSnodeInTotal = swarmNodesCount + pathsCount * 3 + convosInTotal * 6 + if let networkSize = db[.networkSize] { + return min(networkSize, calculatedSnodeInTotal) + } + return calculatedSnodeInTotal + }() + + return DataModel( + snodesInCurrentSwarm: swarmNodesCount, + snodesInTotal: snodeInTotal, + contractAddress: db[.contractAddress], + tokenUSD: db[.tokenUsd], + priceTimestampMs: db[.priceTimestampMs] ?? 0, + stakingRequirement: db[.stakingRequirement] ?? 0, + networkSize: db[.networkSize] ?? 0, + networkStakedTokens: db[.networkStakedTokens] ?? 0, + networkStakedUSD: db[.networkStakedUSD] ?? 0, + stakingRewardPool: db[.stakingRewardPool], + marketCapUSD: db[.marketCapUsd], + lastUpdatedTimestampMs: db[.lastUpdatedTimestampMs] + ) + } + .publisher(in: dependencies[singleton: .storage], scheduling: .immediate) + .sink( + receiveCompletion: { _ in /* ignore error */ }, + receiveValue: { [weak self] dataModel in + self?.dataModel = dataModel + self?.updateLastUpdatedTimeString() + } + ) + } + + public func fetchDataFromNetwork() { + guard !self.isRefreshing else { return } + self.isRefreshing.toggle() + self.lastRefreshWasSuccessful = false + + SessionNetworkAPI.client.getInfo(using: dependencies) + .subscribe(on: SessionNetworkAPI.workQueue, using: dependencies) + .receive(on: DispatchQueue.main) + .sink( + receiveCompletion: { _ in }, + receiveValue: { [weak self] didRefreshSuccessfully in + self?.lastRefreshWasSuccessful = didRefreshSuccessfully + self?.isRefreshing.toggle() + } + ) + .store(in: &disposables) + } + + public func openURL(_ url: URL) { + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } + + private func updateLastUpdatedTimeString() { + self.lastUpdatedTimeString = { + guard let lastUpdatedTimestampMs = dataModel.lastUpdatedTimestampMs else { return nil } + return String.formattedRelativeTime( + lastUpdatedTimestampMs, + minimumUnit: .minute + ) + }() + self.timer?.invalidate() + self.timer = Timer.scheduledTimerOnMainThread(withTimeInterval: 60, repeats: true, using: dependencies) { [weak self] _ in + self?.lastUpdatedTimeString = { + guard let lastUpdatedTimestampMs = self?.dataModel.lastUpdatedTimestampMs else { return nil } + return String.formattedRelativeTime( + lastUpdatedTimestampMs, + minimumUnit: .minute + ) + }() + } + } + } +} diff --git a/Session/Settings/SettingsViewModel.swift b/Session/Settings/SettingsViewModel.swift index 4066a079f2..a56ec8c899 100644 --- a/Session/Settings/SettingsViewModel.swift +++ b/Session/Settings/SettingsViewModel.swift @@ -76,6 +76,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl case messageRequests case appearance case inviteAFriend + case sessionNetwork case recoveryPhrase case help case developerSettings @@ -250,179 +251,227 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl ) ] ) - let menus: SectionModel = SectionModel( - model: .menus, - elements: [ - SessionCell.Info( - id: .path, - leadingAccessory: .custom( - info: PathStatusViewAccessory.Info() - ), - title: "onionRoutingPath".localized(), - onTap: { [weak self, dependencies] in self?.transitionToScreen(PathVC(using: dependencies)) } + var menuElements: [SessionCell.Info] = [] + menuElements.append( + SessionCell.Info( + id: .path, + leadingAccessory: .custom( + info: PathStatusViewAccessory.Info() ), - SessionCell.Info( - id: .privacy, - leadingAccessory: .icon( - UIImage(named: "icon_privacy")? - .withRenderingMode(.alwaysTemplate) - ), - title: "sessionPrivacy".localized(), - onTap: { [weak self, dependencies] in - self?.transitionToScreen( - SessionTableViewController(viewModel: PrivacySettingsViewModel(using: dependencies)) - ) - } + title: "onionRoutingPath".localized(), + onTap: { [weak self, dependencies] in self?.transitionToScreen(PathVC(using: dependencies)) } + ) + ) + menuElements.append( + SessionCell.Info( + id: .privacy, + leadingAccessory: .icon( + UIImage(named: "icon_privacy")? + .withRenderingMode(.alwaysTemplate) ), - SessionCell.Info( - id: .notifications, - leadingAccessory: .icon( - UIImage(named: "icon_speaker")? - .withRenderingMode(.alwaysTemplate) - ), - title: "sessionNotifications".localized(), - onTap: { [weak self, dependencies] in - self?.transitionToScreen( - SessionTableViewController(viewModel: NotificationSettingsViewModel(using: dependencies)) - ) - } + title: "sessionPrivacy".localized(), + onTap: { [weak self, dependencies] in + self?.transitionToScreen( + SessionTableViewController(viewModel: PrivacySettingsViewModel(using: dependencies)) + ) + } + ) + ) + menuElements.append( + SessionCell.Info( + id: .notifications, + leadingAccessory: .icon( + UIImage(named: "icon_speaker")? + .withRenderingMode(.alwaysTemplate) ), - SessionCell.Info( - id: .conversations, - leadingAccessory: .icon( - UIImage(named: "icon_msg")? - .withRenderingMode(.alwaysTemplate) - ), - title: "sessionConversations".localized(), - onTap: { [weak self, dependencies] in - self?.transitionToScreen( - SessionTableViewController(viewModel: ConversationSettingsViewModel(using: dependencies)) - ) - } + title: "sessionNotifications".localized(), + onTap: { [weak self, dependencies] in + self?.transitionToScreen( + SessionTableViewController(viewModel: NotificationSettingsViewModel(using: dependencies)) + ) + } + ) + ) + menuElements.append( + SessionCell.Info( + id: .conversations, + leadingAccessory: .icon( + UIImage(named: "icon_msg")? + .withRenderingMode(.alwaysTemplate) ), - SessionCell.Info( - id: .messageRequests, - leadingAccessory: .icon( - UIImage(named: "icon_msg_req")? - .withRenderingMode(.alwaysTemplate) - ), - title: "sessionMessageRequests".localized(), - onTap: { [weak self, dependencies] in - self?.transitionToScreen( - SessionTableViewController(viewModel: MessageRequestsViewModel(using: dependencies)) - ) - } + title: "sessionConversations".localized(), + onTap: { [weak self, dependencies] in + self?.transitionToScreen( + SessionTableViewController(viewModel: ConversationSettingsViewModel(using: dependencies)) + ) + } + ) + ) + menuElements.append( + SessionCell.Info( + id: .messageRequests, + leadingAccessory: .icon( + UIImage(named: "icon_msg_req")? + .withRenderingMode(.alwaysTemplate) ), - SessionCell.Info( - id: .appearance, - leadingAccessory: .icon( - UIImage(named: "icon_apperance")? - .withRenderingMode(.alwaysTemplate) - ), - title: "sessionAppearance".localized(), - onTap: { [weak self, dependencies] in - self?.transitionToScreen( - SessionTableViewController(viewModel: AppearanceViewModel(using: dependencies)) - ) - } + title: "sessionMessageRequests".localized(), + onTap: { [weak self, dependencies] in + self?.transitionToScreen( + SessionTableViewController(viewModel: MessageRequestsViewModel(using: dependencies)) + ) + } + ) + ) + menuElements.append( + SessionCell.Info( + id: .appearance, + leadingAccessory: .icon( + UIImage(named: "icon_apperance")? + .withRenderingMode(.alwaysTemplate) ), - SessionCell.Info( - id: .inviteAFriend, - leadingAccessory: .icon( - UIImage(named: "icon_invite")? - .withRenderingMode(.alwaysTemplate) - ), - title: "sessionInviteAFriend".localized(), - onTap: { [weak self] in - let invitation: String = "accountIdShare" - .put(key: "app_name", value: Constants.app_name) - .put(key: "account_id", value: state.profile.id) - .put(key: "session_download_url", value: Constants.session_download_url) - .localized() - - self?.transitionToScreen( - UIActivityViewController( - activityItems: [ invitation ], - applicationActivities: nil - ), - transitionType: .present - ) - } + title: "sessionAppearance".localized(), + onTap: { [weak self, dependencies] in + self?.transitionToScreen( + SessionTableViewController(viewModel: AppearanceViewModel(using: dependencies)) + ) + } + ) + ) + menuElements.append( + SessionCell.Info( + id: .inviteAFriend, + leadingAccessory: .icon( + UIImage(named: "icon_invite")? + .withRenderingMode(.alwaysTemplate) ), - (state.hideRecoveryPasswordPermanently ? nil : - SessionCell.Info( - id: .recoveryPhrase, - leadingAccessory: .icon( - UIImage(named: "SessionShield")? - .withRenderingMode(.alwaysTemplate) - ), - title: "sessionRecoveryPassword".localized(), - accessibility: Accessibility( - identifier: "Recovery password menu item", - label: "Recovery password menu item" + title: "sessionInviteAFriend".localized(), + onTap: { [weak self] in + let invitation: String = "accountIdShare" + .put(key: "app_name", value: Constants.app_name) + .put(key: "account_id", value: state.profile.id) + .put(key: "session_download_url", value: Constants.session_download_url) + .localized() + + self?.transitionToScreen( + UIActivityViewController( + activityItems: [ invitation ], + applicationActivities: nil ), - onTap: { [weak self, dependencies] in - guard let recoveryPasswordView: RecoveryPasswordScreen = try? RecoveryPasswordScreen(using: dependencies) else { - let targetViewController: UIViewController = ConfirmationModal( - info: ConfirmationModal.Info( - title: "theError".localized(), - body: .text("recoveryPasswordErrorLoad".localized()), - cancelTitle: "okay".localized(), - cancelStyle: .alert_text - ) - ) - self?.transitionToScreen(targetViewController, transitionType: .present) - return - } - - let viewController: SessionHostingViewController = SessionHostingViewController(rootView: recoveryPasswordView) - viewController.setNavBarTitle("sessionRecoveryPassword".localized()) - self?.transitionToScreen(viewController) - } + transitionType: .present ) + } + ) + ) + menuElements.append( + SessionCell.Info( + id: .sessionNetwork, + leadingAccessory: .icon( + UIImage(named: "icon_session_network")? + .withRenderingMode(.alwaysTemplate) ), + title: Constants.network_name, + trailingAccessory: .custom( + info: NewTagView.Info() + ), + onTap: { [weak self, dependencies] in + let viewController: SessionHostingViewController = SessionHostingViewController( + rootView: SessionNetworkScreen( + viewModel: SessionNetworkScreenContent.ViewModel(dependencies: dependencies) + ) + ) + viewController.setNavBarTitle(Constants.network_name) + self?.transitionToScreen(viewController) + } + ) + ) + + if !state.hideRecoveryPasswordPermanently { + menuElements.append( SessionCell.Info( - id: .help, + id: .recoveryPhrase, leadingAccessory: .icon( - UIImage(named: "icon_help")? + UIImage(named: "SessionShield")? .withRenderingMode(.alwaysTemplate) ), - title: "sessionHelp".localized(), + title: "sessionRecoveryPassword".localized(), + accessibility: Accessibility( + identifier: "Recovery password menu item", + label: "Recovery password menu item" + ), onTap: { [weak self, dependencies] in - self?.transitionToScreen( - SessionTableViewController(viewModel: HelpViewModel(using: dependencies)) - ) - } - ), - (!state.developerModeEnabled ? nil : - SessionCell.Info( - id: .developerSettings, - leadingAccessory: .icon( - UIImage(systemName: "wrench.and.screwdriver")? - .withRenderingMode(.alwaysTemplate) - ), - title: "Developer Settings", // stringlint:ignore - styling: SessionCell.StyleInfo(tintColor: .warning), - onTap: { [weak self, dependencies] in - self?.transitionToScreen( - SessionTableViewController(viewModel: DeveloperSettingsViewModel(using: dependencies)) + guard let recoveryPasswordView: RecoveryPasswordScreen = try? RecoveryPasswordScreen(using: dependencies) else { + let targetViewController: UIViewController = ConfirmationModal( + info: ConfirmationModal.Info( + title: "theError".localized(), + body: .text("recoveryPasswordErrorLoad".localized()), + cancelTitle: "okay".localized(), + cancelStyle: .alert_text + ) ) + self?.transitionToScreen(targetViewController, transitionType: .present) + return } - ) + + let viewController: SessionHostingViewController = SessionHostingViewController(rootView: recoveryPasswordView) + viewController.setNavBarTitle("sessionRecoveryPassword".localized()) + self?.transitionToScreen(viewController) + } + ) + ) + } + + menuElements.append( + SessionCell.Info( + id: .help, + leadingAccessory: .icon( + UIImage(named: "icon_help")? + .withRenderingMode(.alwaysTemplate) ), + title: "sessionHelp".localized(), + onTap: { [weak self, dependencies] in + self?.transitionToScreen( + SessionTableViewController(viewModel: HelpViewModel(using: dependencies)) + ) + } + ) + ) + + if state.developerModeEnabled { + menuElements.append( SessionCell.Info( - id: .clearData, + id: .developerSettings, leadingAccessory: .icon( - Lucide.image(icon: .trash2, size: 24)? + UIImage(systemName: "wrench.and.screwdriver")? .withRenderingMode(.alwaysTemplate) ), - title: "sessionClearData".localized(), - styling: SessionCell.StyleInfo(tintColor: .danger), + title: "Developer Settings", // stringlint:ignore + styling: SessionCell.StyleInfo(tintColor: .warning), onTap: { [weak self, dependencies] in - self?.transitionToScreen(NukeDataModal(using: dependencies), transitionType: .present) + self?.transitionToScreen( + SessionTableViewController(viewModel: DeveloperSettingsViewModel(using: dependencies)) + ) } ) - ].compactMap { $0 } + ) + } + + menuElements.append( + SessionCell.Info( + id: .clearData, + leadingAccessory: .icon( + Lucide.image(icon: .trash2, size: 24)? + .withRenderingMode(.alwaysTemplate) + ), + title: "sessionClearData".localized(), + styling: SessionCell.StyleInfo(tintColor: .danger), + onTap: { [weak self, dependencies] in + self?.transitionToScreen(NukeDataModal(using: dependencies), transitionType: .present) + } + ) + ) + let menus: SectionModel = SectionModel( + model: .menus, + elements: menuElements ) return [profileInfo, sessionId, menus] diff --git a/Session/Settings/Views/NewTagView.swift b/Session/Settings/Views/NewTagView.swift new file mode 100644 index 0000000000..5e3b4ef490 --- /dev/null +++ b/Session/Settings/Views/NewTagView.swift @@ -0,0 +1,62 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import SessionUIKit +import SessionUtilitiesKit + +final class NewTagView: UIView { + public static let size: SessionCell.Accessory.Size = .fillWidthWrapHeight + + // MARK: - Components + + private lazy var newTagLabel: UILabel = { + let result = UILabel() + result.font = .systemFont(ofSize: Values.verySmallFontSize) + result.textAlignment = .natural + result.attributedText = "sessionNew".localizedFormatted(in: result) + + return result + }() + + // MARK: - Initializtion + + init() { + super.init(frame: .zero) + + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("Use init(color:) instead") + } + + // MARK: - Layout + + private func setupUI() { + addSubview(newTagLabel) + newTagLabel.pin(.leading, to: .leading, of: self, withInset: -Values.mediumSpacing + Values.verySmallSpacing) + newTagLabel.pin([ UIView.VerticalEdge.top, UIView.VerticalEdge.bottom, UIView.HorizontalEdge.trailing ], to: self) + } + + // MARK: - Content + + func update() { + newTagLabel.attributedText = "sessionNew".localizedFormatted(in: newTagLabel) + } +} + +// MARK: - Info + +extension NewTagView: SessionCell.Accessory.CustomView { + struct Info: Equatable, SessionCell.Accessory.CustomViewInfo { + typealias View = NewTagView + } + + static func create(maxContentWidth: CGFloat, using dependencies: Dependencies) -> NewTagView { + return NewTagView() + } + + func update(with info: Info) { + update() + } +} diff --git a/SessionMessagingKit/Open Groups/Models/SOGSMessage.swift b/SessionMessagingKit/Open Groups/Models/SOGSMessage.swift index 9b9f385139..da11fef449 100644 --- a/SessionMessagingKit/Open Groups/Models/SOGSMessage.swift +++ b/SessionMessagingKit/Open Groups/Models/SOGSMessage.swift @@ -96,7 +96,7 @@ extension OpenGroupAPI.Message { throw NetworkError.parsingFailed } - case (.some, .none), (.none, _), (_, .group): + case (.some, .none), (.none, _), (_, .group), (_, .versionBlinded07): Log.info("Ignoring message with invalid sender.") throw NetworkError.parsingFailed } diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index 41f2a4b2c9..2b9f470b08 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -924,7 +924,7 @@ public final class OpenGroupManager { .filter(targetRoles.contains(GroupMember.Columns.role)) .isNotEmpty(db) - case .group: return false + case .group, .versionBlinded07: return false } } } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift index cd6f415215..75d3bee423 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift @@ -114,7 +114,7 @@ extension MessageReceiver { .standardIncoming ) - case .group: + case .group, .versionBlinded07: Log.info(.messageReceiver, "Ignoring message with invalid sender.") throw MessageReceiverError.invalidSender } diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsProtocol.swift b/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsProtocol.swift index 43e1b572c7..2dab980912 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsProtocol.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsProtocol.swift @@ -33,6 +33,8 @@ public protocol NotificationsManagerType { func notifyUser(_ db: Database, forReaction reaction: Reaction, in thread: SessionThread, applicationState: UIApplication.State) func notifyForFailedSend(_ db: Database, in thread: SessionThread, applicationState: UIApplication.State) + func scheduleSessionNetworkPageLocalNotifcation(force: Bool) + func cancelNotifications(identifiers: [String]) func clearAllNotifications() } @@ -58,6 +60,8 @@ public struct NoopNotificationsManager: NotificationsManagerType { public func notifyUser(_ db: Database, forReaction reaction: Reaction, in thread: SessionThread, applicationState: UIApplication.State) {} public func notifyForFailedSend(_ db: Database, in thread: SessionThread, applicationState: UIApplication.State) {} + public func scheduleSessionNetworkPageLocalNotifcation(force: Bool) {} + public func cancelNotifications(identifiers: [String]) {} public func clearAllNotifications() {} } diff --git a/SessionMessagingKitTests/_TestUtilities/MockNotificationsManager.swift b/SessionMessagingKitTests/_TestUtilities/MockNotificationsManager.swift index bb083074f8..e4ed767686 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockNotificationsManager.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockNotificationsManager.swift @@ -56,6 +56,10 @@ public class MockNotificationsManager: Mock, Notificat mockNoReturn(args: [thread, applicationState], untrackedArgs: [db]) } + public func scheduleSessionNetworkPageLocalNotifcation(force: Bool) { + mockNoReturn(args: [force]) + } + public func cancelNotifications(identifiers: [String]) { mockNoReturn(args: [identifiers]) } diff --git a/SessionNotificationServiceExtension/NSENotificationPresenter.swift b/SessionNotificationServiceExtension/NSENotificationPresenter.swift index db61e29fe9..54cc8cf079 100644 --- a/SessionNotificationServiceExtension/NSENotificationPresenter.swift +++ b/SessionNotificationServiceExtension/NSENotificationPresenter.swift @@ -286,6 +286,8 @@ public class NSENotificationPresenter: NotificationsManagerType { // Not possible in the NotificationServiceExtension } + public func scheduleSessionNetworkPageLocalNotifcation(force: Bool) {} + // MARK: - Clearing public func cancelNotifications(identifiers: [String]) { diff --git a/SessionSnodeKit/Crypto/Crypto+SessionSnodeKit.swift b/SessionSnodeKit/Crypto/Crypto+SessionSnodeKit.swift index 9badd8070d..94fed80795 100644 --- a/SessionSnodeKit/Crypto/Crypto+SessionSnodeKit.swift +++ b/SessionSnodeKit/Crypto/Crypto+SessionSnodeKit.swift @@ -58,3 +58,85 @@ internal extension Crypto.Generator { } } } + +// MARK: - Version Blinded ID + +public extension Crypto.Generator { + static func versionBlinded07KeyPair( + ed25519SecretKey: [UInt8] + ) -> Crypto.Generator { + return Crypto.Generator( + id: "versionBlinded07KeyPair", + args: [ed25519SecretKey] + ) { + var cEd25519SecretKey: [UInt8] = Array(ed25519SecretKey) + var cBlindedPubkey: [UInt8] = [UInt8](repeating: 0, count: 32) + var cBlindedSeckey: [UInt8] = [UInt8](repeating: 0, count: 64) + + guard + cEd25519SecretKey.count == 64, + session_blind_version_key_pair( + &cEd25519SecretKey, + &cBlindedPubkey, + &cBlindedSeckey + ) + else { throw CryptoError.keyGenerationFailed } + + return KeyPair(publicKey: cBlindedPubkey, secretKey: cBlindedSeckey) + } + } + + static func signatureVersionBlind07( + timestamp: UInt64, + method: String, + path: String, + body: String?, + ed25519SecretKey: [UInt8] + ) -> Crypto.Generator<[UInt8]> { + return Crypto.Generator( + id: "signatureVersionBlind07", + args: [timestamp, method, path, body, ed25519SecretKey] + ) { + var cEd25519SecretKey: [UInt8] = Array(ed25519SecretKey) + guard + cEd25519SecretKey.count == 64, + var cMethod: [CChar] = method.cString(using: .utf8), + var cPath: [CChar] = path.cString(using: .utf8) + else { + throw CryptoError.signatureGenerationFailed + } + + var cSignature: [UInt8] = [UInt8](repeating: 0, count: 64) + + if let body: String = body { + var cBody: [UInt8] = Array(body.bytes) + guard session_blind_version_sign_request( + &cEd25519SecretKey, + timestamp, + &cMethod, + &cPath, + &cBody, + cBody.count, + &cSignature + ) else { + throw CryptoError.signatureGenerationFailed + } + } else { + guard session_blind_version_sign_request( + &cEd25519SecretKey, + timestamp, + &cMethod, + &cPath, + nil, + 0, + &cSignature + ) + else { + throw CryptoError.signatureGenerationFailed + } + } + + return cSignature + } + } +} diff --git a/SessionSnodeKit/LibSession/LibSession+Networking.swift b/SessionSnodeKit/LibSession/LibSession+Networking.swift index d2f83bbc66..74f2fd1dba 100644 --- a/SessionSnodeKit/LibSession/LibSession+Networking.swift +++ b/SessionSnodeKit/LibSession/LibSession+Networking.swift @@ -67,7 +67,16 @@ class LibSessionNetwork: NetworkType { CallbackWrapper.run(ctx, .success(nodes)) }, ctx); } - .tryMap { result in try result.successOrThrow() } + .tryMap { [dependencies = self.dependencies] result in + dependencies + .mutate(cache: .libSessionNetwork) { + $0.setSnodeNumber( + publicKey: swarmPublicKey, + value: (try? result.get())?.count ?? 0 + ) + } + return try result.successOrThrow() + } .eraseToAnyPublisher() } @@ -643,6 +652,7 @@ public extension LibSession { private var network: UnsafeMutablePointer? = nil private let _paths: CurrentValueSubject<[[Snode]], Never> = CurrentValueSubject([]) private let _networkStatus: CurrentValueSubject = CurrentValueSubject(.unknown) + private let _snodeNumber: CurrentValueSubject<[String: Int], Never> = .init([:]) public var isSuspended: Bool = false public var networkStatus: AnyPublisher { _networkStatus.eraseToAnyPublisher() } @@ -651,6 +661,7 @@ public extension LibSession { public var hasPaths: Bool { !_paths.value.isEmpty } public var currentPaths: [[Snode]] { _paths.value } public var pathsDescription: String { _paths.value.prettifiedDescription } + public var snodeNumber: [String: Int] { _snodeNumber.value } // MARK: - Initialization @@ -674,6 +685,7 @@ public extension LibSession { // Send completion events to the observables (so they can resubscribe to a future instance) _paths.send(completion: .finished) _networkStatus.send(completion: .finished) + _snodeNumber.send(completion: .finished) // Clear the network changed callbacks (just in case, since we are going to free the // dependenciesPtr) and then free the network object @@ -840,12 +852,27 @@ public extension LibSession { _paths.send(paths) } + public func setSnodeNumber(publicKey: String, value: Int) { + var snodeNumber = _snodeNumber.value + snodeNumber[publicKey] = value + _snodeNumber.send(snodeNumber) + } + public func clearSnodeCache() { switch network { case .none: break case .some(let network): network_clear_cache(network) } } + + public func snodeCacheSize() -> Int { + switch network { + case .none: + return 0 + case .some(let network): + return network_get_snode_cache_size(network) + } + } } // MARK: - NetworkCacheType @@ -859,6 +886,7 @@ public extension LibSession { var hasPaths: Bool { get } var currentPaths: [[Snode]] { get } var pathsDescription: String { get } + var snodeNumber: [String: Int] { get } } protocol NetworkCacheType: NetworkImmutableCacheType, MutableCacheType { @@ -869,13 +897,16 @@ public extension LibSession { var hasPaths: Bool { get } var currentPaths: [[Snode]] { get } var pathsDescription: String { get } + var snodeNumber: [String: Int] { get } func suspendNetworkAccess() func resumeNetworkAccess() func getOrCreateNetwork() -> AnyPublisher?, Error> func setNetworkStatus(status: NetworkStatus) func setPaths(paths: [[Snode]]) + func setSnodeNumber(publicKey: String, value: Int) func clearSnodeCache() + func snodeCacheSize() -> Int } class NoopNetworkCache: NetworkCacheType { @@ -888,6 +919,7 @@ public extension LibSession { public var hasPaths: Bool { return false } public var currentPaths: [[LibSession.Snode]] { [] } public var pathsDescription: String { "" } + public var snodeNumber: [String: Int] { [:] } public func suspendNetworkAccess() {} public func resumeNetworkAccess() {} @@ -898,6 +930,8 @@ public extension LibSession { public func setNetworkStatus(status: NetworkStatus) {} public func setPaths(paths: [[LibSession.Snode]]) {} + public func setSnodeNumber(publicKey: String, value: Int) {} public func clearSnodeCache() {} + public func snodeCacheSize() -> Int { 0 } } } diff --git a/SessionSnodeKit/SessionNetworkAPI/HTTPHeader+SessionNetwork.swift b/SessionSnodeKit/SessionNetworkAPI/HTTPHeader+SessionNetwork.swift new file mode 100644 index 0000000000..464a3b1b9e --- /dev/null +++ b/SessionSnodeKit/SessionNetworkAPI/HTTPHeader+SessionNetwork.swift @@ -0,0 +1,9 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable + +public extension HTTPHeader { + static let tokenServerPubKey: HTTPHeader = "X-FS-Pubkey" + static let tokenServerTimestamp: HTTPHeader = "X-FS-Timestamp" + static let tokenServerSignature: HTTPHeader = "X-FS-Signature" +} diff --git a/SessionSnodeKit/SessionNetworkAPI/SessionNetworkAPI+Database.swift b/SessionSnodeKit/SessionNetworkAPI/SessionNetworkAPI+Database.swift new file mode 100644 index 0000000000..9316b1a440 --- /dev/null +++ b/SessionSnodeKit/SessionNetworkAPI/SessionNetworkAPI+Database.swift @@ -0,0 +1,32 @@ +// Copyright © 2024 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable + +import Foundation +import AudioToolbox +import GRDB +import DifferenceKit +import SessionUtilitiesKit + +public extension KeyValueStore.StringKey { + static let contractAddress: KeyValueStore.StringKey = "contractAddress" +} + +public extension KeyValueStore.DoubleKey { + static let tokenUsd: KeyValueStore.DoubleKey = "tokenUsd" + static let marketCapUsd: KeyValueStore.DoubleKey = "marketCapUsd" + static let stakingRequirement: KeyValueStore.DoubleKey = "stakingRequirement" + static let stakingRewardPool: KeyValueStore.DoubleKey = "stakingRewardPool" + static let networkStakedTokens: KeyValueStore.DoubleKey = "networkStakedTokens" + static let networkStakedUSD: KeyValueStore.DoubleKey = "networkStakedUSD" +} + +public extension KeyValueStore.IntKey { + static let networkSize: KeyValueStore.IntKey = "networkSize" +} + +public extension KeyValueStore.Int64Key { + static let lastUpdatedTimestampMs: KeyValueStore.Int64Key = "lastUpdatedTimestampMs" + static let staleTimestampMs: KeyValueStore.Int64Key = "staleTimestampMs" + static let priceTimestampMs: KeyValueStore.Int64Key = "priceTimestampMs" +} diff --git a/SessionSnodeKit/SessionNetworkAPI/SessionNetworkAPI+Models.swift b/SessionSnodeKit/SessionNetworkAPI/SessionNetworkAPI+Models.swift new file mode 100644 index 0000000000..cd1cfec84c --- /dev/null +++ b/SessionSnodeKit/SessionNetworkAPI/SessionNetworkAPI+Models.swift @@ -0,0 +1,75 @@ +// Copyright © 2024 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable + +import Foundation +import Combine +import GRDB +import SessionUtilitiesKit + +extension SessionNetworkAPI { + + // MARK: - Price + + public struct Price: Codable, Equatable { + enum CodingKeys: String, CodingKey { + case tokenUsd = "usd" + case marketCapUsd = "usd_market_cap" + case priceTimestamp = "t_price" + case staleTimestamp = "t_stale" + } + + public let tokenUsd: Double? // Current token price (USD) + public let marketCapUsd: Double? // Current market cap value in (USD) + public let priceTimestamp: Int64? // The timestamp the price data is accurate at. (seconds) + public let staleTimestamp: Int64? // Stale timestamp for the price data. (seconds) + } + + // MARK: - Token + + public struct Token: Codable, Equatable { + enum CodingKeys: String, CodingKey { + case stakingRequirement = "staking_requirement" + case stakingRewardPool = "staking_reward_pool" + case contractAddress = "contract_address" + } + + public let stakingRequirement: Double? // The number of tokens required to stake a node. This is the effective "token amount" per node (SESH) + public let stakingRewardPool: Double? // The number of tokens in the staking reward pool (SESH) + public let contractAddress: String? // Token contract address (42 char Hexadecimal - Including 0x prefix) + } + + + // MARK: - Network Info + + public struct NetworkInfo: Codable, Equatable { + enum CodingKeys: String, CodingKey { + case networkSize = "network_size" // The number of nodes in the Session Network (integer) + case networkStakedTokens = "network_staked_tokens" // + case networkStakedUSD = "network_staked_usd" // + } + + public let networkSize: Int? + public let networkStakedTokens: Double? + public let networkStakedUSD: Double? + } + + // MARK: - Info + + public struct Info: Codable, Equatable { + enum CodingKeys: String, CodingKey { + case timestamp = "t" + case statusCode = "status_code" + case price + case token + case network + } + + public let timestamp: Int64? // Request timestamp. (seconds) + public let statusCode: Int? // Status code of the request. + public let price: Price? + public let token: Token? + public let network: NetworkInfo? + } +} + diff --git a/SessionSnodeKit/SessionNetworkAPI/SessionNetworkAPI+Network.swift b/SessionSnodeKit/SessionNetworkAPI/SessionNetworkAPI+Network.swift new file mode 100644 index 0000000000..947e55d15d --- /dev/null +++ b/SessionSnodeKit/SessionNetworkAPI/SessionNetworkAPI+Network.swift @@ -0,0 +1,107 @@ +// Copyright © 2024 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable + +import Foundation +import Combine +import GRDB +import SessionUtilitiesKit + +// MARK: - Log.Category + +public extension Log.Category { + static let sessionNetwork: Log.Category = .create("SessionNetwork", defaultLevel: .info) +} + +extension SessionNetworkAPI { + public final class HTTPClient { + private var cancellable: AnyCancellable? + private var dependencies: Dependencies? + + public func initialize(using dependencies: Dependencies) { + self.dependencies = dependencies + cancellable = getInfo(using: dependencies) + .subscribe(on: Threading.workQueue, using: dependencies) + .receive(on: SessionNetworkAPI.workQueue) + .sink(receiveCompletion: { _ in }, receiveValue: { _ in }) + } + + public func getInfo(using dependencies: Dependencies) -> AnyPublisher { + cancellable?.cancel() + + let staleTimestampMs: Int64 = dependencies[singleton: .storage].read { db in db[.staleTimestampMs] }.defaulting(to: 0) + guard staleTimestampMs < dependencies[cache: .snodeAPI].currentOffsetTimestampMs() else { + return Just(()) + .delay(for: .milliseconds(500), scheduler: Threading.workQueue) + .setFailureType(to: Error.self) + .flatMapStorageWritePublisher(using: dependencies) { [dependencies] db, info -> Bool in + db[.lastUpdatedTimestampMs] = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + return true + } + .eraseToAnyPublisher() + } + + return dependencies[singleton: .storage] + .readPublisher { db -> Network.PreparedRequest in + try SessionNetworkAPI + .prepareInfo( + db, + using: dependencies + ) + } + .flatMap { $0.send(using: dependencies) } + .map { _, info in info } + .flatMapStorageWritePublisher(using: dependencies) { [dependencies] db, info -> Bool in + // Token info + db[.lastUpdatedTimestampMs] = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + db[.tokenUsd] = info.price?.tokenUsd + db[.marketCapUsd] = info.price?.marketCapUsd + if let priceTimestamp = info.price?.priceTimestamp { + db[.priceTimestampMs] = priceTimestamp * 1000 + } else { + db[.priceTimestampMs] = nil + } + if let staleTimestamp = info.price?.staleTimestamp { + db[.staleTimestampMs] = staleTimestamp * 1000 + } else { + db[.staleTimestampMs] = nil + } + db[.stakingRequirement] = info.token?.stakingRequirement + db[.stakingRewardPool] = info.token?.stakingRewardPool + db[.contractAddress] = info.token?.contractAddress + // Network info + db[.networkSize] = info.network?.networkSize + db[.networkStakedTokens] = info.network?.networkStakedTokens + db[.networkStakedUSD] = info.network?.networkStakedUSD + + return true + } + .catch { error -> AnyPublisher in + Log.error(.sessionNetwork, "Failed to fetch token info due to error: \(error).") + return self.cleanUpSessionNetworkPageData(using: dependencies) + .map { _ in false } + .eraseToAnyPublisher() + + } + .eraseToAnyPublisher() + } + + private func cleanUpSessionNetworkPageData(using dependencies: Dependencies) -> AnyPublisher { + dependencies[singleton: .storage].writePublisher { db in + // Token info + db[.lastUpdatedTimestampMs] = nil + db[.tokenUsd] = nil + db[.marketCapUsd] = nil + db[.priceTimestampMs] = nil + db[.staleTimestampMs] = nil + db[.stakingRequirement] = nil + db[.stakingRewardPool] = nil + db[.contractAddress] = nil + // Network info + db[.networkSize] = nil + db[.networkStakedTokens] = nil + db[.networkStakedUSD] = nil + } + } + } +} diff --git a/SessionSnodeKit/SessionNetworkAPI/SessionNetworkAPI.swift b/SessionSnodeKit/SessionNetworkAPI/SessionNetworkAPI.swift new file mode 100644 index 0000000000..1d1891a51d --- /dev/null +++ b/SessionSnodeKit/SessionNetworkAPI/SessionNetworkAPI.swift @@ -0,0 +1,131 @@ +// Copyright © 2024 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable + +import Foundation +import Combine +import GRDB +import SessionUtilitiesKit + +public enum SessionNetworkAPI { + public static let workQueue = DispatchQueue(label: "SessionNetworkAPI.workQueue", qos: .userInitiated) + public static let client = HTTPClient() + + // MARK: - Info + + /// General token info. This endpoint combines the `/price` and `/token` endpoint information. + /// + /// `GET/info` + + public static func prepareInfo( + _ db: Database, + using dependencies: Dependencies + ) throws -> Network.PreparedRequest { + return try Network.PreparedRequest( + request: Request( + endpoint: Network.NetworkAPI.Endpoint.info, + destination: .server( + method: .get, + server: Network.NetworkAPI.networkAPIServer, + queryParameters: [:], + x25519PublicKey: Network.NetworkAPI.networkAPIServerPublicKey + ) + ), + responseType: Info.self, + requestAndPathBuildTimeout: Network.defaultTimeout, + using: dependencies + ) + .signed(db, with: SessionNetworkAPI.signRequest, using: dependencies) + } + + // MARK: - Authentication + + fileprivate static func signatureHeaders( + _ db: Database, + url: URL, + method: HTTPMethod, + body: Data?, + using dependencies: Dependencies + ) throws -> [HTTPHeader: String] { + let timestamp: UInt64 = UInt64(floor(dependencies.dateNow.timeIntervalSince1970)) + let path: String = url.path + .appending(url.query.map { value in "?\(value)" }) + + let signResult: (publicKey: String, signature: [UInt8]) = try sign( + db, + timestamp: timestamp, + method: method.rawValue, + path: path, + body: body, + using: dependencies + ) + + return [ + HTTPHeader.tokenServerPubKey: signResult.publicKey, + HTTPHeader.tokenServerTimestamp: "\(timestamp)", + HTTPHeader.tokenServerSignature: signResult.signature.toBase64() + ] + } + + private static func sign( + _ db: Database, + timestamp: UInt64, + method: String, + path: String, + body: Data?, + using dependencies: Dependencies + ) throws -> (publicKey: String, signature: [UInt8]) { + let bodyString: String? = { + guard let bodyData: Data = body else { return nil } + return String(data: bodyData, encoding: .utf8) + }() + + guard + let userEdKeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db), + let blinded07KeyPair: KeyPair = dependencies[singleton: .crypto].generate( + .versionBlinded07KeyPair(ed25519SecretKey: userEdKeyPair.secretKey) + ), + let signatureResult: [UInt8] = dependencies[singleton: .crypto].generate( + .signatureVersionBlind07( + timestamp: timestamp, + method: method, + path: path, + body: bodyString, + ed25519SecretKey: userEdKeyPair.secretKey + ) + ) + else { throw NetworkError.signingFailed } + + return ( + publicKey: SessionId(.versionBlinded07, publicKey: blinded07KeyPair.publicKey).hexString, + signature: signatureResult + ) + } + + private static func signRequest( + _ db: Database, + preparedRequest: Network.PreparedRequest, + using dependencies: Dependencies + ) throws -> Network.Destination { + guard let url: URL = preparedRequest.destination.url else { + throw NetworkError.signingFailed + } + + guard case let .server(info) = preparedRequest.destination else { + throw NetworkError.signingFailed + } + + return .server( + info: info.updated( + with: try signatureHeaders( + db, + url: url, + method: preparedRequest.method, + body: preparedRequest.body, + using: dependencies + ) + ) + ) + } +} + diff --git a/SessionSnodeKit/Types/Network.swift b/SessionSnodeKit/Types/Network.swift index 4114bab307..5913775129 100644 --- a/SessionSnodeKit/Types/Network.swift +++ b/SessionSnodeKit/Types/Network.swift @@ -55,6 +55,27 @@ public enum NetworkStatus { // MARK: - FileServer Convenience public extension Network { + enum NetworkAPI { + static let networkAPIServer = "http://networkv1.getsession.org" + static let networkAPIServerPublicKey = "cbf461a4431dc9174dceef4421680d743a2a0e1a3131fc794240bcb0bc3dd449" + + public enum Endpoint: EndpointType { + case info + case price + case token + + public static var name: String { "NetworkAPI.Endpoint" } + + public var path: String { + switch self { + case .info: return "info" + case .price: return "price" + case .token: return "token" + } + } + } + } + enum FileServer { fileprivate static let fileServer = "http://filev2.getsession.org" fileprivate static let fileServerPublicKey = "da21e1d886c6fbaea313f75298bd64aab03a97ce985b46bb2dad9f2089c8ee59" diff --git a/SessionSnodeKit/Types/NetworkError.swift b/SessionSnodeKit/Types/NetworkError.swift index 8938775521..841e02d363 100644 --- a/SessionSnodeKit/Types/NetworkError.swift +++ b/SessionSnodeKit/Types/NetworkError.swift @@ -8,6 +8,7 @@ public enum NetworkError: Error, Equatable, CustomStringConvertible { case invalidState case invalidURL case invalidPreparedRequest + case signingFailed case forbidden case notFound case parsingFailed @@ -29,6 +30,7 @@ public enum NetworkError: Error, Equatable, CustomStringConvertible { case .invalidState: return "The network is in an invalid state (NetworkError.invalidState)." case .invalidURL: return "Invalid URL (NetworkError.invalidURL)." case .invalidPreparedRequest: return "Invalid PreparedRequest provided (NetworkError.invalidPreparedRequest)." + case .signingFailed: return "Couldn't sign request (NetworkError.signingFailed)." case .forbidden: return "Forbidden (NetworkError.forbidden)." case .notFound: return "Not Found (NetworkError.notFound)." case .parsingFailed: return "Invalid response (NetworkError.parsingFailed)." diff --git a/SessionUIKit/Components/ConfirmationModal.swift b/SessionUIKit/Components/ConfirmationModal.swift index 5c66e26b37..5190ef8e19 100644 --- a/SessionUIKit/Components/ConfirmationModal.swift +++ b/SessionUIKit/Components/ConfirmationModal.swift @@ -131,6 +131,27 @@ public class ConfirmationModal: Modal, UITextFieldDelegate, UITextViewDelegate { private lazy var profileView: ProfilePictureView = ProfilePictureView(size: .hero, dataManager: nil) + private lazy var textToConfirmContainer: UIView = { + let result: UIView = UIView() + result.themeBorderColor = .borderSeparator + result.layer.cornerRadius = 11 + result.layer.borderWidth = 1 + result.isHidden = true + + return result + }() + + private lazy var textToConfirmLabel: UILabel = { + let result: UILabel = UILabel() + result.font = .systemFont(ofSize: Values.smallFontSize) + result.themeTextColor = .alert_text + result.textAlignment = .center + result.lineBreakMode = .byWordWrapping + result.numberOfLines = 0 + + return result + }() + private lazy var confirmButton: UIButton = { let result: UIButton = Modal.createButton( title: "", @@ -150,7 +171,7 @@ public class ConfirmationModal: Modal, UITextFieldDelegate, UITextViewDelegate { }() private lazy var contentStackView: UIStackView = { - let result = UIStackView(arrangedSubviews: [ titleLabel, explanationLabel, warningLabel, textFieldContainer, textViewContainer, imageViewContainer ]) + let result = UIStackView(arrangedSubviews: [ titleLabel, explanationLabel, warningLabel, textFieldContainer, textToConfirmContainer, textViewContainer, imageViewContainer ]) result.axis = .vertical result.spacing = Values.smallSpacing result.isLayoutMarginsRelativeArrangement = true @@ -227,6 +248,9 @@ public class ConfirmationModal: Modal, UITextFieldDelegate, UITextViewDelegate { textFieldContainer.addSubview(textField) textField.pin(to: textFieldContainer, withInset: 12) + textToConfirmContainer.addSubview(textToConfirmLabel) + textToConfirmLabel.pin(to: textToConfirmContainer, withInset: 12) + textViewContainer.addSubview(textView) textViewContainer.addSubview(textViewPlaceholder) textView.pin(to: textViewContainer, withInset: 12) @@ -407,7 +431,14 @@ public class ConfirmationModal: Modal, UITextFieldDelegate, UITextViewDelegate { internalOnBodyTap = onClick contentTapGestureRecognizer.isEnabled = false imageViewTapGestureRecognizer.isEnabled = true - } + + case .inputConfirmation(let explanation, let textToConfirm): + explanationLabel.attributedText = explanation + explanationLabel.scrollMode = .never + explanationLabel.isHidden = (explanation == nil) + textToConfirmLabel.attributedText = textToConfirm + textToConfirmContainer.isHidden = false + } confirmButton.accessibilityIdentifier = info.confirmTitle confirmButton.isAccessibilityElement = true @@ -821,6 +852,11 @@ public extension ConfirmationModal.Info { onClick: ((@escaping (ConfirmationModal.ValueUpdate) -> Void) -> Void) ) + case inputConfirmation( + explanation: NSAttributedString?, + textToConfirm: NSAttributedString? + ) + public static func == (lhs: ConfirmationModal.Info.Body, rhs: ConfirmationModal.Info.Body) -> Bool { switch (lhs, rhs) { case (.none, .none): return true @@ -888,7 +924,11 @@ public extension ConfirmationModal.Info { icon.hash(into: &hasher) style.hash(into: &hasher) accessibility.hash(into: &hasher) - } + + case .inputConfirmation(let explanation, let textToConfirm): + explanation.hash(into: &hasher) + textToConfirm.hash(into: &hasher) + } } } } diff --git a/SessionUIKit/Components/ScrollableLabel.swift b/SessionUIKit/Components/ScrollableLabel.swift index 87fcde6937..758da847bb 100644 --- a/SessionUIKit/Components/ScrollableLabel.swift +++ b/SessionUIKit/Components/ScrollableLabel.swift @@ -10,6 +10,7 @@ public class ScrollableLabel: UIView { private var oldSize: CGSize = .zero private var layoutLoopCounter: Int = 0 + private var hasFlashedScrollIndicator: Bool = false var scrollMode: ScrollMode = .automatic { didSet { @@ -138,6 +139,11 @@ public class ScrollableLabel: UIView { scrollViewHeightAnchor.isActive = false labelHeightAnchor.isActive = true + if !hasFlashedScrollIndicator { + scrollView.flashScrollIndicators() + hasFlashedScrollIndicator = true + } + case (true, true): labelHeightAnchor.isActive = false scrollViewHeightAnchor.constant = maxCalculatedHeight diff --git a/SessionUIKit/Components/SwiftUI/AdaptiveHStack.swift b/SessionUIKit/Components/SwiftUI/AdaptiveHStack.swift new file mode 100644 index 0000000000..4a914b41ad --- /dev/null +++ b/SessionUIKit/Components/SwiftUI/AdaptiveHStack.swift @@ -0,0 +1,134 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import SwiftUI + +fileprivate struct AdaptiveHStackMaxSpacingWidthPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = .zero + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } +} + +struct AdaptiveHStack: View { + let alignment: VerticalAlignment + let minSpacing: CGFloat? + let maxSpacing: CGFloat? + @ViewBuilder let content: () -> Content + + @State private var idealWidthWithMaxSpacing: CGFloat = .zero + @State private var availableWidth: CGFloat = 0 + @State private var useMinSpacing: Bool = false + + init( + alignment: VerticalAlignment = .center, + minSpacing: CGFloat? = nil, + maxSpacing: CGFloat? = nil, + @ViewBuilder content: @escaping () -> Content + ) { + self.alignment = alignment + self.content = content + + switch (minSpacing, maxSpacing) { + case (.some(let minSpacing), .some(let maxSpacing)): + self.minSpacing = min(minSpacing, maxSpacing) + self.maxSpacing = max(minSpacing, maxSpacing) + + case (.some(let spacing), .none), (.none, .some(let spacing)): + self.minSpacing = spacing + self.maxSpacing = spacing + + case (.none, .none): + self.minSpacing = nil + self.maxSpacing = nil + } + } + + var body: some View { + if #available(iOS 16.0, *) { + // TODO: When minimum target is iOS 16+, consider replacing + ios15Layout + .fixedSize(horizontal: false, vertical: true) + } else { + ios15Layout + .fixedSize(horizontal: false, vertical: true) + } + } + + @ViewBuilder + private var ios15Layout: some View { + switch (minSpacing, maxSpacing) { + case (.none, .none): + HStack(alignment: alignment) { + content() + } + + case (.some(let spacing), .none), (.none, .some(let spacing)): + HStack(alignment: alignment, spacing: spacing) { + content() + } + + case (.some(let minSpacing), .some(let maxSpacing)): + HStack( + alignment: alignment, + spacing: (useMinSpacing ? minSpacing : maxSpacing) + ) { + content() + } + .background( + GeometryReader { geometry in + Color.clear + .onAppear { updateAvailableWidth(geometry.size.width) } + .onChange(of: geometry.size.width) { updateAvailableWidth($0) } + } + .overlay( + HStack(alignment: alignment, spacing: maxSpacing) { + content() + } + .fixedSize(horizontal: true, vertical: false) + .background( + GeometryReader { innerProxy in + Color.clear + .preference( + key: AdaptiveHStackMaxSpacingWidthPreferenceKey.self, + value: innerProxy.size.width + ) + } + ) + .hidden() + ) + .onPreferenceChange(AdaptiveHStackMaxSpacingWidthPreferenceKey.self) { newIdealWidth in + if self.idealWidthWithMaxSpacing != newIdealWidth { + self.idealWidthWithMaxSpacing = newIdealWidth + self.updateSpacingDecision() + } + } + ) + } + } + + private func updateAvailableWidth(_ width: CGFloat) { + if abs(availableWidth - width) > 0.1 { + availableWidth = width + + if minSpacing != nil && maxSpacing != nil && minSpacing != maxSpacing { + updateSpacingDecision() + } + } + } + + private func updateSpacingDecision() { + guard idealWidthWithMaxSpacing > 0, availableWidth > 0 else { + if useMinSpacing != false { + useMinSpacing = false + } + return + } + + let shouldUseMin: Bool = (idealWidthWithMaxSpacing >= availableWidth) + + if useMinSpacing != shouldUseMin { + useMinSpacing = shouldUseMin + } + } +} diff --git a/SessionUIKit/Components/SwiftUI/AdaptiveText.swift b/SessionUIKit/Components/SwiftUI/AdaptiveText.swift new file mode 100644 index 0000000000..6a5875450f --- /dev/null +++ b/SessionUIKit/Components/SwiftUI/AdaptiveText.swift @@ -0,0 +1,146 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import SwiftUI + +fileprivate struct AdaptiveTextIdealWidthPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = .zero + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } +} + +/// A component which will render the longest text value provided that will fit without truncating +/// +/// **Note:** Before iOS 16 this will render _only_ the longest or shortest values +struct AdaptiveText: View { + enum LoadingStyle { + case progressView + case text(String) + } + + let textRepresentations: [(value: String, id: UUID)] + let isLoading: Bool + + private var font: Font = .Body.baseRegular + private var uiKitFont: UIFont = Fonts.Body.baseRegular + @State private var foregroundColor: ThemeValue = .textPrimary + @State private var loadingStyle: LoadingStyle = .progressView + + @State private var idealLongestTextWidth: CGFloat = .zero + @State private var availableWidth: CGFloat = .zero + + private var useAbbreviatedForIOS15: Bool { + guard idealLongestTextWidth > 0, availableWidth > 0 else { return false } + + return (idealLongestTextWidth > (availableWidth - 50.0)) + } + + init( + text: String, + isLoading: Bool = false + ) { + self.textRepresentations = [(text, UUID())] + self.isLoading = isLoading + } + + init( + textOptions: [String], + isLoading: Bool = false + ) { + self.textRepresentations = textOptions + .sorted(by: { $0.count > $1.count }) + .map { ($0, UUID()) } + self.isLoading = isLoading + } + + var body: some View { + ZStack { + if isLoading { + switch loadingStyle { + case .progressView: ProgressView() + case .text(let text): styledText(text).fixedSize(horizontal: true, vertical: true) + } + } + else if textRepresentations.count <= 1 { + styledText(textRepresentations.first?.value ?? "") + } + else { + Group { + if #available(iOS 16.0, *) { + ViewThatFits(in: .horizontal) { + ForEach(textRepresentations, id: \.id) { text, _ in + styledText(text) + } + } + } + else { + let longestText: String = (textRepresentations.first?.value ?? "") + let shortestText: String = (textRepresentations.last?.value ?? "") + + styledText(useAbbreviatedForIOS15 ? longestText : shortestText) + .background( + GeometryReader { geometry in + Color.clear + .onAppear { self.availableWidth = geometry.size.width } + .onChange(of: geometry.size.width) { newWidth in self.availableWidth = newWidth } + .background( + styledText(longestText) + .fixedSize(horizontal: true, vertical: false) + .background( + GeometryReader { innerProxy in + Color.clear + .preference( + key: AdaptiveTextIdealWidthPreferenceKey.self, + value: innerProxy.size.width + ) + } + ) + .hidden() + ) + } + ) + .onPreferenceChange(AdaptiveTextIdealWidthPreferenceKey.self) { newIdealWidth in + /// Update the state, adding a small tolerance check to prevent potential minor floating point differences causing + /// excessive updates/loops + if abs(self.idealLongestTextWidth - newIdealWidth) > 1 { + self.idealLongestTextWidth = newIdealWidth + } + } + } + } + } + } + .frame(idealHeight: uiKitFont.lineHeight) + .clipped() + } + + @ViewBuilder + private func styledText(_ text: String) -> some View { + Text(text) + .font(font) + .foregroundColor(themeColor: foregroundColor) + .lineLimit(1) + } +} + +extension AdaptiveText { + func font(_ font: Font, uiKit: UIFont) -> AdaptiveText { + var view = self + view.font = font + view.uiKitFont = uiKit + return view + } + + func foregroundColor(themeColor: ThemeValue) -> AdaptiveText { + var view = self + view._foregroundColor = State(initialValue: themeColor) + return view + } + + func loadingStyle(_ style: LoadingStyle) -> AdaptiveText { + var view = self + view._loadingStyle = State(initialValue: style) + return view + } +} diff --git a/SessionUIKit/Components/SwiftUI/ArrowCapsule.swift b/SessionUIKit/Components/SwiftUI/ArrowCapsule.swift new file mode 100644 index 0000000000..5eab839233 --- /dev/null +++ b/SessionUIKit/Components/SwiftUI/ArrowCapsule.swift @@ -0,0 +1,99 @@ +// Copyright © 2024 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SwiftUI + +public enum ViewPosition: String, Sendable { + case top + case bottom + case none + + var opposite: ViewPosition { + switch self { + case .top: return .bottom + case .bottom: return .top + default: return .none + } + } +} + +struct ArrowCapsule: Shape { + let arrowPosition: ViewPosition + let arrowLength: CGFloat + + func path(in rect: CGRect) -> Path { + let height = rect.size.height + + let maxX = rect.maxX + let minX = rect.minX + let maxY = rect.maxY + let minY = rect.minY + + let triangleSideLength : CGFloat = arrowLength / CGFloat(sqrt(0.75)) + let actualArrowPosition: ViewPosition = self.arrowLength > 0 ? self.arrowPosition : .none + + var path = Path() + path.move(to: CGPoint(x: minX + height/2, y: minY)) + + if actualArrowPosition == .top { + path = self.makeArrow(path: &path, rect:rect, triangleSideLength: triangleSideLength, position: actualArrowPosition) + } + path.addLine(to: CGPoint(x: maxX - height/2, y: minY)) + path.addArc( + center: CGPoint(x: maxX - height/2, y: minY + height/2), + radius: height/2, + startAngle: Angle(degrees: -90), + endAngle: Angle(degrees: 90), + clockwise: false + ) + if actualArrowPosition == .bottom { + path = self.makeArrow(path: &path, rect:rect, triangleSideLength: triangleSideLength, position: actualArrowPosition) + } + path.addLine(to: CGPoint(x: minX + height/2, y: maxY)) + path.addArc( + center: CGPoint(x: minX + height/2, y: maxY - height/2), + radius: height/2, + startAngle: Angle(degrees: 90), + endAngle: Angle(degrees: 270), + clockwise: false + ) + return path + } + + func trianglePointsFor(arrowPosition: ViewPosition, rect: CGRect, triangleSideLength: CGFloat) -> (CGPoint, CGPoint, CGPoint) { + switch arrowPosition { + case .top: + return ( + CGPoint(x: rect.midX - triangleSideLength / 2 , y: rect.minY), + CGPoint(x: rect.midX, y: rect.minY - arrowLength), + CGPoint(x: rect.midX + triangleSideLength / 2, y: rect.minY) + ) + case .bottom: + return ( + CGPoint(x: rect.midX + triangleSideLength / 2 , y: rect.maxY), + CGPoint(x: rect.midX, y: rect.maxY + arrowLength), + CGPoint(x: rect.midX - triangleSideLength / 2, y: rect.maxY) + ) + default: + return ( + CGPoint.zero, + CGPoint.zero, + CGPoint.zero + ) + } + } + + func makeArrow(path: inout Path, rect: CGRect, triangleSideLength: CGFloat, position: ViewPosition) -> Path { + let points = self.trianglePointsFor( + arrowPosition: position, + rect: rect, + triangleSideLength: triangleSideLength + ) + + path.addLine(to: points.0) + path.addLine(to: points.1) + path.addLine(to: points.2) + + return path + } +} diff --git a/SessionUIKit/Components/SwiftUI/AttributedText.swift b/SessionUIKit/Components/SwiftUI/AttributedText.swift index b5707fd647..b714a7cb56 100644 --- a/SessionUIKit/Components/SwiftUI/AttributedText.swift +++ b/SessionUIKit/Components/SwiftUI/AttributedText.swift @@ -6,6 +6,7 @@ struct AttributedTextBlock { let content: String let font: Font? let color: Color? + let baselineOffset: CGFloat? } public struct AttributedText: View { @@ -25,9 +26,15 @@ public struct AttributedText: View { let substring = (text.string as NSString).substring(with: range) let font = (attribute[.font] as? UIFont).map { Font($0) } let color = (attribute[.foregroundColor] as? UIColor).map { Color($0) } - descriptions.append(AttributedTextBlock(content: substring, - font: font, - color: color)) + let baselineOffset = (attribute[.baselineOffset] as? CGFloat) + descriptions.append( + AttributedTextBlock( + content: substring, + font: font, + color: color, + baselineOffset: baselineOffset + ) + ) }) } } @@ -37,6 +44,7 @@ public struct AttributedText: View { var text: Text = Text(description.content) if let font: Font = description.font { text = text.font(font) } if let color: Color = description.color { text = text.foregroundColor(color) } + if let baselineOffset: CGFloat = description.baselineOffset { text = text.baselineOffset(baselineOffset) } return text }.reduce(Text("")) { (result, text) in result + text diff --git a/SessionUIKit/Components/SwiftUI/PopoverView.swift b/SessionUIKit/Components/SwiftUI/PopoverView.swift new file mode 100644 index 0000000000..5e0cf83942 --- /dev/null +++ b/SessionUIKit/Components/SwiftUI/PopoverView.swift @@ -0,0 +1,187 @@ +// Copyright © 2024 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SwiftUI + +struct PopoverViewModifier: ViewModifier where ContentView: View { + var contentView: () -> ContentView + var backgroundThemeColor: ThemeValue + @Binding var show: Bool + @Binding var frame: CGRect + var position: ViewPosition + var viewId: String + + func body(content: Content) -> some View { + content + .overlayPreferenceValue(PopoverViewOriginPreferenceKey.self) { preferences in + GeometryReader { geometry in + ZStack(alignment: .topLeading) { + self.popoverView( + geometry: geometry, + preferences: preferences, + content: self.contentView, + isPresented: self.$show, + frame: self.$frame, + backgroundThemeColor: self.backgroundThemeColor, + position: self.position, + viewId: self.viewId + ) + } + } + } + } + + internal func popoverView( + geometry: GeometryProxy?, + preferences: [PopoverViewOriginPreference], + @ViewBuilder content: @escaping (() -> PopoverContentView), + isPresented: Binding, + frame: Binding, + backgroundThemeColor: ThemeValue, + position: ViewPosition, + viewId: String + ) -> some View { + var originBounds = CGRect.zero + if let originPreference = preferences.first(where: { $0.viewId == viewId }), let geometry = geometry { + originBounds = geometry[originPreference.bounds] + } + return withAnimation { + content() + .background { + ArrowCapsule( + arrowPosition: position.opposite, + arrowLength: 10 + ) + .fill(themeColor: backgroundThemeColor) + .shadow(color: .black.opacity(0.35), radius: 4) + } + .opacity(isPresented.wrappedValue ? 1 : 0) + .modifier( + PopoverOffset( + viewFrame: frame.wrappedValue, + originBounds: originBounds, + position: position, + arrowLength: 10 + ) + ) + } + } +} + +internal struct PopoverOffset: ViewModifier { + var viewFrame: CGRect + var originBounds: CGRect + var position: ViewPosition + var arrowLength: CGFloat + + func body(content: Content) -> some View { + return content + .offset( + x: self.offsetXFor( + position: position, + frame: viewFrame, + originBounds: originBounds, + arrowLength: arrowLength + ), + y: self.offsetYFor( + position: position, + frame: viewFrame, + originBounds: originBounds, + arrowLength: arrowLength + ) + ) + + } + + func offsetXFor(position: ViewPosition, frame: CGRect, originBounds: CGRect, arrowLength: CGFloat) -> CGFloat { + var offsetX: CGFloat = 0 + switch position { + case .top, .bottom: + offsetX = originBounds.minX + (originBounds.size.width - frame.size.width) / 2 + case .none: + offsetX = 0 + } + + return offsetX + } + + func offsetYFor(position: ViewPosition, frame: CGRect, originBounds: CGRect, arrowLength: CGFloat)->CGFloat { + var offsetY:CGFloat = 0 + switch position { + case .top: + offsetY = originBounds.minY - frame.size.height - arrowLength + case .bottom: + offsetY = originBounds.minY + originBounds.size.height + arrowLength + case .none: + offsetY = 0 + } + + return offsetY + } +} + +public struct AnchorView: ViewModifier { + let viewId: String + + public func body(content: Content) -> some View { + content.anchorPreference(key: PopoverViewOriginPreferenceKey.self, value: .bounds) { [PopoverViewOriginPreference(viewId: self.viewId, bounds: $0)]} + } +} + +public extension View { + func anchorView(viewId: String)-> some View { + self.modifier(AnchorView(viewId: viewId)) + } +} + +public extension View { + func popoverView( + content: @escaping () -> ContentView, + backgroundThemeColor: ThemeValue, + isPresented: Binding, + frame: Binding, + position: ViewPosition, + viewId: String + ) -> some View { + self.modifier( + PopoverViewModifier( + contentView: content, + backgroundThemeColor: backgroundThemeColor, + show: isPresented, + frame: frame, + position: position, + viewId: viewId + ) + ) + } +} + +// MARK: - PopoverViewOriginBoundsPreferenceKey + + struct PopoverViewOriginPreferenceKey: PreferenceKey { + ///PopoverViewOriginPreferenceKey initializer. + init() {} + ///PopoverViewOriginPreferenceKey value array + typealias Value = [PopoverViewOriginPreference] + ///PopoverViewOriginPreferenceKey default value array + static var defaultValue: [PopoverViewOriginPreference] = [] + ///PopoverViewOriginPreferenceKey reduce function. modifies the sequence by adding a new value if needed. + static func reduce(value: inout [PopoverViewOriginPreference], nextValue: () -> [PopoverViewOriginPreference]) { + //value[0] = nextValue().first! + value.append(contentsOf: nextValue()) + } +} + +// MARK: - PopoverViewOriginPreference: holds an identifier for the origin view of the popover and its bounds anchor. + + struct PopoverViewOriginPreference { + ///PopoverViewOriginPreference initializer + init(viewId: String, bounds: Anchor) { + self.viewId = viewId + self.bounds = bounds + } + ///popover origin view identifier. + var viewId: String + /// popover origin view bounds Anchor. + var bounds: Anchor +} diff --git a/SessionUIKit/Components/SwiftUI/SessionTextField.swift b/SessionUIKit/Components/SwiftUI/SessionTextField.swift index 24fd44cf9c..78618551f5 100644 --- a/SessionUIKit/Components/SwiftUI/SessionTextField.swift +++ b/SessionUIKit/Components/SwiftUI/SessionTextField.swift @@ -7,35 +7,54 @@ import UIKit public struct SessionTextField: View where ExplanationView: View { @Binding var text: String @Binding var error: String? - @State var previousError: String = "" + @State var lastErroredText: String? @State var textThemeColor: ThemeValue = .textPrimary + @State fileprivate var textChanged: ((String) -> Void)? + + public enum SessionTextFieldType { + case thin + case normal + } let explanationView: () -> ExplanationView let placeholder: String + let font: Font + let type: SessionTextFieldType let accessibility: Accessibility - var isErrorMode: Bool { - guard previousError.isEmpty else { return true } - if error?.isEmpty == false { return true } - return false - } + var isErrorMode: Bool { error?.isEmpty == false } - let height: CGFloat = isIPhone5OrSmaller ? CGFloat(48) : CGFloat(80) - let cornerRadius: CGFloat = 13 + let height: CGFloat + let padding: CGFloat + let cornerRadius: CGFloat public init( _ text: Binding, placeholder: String, + font: Font = .system(size: Values.smallFontSize), error: Binding, - accessibility: Accessibility = Accessibility(), + type: SessionTextFieldType = .normal, + accessibility: Accessibility = Accessibility(identifier: "SessionTextField"), @ViewBuilder explanationView: @escaping () -> ExplanationView = { EmptyView() } ) { self._text = text self.placeholder = placeholder + self.font = font + self.type = type self.accessibility = accessibility self._error = error self.explanationView = explanationView + switch self.type { + case .thin: + self.height = isIPhone5OrSmaller ? CGFloat(40) : CGFloat(45) + self.padding = Values.mediumSpacing + self.cornerRadius = CGFloat(7) + case .normal: + self.height = isIPhone5OrSmaller ? CGFloat(48) : CGFloat(80) + self.padding = Values.largeSpacing + self.cornerRadius = CGFloat(13) + } UITextView.appearance().backgroundColor = .clear } @@ -49,34 +68,22 @@ public struct SessionTextField: View where ExplanationView: Vie if text.isEmpty { Text(placeholder) .font(.system(size: Values.smallFontSize)) - .foregroundColor(themeColor: isErrorMode ? .danger : .textSecondary) + .foregroundColor(themeColor: .textSecondary) } if #available(iOS 16.0, *) { - SwiftUI.TextField( + TextField( "", - text: $text.onChange{ value in - if error?.isEmpty == false && text != value { - previousError = error! - error = nil - } - }, + text: $text, axis: .vertical ) - .font(.system(size: Values.smallFontSize)) + .font(font) .foregroundColor(themeColor: textThemeColor) .accessibility(self.accessibility) } else { ZStack { - TextEditor( - text: $text.onChange{ value in - if error?.isEmpty == false && text != value { - previousError = error! - error = nil - } - } - ) - .font(.system(size: Values.smallFontSize)) + TextEditor(text: $text) + .font(font) .foregroundColor(themeColor: textThemeColor) .textViewTransparentScrolling() .accessibility(self.accessibility) @@ -85,7 +92,7 @@ public struct SessionTextField: View where ExplanationView: Vie // FIXME: This is a workaround for dynamic height of the TextEditor. Text(text.isEmpty ? placeholder : text) - .font(.system(size: Values.smallFontSize)) + .font(font) .opacity(0) .padding(.all, 4) .frame( @@ -96,44 +103,65 @@ public struct SessionTextField: View where ExplanationView: Vie .fixedSize(horizontal: false, vertical: true) } } - .padding(.horizontal, Values.largeSpacing) - .frame(maxWidth: .infinity) - .frame(height: self.height) + .padding(.horizontal, self.padding) + .padding(.vertical, Values.smallSpacing) + .framing( + maxWidth: .infinity, + minHeight: self.type == .thin ? self.height : nil, + height: self.type == .thin ? nil : self.height + ) .overlay( - RoundedRectangle( - cornerSize: CGSize( - width: self.cornerRadius, - height: self.cornerRadius - ) - ) - .stroke(themeColor: isErrorMode ? .danger : .borderSeparator) + RoundedRectangle(cornerRadius: self.cornerRadius) + .stroke(themeColor: isErrorMode ? .danger : .borderSeparator) ) - .onReceive(Just(error)) { newValue in - textThemeColor = (newValue?.isEmpty == false) ? .danger : .textPrimary + .onChange(of: text) { newText in + textThemeColor = (newText == lastErroredText ? .danger : .textPrimary) + } + .onChange(of: error) { newError in + if newError != nil { + lastErroredText = text + textThemeColor = .danger + } } // Error message - ZStack { - if isErrorMode { - Text(error ?? previousError) - .bold() - .font(.system(size: Values.smallFontSize)) - .foregroundColor(themeColor: .danger) - .multilineTextAlignment(.center) - .accessibility( - Accessibility( - identifier: "Error message", - label: error ?? previousError + switch self.type { + case .thin: + if isErrorMode { + Text(error ?? "") + .bold() + .font(.system(size: Values.smallFontSize)) + .foregroundColor(themeColor: .danger) + .multilineTextAlignment(.center) + .accessibility( + Accessibility( + identifier: "Error message", + label: error + ) ) - ) - } else { - explanationView() - } + } + case .normal: + ZStack { + if isErrorMode { + Text(error ?? "") + .bold() + .font(.system(size: Values.smallFontSize)) + .foregroundColor(themeColor: .danger) + .multilineTextAlignment(.center) + .accessibility( + Accessibility( + identifier: "Error message" + ) + ) + } else { + explanationView() + } + } + .frame( + height: 54, + alignment: .top + ) } - .frame( - height: 54, - alignment: .top - ) } } } diff --git a/SessionUIKit/Components/SwiftUI/Text+CopyButton.swift b/SessionUIKit/Components/SwiftUI/Text+CopyButton.swift new file mode 100644 index 0000000000..3fbccf8ba6 --- /dev/null +++ b/SessionUIKit/Components/SwiftUI/Text+CopyButton.swift @@ -0,0 +1,57 @@ +// Copyright © 2024 Rangeproof Pty Ltd. All rights reserved. + +import SwiftUI +import Lucide + +public struct TextWitCopyButton: View { + @Binding private var copied: String? + + let content: String + let font: Font + let isCopyButtonEnabled: Bool + + public init( + _ content: String, + font: Font = .system(size: Values.smallFontSize), + isCopyButtonEnabled: Bool, + copied: Binding + ) { + self.content = content + self.font = font + self.isCopyButtonEnabled = isCopyButtonEnabled + self._copied = copied + } + + public var body: some View { + HStack( + spacing: 0 + ) { + Text(content) + .lineLimit(1) + .truncationMode(.middle) + .font(font) + .foregroundColor(themeColor: .textSecondary) + + Spacer(minLength: Values.verySmallSpacing) + + AttributedText(Lucide.attributedString(icon: .copy, for: .systemFont(ofSize: Values.smallFontSize))) + .fixedSize() + .foregroundColor(themeColor: isCopyButtonEnabled ? .textPrimary : .disabled) + } + .padding(.horizontal, Values.mediumSpacing) + .framing( + maxWidth: .infinity, + height: Values.largeButtonHeight + ) + .overlay( + RoundedRectangle(cornerRadius: 7) + .stroke(themeColor: .borderSeparator) + ) + .onTapGesture { + guard isCopyButtonEnabled else { return } + + UIPasteboard.general.string = content + copied = "copied".localized() + } + } +} diff --git a/SessionUIKit/Components/SwiftUI/Toast.swift b/SessionUIKit/Components/SwiftUI/Toast.swift index a9aea9810a..e259ceb85c 100644 --- a/SessionUIKit/Components/SwiftUI/Toast.swift +++ b/SessionUIKit/Components/SwiftUI/Toast.swift @@ -61,9 +61,6 @@ public struct ToastModifier: ViewModifier { public struct ToastView_SwiftUI: View { var message: String - static let width: CGFloat = 320 - static let height: CGFloat = 44 - public init(_ message: String) { self.message = message } @@ -73,11 +70,11 @@ public struct ToastView_SwiftUI: View { spacing: 0 ) { Text(message) - .font(.system(size: Values.mediumFontSize)) + .font(.Body.largeRegular) .foregroundColor(themeColor: .textPrimary) .multilineTextAlignment(.center) + .padding(.vertical, Values.mediumSmallSpacing) .padding(.horizontal, Values.largeSpacing) - .frame(height: Self.height) .background( Capsule() .foregroundColor(themeColor: .toast_background) diff --git a/SessionUIKit/Screens/Settings/SessionNetworkScreen/SessionNetworkScreen+Models.swift b/SessionUIKit/Screens/Settings/SessionNetworkScreen/SessionNetworkScreen+Models.swift new file mode 100644 index 0000000000..c37eea2674 --- /dev/null +++ b/SessionUIKit/Screens/Settings/SessionNetworkScreen/SessionNetworkScreen+Models.swift @@ -0,0 +1,195 @@ +// Copyright © 2024 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public enum SessionNetworkScreenContent {} + +public extension SessionNetworkScreenContent { + protocol ViewModelType: ObservableObject { + var dataModel: DataModel { get set } + var isRefreshing: Bool { get set } + var lastRefreshWasSuccessful: Bool { get set } + var errorString: String? { get set } + var lastUpdatedTimeString: String? { get set } + + func fetchDataFromNetwork() + func openURL(_ url: URL) + } + + final class DataModel: Equatable { + public static let defaultPriceString: String = "$-USD" // stringlint:disabled + + // Snode Data + public let snodesInCurrentSwarm: Int + public let snodesInTotal: Int + public var snodesInTotalString: String { "\(snodesInTotal)" } + public var snodesInTotalAbbreviatedString: String { + "\(snodesInTotal.formatted(format: .abbreviated(decimalPlaces: 1)))" + } + public var snodesInTotalAbbreviatedNoDecimalString: String { + "\(snodesInTotal.formatted(format: .abbreviated))" + } + + // Toke Info Data + public let contractAddress: String? + public let tokenUSD: Double? + public var tokenUSDString: String { + guard let tokenUSD: Double = tokenUSD else { + return "unavailable".localized() + } + return "$\(tokenUSD.formatted(format: .currency(decimal: true))) USD" + } + public var tokenUSDNoCentsString: String { + guard let tokenUSD: Double = tokenUSD else { + return "unavailable".localized() + } + return "$\(tokenUSD.formatted(format: .currency(decimal: false))) USD" + } + public var tokenUSDAbbreviatedString: String { + guard let tokenUSD: Double = tokenUSD else { + return "unavailable".localized() + } + return "$\(tokenUSD.formatted(format: .abbreviatedCurrency(decimalPlaces: 1))) USD" + } + public let priceTimestampMs: Int64 + public var priceTimeString: String { + guard tokenUSD != nil else { + return "-" + } + return Date(timeIntervalSince1970: (TimeInterval(priceTimestampMs) / 1000)).formatted("d MMM YYYY hh:mm a") + } + public let stakingRequirement: Double + public let networkSize: Int + public let networkStakedTokens: Double + public var networkStakedTokensString: String { + guard networkStakedTokens > 0 else { + return "unavailable".localized() + } + return "\(networkStakedTokens.formatted(format: .abbreviated)) \(Constants.token_name_short)" + } + public let networkStakedUSD: Double + public var networkStakedUSDString: String { + guard networkStakedUSD > 0 else { + return DataModel.defaultPriceString + } + return "$\(networkStakedUSD.formatted(format: .currency(decimal: false))) USD" + } + public var networkStakedUSDAbbreviatedString: String { + guard networkStakedUSD > 0 else { + return DataModel.defaultPriceString + } + return "$\(networkStakedUSD.formatted(format: .abbreviatedCurrency(decimalPlaces: 1))) USD" + } + public let stakingRewardPool: Double? + public var stakingRewardPoolString: String { + guard let stakingRewardPool: Double = stakingRewardPool else { + return "unavailable".localized() + } + return "\(stakingRewardPool.formatted(format: .decimal)) \(Constants.token_name_short)" + } + public let marketCapUSD: Double? + public var marketCapString: String { + guard let marketCap: Double = marketCapUSD else { + return "unavailable".localized() + } + return "$\(marketCap.formatted(format: .currency(decimal: false))) USD" + } + public var marketCapAbbreviatedString: String { + guard let marketCap: Double = marketCapUSD else { + return "unavailable".localized() + } + return "$\(marketCap.formatted(format: .abbreviatedCurrency(decimalPlaces: 1))) USD" + } + + // Last update time + public let lastUpdatedTimestampMs: Int64? + + public init( + snodesInCurrentSwarm: Int = 0, + snodesInTotal: Int = 0, + contractAddress: String? = nil, + tokenUSD: Double? = nil, + priceTimestampMs: Int64 = 0, + stakingRequirement: Double = 0, + networkSize: Int = 0, + networkStakedTokens: Double = 0, + networkStakedUSD: Double = 0, + stakingRewardPool: Double? = nil, + marketCapUSD: Double? = nil, + lastUpdatedTimestampMs: Int64? = nil + ) { + self.snodesInCurrentSwarm = snodesInCurrentSwarm + self.snodesInTotal = snodesInTotal + self.contractAddress = contractAddress + self.tokenUSD = tokenUSD + self.priceTimestampMs = priceTimestampMs + self.stakingRequirement = stakingRequirement + self.networkSize = networkSize + self.networkStakedTokens = networkStakedTokens + self.networkStakedUSD = networkStakedUSD + self.stakingRewardPool = stakingRewardPool + self.marketCapUSD = marketCapUSD + self.lastUpdatedTimestampMs = lastUpdatedTimestampMs + } + + public static func == (lhs: DataModel, rhs: DataModel) -> Bool { + let isSnodeInfoEqual: Bool = ( + lhs.snodesInCurrentSwarm == rhs.snodesInCurrentSwarm && + lhs.snodesInTotal == rhs.snodesInTotal + ) + + let isTokenInfoDataEqual: Bool = ( + lhs.contractAddress == rhs.contractAddress && + lhs.tokenUSD == rhs.tokenUSD && + lhs.priceTimestampMs == rhs.priceTimestampMs && + lhs.stakingRequirement == rhs.stakingRequirement + ) + + let isNetworkInfoDataEqual: Bool = ( + lhs.networkSize == rhs.networkSize && + lhs.networkStakedTokens == rhs.networkStakedTokens && + lhs.networkStakedUSD == rhs.networkStakedUSD && + lhs.stakingRewardPool == rhs.stakingRewardPool && + lhs.marketCapUSD == rhs.marketCapUSD + ) + + let isUpdateTimeEqual: Bool = lhs.lastUpdatedTimestampMs == rhs.lastUpdatedTimestampMs + + return isSnodeInfoEqual && isTokenInfoDataEqual && isNetworkInfoDataEqual && isUpdateTimeEqual + } + } +} + +// MARK: - Convenience + +extension SessionNetworkScreenContent.DataModel { + public func with( + snodesInCurrentSwarm: Int? = nil, + snodesInTotal: Int? = nil, + contractAddress: String? = nil, + tokenUSD: Double? = nil, + priceTimestampMs: Int64? = nil, + stakingRequirement: Double? = nil, + networkSize: Int? = nil, + networkStakedTokens: Double? = nil, + networkStakedUSD: Double? = nil, + stakingRewardPool: Double? = nil, + marketCapUSD: Double? = nil, + lastUpdatedTimestampMs: Int64? = nil + ) -> SessionNetworkScreenContent.DataModel { + return SessionNetworkScreenContent.DataModel( + snodesInCurrentSwarm: snodesInCurrentSwarm ?? self.snodesInCurrentSwarm, + snodesInTotal: snodesInTotal ?? self.snodesInTotal, + contractAddress: contractAddress ?? self.contractAddress, + tokenUSD: tokenUSD ?? self.tokenUSD, + priceTimestampMs: priceTimestampMs ?? self.priceTimestampMs, + stakingRequirement: stakingRequirement ?? self.stakingRequirement, + networkSize: networkSize ?? self.networkSize, + networkStakedTokens: networkStakedTokens ?? self.networkStakedTokens, + networkStakedUSD: networkStakedUSD ?? self.networkStakedUSD, + stakingRewardPool: stakingRewardPool ?? self.stakingRewardPool, + marketCapUSD: marketCapUSD ?? self.marketCapUSD, + lastUpdatedTimestampMs: lastUpdatedTimestampMs ?? self.lastUpdatedTimestampMs + ) + } +} diff --git a/SessionUIKit/Screens/Settings/SessionNetworkScreen/SessionNetworkScreen.swift b/SessionUIKit/Screens/Settings/SessionNetworkScreen/SessionNetworkScreen.swift new file mode 100644 index 0000000000..6d2e3609af --- /dev/null +++ b/SessionUIKit/Screens/Settings/SessionNetworkScreen/SessionNetworkScreen.swift @@ -0,0 +1,630 @@ +// Copyright © 2024 Rangeproof Pty Ltd. All rights reserved. + +import SwiftUI +import Lucide + +public struct SessionNetworkScreen: View { + @EnvironmentObject var host: HostWrapper + @StateObject private var viewModel: ViewModel + @State private var walletAddress: String = "" + @State private var errorString: String? = nil + @State private var copied: String? = nil + @State private var isShowingTooltip: Bool = false + private let coordinateSpaceName: String = "NetworkScreen" // stringlint:ignore + + public init(viewModel: ViewModel) { + _viewModel = StateObject(wrappedValue: viewModel) + } + + public var body: some View { + ScrollView(.vertical, showsIndicators: false) { + VStack( + alignment: .leading, + spacing: Values.mediumSmallSpacing + ) { + SessionNetworkSection( + linkOutAction: { + openUrl(Constants.session_network_url) + } + ) + .frame( + maxWidth: .infinity, + alignment: .leading + ) + + StatsSection( + dataModel: $viewModel.dataModel, + isRefreshing: $viewModel.isRefreshing, + lastRefreshWasSuccessful: $viewModel.lastRefreshWasSuccessful, + isShowingTooltip: $isShowingTooltip + ) + .frame( + maxWidth: .infinity, + alignment: .leading + ) + + SessionTokenSection( + dataModel: $viewModel.dataModel, + isRefreshing: $viewModel.isRefreshing, + linkOutAction: { + openUrl(Constants.session_staking_url) + } + ) + .frame( + maxWidth: .infinity, + alignment: .leading + ) + + if let lastUpdatedTimeString = viewModel.lastUpdatedTimeString { + Spacer(minLength: Values.largeSpacing) + + Text( + "updated" + .put(key: "relative_time", value: lastUpdatedTimeString) + .localized() + ) + .font(.Body.custom(Values.smallFontSize)) + .foregroundColor(themeColor: .textSecondary) + .frame( + maxWidth: .infinity, + alignment: .center + ) + .accessibility( + Accessibility(identifier: "Last updated timestamp") + ) + } + } + .padding(Values.largeSpacing) + .onAnyInteraction(scrollCoordinateSpaceName: coordinateSpaceName) { + guard self.isShowingTooltip else { + return + } + + withAnimation(.spring()) { + self.isShowingTooltip = false + } + } + } + .onAppear { + viewModel.fetchDataFromNetwork() + } + .refreshable { + viewModel.fetchDataFromNetwork() + } + .backgroundColor(themeColor: .backgroundPrimary) + .toastView(message: $copied) + .coordinateSpace(name: coordinateSpaceName) + } + + private func openUrl(_ urlString: String) { + guard let url: URL = URL(string: urlString) else { return } + + let modal: ConfirmationModal = ConfirmationModal( + info: ConfirmationModal.Info( + title: "urlOpen".localized(), + body: .attributedText( + "urlOpenDescription" + .put(key: "url", value: url.absoluteString) + .localizedFormatted(baseFont: .systemFont(ofSize: Values.smallFontSize)), + scrollMode: .automatic + ), + confirmTitle: "open".localized(), + confirmStyle: .danger, + cancelTitle: "urlCopy".localized(), + cancelStyle: .alert_text, + onConfirm: { _ in viewModel.openURL(url) }, + onCancel: { modal in + UIPasteboard.general.string = url.absoluteString + modal.close() + } + ) + ) + + self.host.controller?.present(modal, animated: true) + } +} + +// MARK: - Session Network Section +/// - Session Network explanation + +extension SessionNetworkScreen { + struct SessionNetworkSection: View { + var linkOutAction: () -> () + + var body: some View { + VStack( + alignment: .leading, + spacing: Values.verySmallSpacing + ) { + Text(Constants.network_name) + .font(.Body.custom(Values.smallFontSize)) + .foregroundColor(themeColor: .textSecondary) + + AttributedText( + "sessionNetworkDescription" + .put(key: "network_name", value: Constants.network_name) + .put(key: "token_name_long", value: Constants.token_name_long) + .put(key: "app_name", value: Constants.app_name) + .put(key: "icon", value: "\(Lucide.Icon.squareArrowUpRight)") + .localizedFormatted(Fonts.Body.largeRegular) + ) + .font(Font.Body.largeRegular) + .foregroundColor(themeColor: .textPrimary) + .accessibility( + Accessibility(identifier: "Learn more link") + ) + } + .contentShape(Rectangle()) + .onTapGesture { + linkOutAction() + } + } + } +} + +// MARK: - Stats Section +/// - Swarm image +/// - Snodes in current user's swarm +/// - Snodes in total +/// - SESH price +/// - SESH in swarm + total price + +extension SessionNetworkScreen { + struct StatsSection: View { + @Binding var dataModel: SessionNetworkScreenContent.DataModel + @Binding var isRefreshing: Bool + @Binding var lastRefreshWasSuccessful: Bool + @Binding var isShowingTooltip: Bool + @State var tooltipContentFrame: CGRect = CGRect.zero + + let tooltipViewId: String = "tooltip" // stringlint:ignore + let scaleRatio: CGFloat = max(UIScreen.main.bounds.width / 390, 1.0) + + var body: some View { + HStack( + alignment: .top, + spacing: 0 + ) { + VStack( + alignment: .leading, + spacing: Values.mediumSmallSpacing + ) { + ZStack { + if isRefreshing || !lastRefreshWasSuccessful { + ProgressView() + } else if dataModel.snodesInCurrentSwarm > 0 { + Image("connection_\(dataModel.snodesInCurrentSwarm)") + .renderingMode(.template) + .foregroundColor(themeColor: .textPrimary) + + Image("snodes_\(dataModel.snodesInCurrentSwarm)") + .renderingMode(.template) + .foregroundColor(themeColor: .primary) + .shadow(themeColor: .settings_glowingBackground, radius: 10) + } + } + .frame( + width: scaleRatio * 153, + height: 132 + ) + .background( + RoundedRectangle(cornerRadius: 8) + .stroke(themeColor: .primary) + ) + .accessibility( + Accessibility(identifier: "Swarm image") + ) + + ZStack( + alignment: .topLeading + ) { + HStack { + Spacer() + + Button { + guard !isRefreshing else { return } + withAnimation { + isShowingTooltip.toggle() + } + } label: { + Image(systemName: "questionmark.circle") + .font(.Body.baseRegular) + .foregroundColor(themeColor: .textPrimary) + .padding(Values.verySmallSpacing) + } + .anchorView(viewId: tooltipViewId) + .accessibility( + Accessibility(identifier: "Tooltip") + ) + } + + VStack( + alignment: .leading, + spacing: Values.verySmallSpacing + ) { + Text( + "sessionNetworkCurrentPrice" + .put(key: "token_name_short", value: Constants.token_name_short) + .localized() + ) + .font(.Body.custom(Values.smallFontSize)) + .foregroundColor(themeColor: .textPrimary) + .lineLimit(1) + .fixedSize() + + AdaptiveText( + textOptions: [ + dataModel.tokenUSDString, + dataModel.tokenUSDNoCentsString, + dataModel.tokenUSDAbbreviatedString + ], + isLoading: isRefreshing + ) + .font(.Headings.H5, uiKit: Fonts.Headings.H5) + .foregroundColor(themeColor: .sessionButton_text) + .loadingStyle(.text("loading".localized())) + + Text(Constants.token_name_long) + .font(.Body.custom(Values.smallFontSize)) + .foregroundColor(themeColor: .textSecondary) + .lineLimit(1) + } + .padding(.horizontal, Values.mediumSmallSpacing) + .padding(.vertical, Values.mediumSpacing) + .accessibility( + Accessibility(identifier: "SENT price") + ) + } + .background( + RoundedRectangle(cornerRadius: 8) + .fill(themeColor: .backgroundSecondary) + ) + } + + Spacer(minLength: Values.mediumSmallSpacing) + + VStack( + alignment: .leading, + spacing: Values.mediumSmallSpacing + ) { + VStack( + alignment: .leading, + spacing: Values.mediumSmallSpacing + ) { + HStack( + spacing: 0 + ) { + AttributedText( + "sessionNetworkNodesSwarm" + .put(key: "app_name", value: Constants.app_name) + .localizedFormatted(Fonts.Body.largeBold) + ) + .font(Font.Body.largeBold) + .foregroundColor(themeColor: .textPrimary) + .frame( + width: 116, + alignment: .leading + ) + + ZStack { + if isRefreshing || !lastRefreshWasSuccessful { + ProgressView() + } else { + Text("\(dataModel.snodesInCurrentSwarm)") + .font(.Headings.H3) + .foregroundColor(themeColor: .sessionButton_text) + .lineLimit(1) + } + } + .frame( + maxWidth: .infinity, + alignment: .trailing + ) + } + .accessibility( + Accessibility(identifier: "Your swarm amount") + ) + + HStack( + spacing: 0 + ) { + AttributedText( + "sessionNetworkNodesSecuring" + .put(key: "app_name", value: Constants.app_name) + .localizedFormatted(Fonts.Body.largeBold) + ) + .font(Font.Body.largeBold) + .foregroundColor(themeColor: .textPrimary) + .frame( + width: 116, + alignment: .leading + ) + + AdaptiveText( + textOptions: [ + dataModel.snodesInTotalString, + dataModel.snodesInTotalAbbreviatedString, + dataModel.snodesInTotalAbbreviatedNoDecimalString + ], + isLoading: isRefreshing || !lastRefreshWasSuccessful + ) + .font(.Headings.H4, uiKit: Fonts.Headings.H4) + .foregroundColor(themeColor: .sessionButton_text) + .loadingStyle(.progressView) + .frame( + maxWidth: .infinity, + alignment: .trailing + ) + } + .accessibility( + Accessibility(identifier: "Nodes securing amount") + ) + } + .framing( + maxWidth: .infinity, + height: 132 + ) + + VStack( + alignment: .leading, + spacing: Values.verySmallSpacing + ) { + Text("sessionNetworkSecuredBy".localized()) + .font(.Body.custom(Values.smallFontSize)) + .foregroundColor(themeColor: .textPrimary) + .lineLimit(1) + .fixedSize() + + Text(isRefreshing ? "loading".localized() : dataModel.networkStakedTokensString) + .font(.Headings.H5) + .foregroundColor(themeColor: .sessionButton_text) + .lineLimit(1) + .frame( + maxHeight: .infinity, + alignment: .leading + ) + + AdaptiveText( + textOptions: [ + dataModel.networkStakedUSDString, + dataModel.networkStakedUSDAbbreviatedString + ], + isLoading: isRefreshing + ) + .font( + .Body.custom(Values.smallFontSize), + uiKit: Fonts.Body.custom(Values.smallFontSize) + ) + .foregroundColor(themeColor: .textSecondary) + .loadingStyle(.text(SessionNetworkScreenContent.DataModel.defaultPriceString)) + } + .padding(.horizontal, Values.mediumSmallSpacing) + .padding(.vertical, Values.mediumSpacing) + .accessibility( + Accessibility(identifier: "Network secured amount") + ) + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .leading + ) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(themeColor: .backgroundSecondary) + ) + } + .layoutPriority(1) + } + .popoverView( + content: { + ZStack { + Text( + Constants.session_network_data_price + .put(key: "date_time", value: dataModel.priceTimeString) // stringlint:ignore + .localized() + ) + .font(.Body.smallRegular) + .multilineTextAlignment(.center) + .foregroundColor(themeColor: .textPrimary) + .padding(.horizontal, Values.mediumSpacing) + .padding(.vertical, Values.smallSpacing) + .accessibility( + Accessibility(identifier: "Tooltip info") + ) + } + .overlay( + GeometryReader { geometry in + Color.clear // Invisible overlay + .onAppear { + self.tooltipContentFrame = geometry.frame(in: .global) + } + } + ) + }, + backgroundThemeColor: .toast_background, + isPresented: $isShowingTooltip, + frame: $tooltipContentFrame, + position: .top, + viewId: tooltipViewId + ) + } + } +} + +// MARK: - Session Token Section +/// - Staking rewards explanation +/// - Staking reward pool +/// - Market Cap +/// - Learn about staking button + +extension SessionNetworkScreen { + struct SessionTokenSection: View { + @Binding var dataModel: SessionNetworkScreenContent.DataModel + @Binding var isRefreshing: Bool + var linkOutAction: () -> () + + var body: some View { + VStack( + alignment: .leading, + spacing: Values.verySmallSpacing + ) { + Text(Constants.token_name_long) + .font(.Body.custom(Values.smallFontSize)) + .foregroundColor(themeColor: .textSecondary) + + Text( + "sessionNetworkTokenDescription" + .put(key: "token_name_long", value: Constants.token_name_long) + .put(key: "token_name_short", value: Constants.token_name_short) + .put(key: "staking_reward_pool", value: Constants.staking_reward_pool) + .localized() + ) + .font(.Body.largeRegular) + .foregroundColor(themeColor: .textPrimary) + + ZStack{ + Line(color: .borderSeparator) + + AdaptiveHStack( + minSpacing: Values.verySmallSpacing, + maxSpacing: Values.largeSpacing + ) { + VStack( + alignment: .leading, + spacing: Values.veryLargeSpacing + ) { + Text(Constants.staking_reward_pool) + .font(.Body.largeBold) + .foregroundColor(themeColor: .textPrimary) + .lineLimit(1) + .fixedSize() + + Text("sessionNetworkMarketCap".localized()) + .font(.Body.largeBold) + .foregroundColor(themeColor: .textPrimary) + .lineLimit(1) + .fixedSize() + } + .padding(.vertical, Values.mediumSmallSpacing) + + VStack( + alignment: .leading, + spacing: Values.veryLargeSpacing + ) { + Text(isRefreshing ? "loading".localized() : dataModel.stakingRewardPoolString) + .font(.Body.largeRegular) + .foregroundColor(themeColor: .textPrimary) + .lineLimit(1) + .fixedSize() + .accessibility( + Accessibility(identifier: "Staking reward pool amount") + ) + + AdaptiveText( + textOptions: [ + dataModel.marketCapString, + dataModel.marketCapAbbreviatedString + ], + isLoading: isRefreshing + ) + .loadingStyle(.text("loading".localized())) + .font(.Body.largeRegular, uiKit: Fonts.Body.largeRegular) + .foregroundColor(themeColor: .textPrimary) + .lineLimit(1) + .frame( + maxWidth: .infinity, + alignment: .leading + ) + .accessibility( + Accessibility(identifier: "Market cap amount") + ) + } + .frame( + maxWidth: .infinity, + alignment: .leading + ) + .padding(.vertical, Values.mediumSmallSpacing) + } + } + + Button { + linkOutAction() + } label: { + Text("sessionNetworkLearnAboutStaking".localized()) + .font(.Body.largeRegular) + .foregroundColor(themeColor: .sessionButton_text) + .framing( + maxWidth: .infinity, + height: Values.largeButtonHeight, + alignment: .center + ) + .overlay( + RoundedRectangle(cornerRadius: 7) + .stroke(themeColor: .sessionButton_border) + ) + } + .padding(.top, Values.mediumSmallSpacing) + .accessibility( + Accessibility(identifier: "Learn about staking link") + ) + } + } + } +} + +#if DEBUG +extension SessionNetworkScreenContent { + class PreviewViewModel: ViewModelType { + var dataModel: DataModel + var isRefreshing: Bool + var lastRefreshWasSuccessful: Bool + var errorString: String? + var lastUpdatedTimeString: String? + + init( + dataModel: DataModel, + isRefreshing: Bool, + lastRefreshWasSuccessful: Bool, + errorString: String? = nil, + lastUpdatedTimeString: String? = nil + ) { + self.dataModel = dataModel + self.isRefreshing = isRefreshing + self.lastRefreshWasSuccessful = lastRefreshWasSuccessful + self.errorString = errorString + self.lastUpdatedTimeString = lastUpdatedTimeString + } + + func fetchDataFromNetwork() {} + func isValidEthereumAddress(_ address: String) -> Bool { + return false + } + func openURL(_ url: URL) {} + } +} + +#Preview { + SessionNetworkScreen( + viewModel: SessionNetworkScreenContent.PreviewViewModel( + dataModel: SessionNetworkScreenContent.DataModel( + snodesInCurrentSwarm: 6, + snodesInTotal: 2254, + contractAddress: "0x7D7fD4E91834A96cD9Fb2369E7f4EB72383bbdEd", + tokenUSD: 1790.9260023480001, + priceTimestampMs: 1745817684000, + stakingRequirement: 20000, + networkSize: 957, + networkStakedTokens: 19_140_000, + networkStakedUSD: 34_278_323_684.940723, + stakingRewardPool: 40_010_040, + marketCapUSD: 216_442_438_046.91196, + lastUpdatedTimestampMs: 1745817920000 + ), + isRefreshing: false, + lastRefreshWasSuccessful: true, + errorString: nil, + lastUpdatedTimeString: "17m" + ) + ) +} +#endif diff --git a/SessionUIKit/Style Guide/Constants+URLs.swift b/SessionUIKit/Style Guide/Constants+URLs.swift new file mode 100644 index 0000000000..ffeffd7010 --- /dev/null +++ b/SessionUIKit/Style Guide/Constants+URLs.swift @@ -0,0 +1,7 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable +public extension Constants { + static let session_network_url = "https://docs.getsession.org/session-network" + static let session_staking_url = "https://docs.getsession.org/session-network/staking" +} diff --git a/SessionUIKit/Style Guide/Fonts.swift b/SessionUIKit/Style Guide/Fonts.swift index 4572cbf442..e2279bc6c6 100644 --- a/SessionUIKit/Style Guide/Fonts.swift +++ b/SessionUIKit/Style Guide/Fonts.swift @@ -80,6 +80,10 @@ public extension Font { static func boldSpaceMono(size: CGFloat) -> Font { return Font.custom("SpaceMono-Bold", size: size) } + + static func lucide(size: CGFloat) -> Font { + return Font.custom("lucide", size: size) + } } public extension Font { diff --git a/SessionUIKit/Style Guide/Themes/SwiftUI+Theme.swift b/SessionUIKit/Style Guide/Themes/SwiftUI+Theme.swift index 37b02cc9a1..94b4b95df3 100644 --- a/SessionUIKit/Style Guide/Themes/SwiftUI+Theme.swift +++ b/SessionUIKit/Style Guide/Themes/SwiftUI+Theme.swift @@ -20,6 +20,13 @@ public extension View { ) } } + + func shadow(themeColor: ThemeValue, radius: CGFloat) -> some View { + return self.shadow( + color: ThemeManager.currentTheme.colorSwiftUI(for: themeColor) ?? Color.primary, + radius: radius + ) + } } public extension Shape { @@ -43,3 +50,11 @@ public extension Shape { ) } } + +public extension Text { + func foregroundColor(themeColor: ThemeValue) -> Text { + return self.foregroundColor( + ThemeManager.currentTheme.colorSwiftUI(for: themeColor) + ) + } +} diff --git a/SessionUIKit/Style Guide/Themes/Theme+ClassicDark.swift b/SessionUIKit/Style Guide/Themes/Theme+ClassicDark.swift index d8ab095909..cb480cd744 100644 --- a/SessionUIKit/Style Guide/Themes/Theme+ClassicDark.swift +++ b/SessionUIKit/Style Guide/Themes/Theme+ClassicDark.swift @@ -74,6 +74,7 @@ internal enum Theme_ClassicDark: ThemeColors { // Settings .settings_tertiaryAction: .primary, .settings_tabBackground: .classicDark1, + .settings_glowingBackground: .primary, // Appearance .appearance_sectionBackground: .classicDark1, @@ -195,6 +196,7 @@ internal enum Theme_ClassicDark: ThemeColors { // Settings .settings_tertiaryAction: .primary, .settings_tabBackground: .classicDark1, + .settings_glowingBackground: .primary, // Appearance .appearance_sectionBackground: .classicDark1, diff --git a/SessionUIKit/Style Guide/Themes/Theme+ClassicLight.swift b/SessionUIKit/Style Guide/Themes/Theme+ClassicLight.swift index 5d43a828f2..80ff994da2 100644 --- a/SessionUIKit/Style Guide/Themes/Theme+ClassicLight.swift +++ b/SessionUIKit/Style Guide/Themes/Theme+ClassicLight.swift @@ -74,6 +74,7 @@ internal enum Theme_ClassicLight: ThemeColors { // Settings .settings_tertiaryAction: .classicLight0, .settings_tabBackground: .classicLight5, + .settings_glowingBackground: .clear, // AppearanceButton .appearance_sectionBackground: .classicLight6, @@ -195,6 +196,7 @@ internal enum Theme_ClassicLight: ThemeColors { // Settings .settings_tertiaryAction: .classicLight0, .settings_tabBackground: .classicLight5, + .settings_glowingBackground: .clear, // AppearanceButton .appearance_sectionBackground: .classicLight6, diff --git a/SessionUIKit/Style Guide/Themes/Theme+OceanDark.swift b/SessionUIKit/Style Guide/Themes/Theme+OceanDark.swift index 724a12fe6c..179378c5f9 100644 --- a/SessionUIKit/Style Guide/Themes/Theme+OceanDark.swift +++ b/SessionUIKit/Style Guide/Themes/Theme+OceanDark.swift @@ -74,6 +74,7 @@ internal enum Theme_OceanDark: ThemeColors { // Settings .settings_tertiaryAction: .primary, .settings_tabBackground: .oceanDark1, + .settings_glowingBackground: .primary, // Appearance .appearance_sectionBackground: .oceanDark3, @@ -195,6 +196,7 @@ internal enum Theme_OceanDark: ThemeColors { // Settings .settings_tertiaryAction: .primary, .settings_tabBackground: .oceanDark1, + .settings_glowingBackground: .primary, // Appearance .appearance_sectionBackground: .oceanDark3, diff --git a/SessionUIKit/Style Guide/Themes/Theme+OceanLight.swift b/SessionUIKit/Style Guide/Themes/Theme+OceanLight.swift index 5b0d7e859b..9cd1c135b5 100644 --- a/SessionUIKit/Style Guide/Themes/Theme+OceanLight.swift +++ b/SessionUIKit/Style Guide/Themes/Theme+OceanLight.swift @@ -74,6 +74,7 @@ internal enum Theme_OceanLight: ThemeColors { // Settings .settings_tertiaryAction: .oceanLight1, .settings_tabBackground: .oceanLight6, + .settings_glowingBackground: .clear, // Appearance .appearance_sectionBackground: .oceanLight7, @@ -195,6 +196,7 @@ internal enum Theme_OceanLight: ThemeColors { // Settings .settings_tertiaryAction: .oceanLight1, .settings_tabBackground: .oceanLight6, + .settings_glowingBackground: .clear, // Appearance .appearance_sectionBackground: .oceanLight7, diff --git a/SessionUIKit/Style Guide/Themes/Theme.swift b/SessionUIKit/Style Guide/Themes/Theme.swift index 73573bed34..67c7f05489 100644 --- a/SessionUIKit/Style Guide/Themes/Theme.swift +++ b/SessionUIKit/Style Guide/Themes/Theme.swift @@ -188,6 +188,7 @@ public indirect enum ThemeValue: Hashable, Equatable { // Settings case settings_tertiaryAction case settings_tabBackground + case settings_glowingBackground // Appearance case appearance_sectionBackground diff --git a/SessionUIKit/Style Guide/Values.swift b/SessionUIKit/Style Guide/Values.swift index c23ae3ea67..3b4e6ec5d3 100644 --- a/SessionUIKit/Style Guide/Values.swift +++ b/SessionUIKit/Style Guide/Values.swift @@ -46,6 +46,7 @@ public enum Values { // MARK: - Distances public static let verySmallSpacing = CGFloat(4) public static let smallSpacing = CGFloat(8) + public static let mediumSmallSpacing = CGFloat(12) public static let mediumSpacing = CGFloat(16) public static let largeSpacing = CGFloat(24) public static let veryLargeSpacing = CGFloat(35) diff --git a/SessionUIKit/Types/Localization.swift b/SessionUIKit/Types/Localization.swift index 9e77ca7e57..ee9f2676a3 100644 --- a/SessionUIKit/Types/Localization.swift +++ b/SessionUIKit/Types/Localization.swift @@ -7,6 +7,25 @@ import UIKit // MARK: - LocalizationHelper final public class LocalizationHelper: CustomStringConvertible { + private static let bundle: Bundle = { + let bundleName = "SessionUIKit" + + let candidates: [URL?] = [ + Bundle.main.resourceURL, + Bundle(for: LocalizationHelper.self).resourceURL, + Bundle.main.bundleURL + ] + + for candidate in candidates { + let bundlePath = candidate?.appendingPathComponent(bundleName + ".bundle") + if let bundle = bundlePath.flatMap(Bundle.init(url:)) { + return bundle + } + } + + return Bundle(identifier: "com.loki-project.SessionUIKit")! + }() + private let template: String private var replacements: [String : String] = [:] private var numbers: [Int] = [] @@ -36,15 +55,16 @@ final public class LocalizationHelper: CustomStringConvertible { // Use English as the default string if the translation is empty let defaultString: String = { - if let englishPath = Bundle.main.path(forResource: "en", ofType: "lproj"), let englishBundle = Bundle(path: englishPath) { - return englishBundle.localizedString(forKey: template, value: nil, table: nil) - } else { - return "" - } + guard + let englishPath: String = LocalizationHelper.bundle.path(forResource: "en", ofType: "lproj"), + let englishBundle: Bundle = Bundle(path: englishPath) + else { return "" } + + return englishBundle.localizedString(forKey: template, value: template, table: nil) }() // If the localized string matches the key provided then the localisation failed - var localizedString: String = NSLocalizedString(template, value: defaultString, comment: "") + var localizedString: String = NSLocalizedString(template, bundle: LocalizationHelper.bundle, value: defaultString, comment: "") // Deal with plurals // Note: We have to deal with plurals first, so we can get the correct string diff --git a/SessionUIKit/Utilities/Date+Utilities.swift b/SessionUIKit/Utilities/Date+Utilities.swift index f6b492d285..689977cf39 100644 --- a/SessionUIKit/Utilities/Date+Utilities.swift +++ b/SessionUIKit/Utilities/Date+Utilities.swift @@ -37,6 +37,14 @@ public extension Date { return formatter.string(from: self) } + func formatted(_ dateFormat: String) -> String { + let formatter: DateFormatter = DateFormatter() + formatter.locale = Locale.current + formatter.dateFormat = dateFormat + + return formatter.string(from: self) + } + var formattedForBanner: String { return Date.localTimeAndDateFormatter.string(from: self) } diff --git a/SessionUIKit/Utilities/Double+Utilities.swift b/SessionUIKit/Utilities/Double+Utilities.swift new file mode 100644 index 0000000000..2d98de8608 --- /dev/null +++ b/SessionUIKit/Utilities/Double+Utilities.swift @@ -0,0 +1,29 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +public extension Double { + // stringlint:disable_contents + func abbreviatedString(decimalPlaces: Int = 0, omitZeroDecimal: Bool = true) -> String { + typealias TargetValue = (value: Double, suffix: String) + + let clampedDecimalPlaces: Int = max(0, decimalPlaces) + let absNumber: Double = abs(self) + let targetValue: TargetValue + + switch absNumber { + case (1_000_000_000_000...): targetValue = (self / 1_000_000_000_000, "T") + case (1_000_000_000...): targetValue = (self / 1_000_000_000, "B") + case (1_000_000...): targetValue = (self / 1_000_000, "M") + case (1000...): targetValue = (self / 1000, "K") + default: targetValue = (self, "") + } + + guard + decimalPlaces > 0 && ( + !omitZeroDecimal || + targetValue.value.truncatingRemainder(dividingBy: 1) != 0 + ) + else { return String(format: "%.0f%@", targetValue.value, targetValue.suffix) } + + return String(format: "%.\(clampedDecimalPlaces)f%@", targetValue.value, targetValue.suffix) + } +} diff --git a/SessionUIKit/Utilities/Localization+Style.swift b/SessionUIKit/Utilities/Localization+Style.swift index e6fe45a4b0..407ef7d8db 100644 --- a/SessionUIKit/Utilities/Localization+Style.swift +++ b/SessionUIKit/Utilities/Localization+Style.swift @@ -171,6 +171,10 @@ private extension Collection where Element == NSAttributedString.HTMLTag { switch tag { case .bold where self.contains(.italic), .italic where self.contains(.bold): result[.font] = fontWith(font, traits: [.traitBold, .traitItalic]) + + case .bold where self.contains(.icon), .icon where self.contains(.bold): + result[.font] = fontWith(Lucide.font(ofSize: (font.pointSize + 1)), traits: [.traitBold]) + result[.baselineOffset] = Lucide.defaultBaselineOffset case .bold: result[.font] = fontWith(font, traits: [.traitBold]) case .italic: result[.font] = fontWith(font, traits: [.traitItalic]) @@ -215,6 +219,10 @@ public extension String { return NSAttributedString(stringWithHTMLTags: self, font: baseFont) } + func formatted() -> NSAttributedString { + return formatted(baseFont: .systemFont(ofSize: Values.smallFontSize)) + } + func deformatted() -> String { return NSAttributedString(stringWithHTMLTags: self, font: .systemFont(ofSize: 14)).string } diff --git a/SessionUIKit/Utilities/Number+Utilities.swift b/SessionUIKit/Utilities/Number+Utilities.swift new file mode 100644 index 0000000000..4a5689267f --- /dev/null +++ b/SessionUIKit/Utilities/Number+Utilities.swift @@ -0,0 +1,77 @@ +// Copyright © 2024 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public enum NumberFormat { + case abbreviated(decimalPlaces: Int, omitZeroDecimal: Bool) + case decimal + case currency(decimal: Bool) + case abbreviatedCurrency(decimalPlaces: Int, omitZeroDecimal: Bool) +} + +public extension NumberFormat { + static let abbreviated: NumberFormat = .abbreviated(decimalPlaces: 0, omitZeroDecimal: true) + + static func abbreviated(decimalPlaces: Int) -> NumberFormat { + return .abbreviated(decimalPlaces: decimalPlaces, omitZeroDecimal: true) + } + + static let abbreviatedCurrency: NumberFormat = .abbreviatedCurrency(decimalPlaces: 0, omitZeroDecimal: true) + static func abbreviatedCurrency(decimalPlaces: Int) -> NumberFormat { + return .abbreviatedCurrency(decimalPlaces: decimalPlaces, omitZeroDecimal: true) + } + + fileprivate func format(_ value: Double) -> String { + switch self { + case .abbreviated(let decimalPlaces, let omitZeroDecimal): + return value.abbreviatedString(decimalPlaces: decimalPlaces, omitZeroDecimal: omitZeroDecimal) + + case .decimal: + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + return formatter.string(from: NSNumber(value: value)) ?? "\(value)" + + case .currency(let decimal): + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.currencySymbol = "" + if !decimal { + formatter.minimumFractionDigits = 0 + formatter.maximumFractionDigits = 0 + } + return formatter.string(from: NSNumber(value: value)) ?? "\(value)" + + case .abbreviatedCurrency(let decimalPlaces, let omitZeroDecimal): + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.currencySymbol = "" + let symbol: String = (formatter.currencySymbol ?? "") + let abbreviatedNumber: String = value.abbreviatedString(decimalPlaces: decimalPlaces, omitZeroDecimal: omitZeroDecimal) + let fullFormattedString: String = (formatter.string(from: NSNumber(value: value)) ?? "\(value)") + + if !symbol.isEmpty && fullFormattedString.hasPrefix(symbol) { + return "\(symbol)\(abbreviatedNumber)" + } + + if !symbol.isEmpty && fullFormattedString.hasSuffix(symbol) { + return "\(abbreviatedNumber)\(symbol)" + } + + // Fallback + return "\(symbol)\(abbreviatedNumber)" + } + } +} + +public extension Double { + func formatted(format: NumberFormat) -> String { + return format.format(self) + } +} + +public extension Int { + func formatted(format: NumberFormat) -> String { + return format.format(Double(self)) + } +} + diff --git a/SessionUIKit/Utilities/SwiftUI+Utilities.swift b/SessionUIKit/Utilities/SwiftUI+Utilities.swift index 6610da08a3..dc07e97cb1 100644 --- a/SessionUIKit/Utilities/SwiftUI+Utilities.swift +++ b/SessionUIKit/Utilities/SwiftUI+Utilities.swift @@ -187,6 +187,23 @@ extension View { return self } } + + @inlinable public func framing(minWidth: CGFloat? = nil, idealWidth: CGFloat? = nil, maxWidth: CGFloat? = nil, minHeight: CGFloat? = nil, idealHeight: CGFloat? = nil, maxHeight: CGFloat? = nil, width: CGFloat? = nil, height: CGFloat? = nil, alignment: Alignment = .center) -> some View { + return frame( + minWidth: minWidth, + idealWidth: idealWidth, + maxWidth: maxWidth, + minHeight: minHeight, + idealHeight: idealHeight, + maxHeight: maxHeight, + alignment: alignment + ) + .frame( + width: width, + height: height, + alignment: alignment + ) + } } extension Binding { @@ -200,3 +217,60 @@ extension Binding { ) } } + +// MARK: - Interaction Callback + +private struct ScrollOffsetPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() + } +} + +// stringlint:ignore_contents +public extension View { + private func onScrolled(scrollCoordinateSpaceName: String, _ action: @escaping () -> Void) -> some View { + self + .background( + GeometryReader { geometry in + let offsetY = geometry.frame(in: .named(scrollCoordinateSpaceName)).minY + + Color.clear + .preference(key: ScrollOffsetPreferenceKey.self, value: offsetY) + .onChange(of: offsetY) { _ in + action() + } + } + ) + } + + /// This function triggers a callback when any interaction is performed on a UI element + /// + /// **Note:** It looks like there were some bugs in the Gesture Recognizer systens prior to iOS 18.0 (specifically breaking scrolling + /// in a `ScrollView` when this function is used), as a result we instead need to call this function on the content within the + /// `ScrollView` and set `.coordinateSpace(name: coordinateSpaceName)` on the `ScrollView` + @ViewBuilder + func onAnyInteraction( + scrollCoordinateSpaceName: String = "scroll", + action: @escaping () -> Void + ) -> some View { + if #unavailable(iOS 18.0) { + self + .onScrolled(scrollCoordinateSpaceName: scrollCoordinateSpaceName) { action() } + .onTapGesture { action() } + .onLongPressGesture {action() } + } else { + self + .simultaneousGesture( + DragGesture().onChanged { _ in action() } + ) + .simultaneousGesture( + LongPressGesture().onEnded { _ in action() } + ) + .simultaneousGesture( + TapGesture().onEnded { action() } + ) + } + } +} diff --git a/SessionUtilitiesKit/Configuration.swift b/SessionUtilitiesKit/Configuration.swift index 597ed203a9..ce693261c0 100644 --- a/SessionUtilitiesKit/Configuration.swift +++ b/SessionUtilitiesKit/Configuration.swift @@ -30,6 +30,9 @@ public enum SNUtilitiesKit: MigratableTarget { // Just to make the external API [], // Fix thread FTS [ _005_AddJobUniqueHash.self + ], + [ + _006_RenameTableSettingToKeyValueStore.self ] ] ) diff --git a/SessionUtilitiesKit/Database/Migrations/_006_RenameTableSettingToKeyValueStore.swift b/SessionUtilitiesKit/Database/Migrations/_006_RenameTableSettingToKeyValueStore.swift new file mode 100644 index 0000000000..1d5af28598 --- /dev/null +++ b/SessionUtilitiesKit/Database/Migrations/_006_RenameTableSettingToKeyValueStore.swift @@ -0,0 +1,17 @@ +// Copyright © 2024 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB + +enum _006_RenameTableSettingToKeyValueStore: Migration { + static let target: TargetMigrations.Identifier = .utilitiesKit + static let identifier: String = "RenameTableSettingToKeyValueStore" // stringlint:disable + static let minExpectedRunDuration: TimeInterval = 0.1 + static let createdTables: [(TableRecord & FetchableRecord).Type] = [ KeyValueStore.self ] + + static func migrate(_ db: Database, using dependencies: Dependencies) throws { + try db.rename(table: "setting", to: "keyValueStore") + + Storage.update(progress: 1, for: self, in: target, using: dependencies) + } +} diff --git a/SessionUtilitiesKit/Database/Models/KeyValueStore.swift b/SessionUtilitiesKit/Database/Models/KeyValueStore.swift new file mode 100644 index 0000000000..a29d9dd73c --- /dev/null +++ b/SessionUtilitiesKit/Database/Models/KeyValueStore.swift @@ -0,0 +1,301 @@ +// Copyright © 2024 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB + +public struct KeyValueStore: Codable, Identifiable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { + public static var databaseTableName: String { "keyValueStore" } + + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression { + case key + case value + } + + public var id: String { key } + public var rawValue: Data { value } + + let key: String + let value: Data +} + +extension KeyValueStore { + // MARK: - Numeric + + fileprivate init?(key: String, value: T?) { + guard var value: T = value else { return nil } + + self.key = key + self.value = withUnsafeBytes(of: &value) { Data($0) } + } + + fileprivate func value(as type: T.Type) -> T? { + return value.withUnsafeBytes { + $0.loadUnaligned(as: T.self) + } + } + + // MARK: - Bool Setting + + fileprivate init?(key: String, value: Bool?) { + guard var value: Bool = value else { return nil } + + self.key = key + self.value = withUnsafeBytes(of: &value) { Data($0) } + } + + public func unsafeValue(as type: Bool.Type) -> Bool? { + return value.withUnsafeBytes { + $0.loadUnaligned(as: Bool.self) + } + } + + // MARK: - String + + fileprivate init?(key: String, value: String?) { + guard + let value: String = value, + let valueData: Data = value.data(using: .utf8) + else { return nil } + + self.key = key + self.value = valueData + } + + fileprivate func value(as type: String.Type) -> String? { + return String(data: value, encoding: .utf8) + } +} + +// MARK: - Keys + +public extension KeyValueStore { + struct BoolKey: RawRepresentable, ExpressibleByStringLiteral, Hashable { + public let rawValue: String + + public init(_ rawValue: String) { self.rawValue = rawValue } + public init?(rawValue: String) { self.rawValue = rawValue } + public init(stringLiteral value: String) { self.init(value) } + public init(unicodeScalarLiteral value: String) { self.init(value) } + public init(extendedGraphemeClusterLiteral value: String) { self.init(value) } + } + + struct DateKey: RawRepresentable, ExpressibleByStringLiteral, Hashable { + public let rawValue: String + + public init(_ rawValue: String) { self.rawValue = rawValue } + public init?(rawValue: String) { self.rawValue = rawValue } + public init(stringLiteral value: String) { self.init(value) } + public init(unicodeScalarLiteral value: String) { self.init(value) } + public init(extendedGraphemeClusterLiteral value: String) { self.init(value) } + } + + struct DoubleKey: RawRepresentable, ExpressibleByStringLiteral, Hashable { + public let rawValue: String + + public init(_ rawValue: String) { self.rawValue = rawValue } + public init?(rawValue: String) { self.rawValue = rawValue } + public init(stringLiteral value: String) { self.init(value) } + public init(unicodeScalarLiteral value: String) { self.init(value) } + public init(extendedGraphemeClusterLiteral value: String) { self.init(value) } + } + + struct IntKey: RawRepresentable, ExpressibleByStringLiteral, Hashable { + public let rawValue: String + + public init(_ rawValue: String) { self.rawValue = rawValue } + public init?(rawValue: String) { self.rawValue = rawValue } + public init(stringLiteral value: String) { self.init(value) } + public init(unicodeScalarLiteral value: String) { self.init(value) } + public init(extendedGraphemeClusterLiteral value: String) { self.init(value) } + } + + struct Int64Key: RawRepresentable, ExpressibleByStringLiteral, Hashable { + public let rawValue: String + + public init(_ rawValue: String) { self.rawValue = rawValue } + public init?(rawValue: String) { self.rawValue = rawValue } + public init(stringLiteral value: String) { self.init(value) } + public init(unicodeScalarLiteral value: String) { self.init(value) } + public init(extendedGraphemeClusterLiteral value: String) { self.init(value) } + } + + struct StringKey: RawRepresentable, ExpressibleByStringLiteral, Hashable { + public let rawValue: String + + public init(_ rawValue: String) { self.rawValue = rawValue } + public init?(rawValue: String) { self.rawValue = rawValue } + public init(stringLiteral value: String) { self.init(value) } + public init(unicodeScalarLiteral value: String) { self.init(value) } + public init(extendedGraphemeClusterLiteral value: String) { self.init(value) } + } + + struct EnumKey: RawRepresentable, ExpressibleByStringLiteral, Hashable { + public let rawValue: String + + public init(_ rawValue: String) { self.rawValue = rawValue } + public init?(rawValue: String) { self.rawValue = rawValue } + public init(stringLiteral value: String) { self.init(value) } + public init(unicodeScalarLiteral value: String) { self.init(value) } + public init(extendedGraphemeClusterLiteral value: String) { self.init(value) } + } +} + +public protocol EnumInt: RawRepresentable where RawValue == Int {} +public protocol EnumString: RawRepresentable where RawValue == String {} + +// MARK: - GRDB Interactions + +public extension Storage { + subscript(key: KeyValueStore.BoolKey) -> Bool { + // Default to false if it doesn't exist + return (read { db in db[key] } ?? false) + } + + subscript(key: KeyValueStore.DoubleKey) -> Double? { return read { db in db[key] } } + subscript(key: KeyValueStore.IntKey) -> Int? { return read { db in db[key] } } + subscript(key: KeyValueStore.Int64Key) -> Int64? { return read { db in db[key] } } + subscript(key: KeyValueStore.StringKey) -> String? { return read { db in db[key] } } + subscript(key: KeyValueStore.DateKey) -> Date? { return read { db in db[key] } } + + subscript(key: KeyValueStore.EnumKey) -> T? { return read { db in db[key] } } + subscript(key: KeyValueStore.EnumKey) -> T? { return read { db in db[key] } } +} + +public extension Database { + @discardableResult func unsafeSet(key: String, value: T?) -> KeyValueStore? { + guard let value: T = value else { + _ = try? Setting.filter(id: key).deleteAll(self) + return nil + } + + return try? KeyValueStore(key: key, value: value)?.saved(self) + } + + private subscript(key: String) -> KeyValueStore? { + get { try? KeyValueStore.filter(id: key).fetchOne(self) } + set { + guard let newValue: KeyValueStore = newValue else { + _ = try? KeyValueStore.filter(id: key).deleteAll(self) + return + } + + try? newValue.save(self) + } + } + + subscript(key: KeyValueStore.BoolKey) -> Bool { + get { + // Default to false if it doesn't exist + (self[key.rawValue]?.unsafeValue(as: Bool.self) ?? false) + } + set { self[key.rawValue] = KeyValueStore(key: key.rawValue, value: newValue) } + } + + subscript(key: KeyValueStore.DoubleKey) -> Double? { + get { self[key.rawValue]?.value(as: Double.self) } + set { self[key.rawValue] = KeyValueStore(key: key.rawValue, value: newValue) } + } + + subscript(key: KeyValueStore.IntKey) -> Int? { + get { self[key.rawValue]?.value(as: Int.self) } + set { self[key.rawValue] = KeyValueStore(key: key.rawValue, value: newValue) } + } + + subscript(key: KeyValueStore.Int64Key) -> Int64? { + get { self[key.rawValue]?.value(as: Int64.self) } + set { self[key.rawValue] = KeyValueStore(key: key.rawValue, value: newValue) } + } + + subscript(key: KeyValueStore.StringKey) -> String? { + get { self[key.rawValue]?.value(as: String.self) } + set { self[key.rawValue] = KeyValueStore(key: key.rawValue, value: newValue) } + } + + subscript(key: KeyValueStore.EnumKey) -> T? { + get { + guard let rawValue: Int = self[key.rawValue]?.value(as: Int.self) else { + return nil + } + + return T(rawValue: rawValue) + } + set { self[key.rawValue] = KeyValueStore(key: key.rawValue, value: newValue?.rawValue) } + } + + subscript(key: KeyValueStore.EnumKey) -> T? { + get { + guard let rawValue: String = self[key.rawValue]?.value(as: String.self) else { + return nil + } + + return T(rawValue: rawValue) + } + set { self[key.rawValue] = KeyValueStore(key: key.rawValue, value: newValue?.rawValue) } + } + + /// Value will be stored as a timestamp in seconds since 1970 + subscript(key: KeyValueStore.DateKey) -> Date? { + get { + let timestamp: TimeInterval? = self[key.rawValue]?.value(as: TimeInterval.self) + + return timestamp.map { Date(timeIntervalSince1970: $0) } + } + set { + self[key.rawValue] = KeyValueStore( + key: key.rawValue, + value: newValue.map { $0.timeIntervalSince1970 } + ) + } + } + + func setting(key: KeyValueStore.BoolKey, to newValue: Bool) -> KeyValueStore? { + let result: KeyValueStore? = KeyValueStore(key: key.rawValue, value: newValue) + self[key.rawValue] = result + return result + } + + func setting(key: KeyValueStore.DoubleKey, to newValue: Double?) -> KeyValueStore? { + let result: KeyValueStore? = KeyValueStore(key: key.rawValue, value: newValue) + self[key.rawValue] = result + return result + } + + func setting(key: KeyValueStore.IntKey, to newValue: Int?) -> KeyValueStore? { + let result: KeyValueStore? = KeyValueStore(key: key.rawValue, value: newValue) + self[key.rawValue] = result + return result + } + + func setting(key: KeyValueStore.Int64Key, to newValue: Int64?) -> KeyValueStore? { + let result: KeyValueStore? = KeyValueStore(key: key.rawValue, value: newValue) + self[key.rawValue] = result + return result + } + + func setting(key: KeyValueStore.StringKey, to newValue: String?) -> KeyValueStore? { + let result: KeyValueStore? = KeyValueStore(key: key.rawValue, value: newValue) + self[key.rawValue] = result + return result + } + + func setting(key: KeyValueStore.EnumKey, to newValue: T?) -> KeyValueStore? { + let result: KeyValueStore? = KeyValueStore(key: key.rawValue, value: newValue?.rawValue) + self[key.rawValue] = result + return result + } + + func setting(key: KeyValueStore.EnumKey, to newValue: T?) -> KeyValueStore? { + let result: KeyValueStore? = KeyValueStore(key: key.rawValue, value: newValue?.rawValue) + self[key.rawValue] = result + return result + } + + /// Value will be stored as a timestamp in seconds since 1970 + func setting(key: KeyValueStore.DateKey, to newValue: Date?) -> KeyValueStore? { + let result: KeyValueStore? = KeyValueStore(key: key.rawValue, value: newValue.map { $0.timeIntervalSince1970 }) + self[key.rawValue] = result + return result + } +} + diff --git a/SessionUtilitiesKit/Database/Models/Setting.swift b/SessionUtilitiesKit/Database/Models/Setting.swift index 3189c3f4f0..cfddc56db3 100644 --- a/SessionUtilitiesKit/Database/Models/Setting.swift +++ b/SessionUtilitiesKit/Database/Models/Setting.swift @@ -4,253 +4,9 @@ import Foundation import GRDB // MARK: - Setting +// TODO: Refactor the usage of these in future release -public struct Setting: Codable, Identifiable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { - public static var databaseTableName: String { "setting" } - - public typealias Columns = CodingKeys - public enum CodingKeys: String, CodingKey, ColumnExpression { - case key - case value - } - - public var id: String { key } - public var rawValue: Data { value } - - let key: String - let value: Data -} +public typealias Setting = KeyValueStore +public typealias EnumIntSetting = EnumInt +public typealias EnumStringSetting = EnumString -extension Setting { - // MARK: - Numeric Setting - - fileprivate init?(key: String, value: T?) { - guard var value: T = value else { return nil } - - self.key = key - self.value = withUnsafeBytes(of: &value) { Data($0) } - } - - fileprivate func value(as type: T.Type) -> T? { - return value.withUnsafeBytes { - $0.loadUnaligned(as: T.self) - } - } - - // MARK: - Bool Setting - - fileprivate init?(key: String, value: Bool?) { - guard var value: Bool = value else { return nil } - - self.key = key - self.value = withUnsafeBytes(of: &value) { Data($0) } - } - - public func unsafeValue(as type: Bool.Type) -> Bool? { - return value.withUnsafeBytes { - $0.loadUnaligned(as: Bool.self) - } - } - - // MARK: - String Setting - - fileprivate init?(key: String, value: String?) { - guard - let value: String = value, - let valueData: Data = value.data(using: .utf8) - else { return nil } - - self.key = key - self.value = valueData - } - - fileprivate func value(as type: String.Type) -> String? { - return String(data: value, encoding: .utf8) - } -} - -// MARK: - Keys - -public extension Setting { - struct BoolKey: RawRepresentable, ExpressibleByStringLiteral, Hashable { - public let rawValue: String - - public init(_ rawValue: String) { self.rawValue = rawValue } - public init?(rawValue: String) { self.rawValue = rawValue } - public init(stringLiteral value: String) { self.init(value) } - public init(unicodeScalarLiteral value: String) { self.init(value) } - public init(extendedGraphemeClusterLiteral value: String) { self.init(value) } - } - - struct DateKey: RawRepresentable, ExpressibleByStringLiteral, Hashable { - public let rawValue: String - - public init(_ rawValue: String) { self.rawValue = rawValue } - public init?(rawValue: String) { self.rawValue = rawValue } - public init(stringLiteral value: String) { self.init(value) } - public init(unicodeScalarLiteral value: String) { self.init(value) } - public init(extendedGraphemeClusterLiteral value: String) { self.init(value) } - } - - struct DoubleKey: RawRepresentable, ExpressibleByStringLiteral, Hashable { - public let rawValue: String - - public init(_ rawValue: String) { self.rawValue = rawValue } - public init?(rawValue: String) { self.rawValue = rawValue } - public init(stringLiteral value: String) { self.init(value) } - public init(unicodeScalarLiteral value: String) { self.init(value) } - public init(extendedGraphemeClusterLiteral value: String) { self.init(value) } - } - - struct IntKey: RawRepresentable, ExpressibleByStringLiteral, Hashable { - public let rawValue: String - - public init(_ rawValue: String) { self.rawValue = rawValue } - public init?(rawValue: String) { self.rawValue = rawValue } - public init(stringLiteral value: String) { self.init(value) } - public init(unicodeScalarLiteral value: String) { self.init(value) } - public init(extendedGraphemeClusterLiteral value: String) { self.init(value) } - } - - struct StringKey: RawRepresentable, ExpressibleByStringLiteral, Hashable { - public let rawValue: String - - public init(_ rawValue: String) { self.rawValue = rawValue } - public init?(rawValue: String) { self.rawValue = rawValue } - public init(stringLiteral value: String) { self.init(value) } - public init(unicodeScalarLiteral value: String) { self.init(value) } - public init(extendedGraphemeClusterLiteral value: String) { self.init(value) } - } - - struct EnumKey: RawRepresentable, ExpressibleByStringLiteral, Hashable { - public let rawValue: String - - public init(_ rawValue: String) { self.rawValue = rawValue } - public init?(rawValue: String) { self.rawValue = rawValue } - public init(stringLiteral value: String) { self.init(value) } - public init(unicodeScalarLiteral value: String) { self.init(value) } - public init(extendedGraphemeClusterLiteral value: String) { self.init(value) } - } -} - -public protocol EnumIntSetting: RawRepresentable where RawValue == Int {} -public protocol EnumStringSetting: RawRepresentable where RawValue == String {} - -// MARK: - GRDB Interactions - -public extension Database { - private subscript(key: String) -> Setting? { - get { try? Setting.filter(id: key).fetchOne(self) } - set { - guard let newValue: Setting = newValue else { - _ = try? Setting.filter(id: key).deleteAll(self) - return - } - - try? newValue.upsert(self) - } - } - - subscript(key: Setting.BoolKey) -> Bool { - get { - // Default to false if it doesn't exist - (self[key.rawValue]?.unsafeValue(as: Bool.self) ?? false) - } - set { self[key.rawValue] = Setting(key: key.rawValue, value: newValue) } - } - - subscript(key: Setting.DoubleKey) -> Double? { - get { self[key.rawValue]?.value(as: Double.self) } - set { self[key.rawValue] = Setting(key: key.rawValue, value: newValue) } - } - - subscript(key: Setting.IntKey) -> Int? { - get { self[key.rawValue]?.value(as: Int.self) } - set { self[key.rawValue] = Setting(key: key.rawValue, value: newValue) } - } - - subscript(key: Setting.StringKey) -> String? { - get { self[key.rawValue]?.value(as: String.self) } - set { self[key.rawValue] = Setting(key: key.rawValue, value: newValue) } - } - - subscript(key: Setting.EnumKey) -> T? { - get { - guard let rawValue: Int = self[key.rawValue]?.value(as: Int.self) else { - return nil - } - - return T(rawValue: rawValue) - } - set { self[key.rawValue] = Setting(key: key.rawValue, value: newValue?.rawValue) } - } - - subscript(key: Setting.EnumKey) -> T? { - get { - guard let rawValue: String = self[key.rawValue]?.value(as: String.self) else { - return nil - } - - return T(rawValue: rawValue) - } - set { self[key.rawValue] = Setting(key: key.rawValue, value: newValue?.rawValue) } - } - - /// Value will be stored as a timestamp in seconds since 1970 - subscript(key: Setting.DateKey) -> Date? { - get { - let timestamp: TimeInterval? = self[key.rawValue]?.value(as: TimeInterval.self) - - return timestamp.map { Date(timeIntervalSince1970: $0) } - } - set { - self[key.rawValue] = Setting( - key: key.rawValue, - value: newValue.map { $0.timeIntervalSince1970 } - ) - } - } - - func setting(key: Setting.BoolKey, to newValue: Bool) -> Setting? { - let result: Setting? = Setting(key: key.rawValue, value: newValue) - self[key.rawValue] = result - return result - } - - func setting(key: Setting.DoubleKey, to newValue: Double?) -> Setting? { - let result: Setting? = Setting(key: key.rawValue, value: newValue) - self[key.rawValue] = result - return result - } - - func setting(key: Setting.IntKey, to newValue: Int?) -> Setting? { - let result: Setting? = Setting(key: key.rawValue, value: newValue) - self[key.rawValue] = result - return result - } - - func setting(key: Setting.StringKey, to newValue: String?) -> Setting? { - let result: Setting? = Setting(key: key.rawValue, value: newValue) - self[key.rawValue] = result - return result - } - - func setting(key: Setting.EnumKey, to newValue: T?) -> Setting? { - let result: Setting? = Setting(key: key.rawValue, value: newValue?.rawValue) - self[key.rawValue] = result - return result - } - - func setting(key: Setting.EnumKey, to newValue: T?) -> Setting? { - let result: Setting? = Setting(key: key.rawValue, value: newValue?.rawValue) - self[key.rawValue] = result - return result - } - - /// Value will be stored as a timestamp in seconds since 1970 - func setting(key: Setting.DateKey, to newValue: Date?) -> Setting? { - let result: Setting? = Setting(key: key.rawValue, value: newValue.map { $0.timeIntervalSince1970 }) - self[key.rawValue] = result - return result - } -} diff --git a/SessionUtilitiesKit/General/SessionId.swift b/SessionUtilitiesKit/General/SessionId.swift index 7a80816efd..39fb00d510 100644 --- a/SessionUtilitiesKit/General/SessionId.swift +++ b/SessionUtilitiesKit/General/SessionId.swift @@ -7,11 +7,12 @@ public struct SessionId: Equatable, Hashable, CustomStringConvertible { public static let invalid: SessionId = SessionId(.standard, publicKey: []) public enum Prefix: String, CaseIterable, Hashable { - case standard = "05" // Used for identified users, open groups, etc. - case blinded15 = "15" // Used for authentication and participants in open groups with blinding enabled - case blinded25 = "25" // Used for authentication and participants in open groups with blinding enabled - case unblinded = "00" // Used for authentication in open groups with blinding disabled - case group = "03" // Used for update group conversations + case standard = "05" // Used for identified users, open groups, etc. + case blinded15 = "15" // Used for authentication and participants in open groups with blinding enabled + case blinded25 = "25" // Used for authentication and participants in open groups with blinding enabled + case unblinded = "00" // Used for authentication in open groups with blinding disabled + case group = "03" // Used for update group conversations + case versionBlinded07 = "07" // Used for authentication with the file and session network servers public init(from stringValue: String?) throws { guard let stringValue: String = stringValue else { throw SessionIdError.emptyValue } @@ -42,6 +43,7 @@ public struct SessionId: Equatable, Hashable, CustomStringConvertible { case .blinded25: return "26" case .unblinded: return "01" case .group: return "04" + case .versionBlinded07: return "08" } } } diff --git a/SessionUtilitiesKit/General/String+Utilities.swift b/SessionUtilitiesKit/General/String+Utilities.swift index 6ffd4ea45d..39e3eb2e09 100644 --- a/SessionUtilitiesKit/General/String+Utilities.swift +++ b/SessionUtilitiesKit/General/String+Utilities.swift @@ -107,9 +107,16 @@ public extension String.StringInterpolation { } public extension String { - static func formattedDuration(_ duration: TimeInterval, format: TimeInterval.DurationFormat = .short) -> String { + static func formattedDuration(_ duration: TimeInterval, format: TimeInterval.DurationFormat = .short, minimumUnit: NSCalendar.Unit = .second) -> String { let dateComponentsFormatter = DateComponentsFormatter() - dateComponentsFormatter.allowedUnits = [.weekOfMonth, .day, .hour, .minute, .second] + var allowedUnits: NSCalendar.Unit = [.weekOfMonth, .day, .hour, .minute, .second] + switch minimumUnit { + case .minute: + allowedUnits.remove(.second) + default: + break + } + dateComponentsFormatter.allowedUnits = allowedUnits var calendar = Calendar.current switch format { @@ -157,6 +164,11 @@ public extension String { return dateComponentsFormatter.string(from: duration) ?? "" } } + + static func formattedRelativeTime(_ timestampMs: Int64, minimumUnit: NSCalendar.Unit) -> String { + let relativeTimestamp: TimeInterval = Date().timeIntervalSince1970 - TimeInterval(timestampMs) / 1000 + return relativeTimestamp.formatted(format: .short, minimumUnit: minimumUnit) + } } // MARK: - Unicode Handling diff --git a/SessionUtilitiesKit/General/TimeInterval+Utilities.swift b/SessionUtilitiesKit/General/TimeInterval+Utilities.swift index 1be8ee0ace..b2b4acbeb6 100644 --- a/SessionUtilitiesKit/General/TimeInterval+Utilities.swift +++ b/SessionUtilitiesKit/General/TimeInterval+Utilities.swift @@ -11,7 +11,7 @@ public extension TimeInterval { case twoUnits } - func formatted(format: DurationFormat) -> String { - return String.formattedDuration(self, format: format) + func formatted(format: DurationFormat, minimumUnit: NSCalendar.Unit = .second) -> String { + return String.formattedDuration(self, format: format, minimumUnit: minimumUnit) } } diff --git a/SessionUtilitiesKit/Types/UserDefaultsType.swift b/SessionUtilitiesKit/Types/UserDefaultsType.swift index 4918be39c7..291c78b7bc 100644 --- a/SessionUtilitiesKit/Types/UserDefaultsType.swift +++ b/SessionUtilitiesKit/Types/UserDefaultsType.swift @@ -109,9 +109,12 @@ public extension UserDefaults.BoolKey { /// Indicates whether we had the microphone permission the last time the app when to the background static let lastSeenHasMicrophonePermission: UserDefaults.BoolKey = "lastSeenHasMicrophonePermission" - + /// Indicates whether we had asked for the local network permission static let hasRequestedLocalNetworkPermission: UserDefaults.BoolKey = "hasRequestedLocalNetworkPermission" + + /// Indicates whether the local notification for token bonus is scheduled + static let isSessionNetworkPageNotificationScheduled: UserDefaults.BoolKey = "isSessionNetworkPageNotificationScheduled" } public extension UserDefaults.DateKey {