diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/ChatFeature.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/ChatFeature.xcscheme new file mode 100644 index 0000000000000000000000000000000000000000..79ecae98fe19f74f098bcfc46a1b8bcbcf078ef5 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/ChatFeature.xcscheme @@ -0,0 +1,78 @@ +<?xml version="1.0" encoding="UTF-8"?> +<Scheme + LastUpgradeVersion = "1340" + version = "1.3"> + <BuildAction + parallelizeBuildables = "YES" + buildImplicitDependencies = "YES"> + <BuildActionEntries> + <BuildActionEntry + buildForTesting = "YES" + buildForRunning = "YES" + buildForProfiling = "YES" + buildForArchiving = "YES" + buildForAnalyzing = "YES"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "ChatFeature" + BuildableName = "ChatFeature" + BlueprintName = "ChatFeature" + ReferencedContainer = "container:"> + </BuildableReference> + </BuildActionEntry> + </BuildActionEntries> + </BuildAction> + <TestAction + buildConfiguration = "Debug" + selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" + selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + shouldUseLaunchSchemeArgsEnv = "YES" + codeCoverageEnabled = "YES"> + <Testables> + <TestableReference + skipped = "NO"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "ChatFeatureTests" + BuildableName = "ChatFeatureTests" + BlueprintName = "ChatFeatureTests" + ReferencedContainer = "container:"> + </BuildableReference> + </TestableReference> + </Testables> + </TestAction> + <LaunchAction + buildConfiguration = "Debug" + selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" + selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + launchStyle = "0" + useCustomWorkingDirectory = "NO" + ignoresPersistentStateOnLaunch = "NO" + debugDocumentVersioning = "YES" + debugServiceExtension = "internal" + allowLocationSimulation = "YES"> + </LaunchAction> + <ProfileAction + buildConfiguration = "Release" + shouldUseLaunchSchemeArgsEnv = "YES" + savedToolIdentifier = "" + useCustomWorkingDirectory = "NO" + debugDocumentVersioning = "YES"> + <MacroExpansion> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "ChatFeature" + BuildableName = "ChatFeature" + BlueprintName = "ChatFeature" + ReferencedContainer = "container:"> + </BuildableReference> + </MacroExpansion> + </ProfileAction> + <AnalyzeAction + buildConfiguration = "Debug"> + </AnalyzeAction> + <ArchiveAction + buildConfiguration = "Release" + revealArchiveInOrganizer = "YES"> + </ArchiveAction> +</Scheme> diff --git a/App/client-ios.xcodeproj/xcshareddata/xcschemes/Release.xcscheme b/App/client-ios.xcodeproj/xcshareddata/xcschemes/Release.xcscheme index 0465975370687b0d9f8df4aa241468f8387aa35f..800c200df2b4ad94d75e5144e7951bdcd91611f2 100644 --- a/App/client-ios.xcodeproj/xcshareddata/xcschemes/Release.xcscheme +++ b/App/client-ios.xcodeproj/xcshareddata/xcschemes/Release.xcscheme @@ -1,7 +1,7 @@ <?xml version="1.0" encoding="UTF-8"?> <Scheme LastUpgradeVersion = "1200" - version = "1.3"> + version = "1.7"> <BuildAction parallelizeBuildables = "YES" buildImplicitDependencies = "YES"> @@ -52,6 +52,24 @@ migratedStopOnEveryIssue = "YES" debugServiceExtension = "internal" allowLocationSimulation = "YES"> + <PreActions> + <ExecutionAction + ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction"> + <ActionContent + title = "Run Script" + scriptText = "#!/bin/sh /usr/libexec/PlistBuddy -c "Set :isReportingOptional YES" "${SRCROOT}/client-ios/Resources/Info.plist" "> + <EnvironmentBuildable> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "02FDD06121EDA39A000F1286" + BuildableName = "client-ios.app" + BlueprintName = "client-ios" + ReferencedContainer = "container:client-ios.xcodeproj"> + </BuildableReference> + </EnvironmentBuildable> + </ActionContent> + </ExecutionAction> + </PreActions> <BuildableProductRunnable runnableDebuggingMode = "0"> <BuildableReference @@ -91,5 +109,23 @@ <ArchiveAction buildConfiguration = "Release" revealArchiveInOrganizer = "YES"> + <PreActions> + <ExecutionAction + ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction"> + <ActionContent + title = "Run Script" + scriptText = "#!/bin/sh /usr/libexec/PlistBuddy -c "Set :isReportingOptional NO" "${SRCROOT}/client-ios/Resources/Info.plist" "> + <EnvironmentBuildable> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "02FDD06121EDA39A000F1286" + BuildableName = "client-ios.app" + BlueprintName = "client-ios" + ReferencedContainer = "container:client-ios.xcodeproj"> + </BuildableReference> + </EnvironmentBuildable> + </ActionContent> + </ExecutionAction> + </PreActions> </ArchiveAction> </Scheme> diff --git a/App/client-ios/Resources/Info.plist b/App/client-ios/Resources/Info.plist index ed25d86cf62d659ee34ae0f720d9de7f8a56fefb..d8cb845b40392068f453251c9c0aa72cd00979db 100644 --- a/App/client-ios/Resources/Info.plist +++ b/App/client-ios/Resources/Info.plist @@ -34,10 +34,10 @@ </dict> <dict> <key>CFBundleURLName</key> - <string>xxmessenger</string> + <string>xxnetwork</string> <key>CFBundleURLSchemes</key> <array> - <string>xxmessenger</string> + <string>xxnetwork</string> </array> </dict> <dict> @@ -104,5 +104,7 @@ </array> <key>UIViewControllerBasedStatusBarAppearance</key> <true/> + <key>isReportingOptional</key> + <false/> </dict> </plist> diff --git a/App/client-ios/Resources/client-ios.entitlements b/App/client-ios/Resources/client-ios.entitlements index 88e463326dd0fda0a358f095f60612c555c77d3b..8e8b5bf0e9493dfab9ec6ddc3a93aed3b0f36e52 100644 --- a/App/client-ios/Resources/client-ios.entitlements +++ b/App/client-ios/Resources/client-ios.entitlements @@ -4,6 +4,10 @@ <dict> <key>aps-environment</key> <string>development</string> + <key>com.apple.developer.associated-domains</key> + <array> + <string>applinks:elixxir.io</string> + </array> <key>com.apple.developer.icloud-container-identifiers</key> <array> <string>iCloud.xxm-cloud</string> diff --git a/Package.swift b/Package.swift index bcec957c2be3321cb1b9f1bcc1dd176814764b54..f5678619dec83e0af6ca965e92d6303b0bc6f373 100644 --- a/Package.swift +++ b/Package.swift @@ -15,7 +15,6 @@ let package = Package( .library(name: "Models", targets: ["Models"]), .library(name: "XXLogger", targets: ["XXLogger"]), .library(name: "Defaults", targets: ["Defaults"]), - .library(name: "Bindings", targets: ["Bindings"]), .library(name: "Keychain", targets: ["Keychain"]), .library(name: "Voxophone", targets: ["Voxophone"]), .library(name: "Countries", targets: ["Countries"]), @@ -29,7 +28,9 @@ let package = Package( .library(name: "PushFeature", targets: ["PushFeature"]), .library(name: "SFTPFeature", targets: ["SFTPFeature"]), .library(name: "CrashService", targets: ["CrashService"]), + .library(name: "TermsFeature", targets: ["TermsFeature"]), .library(name: "Presentation", targets: ["Presentation"]), + .library(name: "ToastFeature", targets: ["ToastFeature"]), .library(name: "BackupFeature", targets: ["BackupFeature"]), .library(name: "LaunchFeature", targets: ["LaunchFeature"]), .library(name: "iCloudFeature", targets: ["iCloudFeature"]), @@ -50,835 +51,709 @@ let package = Package( .library(name: "OnboardingFeature", targets: ["OnboardingFeature"]), .library(name: "GoogleDriveFeature", targets: ["GoogleDriveFeature"]), .library(name: "ContactListFeature", targets: ["ContactListFeature"]), - .library(name: "DependencyInjection", targets: ["DependencyInjection"]) + .library(name: "DependencyInjection", targets: ["DependencyInjection"]), + .library(name: "ReportingFeature", targets: ["ReportingFeature"]), ], dependencies: [ - .package(url: "https://github.com/Quick/Quick", from: "3.0.0"), - .package(url: "https://github.com/Quick/Nimble", from: "9.0.0"), - .package(url: "https://github.com/SnapKit/SnapKit", from: "5.0.1"), - .package(url: "https://github.com/icanzilb/Retry.git", from: "0.6.3"), - .package(url: "https://github.com/ekazaev/ChatLayout", from: "1.1.14"), - .package(url: "https://github.com/ra1028/DifferenceKit", from: "1.2.0"), - .package(url: "https://github.com/apple/swift-protobuf", from: "1.14.0"), - .package(url: "https://github.com/google/GoogleSignIn-iOS", from: "6.1.0"), - .package(url: "https://github.com/dropbox/SwiftyDropbox.git", from: "8.2.1"), - .package(url: "https://github.com/amosavian/FileProvider.git", from: "0.26.0"), - .package(url: "https://github.com/SwiftyBeaver/SwiftyBeaver.git", from: "1.9.5"), - .package(url: "https://github.com/darrarski/ScrollViewController", from: "1.2.0"), - .package(url: "https://github.com/pointfreeco/combine-schedulers", from: "0.5.0"), - .package(url: "https://github.com/kishikawakatsumi/KeychainAccess", from: "4.2.1"), - .package(url: "https://github.com/google/google-api-objectivec-client-for-rest", from: "1.6.0"), - .package(url: "https://git.xx.network/elixxir/client-ios-db.git", .upToNextMajor(from: "1.0.8")), - .package(url: "https://github.com/firebase/firebase-ios-sdk.git", .upToNextMajor(from: "8.10.0")), - .package(url: "https://github.com/darrarski/Shout.git", revision: "df5a662293f0ac15eeb4f2fd3ffd0c07b73d0de0"), - .package(url: "https://github.com/pointfreeco/swift-composable-architecture.git",.upToNextMajor(from: "0.32.0")), - .package(url: "https://github.com/pointfreeco/swift-custom-dump.git", .upToNextMajor(from: "0.5.0")), - .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay.git", .upToNextMajor(from: "0.3.3")), + .package( + url: "https://github.com/Quick/Quick", + .upToNextMajor(from: "3.0.0") + ), + .package( + url: "https://github.com/Quick/Nimble", + .upToNextMajor(from: "9.0.0") + ), + .package( + url: "https://github.com/SnapKit/SnapKit", + .upToNextMajor(from: "5.0.1") + ), + .package( + url: "https://github.com/icanzilb/Retry.git", + .upToNextMajor(from: "0.6.3") + ), + .package( + url: "https://github.com/ekazaev/ChatLayout", + .upToNextMajor(from: "1.1.14") + ), + .package( + url: "https://github.com/ra1028/DifferenceKit", + .upToNextMajor(from: "1.2.0") + ), + .package( + url: "https://github.com/apple/swift-protobuf", + .upToNextMajor(from: "1.14.0") + ), + .package( + url: "https://github.com/google/GoogleSignIn-iOS", + .upToNextMajor(from: "6.1.0") + ), + .package( + url: "https://github.com/dropbox/SwiftyDropbox.git", + .upToNextMajor(from: "8.2.1") + ), + .package( + url: "https://github.com/amosavian/FileProvider.git", + .upToNextMajor(from: "0.26.0") + ), + .package( + url: "https://github.com/SwiftyBeaver/SwiftyBeaver.git", + .upToNextMajor(from: "1.9.5") + ), + .package( + url: "https://github.com/darrarski/ScrollViewController", + .upToNextMajor(from: "1.2.0") + ), + .package( + url: "https://github.com/pointfreeco/combine-schedulers", + .upToNextMajor(from: "0.5.0") + ), + .package( + url: "https://github.com/kishikawakatsumi/KeychainAccess", + .upToNextMajor(from: "4.2.1") + ), + .package( + url: "https://github.com/google/google-api-objectivec-client-for-rest", + .upToNextMajor(from: "1.6.0") + ), + .package( + url: "https://git.xx.network/elixxir/client-ios-db.git", + .upToNextMajor(from: "1.1.0") + ), + .package( + url: "https://github.com/firebase/firebase-ios-sdk.git", + .upToNextMajor(from: "8.10.0") + ), + .package( + url: "https://github.com/darrarski/Shout.git", + revision: "df5a662293f0ac15eeb4f2fd3ffd0c07b73d0de0" + ), + .package( + url: "https://github.com/pointfreeco/swift-composable-architecture.git", + .upToNextMajor(from: "0.32.0") + ), + .package( + url: "https://github.com/pointfreeco/swift-custom-dump.git", + .upToNextMajor(from: "0.5.0") + ), + .package( + url: "https://github.com/swiftcsv/SwiftCSV.git", + from: "0.8.0" + ), + .package( + url: "https://github.com/pointfreeco/xctest-dynamic-overlay.git", + .upToNextMajor(from: "0.3.3") + ), ], targets: [ .target( name: "App", dependencies: [ - "Keychain", - "Voxophone", - "Permissions", - "ScanFeature", - "ChatFeature", - "MenuFeature", - "PushFeature", - "SFTPFeature", - "ToastFeature", - "CrashService", - "BackupFeature", - "SearchFeature", - "LaunchFeature", - "iCloudFeature", - "DropboxFeature", - "ContactFeature", - "RestoreFeature", - "ProfileFeature", - "CrashReporting", - "ChatListFeature", - "SettingsFeature", - "RequestsFeature", - "OnboardingFeature", - "GoogleDriveFeature", - "ContactListFeature" - ] - ), - .target(name: "CrashReporting"), - .target(name: "NetworkMonitor"), - .target(name: "VersionChecking"), - .target(name: "DependencyInjection"), - .target(name: "InputField", dependencies: ["Shared"]), - .binaryTarget(name: "Bindings", path: "XCFrameworks/Bindings.xcframework"), - - // MARK: - Permissions - - .target( - name: "Permissions", - dependencies: [ - "Theme", - "Shared", - "DependencyInjection" - ] - ), - - // MARK: - PushFeature - - .target( - name: "PushFeature", - dependencies: [ - "Models", - "Defaults", - "Integration", - "DependencyInjection" - ] - ), - - // MARK: - TestHelpers - - .target( - name: "TestHelpers", - dependencies: [ - "Models", - "Presentation" - ] - ), - - // MARK: - Keychain - - .target( - name: "Keychain", - dependencies: [ - .product( - name: "KeychainAccess", - package: "KeychainAccess" - ) - ] - ), - - // MARK: - Voxophone - - .target( - name: "Voxophone", - dependencies: [ - "Shared" - ] - ), - - // MARK: - Models - - .target( - name: "Models", - dependencies: [ - .product( - name: "DifferenceKit", - package: "DifferenceKit" - ), - .product( - name: "SwiftProtobuf", - package: "swift-protobuf" - ) - ] - ), - - // MARK: - Defaults - - .target( - name: "Defaults", - dependencies: [ - "DependencyInjection" - ] - ), - - // MARK: - ToastFeature - - .target( - name: "ToastFeature", - dependencies: [ - "Shared" - ] - ), - - // MARK: - CrashService - - .target( - name: "CrashService", - dependencies: [ - "CrashReporting", - .product( - name: "FirebaseCrashlytics", - package: "firebase-ios-sdk" - ) - ] - ), - - // MARK: - SFTPFeature - - .target( - name: "SFTPFeature", - dependencies: [ - "HUD", - "Models", - "Shared", - "Keychain", - "InputField", - "Presentation", - "DependencyInjection", - .product( - name: "Shout", - package: "Shout" - ) - ] - ), - - // MARK: - GoogleDriveFeature - - .target( - name: "GoogleDriveFeature", - dependencies: [ - .product( - name: "GoogleSignIn", - package: "GoogleSignIn-iOS" - ), - .product( - name: "GoogleAPIClientForREST_Drive", - package: "google-api-objectivec-client-for-rest" - ) - ], - resources: [.process("Resources")] - ), - - // MARK: - iCloudFeature - - .target( - name: "iCloudFeature", - dependencies: [ - .product( - name: "FilesProvider", - package: "FileProvider" - ) - ] - ), - - // MARK: - DropboxFeature - - .target( - name: "DropboxFeature", - dependencies: [ - .product( - name: "SwiftyDropbox", - package: "SwiftyDropbox" - ) - ], - resources: [.process("Resources")] - ), - - // MARK: - Countries - - .target( - name: "Countries", - dependencies: [ - "Theme", - "Shared", - "DependencyInjection" - ], - resources: [.process("Resources")] - ), - - // MARK: - Theme - - .target( - name: "Theme", - dependencies: [ - "Defaults", - "DependencyInjection" - ] - ), - - // MARK: - DrawerFeature - - .target( - name: "DrawerFeature", - dependencies: [ - "Shared", - "InputField", - .product( - name: "ScrollViewController", - package: "ScrollViewController" - ) - ] - ), - - // MARK: - HUD - - .target( - name: "HUD", - dependencies: [ - "Theme", - "Shared", - .product( - name: "SnapKit", - package: "SnapKit" - ) - ] - ), - - // MARK: - XXLogger - - .target( - name: "XXLogger", - dependencies: [ - .product( - name: "SwiftyBeaver", - package: "SwiftyBeaver" - ) - ] - ), - - // MARK: - Shared - - .target( - name: "Shared", - dependencies: [ - .product( - name: "SnapKit", - package: "SnapKit" - ), - .product( - name: "ChatLayout", - package: "ChatLayout" - ), - .product( - name: "DifferenceKit", - package: "DifferenceKit" - ) - ], - exclude: ["swiftgen.yml"], - resources: [.process("Resources")] - ), - - // MARK: - Integration - - .target( - name: "Integration", - dependencies: [ - "Shared", - "Bindings", - "XXLogger", - "Keychain", - "ToastFeature", - "BackupFeature", - "CrashReporting", - "NetworkMonitor", - "DependencyInjection", - .product( - name: "Retry", - package: "Retry" - ), - .product( - name: "XXDatabase", - package: "client-ios-db" - ), - .product( - name: "XXLegacyDatabaseMigrator", - package: "client-ios-db" - ) - ], - resources: [.process("Resources")] - ), - - // MARK: - Presentation - - .target( - name: "Presentation", - dependencies: [ - "Theme", - "Shared", - .product( - name: "SnapKit", - package: "SnapKit" - ) - ] - ), - - // MARK: - ChatInputFeature - - .target( - name: "ChatInputFeature", - dependencies: [ - "Voxophone", - .product( - name: "ComposableArchitecture", - package: "swift-composable-architecture" - ) - ] - ), - - // MARK: - RestoreFeature - - .target( - name: "RestoreFeature", - dependencies: [ - "HUD", - "Shared", - "SFTPFeature", - "Integration", - "Presentation", - "iCloudFeature", - "DropboxFeature", - "GoogleDriveFeature", - "DependencyInjection" - ] - ), - - // MARK: - ContactFeature - - .target( - name: "ContactFeature", - dependencies: [ - "Shared", - "InputField", - "ChatFeature", - "Presentation", - .product( - name: "CombineSchedulers", - package: "combine-schedulers" - ), - .product( - name: "ScrollViewController", - package: "ScrollViewController" - ) - ] - ), - - // MARK: - ChatFeature - - .target( - name: "ChatFeature", - dependencies: [ - "HUD", - "Theme", - "Shared", - "Defaults", - "Keychain", - "Voxophone", - "Integration", - "Permissions", - "Presentation", - "DrawerFeature", - "ChatInputFeature", - "DependencyInjection", - .product( - name: "ChatLayout", - package: "ChatLayout" - ), - .product( - name: "DifferenceKit", - package: "DifferenceKit" - ), - .product( - name: "ScrollViewController", - package: "ScrollViewController" - ) - ] - ), - - // MARK: - SearchFeature - - .target( - name: "SearchFeature", - dependencies: [ - "HUD", - "Shared", - "Countries", - "Integration", - "Presentation", - "ContactFeature", - "DependencyInjection" - ] - ), - - // MARK: - LaunchFeature - - .target( - name: "LaunchFeature", - dependencies: [ - "HUD", - "Theme", - "Shared", - "Defaults", - "PushFeature", - "Integration", - "Permissions", - "DropboxFeature", - "VersionChecking", - "DependencyInjection" - ] - ), - - // MARK: - RequestsFeature - - .target( - name: "RequestsFeature", - dependencies: [ - "Theme", - "Shared", - "Integration", - "ToastFeature", - "ContactFeature", - "DependencyInjection", - .product( - name: "DifferenceKit", - package: "DifferenceKit" - ) - ] - ), - - // MARK: - ProfileFeature - - .target( - name: "ProfileFeature", - dependencies: [ - "HUD", - "Theme", - "Shared", - "Keychain", - "Defaults", - "Countries", - "InputField", - "MenuFeature", - "Permissions", - "Integration", - "Presentation", - "DrawerFeature", - "DependencyInjection", - .product( - name: "CombineSchedulers", - package: "combine-schedulers" - ), - .product( - name: "ScrollViewController", - package: "ScrollViewController" - ) - ] - ), - - // MARK: - ChatListFeature - - .target( - name: "ChatListFeature", - dependencies: [ - "Theme", - "Shared", - "Defaults", - "MenuFeature", - "ChatFeature", - "ProfileFeature", - "SettingsFeature", - "ContactListFeature", - "DependencyInjection", - .product( - name: "DifferenceKit", - package: "DifferenceKit" - ) - ] - ), - - // MARK: - OnboardingFeature - - .target( - name: "OnboardingFeature", - dependencies: [ - "HUD", - "Shared", - "Defaults", - "Keychain", - "Countries", - "InputField", - "Permissions", - "PushFeature", - "Integration", - "Presentation", - "DrawerFeature", - "VersionChecking", - "DependencyInjection", - .product( - name: "CombineSchedulers", - package: "combine-schedulers" - ), - .product( - name: "ScrollViewController", - package: "ScrollViewController" - ) - ] - ), - - // MARK: - MenuFeature - - .target( - name: "MenuFeature", - dependencies: [ - "Theme", - "Shared", - "Defaults", - "Integration", - "Presentation", - "DependencyInjection" - ] - ), - - // MARK: - BackupFeature - - .target( - name: "BackupFeature", - dependencies: [ - "HUD", - "Shared", - "Models", - "InputField", - "SFTPFeature", - "Presentation", - "iCloudFeature", - "DrawerFeature", - "DropboxFeature", - "GoogleDriveFeature", - "DependencyInjection" - ] - ), - - // MARK: - ScanFeature - - .target( - name: "ScanFeature", - dependencies: [ - "Theme", - "Shared", - "Countries", - "Permissions", - "Integration", - "Presentation", - "ContactFeature", - "DependencyInjection", - .product( - name: "SnapKit", - package: "SnapKit" - ) - ] - ), - - // MARK: - ContactListFeature - - .target( - name: "ContactListFeature", - dependencies: [ - "Theme", - "Shared", - "Integration", - "Presentation", - "ContactFeature", - "DependencyInjection", - .product( - name: "DifferenceKit", - package: "DifferenceKit" - ) - ] - ), - - // MARK: - SettingsFeature - - .target( - name: "SettingsFeature", - dependencies: [ - "HUD", - "Theme", - "Shared", - "Defaults", - "Keychain", - "InputField", - "PushFeature", - "Permissions", - "MenuFeature", - "Integration", - "Presentation", - "DrawerFeature", - "DependencyInjection", - .product( - name: "CombineSchedulers", - package: "combine-schedulers" - ), - .product( - name: "ScrollViewController", - package: "ScrollViewController" - ) - ] - ), - - // MARK: - DependencyInjectionTests - - .testTarget( - name: "DependencyInjectionTests", - dependencies: ["DependencyInjection"] - ), - - // MARK: - ProfileFeatureTests - - .testTarget( - name: "ProfileFeatureTests", - dependencies: [ - "TestHelpers", - "ProfileFeature", - .product(name: "Quick", package: "Quick"), - .product(name: "Nimble", package: "Nimble") - ] - ), - - // MARK: - ContactFeatureTests - - .testTarget( - name: "ContactFeatureTests", - dependencies: [ - "TestHelpers", - "ContactFeature", - .product(name: "Quick", package: "Quick"), - .product(name: "Nimble", package: "Nimble") - ] - ), - - // MARK: - SearchFeatureTests - - .testTarget( - name: "SearchFeatureTests", - dependencies: [ - "TestHelpers", - "SearchFeature", - .product(name: "Quick", package: "Quick"), - .product(name: "Nimble", package: "Nimble") - ] - ), - - // MARK: - RequestsFeatureTests - - .testTarget( - name: "RequestsFeatureTests", - dependencies: [ - "TestHelpers", - "RequestsFeature", - .product(name: "Quick", package: "Quick"), - .product(name: "Nimble", package: "Nimble") - ] - ), - - // MARK: - SettingsFeatureTests - - .testTarget( - name: "SettingsFeatureTests", - dependencies: [ - "TestHelpers", - "SettingsFeature", - .product(name: "Quick", package: "Quick"), - .product(name: "Nimble", package: "Nimble") - ] - ), - - // MARK: - SettingsFeatureTests - - .testTarget( - name: "ChatListFeatureTests", - dependencies: [ - "TestHelpers", - "ChatListFeature", - .product(name: "Quick", package: "Quick"), - .product(name: "Nimble", package: "Nimble") - ] - ), - - // MARK: - ContactListFeatureTests - - .testTarget( - name: "ContactListFeatureTests", - dependencies: [ - "TestHelpers", - "ContactListFeature", - .product(name: "Quick", package: "Quick"), - .product(name: "Nimble", package: "Nimble") - ] - ), - - // MARK: - OnboardingFeatureTests - - .testTarget( - name: "OnboardingFeatureTests", - dependencies: [ - "TestHelpers", - "OnboardingFeature", - .product(name: "Quick", package: "Quick"), - .product(name: "Nimble", package: "Nimble") - ] - ), - - // MARK: - PresentationTests - - .testTarget( - name: "PresentationTests", - dependencies: [ - "Presentation", - .product(name: "Quick", package: "Quick"), - .product(name: "Nimble", package: "Nimble") - ] - ), - - // MARK: - ThemeTests - - .testTarget( - name: "ThemeTests", - dependencies: [ - "Theme", - .product(name: "Quick", package: "Quick"), - .product(name: "Nimble", package: "Nimble") - ] - ), - - // MARK: - ChatFeatureTests - - .testTarget( - name: "ChatFeatureTests", - dependencies: [ - "ChatFeature", - "TestHelpers", - .product(name: "Quick", package: "Quick"), - .product(name: "Nimble", package: "Nimble") - ] - ), - - // MARK: - ScanFeatureTests - - .testTarget( - name: "ScanFeatureTests", - dependencies: [ - "TestHelpers", - "ScanFeature", - .product(name: "Quick", package: "Quick"), - .product(name: "Nimble", package: "Nimble") - ] - ), - - // MARK: - CollectionView - - .target( - name: "CollectionView", - dependencies: [ - .product(name: "ChatLayout", package: "ChatLayout"), - .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay"), - ] - ), - .testTarget( - name: "CollectionViewTests", - dependencies: [ - .target(name: "CollectionView"), - .product(name: "CustomDump", package: "swift-custom-dump"), - ] - ), + .target(name: "Keychain"), + .target(name: "Voxophone"), + .target(name: "Permissions"), + .target(name: "ScanFeature"), + .target(name: "ChatFeature"), + .target(name: "MenuFeature"), + .target(name: "PushFeature"), + .target(name: "SFTPFeature"), + .target(name: "TermsFeature"), + .target(name: "ToastFeature"), + .target(name: "CrashService"), + .target(name: "BackupFeature"), + .target(name: "SearchFeature"), + .target(name: "LaunchFeature"), + .target(name: "iCloudFeature"), + .target(name: "DropboxFeature"), + .target(name: "ContactFeature"), + .target(name: "RestoreFeature"), + .target(name: "ProfileFeature"), + .target(name: "CrashReporting"), + .target(name: "ChatListFeature"), + .target(name: "SettingsFeature"), + .target(name: "RequestsFeature"), + .target(name: "ReportingFeature"), + .target(name: "OnboardingFeature"), + .target(name: "GoogleDriveFeature"), + .target(name: "ContactListFeature"), + ] + ), + .testTarget( + name: "AppTests", + dependencies: [ + .target(name: "App"), + ] + ), + .target( + name: "CrashReporting" + ), + .target( + name: "NetworkMonitor" + ), + .target( + name: "VersionChecking" + ), + .target( + name: "DependencyInjection" + ), + .testTarget( + name: "DependencyInjectionTests", + dependencies: [ + .target(name: "DependencyInjection"), + ] + ), + .target( + name: "InputField", + dependencies: [ + .target(name: "Shared"), + ] + ), + .binaryTarget( + name: "Bindings", + path: "XCFrameworks/Bindings.xcframework" + ), + .target( + name: "Permissions", + dependencies: [ + .target(name: "Theme"), + .target(name: "Shared"), + .target(name: "DependencyInjection"), + ] + ), + .target( + name: "PushFeature", + dependencies: [ + .target(name: "Models"), + .target(name: "Defaults"), + .target(name: "Integration"), + .target(name: "DependencyInjection"), + ] + ), + .target( + name: "TestHelpers", + dependencies: [ + .target(name: "Models"), + .target(name: "Presentation"), + ] + ), + .target( + name: "Keychain", + dependencies: [ + .product(name: "KeychainAccess", package: "KeychainAccess"), + ] + ), + .target( + name: "Voxophone", + dependencies: [ + .target(name: "Shared"), + ] + ), + .target( + name: "Models", + dependencies: [ + .product(name: "DifferenceKit", package: "DifferenceKit"), + .product(name: "SwiftProtobuf", package: "swift-protobuf"), + ] + ), + .target( + name: "Defaults", + dependencies: [ + .target(name: "DependencyInjection"), + ] + ), + .target( + name: "ToastFeature", + dependencies: [ + .target(name: "Shared"), + ] + ), + .target( + name: "CrashService", + dependencies: [ + .target(name: "CrashReporting"), + .product(name: "FirebaseCrashlytics", package: "firebase-ios-sdk"), + ] + ), + .target( + name: "SFTPFeature", + dependencies: [ + .target(name: "HUD"), + .target(name: "Models"), + .target(name: "Shared"), + .target(name: "Keychain"), + .target(name: "InputField"), + .target(name: "Presentation"), + .target(name: "DependencyInjection"), + .product(name: "Shout", package: "Shout"), + ] + ), + .target( + name: "GoogleDriveFeature", + dependencies: [ + .product(name: "GoogleSignIn", package: "GoogleSignIn-iOS"), + .product(name: "GoogleAPIClientForREST_Drive", package: "google-api-objectivec-client-for-rest"), + ], + resources: [ + .process("Resources"), + ] + ), + .target( + name: "iCloudFeature", + dependencies: [ + .product(name: "FilesProvider", package: "FileProvider"), + ] + ), + .target( + name: "DropboxFeature", + dependencies: [ + .product(name: "SwiftyDropbox", package: "SwiftyDropbox"), + ], + resources: [ + .process("Resources"), + ] + ), + .target( + name: "Countries", + dependencies: [ + .target(name: "Theme"), + .target(name: "Shared"), + .target(name: "DependencyInjection"), + ], + resources: [ + .process("Resources"), + ] + ), + .target( + name: "Theme", + dependencies: [ + .target(name: "Defaults"), + .target(name: "DependencyInjection"), + ] + ), + .testTarget( + name: "ThemeTests", + dependencies: [ + .target(name: "Theme"), + .product(name: "Quick", package: "Quick"), + .product(name: "Nimble", package: "Nimble"), + ] + ), + .target( + name: "DrawerFeature", + dependencies: [ + .target(name: "Shared"), + .target(name: "InputField"), + .product(name: "ScrollViewController", package: "ScrollViewController"), + ] + ), + .target( + name: "HUD", + dependencies: [ + .target(name: "Theme"), + .target(name: "Shared"), + .product(name: "SnapKit", package: "SnapKit"), + ] + ), + .target( + name: "XXLogger", + dependencies: [ + .product(name: "SwiftyBeaver", package: "SwiftyBeaver"), + ] + ), + .target( + name: "Shared", + dependencies: [ + .product(name: "SnapKit", package: "SnapKit"), + .product(name: "ChatLayout", package: "ChatLayout"), + .product(name: "DifferenceKit", package: "DifferenceKit"), + ], + exclude: [ + "swiftgen.yml", + ], + resources: [ + .process("Resources"), + ] + ), + .target( + name: "Integration", + dependencies: [ + .target(name: "Shared"), + .target(name: "Bindings"), + .target(name: "XXLogger"), + .target(name: "Keychain"), + .target(name: "ToastFeature"), + .target(name: "BackupFeature"), + .target(name: "CrashReporting"), + .target(name: "NetworkMonitor"), + .target(name: "DependencyInjection"), + .product(name: "Retry", package: "Retry"), + .product(name: "XXDatabase", package: "client-ios-db"), + .product(name: "XXLegacyDatabaseMigrator", package: "client-ios-db"), + ], + resources: [ + .process("Resources"), + ] + ), + .target( + name: "Presentation", + dependencies: [ + .target(name: "Theme"), + .target(name: "Shared"), + .product(name: "SnapKit", package: "SnapKit"), + ] + ), + .testTarget( + name: "PresentationTests", + dependencies: [ + .target(name: "Presentation"), + .product(name: "Quick", package: "Quick"), + .product(name: "Nimble", package: "Nimble"), + ] + ), + .target( + name: "ChatInputFeature", + dependencies: [ + .target(name: "Voxophone"), + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + ] + ), + .target( + name: "RestoreFeature", + dependencies: [ + .target(name: "HUD"), + .target(name: "Shared"), + .target(name: "SFTPFeature"), + .target(name: "Integration"), + .target(name: "Presentation"), + .target(name: "iCloudFeature"), + .target(name: "DropboxFeature"), + .target(name: "GoogleDriveFeature"), + .target(name: "DependencyInjection"), + ] + ), + .target( + name: "ContactFeature", + dependencies: [ + .target(name: "Shared"), + .target(name: "InputField"), + .target(name: "ChatFeature"), + .target(name: "Presentation"), + .product(name: "CombineSchedulers", package: "combine-schedulers"), + .product(name: "ScrollViewController", package: "ScrollViewController"), + ] + ), + .testTarget( + name: "ContactFeatureTests", + dependencies: [ + .target(name: "TestHelpers"), + .target(name: "ContactFeature"), + .product(name: "Quick", package: "Quick"), + .product(name: "Nimble", package: "Nimble"), + ] + ), + .target( + name: "ChatFeature", + dependencies: [ + .target(name: "HUD"), + .target(name: "Theme"), + .target(name: "Shared"), + .target(name: "Defaults"), + .target(name: "Keychain"), + .target(name: "Voxophone"), + .target(name: "Integration"), + .target(name: "Permissions"), + .target(name: "Presentation"), + .target(name: "DrawerFeature"), + .target(name: "ChatInputFeature"), + .target(name: "ReportingFeature"), + .target(name: "DependencyInjection"), + .product(name: "ChatLayout", package: "ChatLayout"), + .product(name: "DifferenceKit", package: "DifferenceKit"), + .product(name: "ScrollViewController", package: "ScrollViewController"), + ] + ), + .testTarget( + name: "ChatFeatureTests", + dependencies: [ + .target(name: "ChatFeature"), + .target(name: "TestHelpers"), + .product(name: "Quick", package: "Quick"), + .product(name: "Nimble", package: "Nimble"), + ] + ), + .target( + name: "SearchFeature", + dependencies: [ + .target(name: "HUD"), + .target(name: "Shared"), + .target(name: "Countries"), + .target(name: "Integration"), + .target(name: "Presentation"), + .target(name: "ContactFeature"), + .target(name: "DependencyInjection"), + ] + ), + .testTarget( + name: "SearchFeatureTests", + dependencies: [ + .target(name: "TestHelpers"), + .target(name: "SearchFeature"), + .product(name: "Quick", package: "Quick"), + .product(name: "Nimble", package: "Nimble"), + ] + ), + .target( + name: "LaunchFeature", + dependencies: [ + .target(name: "HUD"), + .target(name: "Theme"), + .target(name: "Shared"), + .target(name: "Defaults"), + .target(name: "PushFeature"), + .target(name: "Integration"), + .target(name: "Permissions"), + .target(name: "DropboxFeature"), + .target(name: "VersionChecking"), + .target(name: "ReportingFeature"), + .target(name: "DependencyInjection"), + ] + ), + .target( + name: "TermsFeature", + dependencies: [ + .target(name: "Theme"), + .target(name: "Shared"), + .target(name: "Defaults"), + .target(name: "Presentation"), + ] + ), + .target( + name: "RequestsFeature", + dependencies: [ + .target(name: "Theme"), + .target(name: "Shared"), + .target(name: "Integration"), + .target(name: "ToastFeature"), + .target(name: "ContactFeature"), + .target(name: "DependencyInjection"), + .product(name: "DifferenceKit", package: "DifferenceKit"), + ] + ), + .testTarget( + name: "RequestsFeatureTests", + dependencies: [ + .target(name: "TestHelpers"), + .target(name: "RequestsFeature"), + .product(name: "Quick", package: "Quick"), + .product(name: "Nimble", package: "Nimble"), + ] + ), + .target( + name: "ProfileFeature", + dependencies: [ + .target(name: "HUD"), + .target(name: "Theme"), + .target(name: "Shared"), + .target(name: "Keychain"), + .target(name: "Defaults"), + .target(name: "Countries"), + .target(name: "InputField"), + .target(name: "MenuFeature"), + .target(name: "Permissions"), + .target(name: "Integration"), + .target(name: "Presentation"), + .target(name: "DrawerFeature"), + .target(name: "DependencyInjection"), + .product(name: "CombineSchedulers", package: "combine-schedulers"), + .product(name: "ScrollViewController", package: "ScrollViewController"), + ] + ), + .testTarget( + name: "ProfileFeatureTests", + dependencies: [ + .target(name: "TestHelpers"), + .target(name: "ProfileFeature"), + .product(name: "Quick", package: "Quick"), + .product(name: "Nimble", package: "Nimble"), + ] + ), + .target( + name: "ChatListFeature", + dependencies: [ + .target(name: "Theme"), + .target(name: "Shared"), + .target(name: "Defaults"), + .target(name: "MenuFeature"), + .target(name: "ChatFeature"), + .target(name: "ProfileFeature"), + .target(name: "SettingsFeature"), + .target(name: "ContactListFeature"), + .target(name: "DependencyInjection"), + .product(name: "DifferenceKit", package: "DifferenceKit"), + ] + ), + .testTarget( + name: "ChatListFeatureTests", + dependencies: [ + .target(name: "TestHelpers"), + .target(name: "ChatListFeature"), + .product(name: "Quick", package: "Quick"), + .product(name: "Nimble", package: "Nimble"), + ] + ), + .target( + name: "OnboardingFeature", + dependencies: [ + .target(name: "HUD"), + .target(name: "Shared"), + .target(name: "Defaults"), + .target(name: "Keychain"), + .target(name: "Countries"), + .target(name: "InputField"), + .target(name: "Permissions"), + .target(name: "PushFeature"), + .target(name: "Integration"), + .target(name: "Presentation"), + .target(name: "DrawerFeature"), + .target(name: "VersionChecking"), + .target(name: "DependencyInjection"), + .product(name: "CombineSchedulers", package: "combine-schedulers"), + .product(name: "ScrollViewController", package: "ScrollViewController"), + ] + ), + .testTarget( + name: "OnboardingFeatureTests", + dependencies: [ + .target(name: "TestHelpers"), + .target(name: "OnboardingFeature"), + .product(name: "Quick", package: "Quick"), + .product(name: "Nimble", package: "Nimble"), + ] + ), + .target( + name: "MenuFeature", + dependencies: [ + .target(name: "Theme"), + .target(name: "Shared"), + .target(name: "Defaults"), + .target(name: "Integration"), + .target(name: "Presentation"), + .target(name: "DependencyInjection"), + ] + ), + .target( + name: "BackupFeature", + dependencies: [ + .target(name: "HUD"), + .target(name: "Shared"), + .target(name: "Models"), + .target(name: "InputField"), + .target(name: "SFTPFeature"), + .target(name: "Presentation"), + .target(name: "iCloudFeature"), + .target(name: "DrawerFeature"), + .target(name: "DropboxFeature"), + .target(name: "GoogleDriveFeature"), + .target(name: "DependencyInjection"), + ] + ), + .target( + name: "ScanFeature", + dependencies: [ + .target(name: "Theme"), + .target(name: "Shared"), + .target(name: "Countries"), + .target(name: "Permissions"), + .target(name: "Integration"), + .target(name: "Presentation"), + .target(name: "ContactFeature"), + .target(name: "DependencyInjection"), + .product(name: "SnapKit", package: "SnapKit"), + ] + ), + .testTarget( + name: "ScanFeatureTests", + dependencies: [ + .target(name: "ScanFeature"), + .target(name: "TestHelpers"), + .product(name: "Quick", package: "Quick"), + .product(name: "Nimble", package: "Nimble"), + ] + ), + .target( + name: "ContactListFeature", + dependencies: [ + .target(name: "Theme"), + .target(name: "Shared"), + .target(name: "Integration"), + .target(name: "Presentation"), + .target(name: "ContactFeature"), + .target(name: "DependencyInjection"), + .product(name: "DifferenceKit", package: "DifferenceKit"), + ] + ), + .testTarget( + name: "ContactListFeatureTests", + dependencies: [ + .target(name: "TestHelpers"), + .target(name: "ContactListFeature"), + .product(name: "Quick", package: "Quick"), + .product(name: "Nimble", package: "Nimble"), + ] + ), + .target( + name: "SettingsFeature", + dependencies: [ + .target(name: "HUD"), + .target(name: "Theme"), + .target(name: "Shared"), + .target(name: "Defaults"), + .target(name: "Keychain"), + .target(name: "InputField"), + .target(name: "PushFeature"), + .target(name: "Permissions"), + .target(name: "MenuFeature"), + .target(name: "Integration"), + .target(name: "Presentation"), + .target(name: "DrawerFeature"), + .target(name: "DependencyInjection"), + .product(name: "CombineSchedulers", package: "combine-schedulers"), + .product(name: "ScrollViewController", package: "ScrollViewController"), + ] + ), + .testTarget( + name: "SettingsFeatureTests", + dependencies: [ + .target(name: "TestHelpers"), + .target(name: "SettingsFeature"), + .product(name: "Quick", package: "Quick"), + .product(name: "Nimble", package: "Nimble"), + ] + ), + .target( + name: "CollectionView", + dependencies: [ + .product(name: "ChatLayout", package: "ChatLayout"), + .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay"), + ] + ), + .testTarget( + name: "CollectionViewTests", + dependencies: [ + .target(name: "CollectionView"), + .product(name: "CustomDump", package: "swift-custom-dump"), + ] + ), + .target( + name: "ReportingFeature", + dependencies: [ + .target(name: "DrawerFeature"), + .target(name: "Shared"), + .product(name: "SwiftCSV", package: "SwiftCSV"), + .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay"), + ], + resources: [ + .process("Resources"), + ] + ), ] ) diff --git a/Sources/App/AppDelegate.swift b/Sources/App/AppDelegate.swift index 72a7f682477967599a823601b8315c46ed429ee6..4143a5f270c7179e9f11892702bf7f7db79a6800 100644 --- a/Sources/App/AppDelegate.swift +++ b/Sources/App/AppDelegate.swift @@ -144,6 +144,35 @@ public class AppDelegate: UIResponder, UIApplicationDelegate { ) -> Bool { dropboxService.handleOpenUrl(url) } + + public func application( + _ application: UIApplication, + continue userActivity: NSUserActivity, + restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void + ) -> Bool { + guard userActivity.activityType == NSUserActivityTypeBrowsingWeb, + let incomingURL = userActivity.webpageURL, + let username = getUsernameFromInvitationDeepLink(incomingURL) else { + return false + } + + let router = try! DependencyInjection.Container.shared.resolve() as PushRouter + router.navigateTo(.search(username: username), {}) + return true + } +} + +func getUsernameFromInvitationDeepLink(_ url: URL) -> String? { + if let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + components.scheme == "https", + components.host == "elixxir.io", + components.path == "/connect", + let queryItem = components.queryItems?.first(where: { $0.name == "username" }), + let username = queryItem.value { + return username + } + + return nil } // MARK: Notifications diff --git a/Sources/App/DependencyRegistrator.swift b/Sources/App/DependencyRegistrator.swift index 14a7b7bdb482974cff2b5b2be573d3f5fb1a33fc..c5d0b830d4c0c028fd8e6642ffad1503df18eee3 100644 --- a/Sources/App/DependencyRegistrator.swift +++ b/Sources/App/DependencyRegistrator.swift @@ -26,6 +26,7 @@ import CrashReporting import NetworkMonitor import DropboxFeature import VersionChecking +import ReportingFeature import GoogleDriveFeature import DependencyInjection @@ -34,6 +35,7 @@ import DependencyInjection import ScanFeature import ChatFeature import MenuFeature +import TermsFeature import BackupFeature import SearchFeature import LaunchFeature @@ -79,6 +81,7 @@ struct DependencyRegistrator { container.register(XXLogger.live()) container.register(CrashReporter.live) container.register(VersionChecker.live()) + container.register(ReportingStatus.live()) container.register(XXNetwork<BindingsClient>() as XXNetworking) container.register(NetworkMonitor() as NetworkMonitoring) @@ -101,6 +104,11 @@ struct DependencyRegistrator { static private func registerCommonDependencies() { container.register(Voxophone()) container.register(BackupService()) + container.register(MakeAppScreenshot.live) + container.register(SendReport.live) + container.register(FetchBannedList.live) + container.register(ProcessBannedList.live) + container.register(MakeReportDrawer.live) // MARK: Isolated @@ -111,8 +119,17 @@ struct DependencyRegistrator { // MARK: Coordinators + container.register( + TermsCoordinator.live( + usernameFactory: OnboardingUsernameController.init(_:), + chatListFactory: ChatListController.init + ) + ) + container.register( LaunchCoordinator( + termsFactory: TermsConditionsController.init(_:), + searchFactory: SearchContainerController.init, requestsFactory: RequestsContainerController.init, chatListFactory: ChatListController.init, onboardingFactory: OnboardingStartController.init(_:), @@ -205,6 +222,7 @@ struct DependencyRegistrator { searchFactory: SearchContainerController.init, welcomeFactory: OnboardingWelcomeController.init, chatListFactory: ChatListController.init, + termsFactory: TermsConditionsController.init(_:), usernameFactory: OnboardingUsernameController.init(_:), restoreListFactory: RestoreListController.init(_:), successFactory: OnboardingSuccessController.init(_:), @@ -250,38 +268,3 @@ struct DependencyRegistrator { ) as ChatListCoordinating) } } - -extension PushRouter { - static func live(navigationController: UINavigationController) -> PushRouter { - PushRouter { route, completion in - if let launchController = navigationController.viewControllers.last as? LaunchController { - launchController.pendingPushRoute = route - } else { - switch route { - case .requests: - if (navigationController.viewControllers.last as? RequestsContainerController) == nil { - navigationController.setViewControllers([RequestsContainerController()], animated: true) - } - case .contactChat(id: let id): - if let session = try? DependencyInjection.Container.shared.resolve() as SessionType, - let contact = try? session.dbManager.fetchContacts(.init(id: [id])).first { - navigationController.setViewControllers([ - ChatListController(), - SingleChatController(contact) - ], animated: true) - } - case .groupChat(id: let id): - if let session = try? DependencyInjection.Container.shared.resolve() as SessionType, - let info = try? session.dbManager.fetchGroupInfos(.init(groupId: id)).first { - navigationController.setViewControllers([ - ChatListController(), - GroupChatController(info) - ], animated: true) - } - } - } - - completion() - } - } -} diff --git a/Sources/App/PushRouter.swift b/Sources/App/PushRouter.swift new file mode 100644 index 0000000000000000000000000000000000000000..a2d2d809d54818566c1aec693ed4ad07937c6999 --- /dev/null +++ b/Sources/App/PushRouter.swift @@ -0,0 +1,52 @@ +import UIKit +import PushFeature +import Integration +import ChatFeature +import SearchFeature +import LaunchFeature +import ChatListFeature +import RequestsFeature +import DependencyInjection + +extension PushRouter { + static func live(navigationController: UINavigationController) -> PushRouter { + PushRouter { route, completion in + if let launchController = navigationController.viewControllers.last as? LaunchController { + launchController.pendingPushRoute = route + } else { + switch route { + case .requests: + if !(navigationController.viewControllers.last is RequestsContainerController) { + navigationController.setViewControllers([RequestsContainerController()], animated: true) + } + case .search(username: let username): + if let _ = try? DependencyInjection.Container.shared.resolve() as SessionType, + !(navigationController.viewControllers.last is SearchContainerController) { + navigationController.setViewControllers([ + ChatListController(), + SearchContainerController(username) + ], animated: true) + } + case .contactChat(id: let id): + if let session = try? DependencyInjection.Container.shared.resolve() as SessionType, + let contact = try? session.dbManager.fetchContacts(.init(id: [id])).first { + navigationController.setViewControllers([ + ChatListController(), + SingleChatController(contact) + ], animated: true) + } + case .groupChat(id: let id): + if let session = try? DependencyInjection.Container.shared.resolve() as SessionType, + let info = try? session.dbManager.fetchGroupInfos(.init(groupId: id)).first { + navigationController.setViewControllers([ + ChatListController(), + GroupChatController(info) + ], animated: true) + } + } + } + + completion() + } + } +} diff --git a/Sources/BackupFeature/Controllers/BackupController.swift b/Sources/BackupFeature/Controllers/BackupController.swift index a9942d8c03466fe8b620d156c9eb1cc02f2ef70a..822ebbc77c3fa46acd0d842579382101aac5b3f9 100644 --- a/Sources/BackupFeature/Controllers/BackupController.swift +++ b/Sources/BackupFeature/Controllers/BackupController.swift @@ -11,6 +11,13 @@ public final class BackupController: UIViewController { private let viewModel = BackupViewModel.live() private var cancellables = Set<AnyCancellable>() + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationItem.backButtonTitle = "" + navigationController?.navigationBar + .customize(backgroundColor: Asset.neutralWhite.color) + } + public override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = Asset.neutralWhite.color @@ -21,19 +28,12 @@ public final class BackupController: UIViewController { } private func setupNavigationBar() { - navigationItem.backButtonTitle = "" - let title = UILabel() title.text = Localized.Backup.header title.textColor = Asset.neutralActive.color title.font = Fonts.Mulish.semiBold.font(size: 18.0) - - let back = UIButton.back() - back.addTarget(self, action: #selector(didTapBack), for: .touchUpInside) - - navigationItem.leftBarButtonItem = UIBarButtonItem( - customView: UIStackView(arrangedSubviews: [back, title]) - ) + navigationItem.leftBarButtonItem = UIBarButtonItem(customView: title) + navigationItem.leftItemsSupplementBackButton = true } private func setupBindings() { @@ -70,8 +70,4 @@ public final class BackupController: UIViewController { } } } - - @objc private func didTapBack() { - navigationController?.popViewController(animated: true) - } } diff --git a/Sources/BackupFeature/Controllers/BackupPassphraseController.swift b/Sources/BackupFeature/Controllers/BackupPassphraseController.swift index b5bd426b6f9242c42d507f4dd5feedd8abccdf17..97c23d21b497a643a7850f3b9147723053dd1e9e 100644 --- a/Sources/BackupFeature/Controllers/BackupPassphraseController.swift +++ b/Sources/BackupFeature/Controllers/BackupPassphraseController.swift @@ -2,7 +2,6 @@ import UIKit import Shared import Combine import InputField -import ScrollViewController public final class BackupPassphraseController: UIViewController { lazy private var screenView = BackupPassphraseView() @@ -21,7 +20,6 @@ public final class BackupPassphraseController: UIViewController { private let cancelClosure: EmptyClosure private let stringClosure: StringClosure private var cancellables = Set<AnyCancellable>() - private let keyboardListener = KeyboardFrameChangeListener(notificationCenter: .default) public init( _ cancelClosure: @escaping EmptyClosure, @@ -35,66 +33,41 @@ public final class BackupPassphraseController: UIViewController { required init?(coder: NSCoder) { nil } public override func loadView() { - let view = UIView() - view.addSubview(screenView) - - screenView.snp.makeConstraints { make in - make.top.equalToSuperview() - make.left.equalToSuperview() - make.right.equalToSuperview() - make.bottom.equalToSuperview().offset(0) - } - - self.view = view + view = screenView } public override func viewDidLoad() { super.viewDidLoad() - setupKeyboard() setupBindings() - - screenView.continueButton.isEnabled = false - } - - private func setupKeyboard() { - keyboardListener.keyboardFrameWillChange = { [weak self] keyboard in - guard let self = self else { return } - - let inset = self.view.frame.height - self.view.convert(keyboard.frame, from: nil).minY - - self.screenView.snp.updateConstraints { - $0.bottom.equalToSuperview().offset(-inset) - } - - self.view.setNeedsLayout() - - UIView.animate(withDuration: keyboard.animationDuration) { - self.view.layoutIfNeeded() - } - } } private func setupBindings() { - screenView.inputField.returnPublisher - .sink { [unowned self] in screenView.inputField.endEditing(true) } - .store(in: &cancellables) - - screenView.cancelButton - .publisher(for: .touchUpInside) - .sink { [unowned self] in dismiss(animated: true) { self.cancelClosure() }} - .store(in: &cancellables) + screenView + .inputField + .returnPublisher + .sink { [unowned self] in + screenView.inputField.endEditing(true) + }.store(in: &cancellables) - screenView.inputField + screenView + .inputField .textPublisher - .sink { [unowned self] in passphrase = $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .store(in: &cancellables) + .sink { [unowned self] in + passphrase = $0.trimmingCharacters(in: .whitespacesAndNewlines) + }.store(in: &cancellables) + + screenView + .continueButton + .publisher(for: .touchUpInside) + .sink { [unowned self] in + dismiss(animated: true) { self.stringClosure(self.passphrase) } + }.store(in: &cancellables) - screenView.continueButton + screenView + .cancelButton .publisher(for: .touchUpInside) .sink { [unowned self] in - dismiss(animated: true) { - self.stringClosure(self.passphrase) - } + dismiss(animated: true) { self.cancelClosure() } }.store(in: &cancellables) } } diff --git a/Sources/BackupFeature/Coordinator/BackupCoordinator.swift b/Sources/BackupFeature/Coordinator/BackupCoordinator.swift index a49b51bd28fe3a670efeba3c2c9cff8e34e31af6..f16acd561f4ca41c9f2abe0db1101a58df4cfcad 100644 --- a/Sources/BackupFeature/Coordinator/BackupCoordinator.swift +++ b/Sources/BackupFeature/Coordinator/BackupCoordinator.swift @@ -1,6 +1,7 @@ import UIKit import Shared import Presentation +import ScrollViewController public protocol BackupCoordinating { func toDrawer( @@ -16,12 +17,18 @@ public protocol BackupCoordinating { } public struct BackupCoordinator: BackupCoordinating { - var bottomPresenter: Presenting = BottomPresenter() + var fullscreenPresenter: Presenting = FullscreenPresenter() - var passphraseFactory: (@escaping EmptyClosure, @escaping StringClosure) -> UIViewController + var passphraseFactory: ( + @escaping EmptyClosure, + @escaping StringClosure + ) -> UIViewController public init( - passphraseFactory: @escaping (@escaping EmptyClosure, @escaping StringClosure) -> UIViewController + passphraseFactory: @escaping ( + @escaping EmptyClosure, + @escaping StringClosure + ) -> UIViewController ) { self.passphraseFactory = passphraseFactory } @@ -32,7 +39,8 @@ public extension BackupCoordinator { _ screen: UIViewController, from parent: UIViewController ) { - bottomPresenter.present(screen, from: parent) + let target = ScrollViewController.embedding(screen) + fullscreenPresenter.present(target, from: parent) } func toPassphrase( @@ -41,6 +49,21 @@ public extension BackupCoordinator { passphraseClosure: @escaping StringClosure ) { let screen = passphraseFactory(cancelClosure, passphraseClosure) - bottomPresenter.present(screen, from: parent) + let target = ScrollViewController.embedding(screen) + fullscreenPresenter.present(target, from: parent) + } +} + +extension ScrollViewController { + static func embedding(_ viewController: UIViewController) -> ScrollViewController { + let scrollViewController = ScrollViewController() + scrollViewController.addChild(viewController) + scrollViewController.contentView = viewController.view + scrollViewController.wrapperView.handlesTouchesOutsideContent = false + scrollViewController.wrapperView.alignContentToBottom = true + scrollViewController.scrollView.bounces = false + + viewController.didMove(toParent: scrollViewController) + return scrollViewController } } diff --git a/Sources/BackupFeature/Views/BackupPassphraseView.swift b/Sources/BackupFeature/Views/BackupPassphraseView.swift index b23c5dcdb5195a7d0f5ff3ac10757ac143ae683f..f2ff27b4d01652f72abab2482a70db9f031d5e55 100644 --- a/Sources/BackupFeature/Views/BackupPassphraseView.swift +++ b/Sources/BackupFeature/Views/BackupPassphraseView.swift @@ -4,49 +4,62 @@ import InputField final class BackupPassphraseView: UIView { let titleLabel = UILabel() - let subtitleLabel = UILabel() - let inputField = InputField() let stackView = UIStackView() - let continueButton = CapsuleButton() + let inputField = InputField() + let subtitleLabel = UILabel() let cancelButton = CapsuleButton() + let continueButton = CapsuleButton() init() { super.init(frame: .zero) - setup() - } - - required init?(coder: NSCoder) { nil } - - private func setup() { layer.cornerRadius = 40 backgroundColor = Asset.neutralWhite.color layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] - subtitleLabel.numberOfLines = 0 - titleLabel.textColor = Asset.neutralActive.color - subtitleLabel.textColor = Asset.neutralActive.color + setupInput() + setupLabels() + setupButtons() + setupStackView() + } + + required init?(coder: NSCoder) { nil } + private func setupInput() { inputField.setup( style: .regular, - title: "Passphrase", - placeholder: "* * * * * *", - subtitleColor: Asset.neutralDisabled.color + title: Localized.Backup.Passphrase.Input.title, + placeholder: Localized.Backup.Passphrase.Input.placeholder, + rightView: .toggleSecureEntry, + subtitleColor: Asset.neutralDisabled.color, + allowsEmptySpace: false, + autocapitalization: .none, + contentType: .newPassword ) + } - titleLabel.text = "Secure your backup" + private func setupLabels() { titleLabel.textAlignment = .left + titleLabel.text = Localized.Backup.Passphrase.title + titleLabel.textColor = Asset.neutralActive.color titleLabel.font = Fonts.Mulish.bold.font(size: 26.0) - subtitleLabel.text = "Please select a password for your backup. If you lose this password, you will not be able to restore your account. Make sure to keep a record somewhere safe. Your password needs to be at least 8 characters with at least 1 uppercase, 1 lowercase and 1 number characters" + subtitleLabel.numberOfLines = 0 subtitleLabel.textAlignment = .left + subtitleLabel.textColor = Asset.neutralActive.color + subtitleLabel.text = Localized.Backup.Passphrase.subtitle subtitleLabel.font = Fonts.Mulish.regular.font(size: 16.0) + } - continueButton.setStyle(.brandColored) - continueButton.setTitle("Set password and continue", for: .normal) - + private func setupButtons() { cancelButton.setStyle(.seeThrough) - cancelButton.setTitle("Cancel", for: .normal) + cancelButton.setTitle(Localized.Backup.Passphrase.cancel, for: .normal) + + continueButton.isEnabled = false + continueButton.setStyle(.brandColored) + continueButton.setTitle(Localized.Backup.Passphrase.continue, for: .normal) + } + private func setupStackView() { stackView.spacing = 20 stackView.axis = .vertical stackView.addArrangedSubview(titleLabel) @@ -57,11 +70,11 @@ final class BackupPassphraseView: UIView { addSubview(stackView) - stackView.snp.makeConstraints { make in - make.top.equalToSuperview().offset(60) - make.left.equalToSuperview().offset(50) - make.right.equalToSuperview().offset(-50) - make.bottom.equalToSuperview().offset(-70) + stackView.snp.makeConstraints { + $0.top.equalToSuperview().offset(60) + $0.left.equalToSuperview().offset(50) + $0.right.equalToSuperview().offset(-50) + $0.bottom.equalToSuperview().offset(-70) } } } diff --git a/Sources/ChatFeature/Controllers/GroupChatController.swift b/Sources/ChatFeature/Controllers/GroupChatController.swift index b168e70c438b6660f637758270bc28761dfa5c0c..5cae4a063a3969e806e1b7c9089d888d345357f3 100644 --- a/Sources/ChatFeature/Controllers/GroupChatController.swift +++ b/Sources/ChatFeature/Controllers/GroupChatController.swift @@ -1,3 +1,4 @@ +import HUD import UIKit import Theme import Models @@ -6,8 +7,10 @@ import Combine import XXModels import Voxophone import ChatLayout +import Integration import DrawerFeature import DifferenceKit +import ReportingFeature import ChatInputFeature import DependencyInjection @@ -19,7 +22,12 @@ typealias OutgoingFailedGroupTextCell = CollectionCell<FlexibleSpace, StackMessa typealias OutgoingFailedGroupReplyCell = CollectionCell<FlexibleSpace, ReplyStackMessageView> public final class GroupChatController: UIViewController { + @Dependency private var hud: HUD + @Dependency private var session: SessionType @Dependency private var coordinator: ChatCoordinating + @Dependency private var reportingStatus: ReportingStatus + @Dependency private var makeReportDrawer: MakeReportDrawer + @Dependency private var makeAppScreenshot: MakeAppScreenshot @Dependency private var statusBarController: StatusBarStyleControlling private let members: MembersController @@ -32,7 +40,6 @@ public final class GroupChatController: UIViewController { private let viewModel: GroupChatViewModel private let layoutDelegate = LayoutDelegate() private var cancellables = Set<AnyCancellable>() - private var drawerCancellables = Set<AnyCancellable>() private var sections = [ArraySection<ChatSection, Message>]() private var currentInterfaceActions = SetActor<Set<InterfaceActions>, ReactionTypes>() @@ -122,14 +129,10 @@ public final class GroupChatController: UIViewController { } private func setupNavigationBar() { - let back = UIButton.back() - back.addTarget(self, action: #selector(didTapBack), for: .touchUpInside) - let more = UIButton() more.setImage(Asset.chatMore.image, for: .normal) more.addTarget(self, action: #selector(didTapDots), for: .touchUpInside) - navigationItem.leftBarButtonItem = UIBarButtonItem(customView: back) navigationItem.titleView = header navigationItem.rightBarButtonItem = UIBarButtonItem(customView: more) } @@ -180,6 +183,17 @@ public final class GroupChatController: UIViewController { } }.store(in: &cancellables) + viewModel.hudPublisher + .receive(on: DispatchQueue.main) + .sink { [hud] in hud.update(with: $0) } + .store(in: &cancellables) + + viewModel.reportPopupPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] contact in + presentReportDrawer(contact) + }.store(in: &cancellables) + viewModel.messages .receive(on: DispatchQueue.main) .sink { [unowned self] sections in @@ -229,14 +243,23 @@ public final class GroupChatController: UIViewController { .store(in: &cancellables) } - @objc private func didTapBack() { - navigationController?.popViewController(animated: true) - } - @objc private func didTapDots() { coordinator.toMembersList(members, from: self) } + private func presentReportDrawer(_ contact: Contact) { + var config = MakeReportDrawer.Config() + config.onReport = { [weak self] in + guard let self = self else { return } + let screenshot = try! self.makeAppScreenshot() + self.viewModel.report(contact: contact, screenshot: screenshot) { + self.collectionView.reloadData() + } + } + let drawer = makeReportDrawer(config) + coordinator.toDrawer(drawer, from: self) + } + private func makeWaitingRoundDrawer() -> UIViewController { let text = DrawerText( font: Fonts.Mulish.semiBold.font(size: 14.0), @@ -256,11 +279,8 @@ public final class GroupChatController: UIViewController { button.action .receive(on: DispatchQueue.main) .sink { [weak drawer] in - drawer?.dismiss(animated: true) { [weak self] in - guard let self = self else { return } - self.drawerCancellables.removeAll() - } - }.store(in: &drawerCancellables) + drawer?.dismiss(animated: true) + }.store(in: &drawer.cancellables) return drawer } @@ -325,7 +345,7 @@ extension GroupChatController: UICollectionViewDataSource { cellForItemAt indexPath: IndexPath ) -> UICollectionViewCell { - let item = sections[indexPath.section].elements[indexPath.item] + var item = sections[indexPath.section].elements[indexPath.item] let canReply: () -> Bool = { (item.status == .sent || item.status == .received) && item.networkId != nil } @@ -338,7 +358,30 @@ extension GroupChatController: UICollectionViewDataSource { let showRound: (String?) -> Void = viewModel.showRoundFrom(_:) let replyContent: (Data) -> (String, String) = viewModel.getReplyContent(for:) + var isSenderBanned = false + + if let sender = try? session.dbManager.fetchContacts(.init(id: [item.senderId])).first { + isSenderBanned = sender.isBanned + } + if item.status == .received { + guard isSenderBanned == false else { + item.text = "This user has been banned" + + let cell: IncomingGroupTextCell = collectionView.dequeueReusableCell(forIndexPath: indexPath) + Bubbler.buildGroup( + bubble: cell.leftView, + with: item, + with: "Banned user" + ) + + cell.canReply = false + cell.performReply = {} + cell.leftView.didTapShowRound = {} + + return cell + } + if let replyMessageId = item.replyMessageId { let cell: IncomingGroupReplyCell = collectionView.dequeueReusableCell(forIndexPath: indexPath) @@ -552,21 +595,29 @@ extension GroupChatController: UICollectionViewDelegate { self?.viewModel.didRequestDelete([item]) } + let report = UIAction(title: Localized.Chat.BubbleMenu.report, state: .off) { [weak self] _ in + self?.viewModel.didRequestReport(item) + } + let retry = UIAction(title: Localized.Chat.BubbleMenu.retry, state: .off) { [weak self] _ in self?.viewModel.retry(item) } - let menu: UIMenu + var children = [UIAction]() if item.status == .sendingFailed { - menu = UIMenu(title: "", children: [copy, retry, delete]) + children = [copy, retry, delete] } else if item.status == .sending { - menu = UIMenu(title: "", children: [copy]) + children = [copy] } else { - menu = UIMenu(title: "", children: [copy, reply, delete]) + children = [copy, reply, delete] + + if self.reportingStatus.isEnabled() { + children.append(report) + } } - return menu + return UIMenu(title: "", children: children) } } } diff --git a/Sources/ChatFeature/Controllers/SheetController.swift b/Sources/ChatFeature/Controllers/SheetController.swift index f0f4fde8279ddd39cf785ec6f1dd2e310563d4b6..974f086d5df232b982c0ab101b0f04f48f1bc578 100644 --- a/Sources/ChatFeature/Controllers/SheetController.swift +++ b/Sources/ChatFeature/Controllers/SheetController.swift @@ -5,6 +5,7 @@ final class SheetController: UIViewController { enum Action { case clear case details + case report } lazy private var screenView = SheetView() @@ -23,7 +24,7 @@ final class SheetController: UIViewController { public override func viewDidLoad() { super.viewDidLoad() - screenView.clear + screenView.clearButton .publisher(for: .touchUpInside) .sink { [unowned self] in dismiss(animated: true) { [weak actionRelay] in @@ -31,12 +32,20 @@ final class SheetController: UIViewController { } }.store(in: &cancellables) - screenView.details + screenView.detailsButton .publisher(for: .touchUpInside) .sink { [unowned self] in dismiss(animated: true) { [weak actionRelay] in actionRelay?.send(.details) } }.store(in: &cancellables) + + screenView.reportButton + .publisher(for: .touchUpInside) + .sink { [unowned self] in + dismiss(animated: true) { [weak actionRelay] in + actionRelay?.send(.report) + } + }.store(in: &cancellables) } } diff --git a/Sources/ChatFeature/Controllers/SingleChatController.swift b/Sources/ChatFeature/Controllers/SingleChatController.swift index f34be438f7df3afd84d5ba7c6db04612bc891cc6..91aa66ec122b86593e3c0e9eab9c3eaf72793a31 100644 --- a/Sources/ChatFeature/Controllers/SingleChatController.swift +++ b/Sources/ChatFeature/Controllers/SingleChatController.swift @@ -12,6 +12,7 @@ import ChatLayout import DrawerFeature import DifferenceKit import ChatInputFeature +import ReportingFeature import DependencyInjection import ScrollViewController @@ -28,6 +29,9 @@ public final class SingleChatController: UIViewController { @Dependency private var logger: XXLogger @Dependency private var voxophone: Voxophone @Dependency private var coordinator: ChatCoordinating + @Dependency private var reportingStatus: ReportingStatus + @Dependency private var makeReportDrawer: MakeReportDrawer + @Dependency private var makeAppScreenshot: MakeAppScreenshot @Dependency private var statusBarController: StatusBarStyleControlling lazy private var infoView = UIControl() @@ -35,7 +39,6 @@ public final class SingleChatController: UIViewController { lazy private var avatarView = AvatarView() lazy private var moreButton = UIButton() - lazy private var backButton = UIButton.back() lazy private var screenView = ChatView() lazy private var sheet = SheetController() @@ -47,7 +50,6 @@ public final class SingleChatController: UIViewController { private let viewModel: SingleChatViewModel private let layoutDelegate = LayoutDelegate() private var cancellables = Set<AnyCancellable>() - private var drawerCancellables = Set<AnyCancellable>() private var sections = [ArraySection<ChatSection, Message>]() private var currentInterfaceActions: SetActor<Set<InterfaceActions>, ReactionTypes> = SetActor() @@ -168,8 +170,6 @@ public final class SingleChatController: UIViewController { nameLabel.textColor = Asset.neutralActive.color nameLabel.font = Fonts.Mulish.semiBold.font(size: 18.0) - backButton.addTarget(self, action: #selector(didTapBack), for: .touchUpInside) - moreButton.setImage(Asset.chatMore.image, for: .normal) moreButton.addTarget(self, action: #selector(didTapDots), for: .touchUpInside) @@ -188,12 +188,9 @@ public final class SingleChatController: UIViewController { $0.right.lessThanOrEqualToSuperview() } - let stackView = UIStackView() - stackView.addArrangedSubview(backButton) - stackView.addArrangedSubview(infoView) - navigationItem.rightBarButtonItem = UIBarButtonItem(customView: moreButton) - navigationItem.leftBarButtonItem = UIBarButtonItem(customView: stackView) + navigationItem.leftBarButtonItem = UIBarButtonItem(customView: infoView) + navigationItem.leftItemsSupplementBackButton = true } private func setupInputController() { @@ -256,6 +253,8 @@ public final class SingleChatController: UIViewController { presentDeleteAllDrawer() case .details: coordinator.toContact(viewModel.contact, from: self) + case .report: + presentReportDrawer() } }.store(in: &cancellables) @@ -270,6 +269,12 @@ public final class SingleChatController: UIViewController { } }.store(in: &cancellables) + viewModel.reportPopupPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] in + presentReportDrawer() + }.store(in: &cancellables) + viewModel.isOnline .removeDuplicates() .receive(on: DispatchQueue.main) @@ -380,16 +385,26 @@ public final class SingleChatController: UIViewController { button.action .receive(on: DispatchQueue.main) - .sink { [weak drawer] in - drawer?.dismiss(animated: true) { [weak self] in - guard let self = self else { return } - self.drawerCancellables.removeAll() - } - }.store(in: &drawerCancellables) + .sink { [unowned drawer] in drawer.dismiss(animated: true) } + .store(in: &drawer.cancellables) return drawer } + private func presentReportDrawer() { + var config = MakeReportDrawer.Config() + config.onReport = { [weak self] in + guard let self = self else { return } + let screenshot = try! self.makeAppScreenshot() + self.viewModel.report(screenshot: screenshot) { success in + guard success else { return } + self.navigationController?.popViewController(animated: true) + } + } + let drawer = makeReportDrawer(config) + coordinator.toDrawer(drawer, from: self) + } + private func presentDeleteAllDrawer() { let clearButton = CapsuleButton() clearButton.setStyle(.red) @@ -423,21 +438,17 @@ public final class SingleChatController: UIViewController { clearButton.publisher(for: .touchUpInside) .receive(on: DispatchQueue.main) - .sink { - drawer.dismiss(animated: true) { [weak self] in - guard let self = self else { return } - self.drawerCancellables.removeAll() - self.viewModel.didRequestDeleteAll() + .sink { [unowned drawer, weak self] in + drawer.dismiss(animated: true) { + self?.viewModel.didRequestDeleteAll() } - }.store(in: &drawerCancellables) + } + .store(in: &drawer.cancellables) cancelButton.publisher(for: .touchUpInside) .receive(on: DispatchQueue.main) - .sink { - drawer.dismiss(animated: true) { [weak self] in - self?.drawerCancellables.removeAll() - } - }.store(in: &drawerCancellables) + .sink { [unowned drawer] in drawer.dismiss(animated: true) } + .store(in: &drawer.cancellables) coordinator.toDrawer(drawer, from: self) } @@ -462,10 +473,6 @@ public final class SingleChatController: UIViewController { @objc private func didTapInfo() { coordinator.toContact(viewModel.contact, from: self) } - - @objc private func didTapBack() { - navigationController?.popViewController(animated: true) - } } extension SingleChatController: UICollectionViewDataSource { @@ -523,11 +530,21 @@ extension SingleChatController: KeyboardListenerDelegate { } func keyboardWillChangeFrame(info: KeyboardInfo) { - let keyWindow = UIApplication.shared.windows.filter { $0.isKeyWindow }.first + let keyWindow: UIWindow? = UIApplication.shared.connectedScenes + .filter { $0.activationState == .foregroundActive } + .compactMap { $0 as? UIWindowScene } + .first? + .windows + .first(where: \.isKeyWindow) + + guard let keyWindow = keyWindow else { + fatalError("[keyboardWillChangeFrame]: Couldn't get key window") + } + + let keyboardFrame = keyWindow.convert(info.frameEnd, to: view) guard !currentInterfaceActions.options.contains(.changingFrameSize), collectionView.contentInsetAdjustmentBehavior != .never, - let keyboardFrame = keyWindow?.convert(info.frameEnd, to: view), collectionView.convert(collectionView.bounds, to: keyWindow).maxY > info.frameEnd.minY else { return } currentInterfaceActions.options.insert(.changingKeyboardFrame) @@ -639,12 +656,20 @@ extension SingleChatController: UICollectionViewDelegate { guard let self = self else { return nil } let item = self.sections[indexPath.section].elements[indexPath.item] - return UIMenu(title: "", children: [ + var children = [ ActionFactory.build(from: item, action: .copy, closure: self.viewModel.didRequestCopy(_:)), ActionFactory.build(from: item, action: .retry, closure: self.viewModel.didRequestRetry(_:)), ActionFactory.build(from: item, action: .reply, closure: self.viewModel.didRequestReply(_:)), ActionFactory.build(from: item, action: .delete, closure: self.viewModel.didRequestDeleteSingle(_:)) - ].compactMap { $0 }) + ] + + if self.reportingStatus.isEnabled() { + children.append( + ActionFactory.build(from: item, action: .report, closure: self.viewModel.didRequestReport(_:)) + ) + } + + return UIMenu(title: "", children: children.compactMap { $0 }) } } diff --git a/Sources/ChatFeature/Helpers/CellConfigurator.swift b/Sources/ChatFeature/Helpers/CellConfigurator.swift index 4f0ad3ca31e71223f57f6b9593b918c1f745c2e1..d9b61fd9d3be46405ab170259dbe82aa0b1961a0 100644 --- a/Sources/ChatFeature/Helpers/CellConfigurator.swift +++ b/Sources/ChatFeature/Helpers/CellConfigurator.swift @@ -399,6 +399,7 @@ struct ActionFactory { case retry case reply case delete + case report var title: String { switch self { @@ -411,6 +412,8 @@ struct ActionFactory { return Localized.Chat.BubbleMenu.reply case .delete: return Localized.Chat.BubbleMenu.delete + case .report: + return Localized.Chat.BubbleMenu.report } } } @@ -422,6 +425,8 @@ struct ActionFactory { ) -> UIAction? { switch action { + case .report: + guard item.status == .received else { return nil } case .reply: guard item.status == .received || item.status == .sent else { return nil } case .retry: diff --git a/Sources/ChatFeature/ViewModels/GroupChatViewModel.swift b/Sources/ChatFeature/ViewModels/GroupChatViewModel.swift index aa1dbefc8ed34f582d84df609d70b65631c69013..1dbce8a895ebfeabceb5900d3e5d258bcfb31199 100644 --- a/Sources/ChatFeature/ViewModels/GroupChatViewModel.swift +++ b/Sources/ChatFeature/ViewModels/GroupChatViewModel.swift @@ -1,10 +1,15 @@ +import HUD import UIKit import Models +import Shared import Combine import XXModels +import Defaults import Foundation import Integration +import ToastFeature import DifferenceKit +import ReportingFeature import DependencyInjection enum GroupChatNavigationRoutes: Equatable { @@ -14,6 +19,19 @@ enum GroupChatNavigationRoutes: Equatable { final class GroupChatViewModel { @Dependency private var session: SessionType + @Dependency private var sendReport: SendReport + @Dependency private var reportingStatus: ReportingStatus + @Dependency private var toastController: ToastController + + @KeyObject(.username, defaultValue: nil) var username: String? + + var hudPublisher: AnyPublisher<HUDStatus, Never> { + hudSubject.eraseToAnyPublisher() + } + + var reportPopupPublisher: AnyPublisher<Contact, Never> { + reportPopupSubject.eraseToAnyPublisher() + } var replyPublisher: AnyPublisher<(String, String), Never> { replySubject.eraseToAnyPublisher() @@ -26,6 +44,8 @@ final class GroupChatViewModel { let info: GroupInfo private var stagedReply: Reply? private var cancellables = Set<AnyCancellable>() + private let hudSubject = CurrentValueSubject<HUDStatus, Never>(.none) + private let reportPopupSubject = PassthroughSubject<Contact, Never>() private let replySubject = PassthroughSubject<(String, String), Never>() private let routesSubject = PassthroughSubject<GroupChatNavigationRoutes, Never>() @@ -63,6 +83,12 @@ final class GroupChatViewModel { _ = try? session.dbManager.deleteMessages(.init(id: Set(messages.map(\.id)))) } + func didRequestReport(_ message: Message) { + if let contact = try? session.dbManager.fetchContacts(.init(id: [message.senderId])).first { + reportPopupSubject.send(contact) + } + } + func send(_ text: String) { session.send(.init( text: text.trimmingCharacters(in: .whitespacesAndNewlines), @@ -103,7 +129,13 @@ final class GroupChatViewModel { return "[DELETED]" } - return (contact.nickname ?? contact.username) ?? "Fetching username..." + var name = (contact.nickname ?? contact.username) ?? "Fetching username..." + + if contact.isBlocked, reportingStatus.isEnabled() { + name = "\(name) (Blocked)" + } + + return name } func didRequestReply(_ message: Message) { @@ -111,4 +143,57 @@ final class GroupChatViewModel { stagedReply = Reply(messageId: networkId, senderId: message.senderId) replySubject.send(getReplyContent(for: networkId)) } + + func report(contact: Contact, screenshot: UIImage, completion: @escaping () -> Void) { + let report = Report( + sender: .init( + userId: contact.id.base64EncodedString(), + username: contact.username! + ), + recipient: .init( + userId: session.myId.base64EncodedString(), + username: username! + ), + type: .group, + screenshot: screenshot.pngData()!, + partyName: info.group.name, + partyBlob: info.group.id.base64EncodedString(), + partyMembers: info.members.map { Report.ReportUser( + userId: $0.id.base64EncodedString(), + username: $0.username ?? "") + } + ) + + hudSubject.send(.on) + sendReport(report) { result in + switch result { + case .failure(let error): + DispatchQueue.main.async { + self.hudSubject.send(.error(.init(with: error))) + } + + case .success(_): + self.blockContact(contact) + DispatchQueue.main.async { + self.hudSubject.send(.none) + self.presentReportConfirmation(contact: contact) + completion() + } + } + } + } + + private func blockContact(_ contact: Contact) { + var contact = contact + contact.isBlocked = true + _ = try? session.dbManager.saveContact(contact) + } + + private func presentReportConfirmation(contact: Contact) { + let name = (contact.nickname ?? contact.username) ?? "the contact" + toastController.enqueueToast(model: .init( + title: "Your report has been sent and \(name) is now blocked.", + leftImage: Asset.requestSentToaster.image + )) + } } diff --git a/Sources/ChatFeature/ViewModels/SingleChatViewModel.swift b/Sources/ChatFeature/ViewModels/SingleChatViewModel.swift index f51a893610402c317f938f0b29445317de9bb6d4..8ba933ad52ff63b6beca403e84bd688ccb043ac9 100644 --- a/Sources/ChatFeature/ViewModels/SingleChatViewModel.swift +++ b/Sources/ChatFeature/ViewModels/SingleChatViewModel.swift @@ -7,8 +7,11 @@ import XXLogger import XXModels import Foundation import Integration +import Defaults import Permissions +import ToastFeature import DifferenceKit +import ReportingFeature import DependencyInjection enum SingleChatNavigationRoutes: Equatable { @@ -22,10 +25,14 @@ enum SingleChatNavigationRoutes: Equatable { case webview(String) } -final class SingleChatViewModel { +final class SingleChatViewModel: NSObject { @Dependency private var logger: XXLogger @Dependency private var session: SessionType @Dependency private var permissions: PermissionHandling + @Dependency private var toastController: ToastController + @Dependency private var sendReport: SendReport + + @KeyObject(.username, defaultValue: nil) var username: String? var contact: Contact { contactSubject.value } private var stagedReply: Reply? @@ -34,6 +41,7 @@ final class SingleChatViewModel { private let replySubject = PassthroughSubject<(String, String), Never>() private let navigationRoutes = PassthroughSubject<SingleChatNavigationRoutes, Never>() private let sectionsRelay = CurrentValueSubject<[ArraySection<ChatSection, Message>], Never>([]) + private let reportPopupSubject = PassthroughSubject<Void, Never>() var hud: AnyPublisher<HUDStatus, Never> { hudRelay.eraseToAnyPublisher() } private let hudRelay = CurrentValueSubject<HUDStatus, Never>(.none) @@ -44,6 +52,10 @@ final class SingleChatViewModel { var navigation: AnyPublisher<SingleChatNavigationRoutes, Never> { navigationRoutes.eraseToAnyPublisher() } var shouldDisplayEmptyView: AnyPublisher<Bool, Never> { sectionsRelay.map { $0.isEmpty }.eraseToAnyPublisher() } + var reportPopupPublisher: AnyPublisher<Void, Never> { + reportPopupSubject.eraseToAnyPublisher() + } + var messages: AnyPublisher<[ArraySection<ChatSection, Message>], Never> { sectionsRelay.map { sections -> [ArraySection<ChatSection, Message>] in var snapshot = [ArraySection<ChatSection, Message>]() @@ -66,6 +78,7 @@ final class SingleChatViewModel { init(_ contact: Contact) { self.contactSubject = .init(contact) + super.init() updateRecentState(contact) @@ -133,11 +146,11 @@ final class SingleChatViewModel { guard let id = message.id else { return } session.retryMessage(id) } - + func didNavigateSomewhere() { navigationRoutes.send(.none) } - + @discardableResult func didTest(permission: PermissionType) -> Bool { switch permission { @@ -172,6 +185,10 @@ final class SingleChatViewModel { didRequestDelete([model]) } + func didRequestReport(_: Message) { + reportPopupSubject.send() + } + func abortReply() { stagedReply = nil } @@ -237,4 +254,52 @@ final class SingleChatViewModel { func section(at index: Int) -> ChatSection? { sectionsRelay.value.count > 0 ? sectionsRelay.value[index].model : nil } + + func report(screenshot: UIImage, completion: @escaping (Bool) -> Void) { + let report = Report( + sender: .init( + userId: contact.id.base64EncodedString(), + username: contact.username! + ), + recipient: .init( + userId: session.myId.base64EncodedString(), + username: username! + ), + type: .dm, + screenshot: screenshot.pngData()! + ) + + hudRelay.send(.on) + sendReport(report) { result in + switch result { + case .failure(let error): + DispatchQueue.main.async { + self.hudRelay.send(.error(.init(with: error))) + completion(false) + } + + case .success(_): + self.blockContact() + DispatchQueue.main.async { + self.hudRelay.send(.none) + self.presentReportConfirmation() + completion(true) + } + } + } + } + + private func blockContact() { + var contact = contact + contact.isBlocked = true + _ = try? session.dbManager.saveContact(contact) + } + + private func presentReportConfirmation() { + let name = (contact.nickname ?? contact.username) ?? "the contact" + toastController.enqueueToast(model: .init( + title: "Your report has been sent and \(name) is now blocked.", + leftImage: Asset.requestSentToaster.image + )) + } } diff --git a/Sources/ChatFeature/Views/SheetView.swift b/Sources/ChatFeature/Views/SheetView.swift index 86459e76e8947df8728e3daaa4569d62e8e5c7cd..a4cdfeb1348a6cd8b369115d6c34272df7649d87 100644 --- a/Sources/ChatFeature/Views/SheetView.swift +++ b/Sources/ChatFeature/Views/SheetView.swift @@ -2,9 +2,10 @@ import UIKit import Shared final class SheetView: UIView { - let stack = UIStackView() - let clear = SheetButton() - let details = SheetButton() + let stackView = UIStackView() + let clearButton = SheetButton() + let reportButton = SheetButton() + let detailsButton = SheetButton() init() { super.init(frame: .zero) @@ -13,23 +14,28 @@ final class SheetView: UIView { layer.masksToBounds = true backgroundColor = Asset.neutralWhite.color - clear.image.image = Asset.chatListDeleteSwipe.image - clear.title.text = Localized.Chat.SheetMenu.clear + clearButton.image.image = Asset.chatListDeleteSwipe.image + clearButton.title.text = Localized.Chat.SheetMenu.clear - details.tintColor = Asset.neutralDark.color - details.image.image = Asset.searchUsername.image - details.title.text = Localized.Chat.SheetMenu.details + detailsButton.tintColor = Asset.neutralDark.color + detailsButton.image.image = Asset.searchUsername.image + detailsButton.title.text = Localized.Chat.SheetMenu.details - stack.axis = .vertical - stack.distribution = .fillEqually - stack.addArrangedSubview(clear) - stack.addArrangedSubview(details) - addSubview(stack) + reportButton.tintColor = Asset.accentDanger.color + reportButton.image.image = Asset.searchUsername.image + reportButton.title.text = Localized.Chat.SheetMenu.report - stack.snp.makeConstraints { make in - make.top.equalToSuperview().offset(25) - make.left.right.equalToSuperview() - make.bottom.equalTo(safeAreaLayoutGuide) + stackView.axis = .vertical + stackView.distribution = .fillEqually + stackView.addArrangedSubview(clearButton) + stackView.addArrangedSubview(detailsButton) + stackView.addArrangedSubview(reportButton) + addSubview(stackView) + + stackView.snp.makeConstraints { + $0.top.equalToSuperview().offset(25) + $0.left.right.equalToSuperview() + $0.bottom.equalTo(safeAreaLayoutGuide) } } diff --git a/Sources/ChatListFeature/Controller/ChatListController.swift b/Sources/ChatListFeature/Controller/ChatListController.swift index bddccd284ce09ecb2b9e479044aa8fb41eb5e757..57e744a3b29d7ab15371624fe2ce0932e7f9d772 100644 --- a/Sources/ChatListFeature/Controller/ChatListController.swift +++ b/Sources/ChatListFeature/Controller/ChatListController.swift @@ -36,6 +36,13 @@ public final class ChatListController: UIViewController { } } + public override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) { + super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) + navigationItem.backButtonTitle = "" + } + + required init?(coder: NSCoder) { nil } + public override func loadView() { view = screenView } @@ -55,7 +62,6 @@ public final class ChatListController: UIViewController { } private func setupNavigationBar() { - navigationItem.backButtonTitle = "" navigationItem.leftBarButtonItem = UIBarButtonItem(customView: topLeftView) navigationItem.rightBarButtonItem = UIBarButtonItem(customView: topRightView) diff --git a/Sources/ChatListFeature/Coordinator/ChatListCoordinator.swift b/Sources/ChatListFeature/Coordinator/ChatListCoordinator.swift index bb412859b16fe99591c8b658fc4b8798fe27fb00..acd4fbcaecf645611fd30d058a0172f785b03fab 100644 --- a/Sources/ChatListFeature/Coordinator/ChatListCoordinator.swift +++ b/Sources/ChatListFeature/Coordinator/ChatListCoordinator.swift @@ -27,7 +27,7 @@ public struct ChatListCoordinator: ChatListCoordinating { var bottomPresenter: Presenting = BottomPresenter() var scanFactory: () -> UIViewController - var searchFactory: () -> UIViewController + var searchFactory: (String?) -> UIViewController var newGroupFactory: () -> UIViewController var contactsFactory: () -> UIViewController var contactFactory: (Contact) -> UIViewController @@ -37,7 +37,7 @@ public struct ChatListCoordinator: ChatListCoordinating { public init( scanFactory: @escaping () -> UIViewController, - searchFactory: @escaping () -> UIViewController, + searchFactory: @escaping (String?) -> UIViewController, newGroupFactory: @escaping () -> UIViewController, contactsFactory: @escaping () -> UIViewController, contactFactory: @escaping (Contact) -> UIViewController, @@ -58,7 +58,7 @@ public struct ChatListCoordinator: ChatListCoordinating { public extension ChatListCoordinator { func toSearch(from parent: UIViewController) { - let screen = searchFactory() + let screen = searchFactory(nil) pushPresenter.present(screen, from: parent) } diff --git a/Sources/ChatListFeature/ViewModel/ChatListViewModel.swift b/Sources/ChatListFeature/ViewModel/ChatListViewModel.swift index f481b96fb7e3e14b74096919090bf1df8c96c840..af0a8401592ec2715527a3af901846253c20ba80 100644 --- a/Sources/ChatListFeature/ViewModel/ChatListViewModel.swift +++ b/Sources/ChatListFeature/ViewModel/ChatListViewModel.swift @@ -6,6 +6,7 @@ import Combine import XXModels import Defaults import Integration +import ReportingFeature import DependencyInjection enum SearchSection { @@ -23,6 +24,7 @@ typealias SearchSnapshot = NSDiffableDataSourceSnapshot<SearchSection, SearchIte final class ChatListViewModel { @Dependency private var session: SessionType + @Dependency private var reportingStatus: ReportingStatus var isOnline: AnyPublisher<Bool, Never> { session.isOnline @@ -37,7 +39,13 @@ final class ChatListViewModel { } var recentsPublisher: AnyPublisher<RecentsSnapshot, Never> { - session.dbManager.fetchContactsPublisher(.init(isRecent: true)) + let query = Contact.Query( + isRecent: true, + isBlocked: reportingStatus.isEnabled() ? false : nil, + isBanned: reportingStatus.isEnabled() ? false : nil + ) + + return session.dbManager.fetchContactsPublisher(query) .assertNoFailure() .map { let section = SectionId() @@ -49,8 +57,18 @@ final class ChatListViewModel { } var searchPublisher: AnyPublisher<SearchSnapshot, Never> { - Publishers.CombineLatest3( - session.dbManager.fetchContactsPublisher(.init()).assertNoFailure(), + let contactsQuery = Contact.Query( + isBlocked: reportingStatus.isEnabled() ? false : nil, + isBanned: reportingStatus.isEnabled() ? false : nil + ) + + let contactsStream = session.dbManager + .fetchContactsPublisher(contactsQuery) + .assertNoFailure() + .map { $0.filter { $0.id != self.session.myId }} + + return Publishers.CombineLatest3( + contactsStream, chatsPublisher, searchSubject .removeDuplicates() @@ -101,13 +119,17 @@ final class ChatListViewModel { var badgeCountPublisher: AnyPublisher<Int, Never> { let groupQuery = Group.Query(authStatus: [.pending]) - let contactsQuery = Contact.Query(authStatus: [ - .verified, - .confirming, - .confirmationFailed, - .verificationFailed, - .verificationInProgress - ]) + let contactsQuery = Contact.Query( + authStatus: [ + .verified, + .confirming, + .confirmationFailed, + .verificationFailed, + .verificationInProgress + ], + isBlocked: reportingStatus.isEnabled() ? false : nil, + isBanned: reportingStatus.isEnabled() ? false : nil + ) return Publishers.CombineLatest( session.dbManager.fetchContactsPublisher(contactsQuery).assertNoFailure(), @@ -127,10 +149,13 @@ final class ChatListViewModel { ChatInfo.Query( contactChatInfoQuery: .init( userId: session.myId, - authStatus: [.friend] + authStatus: [.friend], + isBlocked: reportingStatus.isEnabled() ? false : nil, + isBanned: reportingStatus.isEnabled() ? false : nil ), groupChatInfoQuery: GroupChatInfo.Query( - authStatus: [.participating] + authStatus: [.participating], + excludeBannedContactsMessages: reportingStatus.isEnabled() ), groupQuery: Group.Query( withMessages: false, diff --git a/Sources/ContactFeature/Controllers/ContactController.swift b/Sources/ContactFeature/Controllers/ContactController.swift index 02859bab60bf861a5615795a016352a4f4509900..30dd3f5e48444176c274a0f6f18502b12e172c17 100644 --- a/Sources/ContactFeature/Controllers/ContactController.swift +++ b/Sources/ContactFeature/Controllers/ContactController.swift @@ -30,9 +30,13 @@ public final class ContactController: UIViewController { public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + navigationItem.backButtonTitle = "" statusBarController.style.send(.lightContent) navigationController?.navigationBar - .customize(backgroundColor: Asset.neutralBody.color) + .customize( + backgroundColor: Asset.neutralBody.color, + tint: Asset.neutralWhite.color + ) } public override func viewSafeAreaInsetsDidChange() { @@ -43,7 +47,6 @@ public final class ContactController: UIViewController { public override func viewDidLoad() { super.viewDidLoad() - setupNavigationBar() setupScrollView() setupBindings() @@ -62,14 +65,6 @@ public final class ContactController: UIViewController { screenView.set(status: viewModel.contact.authStatus) } - private func setupNavigationBar() { - navigationItem.backButtonTitle = "" - - let back = UIButton.back(color: Asset.neutralWhite.color) - back.addTarget(self, action: #selector(didTapBack), for: .touchUpInside) - navigationItem.leftBarButtonItem = UIBarButtonItem(customView: back) - } - private func setupScrollView() { addChild(scrollViewController) view.addSubview(scrollViewController.view) @@ -333,10 +328,6 @@ public final class ContactController: UIViewController { coordinator.toDrawer(drawer, from: self) } - - @objc private func didTapBack() { - navigationController?.popViewController(animated: true) - } } extension ContactController: UIImagePickerControllerDelegate { diff --git a/Sources/ContactListFeature/Controllers/CreateGroupController.swift b/Sources/ContactListFeature/Controllers/CreateGroupController.swift index a9559f5c3c38ccdf7a90543a9963748155e6fa6b..e55860fc0a48220413dc33373c915e754351ebe5 100644 --- a/Sources/ContactListFeature/Controllers/CreateGroupController.swift +++ b/Sources/ContactListFeature/Controllers/CreateGroupController.swift @@ -40,22 +40,25 @@ public final class CreateGroupController: UIViewController { view = screenView } + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationItem.backButtonTitle = "" + navigationController?.navigationBar + .customize(backgroundColor: Asset.neutralWhite.color) + } + public override func viewDidLoad() { super.viewDidLoad() setupNavigationBar() setupTableAndCollection() setupBindings() + + count = 0 } private func setupNavigationBar() { - navigationItem.backButtonTitle = " " - - let back = UIButton.back() - back.addTarget(self, action: #selector(didTapBack), for: .touchUpInside) - - navigationItem.leftBarButtonItem = UIBarButtonItem( - customView: UIStackView(arrangedSubviews: [back, titleLabel]) - ) + navigationItem.leftBarButtonItem = UIBarButtonItem(customView: titleLabel) + navigationItem.leftItemsSupplementBackButton = true createButton.setTitle(Localized.CreateGroup.create, for: .normal) createButton.setTitleColor(Asset.brandPrimary.color, for: .normal) @@ -166,10 +169,6 @@ public final class CreateGroupController: UIViewController { ) }.store(in: &cancellables) } - - @objc private func didTapBack() { - navigationController?.popViewController(animated: true) - } } extension CreateGroupController: UITableViewDelegate { diff --git a/Sources/ContactListFeature/Coordinator/ContactListCoordinator.swift b/Sources/ContactListFeature/Coordinator/ContactListCoordinator.swift index 56043d7556f0d0d4eb19c4e9fae21cd9163c878c..eb967cf04231cb85c203714bb66dd9546bac3209 100644 --- a/Sources/ContactListFeature/Coordinator/ContactListCoordinator.swift +++ b/Sources/ContactListFeature/Coordinator/ContactListCoordinator.swift @@ -28,7 +28,7 @@ public struct ContactListCoordinator: ContactListCoordinating { var replacePresenter: Presenting = ReplacePresenter(mode: .replaceLast) var scanFactory: () -> UIViewController - var searchFactory: () -> UIViewController + var searchFactory: (String?) -> UIViewController var newGroupFactory: () -> UIViewController var requestsFactory: () -> UIViewController var contactFactory: (Contact) -> UIViewController @@ -39,7 +39,7 @@ public struct ContactListCoordinator: ContactListCoordinating { public init( scanFactory: @escaping () -> UIViewController, - searchFactory: @escaping () -> UIViewController, + searchFactory: @escaping (String?) -> UIViewController, newGroupFactory: @escaping () -> UIViewController, requestsFactory: @escaping () -> UIViewController, contactFactory: @escaping (Contact) -> UIViewController, @@ -84,7 +84,7 @@ public extension ContactListCoordinator { } func toSearch(from parent: UIViewController) { - let screen = searchFactory() + let screen = searchFactory(nil) pushPresenter.present(screen, from: parent) } diff --git a/Sources/ContactListFeature/ViewModels/ContactListViewModel.swift b/Sources/ContactListFeature/ViewModels/ContactListViewModel.swift index 0467446065b81d8f52a0f9517948160e623de1e2..830172f3b9bd298be2c8c8200e2c96f89d264325 100644 --- a/Sources/ContactListFeature/ViewModels/ContactListViewModel.swift +++ b/Sources/ContactListFeature/ViewModels/ContactListViewModel.swift @@ -1,28 +1,46 @@ import Models import Combine import XXModels +import Defaults import Integration +import ReportingFeature import DependencyInjection final class ContactListViewModel { @Dependency private var session: SessionType + @Dependency private var reportingStatus: ReportingStatus var contacts: AnyPublisher<[Contact], Never> { - session.dbManager.fetchContactsPublisher(.init(authStatus: [.friend])) + let query = Contact.Query( + authStatus: [.friend], + isBlocked: reportingStatus.isEnabled() ? false : nil, + isBanned: reportingStatus.isEnabled() ? false: nil + ) + + return session.dbManager.fetchContactsPublisher(query) .assertNoFailure() .map { $0.filter { $0.id != self.session.myId }} .eraseToAnyPublisher() } var requestCount: AnyPublisher<Int, Never> { - let groupQuery = Group.Query(authStatus: [.pending]) - let contactsQuery = Contact.Query(authStatus: [ - .verified, - .confirming, - .confirmationFailed, - .verificationFailed, - .verificationInProgress - ]) + let groupQuery = Group.Query( + authStatus: [.pending], + isLeaderBlocked: reportingStatus.isEnabled() ? false : nil, + isLeaderBanned: reportingStatus.isEnabled() ? false : nil + ) + + let contactsQuery = Contact.Query( + authStatus: [ + .verified, + .confirming, + .confirmationFailed, + .verificationFailed, + .verificationInProgress + ], + isBlocked: reportingStatus.isEnabled() ? false : nil, + isBanned: reportingStatus.isEnabled() ? false : nil + ) return Publishers.CombineLatest( session.dbManager.fetchContactsPublisher(contactsQuery).assertNoFailure(), diff --git a/Sources/ContactListFeature/ViewModels/CreateGroupViewModel.swift b/Sources/ContactListFeature/ViewModels/CreateGroupViewModel.swift index a8f94de8042f3cd7f7d0da14a9de703f217f573b..555407535ca52ab7ed00c7d1060d6f98a967030b 100644 --- a/Sources/ContactListFeature/ViewModels/CreateGroupViewModel.swift +++ b/Sources/ContactListFeature/ViewModels/CreateGroupViewModel.swift @@ -5,6 +5,7 @@ import Combine import XXModels import Defaults import Integration +import ReportingFeature import DependencyInjection final class CreateGroupViewModel { @@ -13,6 +14,7 @@ final class CreateGroupViewModel { // MARK: Injected @Dependency private var session: SessionType + @Dependency private var reportingStatus: ReportingStatus // MARK: Properties @@ -42,7 +44,13 @@ final class CreateGroupViewModel { // MARK: Lifecycle init() { - session.dbManager.fetchContactsPublisher(.init(authStatus: [.friend])) + let query = Contact.Query( + authStatus: [.friend], + isBlocked: reportingStatus.isEnabled() ? false : nil, + isBanned: reportingStatus.isEnabled() ? false : nil + ) + + session.dbManager.fetchContactsPublisher(query) .assertNoFailure() .map { $0.filter { $0.id != self.session.myId }} .map { $0.sorted(by: { $0.username! < $1.username! })} diff --git a/Sources/Countries/CountryListController.swift b/Sources/Countries/CountryListController.swift index ab7ca357c0d9f074d73c38c1616cbba81d9ed9c1..a11c0e5724696bc172f58cfdc7b4333288639b7f 100644 --- a/Sources/Countries/CountryListController.swift +++ b/Sources/Countries/CountryListController.swift @@ -24,6 +24,7 @@ public final class CountryListController: UIViewController { public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + navigationItem.backButtonTitle = "" statusBarController.style.send(.darkContent) navigationController?.navigationBar.customize( @@ -46,19 +47,13 @@ public final class CountryListController: UIViewController { } private func setupNavigationBar() { - navigationItem.backButtonTitle = " " - let title = UILabel() title.text = Localized.Countries.title title.textColor = Asset.neutralActive.color title.font = Fonts.Mulish.semiBold.font(size: 18.0) - let back = UIButton.back() - back.addTarget(self, action: #selector(didTapBack), for: .touchUpInside) - - navigationItem.leftBarButtonItem = UIBarButtonItem( - customView: UIStackView(arrangedSubviews: [back, title]) - ) + navigationItem.leftBarButtonItem = UIBarButtonItem(customView: title) + navigationItem.leftItemsSupplementBackButton = true } private func setupBindings() { @@ -86,10 +81,6 @@ public final class CountryListController: UIViewController { screenView.tableView.delegate = self screenView.tableView.dataSource = dataSource } - - @objc private func didTapBack() { - navigationController?.popViewController(animated: true) - } public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { if let country = dataSource.itemIdentifier(for: indexPath) { diff --git a/Sources/Defaults/KeyObject.swift b/Sources/Defaults/KeyObject.swift index 7757f7ed4fa550c478736b76cd7c1036ef4fde34..0ade4e83639f54a5181b4292936a2d9dee049f60 100644 --- a/Sources/Defaults/KeyObject.swift +++ b/Sources/Defaults/KeyObject.swift @@ -21,6 +21,7 @@ public enum Key: String { // MARK: General case theme + case acceptedTerms // MARK: Requests diff --git a/Sources/DrawerFeature/DrawerController.swift b/Sources/DrawerFeature/DrawerController.swift index d2eba3624041cd025d10e26362c6c9b8c6806f4f..d907c26ba78956a919bcf58034b6e50720c0456a 100644 --- a/Sources/DrawerFeature/DrawerController.swift +++ b/Sources/DrawerFeature/DrawerController.swift @@ -1,9 +1,10 @@ import UIKit +import Combine public final class DrawerController: UIViewController { lazy private var screenView = DrawerView() - private let content: [DrawerItem] + public var cancellables = Set<AnyCancellable>() public init(with content: [DrawerItem]) { self.content = content diff --git a/Sources/InputField/InputField.swift b/Sources/InputField/InputField.swift index 4174ff0a78abc8f3f597e3d7298a92f93f2a3fa8..e041db906a504c4a1e6295c84e14a456a18b357c 100644 --- a/Sources/InputField/InputField.swift +++ b/Sources/InputField/InputField.swift @@ -197,7 +197,9 @@ public final class InputField: UIView { } private func hideButtonImage(isSecureEntry: Bool) -> UIImage? { - isSecureEntry ? Asset.eyeClosed.image : Asset.eyeOpen.image + let openImage = Asset.eyeOpen.image.withTintColor(Asset.neutralWeak.color) + let closedImage = Asset.eyeClosed.image.withTintColor(Asset.neutralWeak.color) + return isSecureEntry ? closedImage : openImage } private func setup() { diff --git a/Sources/Integration/Client.swift b/Sources/Integration/Client.swift index 450c674ebc5647268114eb3b251e6b56c12efca4..8fc201af93fe681852c550f62965029316e00811 100644 --- a/Sources/Integration/Client.swift +++ b/Sources/Integration/Client.swift @@ -93,8 +93,6 @@ public class Client { fatalError("Trying to add json parameters to backup but no backup manager created yet") } - print("^^^ Set params: \(string) to backup") - backupManager.addJson(string) } diff --git a/Sources/Integration/Session/Session+Contacts.swift b/Sources/Integration/Session/Session+Contacts.swift index 7bbe5254ab00b17b8053323586347ef819f10ff1..d30f78c24e014cb411b0608638904d5664f626aa 100644 --- a/Sources/Integration/Session/Session+Contacts.swift +++ b/Sources/Integration/Session/Session+Contacts.swift @@ -244,15 +244,15 @@ extension Session { /// //try dbManager.deleteContact(contact) - _ = try? dbManager.deleteMessages(Message.Query(chat: .direct(myId, contact.id))) var contact = contact contact.email = nil contact.phone = nil contact.photo = nil contact.isRecent = false contact.marshaled = nil + contact.isBlocked = true contact.authStatus = .stranger contact.nickname = contact.username - _ = try? dbManager.saveContact(contact) + _ = try! dbManager.saveContact(contact) } } diff --git a/Sources/Integration/Session/Session+Group.swift b/Sources/Integration/Session/Session+Group.swift index a3cf68964a307dceecb77893eb5f67fe15367bab..47652ee94cc4ec7d870005ff69d2d21ea3839ce3 100644 --- a/Sources/Integration/Session/Session+Group.swift +++ b/Sources/Integration/Session/Session+Group.swift @@ -53,7 +53,7 @@ extension Session { recipientId: nil, groupId: group.id, date: group.createdAt, - status: .received, + status: .sent, isUnread: false, text: welcome, replyMessageId: nil, diff --git a/Sources/Integration/Session/Session+UD.swift b/Sources/Integration/Session/Session+UD.swift index 1fe3e2e09b2f7f0ae327b039602320bbc884eb2b..27add4b13b1839482aefe29badc0f0fbe4e61e8f 100644 --- a/Sources/Integration/Session/Session+UD.swift +++ b/Sources/Integration/Session/Session+UD.swift @@ -1,9 +1,24 @@ +import Retry import Models +import Combine import XXModels import Foundation -import Combine extension Session { + public func waitForNodes(timeout: Int) -> AnyPublisher<Void, Error> { + Deferred { + Future { promise in + retry(max: timeout, retryStrategy: .delay(seconds: 1)) { [weak self] in + guard let self = self else { return } + try self.client.bindings.nodeRegistrationStatus() + promise(.success(())) + }.finalCatch { + promise(.failure($0)) + } + } + }.eraseToAnyPublisher() + } + public func search(fact: String) -> AnyPublisher<Contact, Error> { Deferred { Future { promise in diff --git a/Sources/Integration/Session/Session.swift b/Sources/Integration/Session/Session.swift index f85a6c6f0e3350e20c2bb234e6a92935e589b01f..49e1c60e20b3be9765529620893c493d900b9cfd 100644 --- a/Sources/Integration/Session/Session.swift +++ b/Sources/Integration/Session/Session.swift @@ -10,6 +10,7 @@ import Foundation import ToastFeature import BackupFeature import NetworkMonitor +import ReportingFeature import DependencyInjection import XXLegacyDatabaseMigrator @@ -50,6 +51,7 @@ public final class Session: SessionType { @Dependency var backupService: BackupService @Dependency var toastController: ToastController + @Dependency var reportingStatus: ReportingStatus @Dependency var networkMonitor: NetworkMonitoring public let client: Client @@ -140,9 +142,7 @@ public final class Session: SessionType { } } - print("^^^ \(report.parameters)") - - guard username!.isEmpty == false else { + guard let username = username, username.isEmpty == false else { fatalError("Trying to restore an account that has no username") } @@ -444,6 +444,7 @@ public final class Session: SessionType { client.messages .sink { [unowned self] in if var contact = try? dbManager.fetchContacts(.init(id: [$0.senderId])).first { + guard contact.isBanned == false else { return } contact.isRecent = false _ = try? dbManager.saveContact(contact) } @@ -461,6 +462,12 @@ public final class Session: SessionType { return } + if let contact = try! dbManager.fetchContacts(.init(id: [request.0.leaderId])).first { + if reportingStatus.isEnabled(), (contact.isBlocked || contact.isBanned) { + return + } + } + DispatchQueue.global().async { [weak self] in self?.processGroupCreation(request.0, memberIds: request.1, welcome: request.2) } diff --git a/Sources/Integration/Session/SessionType.swift b/Sources/Integration/Session/SessionType.swift index b871332b216e5c7dbdc0adfebc4bac3f306913b7..effd6c96c3239904722b74f2856f2a953f0b986e 100644 --- a/Sources/Integration/Session/SessionType.swift +++ b/Sources/Integration/Session/SessionType.swift @@ -68,4 +68,6 @@ public protocol SessionType { ) func search(fact: String) -> AnyPublisher<Contact, Error> + + func waitForNodes(timeout: Int) -> AnyPublisher<Void, Error> } diff --git a/Sources/LaunchFeature/LaunchController.swift b/Sources/LaunchFeature/LaunchController.swift index 2eb373f55784bed3e9a11293acc7cca9aee2fc08..cf8b093b2131d2becbb7d5e6bb55a3b02fb73c0e 100644 --- a/Sources/LaunchFeature/LaunchController.swift +++ b/Sources/LaunchFeature/LaunchController.swift @@ -2,14 +2,16 @@ import HUD import UIKit import Shared import Combine +import Defaults import PushFeature import DependencyInjection - public final class LaunchController: UIViewController { @Dependency private var hud: HUD @Dependency private var coordinator: LaunchCoordinating + @KeyObject(.acceptedTerms, defaultValue: false) var didAcceptTerms: Bool + lazy private var screenView = LaunchView() private let blocker = UpdateBlocker() @@ -51,11 +53,19 @@ public final class LaunchController: UIViewController { .sink { [unowned self] in switch $0 { case .chats: + guard didAcceptTerms == true else { + coordinator.toTerms(from: self) + return + } + if let pushRoute = pendingPushRoute { switch pushRoute { case .requests: coordinator.toRequests(from: self) + case .search(username: let username): + coordinator.toSearch(searching: username, from: self) + case .groupChat(id: let groupId): if let groupInfo = viewModel.getGroupInfoWith(groupId: groupId) { coordinator.toGroupChat(with: groupInfo, from: self) diff --git a/Sources/LaunchFeature/LaunchCoordinator.swift b/Sources/LaunchFeature/LaunchCoordinator.swift index 4f5a56291b19634df4c46edc2634acb55ba5812e..37035773d6cfd875d5acf6de309b4ac84d72ae6f 100644 --- a/Sources/LaunchFeature/LaunchCoordinator.swift +++ b/Sources/LaunchFeature/LaunchCoordinator.swift @@ -5,7 +5,9 @@ import Presentation public protocol LaunchCoordinating { func toChats(from: UIViewController) + func toTerms(from: UIViewController) func toRequests(from: UIViewController) + func toSearch(searching: String, from: UIViewController) func toOnboarding(with: String, from: UIViewController) func toSingleChat(with: Contact, from: UIViewController) func toGroupChat(with: GroupInfo, from: UIViewController) @@ -14,6 +16,8 @@ public protocol LaunchCoordinating { public struct LaunchCoordinator: LaunchCoordinating { var replacePresenter: Presenting = ReplacePresenter() + var termsFactory: (String?) -> UIViewController + var searchFactory: (String) -> UIViewController var requestsFactory: () -> UIViewController var chatListFactory: () -> UIViewController var onboardingFactory: (String) -> UIViewController @@ -21,12 +25,16 @@ public struct LaunchCoordinator: LaunchCoordinating { var groupChatFactory: (GroupInfo) -> UIViewController public init( + termsFactory: @escaping (String?) -> UIViewController, + searchFactory: @escaping (String) -> UIViewController, requestsFactory: @escaping () -> UIViewController, chatListFactory: @escaping () -> UIViewController, onboardingFactory: @escaping (String) -> UIViewController, singleChatFactory: @escaping (Contact) -> UIViewController, groupChatFactory: @escaping (GroupInfo) -> UIViewController ) { + self.termsFactory = termsFactory + self.searchFactory = searchFactory self.requestsFactory = requestsFactory self.chatListFactory = chatListFactory self.groupChatFactory = groupChatFactory @@ -36,6 +44,17 @@ public struct LaunchCoordinator: LaunchCoordinating { } public extension LaunchCoordinator { + func toSearch(searching: String, from parent: UIViewController) { + let screen = searchFactory(searching) + let chatListScreen = chatListFactory() + replacePresenter.present(chatListScreen, screen, from: parent) + } + + func toTerms(from parent: UIViewController) { + let screen = termsFactory(nil) + replacePresenter.present(screen, from: parent) + } + func toChats(from parent: UIViewController) { let screen = chatListFactory() replacePresenter.present(screen, from: parent) diff --git a/Sources/LaunchFeature/LaunchViewModel.swift b/Sources/LaunchFeature/LaunchViewModel.swift index fefbe7cf6035b19d804febd7e544e22166d11bf8..73fa6722f37c0fec119a11866046db37ddb0ae9e 100644 --- a/Sources/LaunchFeature/LaunchViewModel.swift +++ b/Sources/LaunchFeature/LaunchViewModel.swift @@ -1,7 +1,6 @@ import HUD import Shared import Models - import Combine import Defaults import XXModels @@ -9,8 +8,10 @@ import Keychain import Foundation import Integration import Permissions +import ToastFeature import DropboxFeature import VersionChecking +import ReportingFeature import CombineSchedulers import DependencyInjection @@ -34,6 +35,11 @@ final class LaunchViewModel { @Dependency private var dropboxService: DropboxInterface @Dependency private var keychainHandler: KeychainHandling @Dependency private var permissionHandler: PermissionHandling + @Dependency private var fetchBannedList: FetchBannedList + @Dependency private var reportingStatus: ReportingStatus + @Dependency private var processBannedList: ProcessBannedList + @Dependency private var toastController: ToastController + @Dependency private var session: SessionType @KeyObject(.username, defaultValue: nil) var username: String? @KeyObject(.biometrics, defaultValue: false) var isBiometricsOn: Bool @@ -46,36 +52,37 @@ final class LaunchViewModel { routeSubject.eraseToAnyPublisher() } + var mainScheduler: AnySchedulerOf<DispatchQueue> = { + DispatchQueue.main.eraseToAnyScheduler() + }() + var backgroundScheduler: AnySchedulerOf<DispatchQueue> = { DispatchQueue.global().eraseToAnyScheduler() }() - var getSession: (String) throws -> SessionType = Session.init - private var cancellables = Set<AnyCancellable>() private let routeSubject = PassthroughSubject<LaunchRoute, Never>() private let hudSubject = CurrentValueSubject<HUDStatus, Never>(.none) func viewDidAppear() { - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in - self?.hudSubject.send(.on) - self?.checkVersion() - } - } + mainScheduler.schedule(after: .init(.now() + 1)) { [weak self] in + guard let self = self else { return } - private func checkVersion() { - versionChecker().sink { [unowned self] in - switch $0 { - case .upToDate: - versionApproved() - case .failure(let error): - versionFailed(error: error) - case .updateRequired(let info): - versionUpdateRequired(info) - case .updateRecommended(let info): - versionUpdateRecommended(info) - } - }.store(in: &cancellables) + self.hudSubject.send(.on) + + self.versionChecker().sink { [unowned self] in + switch $0 { + case .upToDate: + self.versionApproved() + case .failure(let error): + self.versionFailed(error: error) + case .updateRequired(let info): + self.versionUpdateRequired(info) + case .updateRecommended(let info): + self.versionUpdateRecommended(info) + } + }.store(in: &self.cancellables) + } } func versionApproved() { @@ -109,14 +116,22 @@ final class LaunchViewModel { guard let self = self else { return } do { - let session = try self.getSession(ndf) + let session = try Session(ndf: ndf) DependencyInjection.Container.shared.register(session as SessionType) - self.hudSubject.send(.none) - self.checkBiometrics() + + self.updateBannedList { + DispatchQueue.main.async { + self.hudSubject.send(.none) + self.checkBiometrics() + } + } } catch { - self.hudSubject.send(.error(HUDError(with: error))) + DispatchQueue.main.async { + self.hudSubject.send(.error(HUDError(with: error))) + } } } + case .failure(let error): self.hudSubject.send(.error(HUDError(with: error))) } @@ -124,21 +139,18 @@ final class LaunchViewModel { } func getContactWith(userId: Data) -> Contact? { - guard let session = try? DependencyInjection.Container.shared.resolve() as SessionType, - let contact = try? session.dbManager.fetchContacts(.init(id: [userId])).first else { - return nil - } + let query = Contact.Query( + id: [userId], + isBlocked: reportingStatus.isEnabled() ? false : nil, + isBanned: reportingStatus.isEnabled() ? false : nil + ) - return contact + return try! session.dbManager.fetchContacts(query).first } func getGroupInfoWith(groupId: Data) -> GroupInfo? { - guard let session: SessionType = try? DependencyInjection.Container.shared.resolve(), - let info = try? session.dbManager.fetchGroupInfos(.init(groupId: groupId)).first else { - return nil - } - - return info + let query = GroupInfo.Query(groupId: groupId) + return try! session.dbManager.fetchGroupInfos(query).first } private func versionFailed(error: Error) { @@ -180,17 +192,75 @@ final class LaunchViewModel { private func checkBiometrics() { if permissionHandler.isBiometricsAvailable && isBiometricsOn { permissionHandler.requestBiometrics { [weak self] in + guard let self = self else { return } + switch $0 { case .success(let granted): guard granted else { return } - self?.routeSubject.send(.chats) + self.routeSubject.send(.chats) case .failure(let error): - self?.hudSubject.send(.error(HUDError(with: error))) + self.hudSubject.send(.error(HUDError(with: error))) } } } else { - routeSubject.send(.chats) + self.routeSubject.send(.chats) + } + } + + private func updateBannedList(completion: @escaping () -> Void) { + fetchBannedList { result in + switch result { + case .failure(_): + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + self.updateBannedList(completion: completion) + } + case .success(let data): + self.processBannedList(data, completion: completion) + } } } + + private func processBannedList(_ data: Data, completion: @escaping () -> Void) { + processBannedList( + data: data, + forEach: { result in + switch result { + case .success(let userId): + let query = Contact.Query(id: [userId]) + if var contact = try! self.session.dbManager.fetchContacts(query).first { + if contact.isBanned == false { + contact.isBanned = true + try! self.session.dbManager.saveContact(contact) + self.enqueueBanWarning(contact: contact) + } + } else { + try! self.session.dbManager.saveContact(.init(id: userId, isBanned: true)) + } + + case .failure(_): + break + } + }, + completion: { result in + switch result { + case .failure(_): + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + self.updateBannedList(completion: completion) + } + + case .success(_): + completion() + } + } + ) + } + + private func enqueueBanWarning(contact: Contact) { + let name = (contact.nickname ?? contact.username) ?? "One of your contacts" + toastController.enqueueToast(model: .init( + title: "\(name) has been banned for offensive content.", + leftImage: Asset.requestSentToaster.image + )) + } } diff --git a/Sources/MenuFeature/Controllers/MenuController.swift b/Sources/MenuFeature/Controllers/MenuController.swift index 682e23d450bde134916a2db8a00d13c2279c9321..93f1fcf4a328da1f73f077b8f0f8806ceee0c7f5 100644 --- a/Sources/MenuFeature/Controllers/MenuController.swift +++ b/Sources/MenuFeature/Controllers/MenuController.swift @@ -9,6 +9,7 @@ public enum MenuItem { case join case scan case chats + case share case profile case contacts case requests @@ -171,6 +172,19 @@ public final class MenuController: UIViewController { } }.store(in: &cancellables) + screenView.shareButton + .publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { [unowned self] in + dismiss(animated: true) { [weak self] in + guard let self = self, self.previousItem != .share else { return } + self.coordinator.toActivityController( + with: [Localized.Menu.shareContent(self.viewModel.referralDeeplink)], + from: self.previousController + ) + } + }.store(in: &cancellables) + viewModel.requestCount .receive(on: DispatchQueue.main) .sink { [weak screenView] in screenView?.requestsButton.updateNotification($0) } diff --git a/Sources/MenuFeature/Coordinator/MenuCoordinator.swift b/Sources/MenuFeature/Coordinator/MenuCoordinator.swift index fba5c8ea4f8bfb1aa155dcf4ada8fba1a58c3f39..3e7d20cc85f6a4afa7e50e2f8133bdf041f5ace6 100644 --- a/Sources/MenuFeature/Coordinator/MenuCoordinator.swift +++ b/Sources/MenuFeature/Coordinator/MenuCoordinator.swift @@ -4,9 +4,11 @@ import Presentation public protocol MenuCoordinating { func toFlow(_ item: MenuItem, from: UIViewController) func toDrawer(_: UIViewController, from: UIViewController) + func toActivityController(with: [Any], from: UIViewController) } public struct MenuCoordinator: MenuCoordinating { + var modalPresenter: Presenting = ModalPresenter() var bottomPresenter: Presenting = BottomPresenter() var replacePresenter: Presenting = ReplacePresenter() @@ -16,6 +18,8 @@ public struct MenuCoordinator: MenuCoordinating { var settingsFactory: () -> UIViewController var contactsFactory: () -> UIViewController var requestsFactory: () -> UIViewController + var activityControllerFactory: ([Any]) -> UIViewController + = { UIActivityViewController(activityItems: $0, applicationActivities: nil) } public init( scanFactory: @escaping () -> UIViewController, @@ -61,4 +65,9 @@ public extension MenuCoordinator { replacePresenter.present(controller, from: parent) } + + func toActivityController(with items: [Any], from parent: UIViewController) { + let screen = activityControllerFactory(items) + modalPresenter.present(screen, from: parent) + } } diff --git a/Sources/MenuFeature/ViewModels/MenuViewModel.swift b/Sources/MenuFeature/ViewModels/MenuViewModel.swift index f3e4bcbd5260af8c2e62849c7eca05b4098cffe4..72fbe071d7b81db1f192b41d1ee5480a7a03d226 100644 --- a/Sources/MenuFeature/ViewModels/MenuViewModel.swift +++ b/Sources/MenuFeature/ViewModels/MenuViewModel.swift @@ -3,23 +3,34 @@ import XXModels import Defaults import Foundation import Integration +import ReportingFeature import DependencyInjection final class MenuViewModel { @Dependency private var session: SessionType + @Dependency private var reportingStatus: ReportingStatus @KeyObject(.avatar, defaultValue: nil) var avatar: Data? @KeyObject(.username, defaultValue: "") var username: String var requestCount: AnyPublisher<Int, Never> { - let groupQuery = Group.Query(authStatus: [.pending]) - let contactsQuery = Contact.Query(authStatus: [ - .verified, - .confirming, - .confirmationFailed, - .verificationFailed, - .verificationInProgress - ]) + let groupQuery = Group.Query( + authStatus: [.pending], + isLeaderBlocked: reportingStatus.isEnabled() ? false : nil, + isLeaderBanned: reportingStatus.isEnabled() ? false : nil + ) + + let contactsQuery = Contact.Query( + authStatus: [ + .verified, + .confirming, + .confirmationFailed, + .verificationFailed, + .verificationInProgress + ], + isBlocked: reportingStatus.isEnabled() ? false : nil, + isBanned: reportingStatus.isEnabled() ? false : nil + ) return Publishers.CombineLatest( session.dbManager.fetchContactsPublisher(contactsQuery).assertNoFailure(), @@ -40,4 +51,8 @@ final class MenuViewModel { var version: String { Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" } + + var referralDeeplink: String { + "https://elixxir.io/connect?username=\(username)" + } } diff --git a/Sources/MenuFeature/Views/MenuView.swift b/Sources/MenuFeature/Views/MenuView.swift index b19ec9d1d1367f5d910dfe97d40ee9198b7e3ec0..e256782eddaa5e686d2bfc7e3ef4af237c3eb51c 100644 --- a/Sources/MenuFeature/Views/MenuView.swift +++ b/Sources/MenuFeature/Views/MenuView.swift @@ -2,31 +2,33 @@ import UIKit import Shared final class MenuView: UIView { - let headerView = MenuHeaderView() + let buildLabel = UILabel() + let versionLabel = UILabel() let stackView = UIStackView() + let xxdkVersionLabel = UILabel() + let infoStackView = UIStackView() + let headerView = MenuHeaderView() + let joinButton = MenuSectionButton() let scanButton = MenuSectionButton() + let shareButton = MenuSectionButton() let chatsButton = MenuSectionButton() let contactsButton = MenuSectionButton() let requestsButton = MenuSectionButton() let settingsButton = MenuSectionButton() let dashboardButton = MenuSectionButton() - let joinButton = MenuSectionButton() - let infoStackView = UIStackView() - let buildLabel = UILabel() - let versionLabel = UILabel() - let xxdkVersionLabel = UILabel() init() { super.init(frame: .zero) backgroundColor = Asset.neutralDark.color - chatsButton.set(title: Localized.Menu.chats, image: Asset.menuChats.image) scanButton.set(title: Localized.Menu.scan, image: Asset.menuScan.image) + shareButton.set(title: Localized.Menu.share, image: Asset.menuShare.image) + chatsButton.set(title: Localized.Menu.chats, image: Asset.menuChats.image) + joinButton.set(title: Localized.Menu.join, image: Asset.permissionLogo.image) requestsButton.set(title: Localized.Menu.requests, image: Asset.menuRequests.image) contactsButton.set(title: Localized.Menu.contacts, image: Asset.menuContacts.image) settingsButton.set(title: Localized.Menu.settings, image: Asset.menuSettings.image) dashboardButton.set(title: Localized.Menu.dashboard, image: Asset.menuDashboard.image) - joinButton.set(title: "Join xx network", image: Asset.permissionLogo.image) stackView.addArrangedSubview(chatsButton) stackView.addArrangedSubview(contactsButton) @@ -35,6 +37,7 @@ final class MenuView: UIView { stackView.addArrangedSubview(settingsButton) stackView.addArrangedSubview(dashboardButton) stackView.addArrangedSubview(joinButton) + stackView.addArrangedSubview(shareButton) infoStackView.spacing = 10 infoStackView.axis = .vertical @@ -59,17 +62,17 @@ final class MenuView: UIView { func select(item: MenuItem) { switch item { + case .scan: + scanButton.set(color: Asset.brandPrimary.color) case .chats: chatsButton.set(color: Asset.brandPrimary.color) case .contacts: contactsButton.set(color: Asset.brandPrimary.color) case .requests: requestsButton.set(color: Asset.brandPrimary.color) - case .scan: - scanButton.set(color: Asset.brandPrimary.color) case .settings: settingsButton.set(color: Asset.brandPrimary.color) - case .profile, .dashboard, .join: + case .share, .join, .profile, .dashboard: break } } diff --git a/Sources/OnboardingFeature/Controllers/OnboardingEmailConfirmationController.swift b/Sources/OnboardingFeature/Controllers/OnboardingEmailConfirmationController.swift index 90c210569b58b904473f5c2e2020740e2945a09d..578a27dc5bdedfcd58ab9dbecfd1338c07bad324 100644 --- a/Sources/OnboardingFeature/Controllers/OnboardingEmailConfirmationController.swift +++ b/Sources/OnboardingFeature/Controllers/OnboardingEmailConfirmationController.swift @@ -34,15 +34,13 @@ public final class OnboardingEmailConfirmationController: UIViewController { public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + navigationItem.backButtonTitle = "" statusBarController.style.send(.darkContent) navigationController?.navigationBar.customize(translucent: true) } public override func viewDidLoad() { super.viewDidLoad() - navigationItem.backButtonTitle = " " - - setupNavigationBar() setupScrollView() setupBindings() @@ -58,12 +56,6 @@ public final class OnboardingEmailConfirmationController: UIViewController { } } - private func setupNavigationBar() { - let back = UIButton.back() - back.addTarget(self, action: #selector(didTapBack), for: .touchUpInside) - navigationItem.leftBarButtonItem = UIBarButtonItem(customView: back) - } - private func setupScrollView() { addChild(scrollViewController) view.addSubview(scrollViewController.view) @@ -157,8 +149,4 @@ public final class OnboardingEmailConfirmationController: UIViewController { coordinator.toDrawer(drawer, from: self) } - - @objc private func didTapBack() { - navigationController?.popViewController(animated: true) - } } diff --git a/Sources/OnboardingFeature/Controllers/OnboardingPhoneConfirmationController.swift b/Sources/OnboardingFeature/Controllers/OnboardingPhoneConfirmationController.swift index 0017798bc5fc2a4703a56cf6b4fc2a45fbcffa55..6207a5a7c9b19b1a5184557eb45cd07bd8aeb21b 100644 --- a/Sources/OnboardingFeature/Controllers/OnboardingPhoneConfirmationController.swift +++ b/Sources/OnboardingFeature/Controllers/OnboardingPhoneConfirmationController.swift @@ -34,15 +34,13 @@ public final class OnboardingPhoneConfirmationController: UIViewController { public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + navigationItem.backButtonTitle = "" statusBarController.style.send(.darkContent) navigationController?.navigationBar.customize(translucent: true) } public override func viewDidLoad() { super.viewDidLoad() - navigationItem.backButtonTitle = " " - - setupNavigationBar() setupScrollView() setupBindings() @@ -58,12 +56,6 @@ public final class OnboardingPhoneConfirmationController: UIViewController { } } - private func setupNavigationBar() { - let back = UIButton.back() - back.addTarget(self, action: #selector(didTapBack), for: .touchUpInside) - navigationItem.leftBarButtonItem = UIBarButtonItem(customView: back) - } - private func setupScrollView() { addChild(scrollViewController) view.addSubview(scrollViewController.view) @@ -157,8 +149,4 @@ public final class OnboardingPhoneConfirmationController: UIViewController { coordinator.toDrawer(drawer, from: self) } - - @objc private func didTapBack() { - navigationController?.popViewController(animated: true) - } } diff --git a/Sources/OnboardingFeature/Controllers/OnboardingStartController.swift b/Sources/OnboardingFeature/Controllers/OnboardingStartController.swift index d95169bbd9c5ce078687a77c96768d91e9d2336d..d9b970329040862abe2222493c0ecaf0ee2b34c4 100644 --- a/Sources/OnboardingFeature/Controllers/OnboardingStartController.swift +++ b/Sources/OnboardingFeature/Controllers/OnboardingStartController.swift @@ -27,6 +27,7 @@ public final class OnboardingStartController: UIViewController { public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + navigationItem.backButtonTitle = "" navigationController?.navigationBar.customize(translucent: true) } @@ -52,7 +53,7 @@ public final class OnboardingStartController: UIViewController { super.viewDidLoad() screenView.startButton.publisher(for: .touchUpInside) - .sink { [unowned self] in coordinator.toUsername(with: ndf, from: self) } + .sink { [unowned self] in coordinator.toTerms(ndf: ndf, from: self) } .store(in: &cancellables) } } diff --git a/Sources/OnboardingFeature/Controllers/OnboardingUsernameController.swift b/Sources/OnboardingFeature/Controllers/OnboardingUsernameController.swift index 3bc4493ebd99899a4b9ea646964816ff0140009e..d42e1f81f055d800cb2fe86afb5ac0870c6ca39a 100644 --- a/Sources/OnboardingFeature/Controllers/OnboardingUsernameController.swift +++ b/Sources/OnboardingFeature/Controllers/OnboardingUsernameController.swift @@ -22,6 +22,7 @@ public final class OnboardingUsernameController: UIViewController { public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + navigationItem.backButtonTitle = "" statusBarController.style.send(.darkContent) navigationController?.navigationBar.customize(translucent: true) } diff --git a/Sources/OnboardingFeature/Coordinator/OnboardingCoordinator.swift b/Sources/OnboardingFeature/Coordinator/OnboardingCoordinator.swift index ca73d7c55d96709e85f076e6e06d74e6bc09a155..9ea57da2c51a02679f40c9132b88498fb139fa68 100644 --- a/Sources/OnboardingFeature/Coordinator/OnboardingCoordinator.swift +++ b/Sources/OnboardingFeature/Coordinator/OnboardingCoordinator.swift @@ -11,6 +11,7 @@ public protocol OnboardingCoordinating { func toEmail(from: UIViewController) func toPhone(from: UIViewController) func toWelcome(from: UIViewController) + func toTerms(ndf: String, from: UIViewController) func toUsername(with: String, from: UIViewController) func toRestoreList(with: String, from: UIViewController) func toDrawer(_: UIViewController, from: UIViewController) @@ -41,11 +42,12 @@ public struct OnboardingCoordinator: OnboardingCoordinating { var emailFactory: () -> UIViewController var phoneFactory: () -> UIViewController - var searchFactory: () -> UIViewController + var searchFactory: (String?) -> UIViewController var welcomeFactory: () -> UIViewController var chatListFactory: () -> UIViewController var usernameFactory: (String) -> UIViewController var restoreListFactory: (String) -> UIViewController + var termsFactory: (String?) -> UIViewController var successFactory: (OnboardingSuccessModel) -> UIViewController var countriesFactory: (@escaping (Country) -> Void) -> UIViewController var phoneConfirmationFactory: (AttributeConfirmation, @escaping AttributeControllerClosure) -> UIViewController @@ -54,9 +56,10 @@ public struct OnboardingCoordinator: OnboardingCoordinating { public init( emailFactory: @escaping () -> UIViewController, phoneFactory: @escaping () -> UIViewController, - searchFactory: @escaping () -> UIViewController, + searchFactory: @escaping (String?) -> UIViewController, welcomeFactory: @escaping () -> UIViewController, chatListFactory: @escaping () -> UIViewController, + termsFactory: @escaping (String?) -> UIViewController, usernameFactory: @escaping (String) -> UIViewController, restoreListFactory: @escaping (String) -> UIViewController, successFactory: @escaping (OnboardingSuccessModel) -> UIViewController, @@ -65,6 +68,7 @@ public struct OnboardingCoordinator: OnboardingCoordinating { emailConfirmationFactory: @escaping (AttributeConfirmation, @escaping AttributeControllerClosure) -> UIViewController ) { self.emailFactory = emailFactory + self.termsFactory = termsFactory self.phoneFactory = phoneFactory self.searchFactory = searchFactory self.welcomeFactory = welcomeFactory @@ -79,6 +83,14 @@ public struct OnboardingCoordinator: OnboardingCoordinating { } public extension OnboardingCoordinator { + func toTerms( + ndf: String, + from parent: UIViewController + ) { + let screen = termsFactory(ndf) + pushPresenter.present(screen, from: parent) + } + func toEmail(from parent: UIViewController) { let screen = emailFactory() replacePresenter.present(screen, from: parent) @@ -114,7 +126,7 @@ public extension OnboardingCoordinator { } func toChats(from parent: UIViewController) { - let searchScreen = searchFactory() + let searchScreen = searchFactory(nil) let chatListScreen = chatListFactory() replacePresenter.present(chatListScreen, searchScreen, from: parent) } diff --git a/Sources/Permissions/RequestPermissionController.swift b/Sources/Permissions/RequestPermissionController.swift index 8c787bed838c4e621c9fb17a0e713214dbf18946..d892ab60e8722633e8d7c2930eb7ec97b4049d8c 100644 --- a/Sources/Permissions/RequestPermissionController.swift +++ b/Sources/Permissions/RequestPermissionController.swift @@ -25,13 +25,13 @@ public final class RequestPermissionController: UIViewController { public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + navigationItem.backButtonTitle = "" statusBarController.style.send(.darkContent) navigationController?.navigationBar.customize(backgroundColor: Asset.neutralWhite.color) } public override func viewDidLoad() { super.viewDidLoad() - setupNavigationBar() setupBindings() } @@ -60,14 +60,6 @@ public final class RequestPermissionController: UIViewController { } } - private func setupNavigationBar() { - navigationItem.backButtonTitle = "" - - let back = UIButton.back() - back.addTarget(self, action: #selector(didTapBack), for: .touchUpInside) - navigationItem.leftBarButtonItem = UIBarButtonItem(customView: back) - } - private func setupBindings() { screenView.notNowButton .publisher(for: .touchUpInside) @@ -105,7 +97,4 @@ public final class RequestPermissionController: UIViewController { }.store(in: &cancellables) } - @objc private func didTapBack() { - navigationController?.popViewController(animated: true) - } } diff --git a/Sources/ProfileFeature/Controllers/ProfileCodeController.swift b/Sources/ProfileFeature/Controllers/ProfileCodeController.swift index c005b0bf9c10daa6db1f8521799da86bbfad26a4..d612b9e9e79c4dbb362b5a0c7782eb1463dc4fe6 100644 --- a/Sources/ProfileFeature/Controllers/ProfileCodeController.swift +++ b/Sources/ProfileFeature/Controllers/ProfileCodeController.swift @@ -22,6 +22,7 @@ public final class ProfileCodeController: UIViewController { public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + navigationItem.backButtonTitle = "" navigationController?.navigationBar .customize(backgroundColor: Asset.neutralWhite.color) } @@ -39,20 +40,11 @@ public final class ProfileCodeController: UIViewController { public override func viewDidLoad() { super.viewDidLoad() - setupNavigationBar() setupScrollView() setupBindings() setupDetail() } - private func setupNavigationBar() { - navigationItem.backButtonTitle = " " - - let back = UIButton.back() - back.addTarget(self, action: #selector(didTapBack), for: .touchUpInside) - navigationItem.leftBarButtonItem = UIBarButtonItem(customView: back) - } - private func setupScrollView() { addChild(scrollViewController) view.addSubview(scrollViewController.view) @@ -128,8 +120,4 @@ public final class ProfileCodeController: UIViewController { screenView.set(content, isEmail: confirmation.isEmail) } - - @objc private func didTapBack() { - navigationController?.popViewController(animated: true) - } } diff --git a/Sources/ProfileFeature/Controllers/ProfileEmailController.swift b/Sources/ProfileFeature/Controllers/ProfileEmailController.swift index 97ced65c65a10b7d4ac8033a5b2c0d80cf959997..3fb88d2b649ed6f9f20b8189467a021563188c13 100644 --- a/Sources/ProfileFeature/Controllers/ProfileEmailController.swift +++ b/Sources/ProfileFeature/Controllers/ProfileEmailController.swift @@ -19,6 +19,7 @@ public final class ProfileEmailController: UIViewController { public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + navigationItem.backButtonTitle = "" statusBarController.style.send(.darkContent) navigationController?.navigationBar .customize(backgroundColor: Asset.neutralWhite.color) @@ -26,19 +27,10 @@ public final class ProfileEmailController: UIViewController { public override func viewDidLoad() { super.viewDidLoad() - setupNavigationBar() setupScrollView() setupBindings() } - private func setupNavigationBar() { - navigationItem.backButtonTitle = " " - - let back = UIButton.back() - back.addTarget(self, action: #selector(didTapBack), for: .touchUpInside) - navigationItem.leftBarButtonItem = UIBarButtonItem(customView: back) - } - private func setupScrollView() { addChild(scrollViewController) view.addSubview(scrollViewController.view) @@ -89,8 +81,4 @@ public final class ProfileEmailController: UIViewController { .sink { [unowned self] in viewModel.didTapNext() } .store(in: &cancellables) } - - @objc private func didTapBack() { - navigationController?.popViewController(animated: true) - } } diff --git a/Sources/ProfileFeature/Controllers/ProfilePhoneController.swift b/Sources/ProfileFeature/Controllers/ProfilePhoneController.swift index eb77fd14d36bce3960f30a73aa4ac7c117e453e7..01737802adc9611476892ca38675b5dd8ff6d743 100644 --- a/Sources/ProfileFeature/Controllers/ProfilePhoneController.swift +++ b/Sources/ProfileFeature/Controllers/ProfilePhoneController.swift @@ -21,26 +21,18 @@ public final class ProfilePhoneController: UIViewController { public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + navigationItem.backButtonTitle = "" statusBarController.style.send(.darkContent) navigationController?.navigationBar - .customize(backgroundColor: Asset.neutralWhite.color) + .customize(backgroundColor: Asset.neutralWhite.color) } public override func viewDidLoad() { super.viewDidLoad() - setupNavigationBar() setupScrollView() setupBindings() } - private func setupNavigationBar() { - navigationItem.backButtonTitle = " " - - let back = UIButton.back() - back.addTarget(self, action: #selector(didTapBack), for: .touchUpInside) - navigationItem.leftBarButtonItem = UIBarButtonItem(customView: back) - } - private func setupScrollView() { addChild(scrollViewController) view.addSubview(scrollViewController.view) @@ -107,8 +99,4 @@ public final class ProfilePhoneController: UIViewController { .sink { [unowned self] in viewModel.didTapNext() } .store(in: &cancellables) } - - @objc private func didTapBack() { - navigationController?.popViewController(animated: true) - } } diff --git a/Sources/PushFeature/PushHandler.swift b/Sources/PushFeature/PushHandler.swift index f750c575899229ba5f6212261197087755dc8442..b090664f1f7bb55086cc4909afd8bf2639cf7e59 100644 --- a/Sources/PushFeature/PushHandler.swift +++ b/Sources/PushFeature/PushHandler.swift @@ -3,6 +3,7 @@ import Models import Defaults import XXModels import Integration +import ReportingFeature import DependencyInjection public final class PushHandler: PushHandling { @@ -11,6 +12,8 @@ public final class PushHandler: PushHandling { static let usernamesSetting = "isShowingUsernames" } + @Dependency var reportingStatus: ReportingStatus + @KeyObject(.pushNotifications, defaultValue: false) var isPushEnabled: Bool let requestAuth: RequestAuth @@ -96,13 +99,6 @@ public final class PushHandler: PushHandling { return } - guard let showSender = defaults.value(forKey: Constants.usernamesSetting) as? Bool, showSender == true else { - pushes.map { ($0.type.unknownSenderContent!, $0) } - .map(contentsBuilder.build) - .forEach { completion($0) } - return - } - let dbPath = FileManager.default .containerURL(forSecurityApplicationGroupIdentifier: "group.elixxir.messenger")! .appendingPathComponent("xxm_database") @@ -115,8 +111,16 @@ public final class PushHandler: PushHandling { return ($0.type.unknownSenderContent!, $0) } - let name = (contact.nickname ?? contact.username) ?? "" - return ($0.type.knownSenderContent(name)!, $0) + if reportingStatus.isEnabled(), (contact.isBlocked || contact.isBanned) { + return nil + } + + if let showSender = defaults.value(forKey: Constants.usernamesSetting) as? Bool, showSender == true { + let name = (contact.nickname ?? contact.username) ?? "" + return ($0.type.knownSenderContent(name)!, $0) + } else { + return ($0.type.unknownSenderContent!, $0) + } } tuples diff --git a/Sources/PushFeature/PushRouter.swift b/Sources/PushFeature/PushRouter.swift index 6fc66797612acf41e56213ab64ffc9a7029d873e..05885d5196b6025f24d18bd47f5922f1164d1f43 100644 --- a/Sources/PushFeature/PushRouter.swift +++ b/Sources/PushFeature/PushRouter.swift @@ -7,6 +7,7 @@ public struct PushRouter { case requests case groupChat(id: Data) case contactChat(id: Data) + case search(username: String) } public var navigateTo: NavigateTo diff --git a/Sources/ReportingFeature/FetchBannedList.swift b/Sources/ReportingFeature/FetchBannedList.swift new file mode 100644 index 0000000000000000000000000000000000000000..2620b15c84e5d31316f663a363ff6a4526d1880d --- /dev/null +++ b/Sources/ReportingFeature/FetchBannedList.swift @@ -0,0 +1,46 @@ +import Foundation +import XCTestDynamicOverlay + +public struct FetchBannedList { + public enum Error: Swift.Error, Equatable { + case network(URLError) + case invalidResponse + } + + public typealias Completion = (Result<Data, Error>) -> Void + + public var run: (@escaping Completion) -> Void + + public func callAsFunction(completion: @escaping Completion) { + run(completion) + } +} + +extension FetchBannedList { + public static let live = FetchBannedList { completion in + let url = URL(string: "https://elixxir-bins.s3.us-west-1.amazonaws.com/client/bannedUsers/banned.csv")! + let session = URLSession.shared + let request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalAndRemoteCacheData) + let task = session.dataTask(with: request) { data, response, error in + if let error = error { + completion(.failure(.network(error as! URLError))) + return + } + guard let response = response as? HTTPURLResponse, + (200..<300).contains(response.statusCode), + let data = data + else { + completion(.failure(.invalidResponse)) + return + } + completion(.success(data)) + } + task.resume() + } +} + +extension FetchBannedList { + public static let unimplemented = FetchBannedList( + run: XCTUnimplemented("\(Self.self)") + ) +} diff --git a/Sources/ReportingFeature/MakeAppScreenshot.swift b/Sources/ReportingFeature/MakeAppScreenshot.swift new file mode 100644 index 0000000000000000000000000000000000000000..7d44af879f35d792685f3e278cc6ce9c044d8a07 --- /dev/null +++ b/Sources/ReportingFeature/MakeAppScreenshot.swift @@ -0,0 +1,53 @@ +import Foundation +import UIKit +import XCTestDynamicOverlay + +public struct MakeAppScreenshot { + public enum Error: Swift.Error, Equatable { + case unableToGetForegroundWindowScene + case unableToGetKeyWindow + } + + public var run: () throws -> UIImage + + public func callAsFunction() throws -> UIImage { + try run() + } +} + +extension MakeAppScreenshot { + public static let live = MakeAppScreenshot { + let scene: UIWindowScene? = UIApplication.shared.connectedScenes + .filter { $0.activationState == .foregroundActive } + .compactMap { $0 as? UIWindowScene } + .first + + guard let scene = scene else { + throw Error.unableToGetForegroundWindowScene + } + + let window: UIWindow? = scene.windows.first(where: \.isKeyWindow) + + guard let keyWindow = window else { + throw Error.unableToGetKeyWindow + } + + let rendererFormat = UIGraphicsImageRendererFormat() + rendererFormat.scale = scene.screen.scale + + let renderer = UIGraphicsImageRenderer( + bounds: keyWindow.bounds, + format: rendererFormat + ) + + return renderer.image { ctx in + keyWindow.layer.render(in: ctx.cgContext) + } + } +} + +extension MakeAppScreenshot { + public static let unimplemented = MakeAppScreenshot( + run: XCTUnimplemented("\(Self.self)") + ) +} diff --git a/Sources/ReportingFeature/MakeReportDrawer.swift b/Sources/ReportingFeature/MakeReportDrawer.swift new file mode 100644 index 0000000000000000000000000000000000000000..b8b4aa394481d3e292ef236711b1f31f82704d3e --- /dev/null +++ b/Sources/ReportingFeature/MakeReportDrawer.swift @@ -0,0 +1,86 @@ +import DrawerFeature +import Shared +import UIKit +import XCTestDynamicOverlay + +public struct MakeReportDrawer { + public struct Config { + public init( + onReport: @escaping () -> Void = {}, + onCancel: @escaping () -> Void = {} + ) { + self.onReport = onReport + self.onCancel = onCancel + } + + public var onReport: () -> Void + public var onCancel: () -> Void + } + + public var run: (Config) -> UIViewController + + public func callAsFunction(_ config: Config) -> UIViewController { + run(config) + } +} + +extension MakeReportDrawer { + public static let live = MakeReportDrawer { config in + let cancelButton = CapsuleButton() + cancelButton.setStyle(.seeThrough) + cancelButton.setTitle(Localized.Chat.Report.cancel, for: .normal) + + let reportButton = CapsuleButton() + reportButton.setStyle(.red) + reportButton.setTitle(Localized.Chat.Report.action, for: .normal) + + let drawer = DrawerController(with: [ + DrawerImage( + image: Asset.drawerNegative.image + ), + DrawerText( + font: Fonts.Mulish.semiBold.font(size: 18.0), + text: Localized.Chat.Report.title, + color: Asset.neutralActive.color + ), + DrawerText( + font: Fonts.Mulish.semiBold.font(size: 14.0), + text: Localized.Chat.Report.subtitle, + color: Asset.neutralWeak.color, + lineHeightMultiple: 1.35, + spacingAfter: 25 + ), + DrawerStack( + axis: .vertical, + spacing: 20.0, + views: [reportButton, cancelButton] + ) + ]) + + reportButton.publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { [unowned drawer] in + drawer.dismiss(animated: true) { + config.onReport() + } + } + .store(in: &drawer.cancellables) + + cancelButton.publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { [unowned drawer] in + drawer.dismiss(animated: true) { + config.onCancel() + } + } + .store(in: &drawer.cancellables) + + return drawer + } +} + +extension MakeReportDrawer { + public static let unimplemented = MakeReportDrawer( + run: XCTUnimplemented("\(Self.self)") + ) +} diff --git a/Sources/ReportingFeature/ProcessBannedList.swift b/Sources/ReportingFeature/ProcessBannedList.swift new file mode 100644 index 0000000000000000000000000000000000000000..3399a34ee3614bc41df393251d1e21a9309cdf52 --- /dev/null +++ b/Sources/ReportingFeature/ProcessBannedList.swift @@ -0,0 +1,64 @@ +import Foundation +import SwiftCSV +import XCTestDynamicOverlay + +public struct ProcessBannedList { + public enum ElementError: Swift.Error { + case missingUserId + case invalidUserId(String) + } + + public enum Error: Swift.Error { + case invalidData + case csv(Swift.Error) + } + + public typealias ForEach = (Result<Data, ElementError>) -> Void + public typealias Completion = (Result<Void, Error>) -> Void + + public var run: (Data, ForEach, Completion) -> Void + + public func callAsFunction( + data: Data, + forEach: ForEach, + completion: Completion + ) { + run(data, forEach, completion) + } +} + +extension ProcessBannedList { + public static let live = ProcessBannedList { data, forEach, completion in + guard let csvString = String(data: data, encoding: .utf8) else { + completion(.failure(.invalidData)) + return + } + let csv: EnumeratedCSV + do { + csv = try EnumeratedCSV(string: csvString) + } + catch { + completion(.failure(.csv(error))) + return + } + csv.rows.forEach { row in + guard let userIdString = row.first else { + forEach(.failure(.missingUserId)) + return + } + guard let userId = Data(base64Encoded: userIdString) else { + forEach(.failure(.invalidUserId(userIdString))) + return + } + forEach(.success(userId)) + } + completion(.success(())) + } +} + +extension ProcessBannedList { + public static let unimplemented = ProcessBannedList { _, _, _ in + let run: () -> Void = XCTUnimplemented("\(Self.self)") + run() + } +} diff --git a/Sources/ReportingFeature/Report.swift b/Sources/ReportingFeature/Report.swift new file mode 100644 index 0000000000000000000000000000000000000000..c2032b6a6b87d096f8f6883085d4c6e887630600 --- /dev/null +++ b/Sources/ReportingFeature/Report.swift @@ -0,0 +1,52 @@ +import Foundation + +public struct Report: Encodable { + public init( + sender: ReportUser, + recipient: ReportUser, + type: ReportType, + screenshot: Data, + partyName: String? = nil, + partyBlob: String? = nil, + partyMembers: [ReportUser]? = nil + ) { + self.sender = sender + self.recipient = recipient + self.type = type + self.screenshot = screenshot + self.partyName = partyName + self.partyBlob = partyBlob + self.partyMembers = partyMembers + } + + public var sender: ReportUser + public var recipient: ReportUser + public var type: ReportType + public var screenshot: Data + public var partyName: String? + public var partyBlob: String? + public var partyMembers: [ReportUser]? +} + +extension Report { + public struct ReportUser: Encodable { + public init( + userId: String, + username: String + ) { + self.userId = userId + self.username = username + } + + public var userId: String + public var username: String + } +} + +extension Report { + public enum ReportType: String, Encodable { + case dm + case group + case channel + } +} diff --git a/Sources/ReportingFeature/ReportingStatus.swift b/Sources/ReportingFeature/ReportingStatus.swift new file mode 100644 index 0000000000000000000000000000000000000000..2ed407a84c13f84cc39060f4dd3457688437e324 --- /dev/null +++ b/Sources/ReportingFeature/ReportingStatus.swift @@ -0,0 +1,38 @@ +import Combine + +public struct ReportingStatus { + public var isOptional: () -> Bool + public var isEnabled: () -> Bool + public var isEnabledPublisher: () -> AnyPublisher<Bool, Never> + public var enable: (Bool) -> Void +} + +extension ReportingStatus { + public static func live( + isOptional: ReportingStatusIsOptional = .live(), + isEnabled: ReportingStatusIsEnabled = .live() + ) -> ReportingStatus { + ReportingStatus( + isOptional: { + isOptional.get() + }, + isEnabled: { + if isOptional.get() == false { + return true + } + + return isEnabled.get() + }, + isEnabledPublisher: { + if isOptional.get() == false { + return Just(true).eraseToAnyPublisher() + } + + return isEnabled.publisher() + }, + enable: { enabled in + isEnabled.set(enabled) + } + ) + } +} diff --git a/Sources/ReportingFeature/ReportingStatusIsEnabled.swift b/Sources/ReportingFeature/ReportingStatusIsEnabled.swift new file mode 100644 index 0000000000000000000000000000000000000000..32f23fcb78a3b5e8fe7f6dc15a95a1b27e278bfb --- /dev/null +++ b/Sources/ReportingFeature/ReportingStatusIsEnabled.swift @@ -0,0 +1,38 @@ +import Combine +import Foundation + +public struct ReportingStatusIsEnabled { + public var get: () -> Bool + public var set: (Bool) -> Void + public var publisher: () -> AnyPublisher<Bool, Never> +} + +extension ReportingStatusIsEnabled { + public static func live( + userDefaults: UserDefaults = .standard + ) -> ReportingStatusIsEnabled { + ReportingStatusIsEnabled( + get: { + userDefaults.isReportingEnabled + }, + set: { enabled in + userDefaults.isReportingEnabled = enabled + }, + publisher: { + userDefaults.publisher(for: \.isReportingEnabled).eraseToAnyPublisher() + } + ) + } +} + +private extension UserDefaults { + static let isReportingEnabledKey = "isReportingEnabled" + + @objc var isReportingEnabled: Bool { + get { + bool(forKey: Self.isReportingEnabledKey) + } set { + set(newValue, forKey: Self.isReportingEnabledKey) + } + } +} diff --git a/Sources/ReportingFeature/ReportingStatusIsOptional.swift b/Sources/ReportingFeature/ReportingStatusIsOptional.swift new file mode 100644 index 0000000000000000000000000000000000000000..e0cc6591bf1d13c68d022a2f8587cc495403f89d --- /dev/null +++ b/Sources/ReportingFeature/ReportingStatusIsOptional.swift @@ -0,0 +1,24 @@ +import Foundation + +public struct ReportingStatusIsOptional { + public var get: () -> Bool +} + +extension ReportingStatusIsOptional { + public static func live( + plist url: URL = Bundle.main.url(forResource: "Info", withExtension: "plist")! + ) -> ReportingStatusIsOptional { + ReportingStatusIsOptional { + struct Plist: Decodable { + let isReportingOptional: Bool + } + + guard let data = try? Data(contentsOf: url), + let infoPlist = try? PropertyListDecoder().decode(Plist.self, from: data) else { + return true + } + + return infoPlist.isReportingOptional + } + } +} diff --git a/Sources/ReportingFeature/Resources/report_cert.der b/Sources/ReportingFeature/Resources/report_cert.der new file mode 100644 index 0000000000000000000000000000000000000000..978f65098ea8f361f1f369194e24d68d258f4dc2 --- /dev/null +++ b/Sources/ReportingFeature/Resources/report_cert.der @@ -0,0 +1 @@ +[REPLACE THIS FILE WITH DER CERTIFICATE] \ No newline at end of file diff --git a/Sources/ReportingFeature/SendReport.swift b/Sources/ReportingFeature/SendReport.swift new file mode 100644 index 0000000000000000000000000000000000000000..acc24a0ce640a40bb0ed1df5eede6053603c3c2f --- /dev/null +++ b/Sources/ReportingFeature/SendReport.swift @@ -0,0 +1,82 @@ +import Foundation +import XCTestDynamicOverlay + +public struct SendReport { + public typealias Completion = (Result<Void, Error>) -> Void + + public var run: (Report, @escaping Completion) -> Void + + public func callAsFunction(_ report: Report, completion: @escaping Completion) { + run(report, completion) + } +} + +extension SendReport { + public static let live = SendReport { report, completion in + let url = URL(string: "https://3.74.237.181:11420/report")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + do { + request.httpBody = try JSONEncoder().encode(report) + } catch { + completion(.failure(error)) + return + } + let session = URLSession( + configuration: .default, + delegate: SessionDelegate(), + delegateQueue: nil + ) + let task = session.dataTask(with: request) { _, _, error in + defer { session.invalidateAndCancel() } + if let error = error { + completion(.failure(error)) + return + } + completion(.success(())) + } + task.resume() + } +} + +extension SendReport { + public static let unimplemented = SendReport( + run: XCTUnimplemented("\(Self.self)") + ) +} + +private final class SessionDelegate: NSObject, URLSessionDelegate { + func urlSession( + _ session: URLSession, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + let authMethod = challenge.protectionSpace.authenticationMethod + guard authMethod == NSURLAuthenticationMethodServerTrust else { + return completionHandler(.cancelAuthenticationChallenge, nil) + } + + guard let serverTrust = challenge.protectionSpace.serverTrust else { + return completionHandler(.cancelAuthenticationChallenge, nil) + } + + guard let serverCert = SecTrustGetCertificateAtIndex(serverTrust, 0) else { + return completionHandler(.cancelAuthenticationChallenge, nil) + } + + let serverCertCFData = SecCertificateCopyData(serverCert) + let serverCertData = Data( + bytes: CFDataGetBytePtr(serverCertCFData), + count: CFDataGetLength(serverCertCFData) + ) + + let localCertURL = Bundle.module.url(forResource: "report_cert", withExtension: "der")! + let localCertData = try! Data(contentsOf: localCertURL) + + guard serverCertData == localCertData else { + return completionHandler(.cancelAuthenticationChallenge, nil) + } + + completionHandler(.useCredential, URLCredential(trust: serverTrust)) + } +} diff --git a/Sources/RequestsFeature/Coordinator/RequestsCoordinator.swift b/Sources/RequestsFeature/Coordinator/RequestsCoordinator.swift index 3c798cfc0243bffbbf54939c37d7ea7eb90c1294..571bf625fc1a98b1cf2905154a660fd4cbebd92e 100644 --- a/Sources/RequestsFeature/Coordinator/RequestsCoordinator.swift +++ b/Sources/RequestsFeature/Coordinator/RequestsCoordinator.swift @@ -24,7 +24,7 @@ public struct RequestsCoordinator: RequestsCoordinating { var bottomPresenter: Presenting = BottomPresenter() var fullscreenPresenter: Presenting = FullscreenPresenter() - var searchFactory: () -> UIViewController + var searchFactory: (String?) -> UIViewController var contactFactory: (Contact) -> UIViewController var singleChatFactory: (Contact) -> UIViewController var groupChatFactory: (GroupInfo) -> UIViewController @@ -32,7 +32,7 @@ public struct RequestsCoordinator: RequestsCoordinating { var nicknameFactory: (String, @escaping StringClosure) -> UIViewController public init( - searchFactory: @escaping () -> UIViewController, + searchFactory: @escaping (String?) -> UIViewController, contactFactory: @escaping (Contact) -> UIViewController, singleChatFactory: @escaping (Contact) -> UIViewController, groupChatFactory: @escaping (GroupInfo) -> UIViewController, @@ -81,7 +81,7 @@ public extension RequestsCoordinator { } func toSearch(from parent: UIViewController) { - let screen = searchFactory() + let screen = searchFactory(nil) pushPresenter.present(screen, from: parent) } diff --git a/Sources/RequestsFeature/ViewModels/RequestsReceivedViewModel.swift b/Sources/RequestsFeature/ViewModels/RequestsReceivedViewModel.swift index ded18f77a4da0d956d2bdb0b1779495c818d41f5..3f4be9cbe3541dd0b2a2431967c1e4ec8df5ce93 100644 --- a/Sources/RequestsFeature/ViewModels/RequestsReceivedViewModel.swift +++ b/Sources/RequestsFeature/ViewModels/RequestsReceivedViewModel.swift @@ -7,6 +7,7 @@ import Defaults import XXModels import Integration import DrawerFeature +import ReportingFeature import CombineSchedulers import DependencyInjection @@ -18,6 +19,7 @@ struct RequestReceived: Hashable, Equatable { final class RequestsReceivedViewModel { @Dependency private var session: SessionType + @Dependency private var reportingStatus: ReportingStatus @KeyObject(.isShowingHiddenRequests, defaultValue: false) var isShowingHiddenRequests: Bool @@ -56,7 +58,10 @@ final class RequestsReceivedViewModel { authStatus: [ .hidden, .pending - ]) + ], + isLeaderBlocked: reportingStatus.isEnabled() ? false : nil, + isLeaderBanned: reportingStatus.isEnabled() ? false : nil + ) let contactsQuery = Contact.Query( authStatus: [ @@ -65,7 +70,10 @@ final class RequestsReceivedViewModel { .verified, .verificationFailed, .verificationInProgress - ]) + ], + isBlocked: reportingStatus.isEnabled() ? false : nil, + isBanned: reportingStatus.isEnabled() ? false : nil + ) let groupStream = session.dbManager.fetchGroupsPublisher(groupsQuery).assertNoFailure() let contactsStream = session.dbManager.fetchContactsPublisher(contactsQuery).assertNoFailure() diff --git a/Sources/RequestsFeature/ViewModels/RequestsSentViewModel.swift b/Sources/RequestsFeature/ViewModels/RequestsSentViewModel.swift index f94ed8f5164de5c5a419a9b9271499ae7daa4952..40964837a7a4254badcc48c5750389def82a3fda 100644 --- a/Sources/RequestsFeature/ViewModels/RequestsSentViewModel.swift +++ b/Sources/RequestsFeature/ViewModels/RequestsSentViewModel.swift @@ -3,9 +3,11 @@ import UIKit import Models import Shared import Combine +import Defaults import XXModels import Integration import ToastFeature +import ReportingFeature import CombineSchedulers import DependencyInjection @@ -16,6 +18,7 @@ struct RequestSent: Hashable, Equatable { final class RequestsSentViewModel { @Dependency private var session: SessionType + @Dependency private var reportingStatus: ReportingStatus @Dependency private var toastController: ToastController var hudPublisher: AnyPublisher<HUDStatus, Never> { @@ -33,10 +36,14 @@ final class RequestsSentViewModel { var backgroundScheduler: AnySchedulerOf<DispatchQueue> = DispatchQueue.global().eraseToAnyScheduler() init() { - let query = Contact.Query(authStatus: [ - .requested, - .requesting - ]) + let query = Contact.Query( + authStatus: [ + .requested, + .requesting + ], + isBlocked: reportingStatus.isEnabled() ? false : nil, + isBanned: reportingStatus.isEnabled() ? false : nil + ) session.dbManager.fetchContactsPublisher(query) .assertNoFailure() diff --git a/Sources/RestoreFeature/Controllers/RestoreController.swift b/Sources/RestoreFeature/Controllers/RestoreController.swift index b88932c148df5249e2c88184b3506e335596020f..f2081aaef8eca0773f552bf685976dbfe67d75c9 100644 --- a/Sources/RestoreFeature/Controllers/RestoreController.swift +++ b/Sources/RestoreFeature/Controllers/RestoreController.swift @@ -26,6 +26,12 @@ public final class RestoreController: UIViewController { presentWarning() } + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationItem.backButtonTitle = "" + navigationController?.navigationBar.customize() + } + public override func viewDidLoad() { super.viewDidLoad() setupNavigationBar() @@ -33,19 +39,13 @@ public final class RestoreController: UIViewController { } private func setupNavigationBar() { - navigationItem.backButtonTitle = "" - let title = UILabel() title.text = Localized.AccountRestore.header title.textColor = Asset.neutralActive.color title.font = Fonts.Mulish.semiBold.font(size: 18.0) - let back = UIButton.back() - back.addTarget(self, action: #selector(didTapBack), for: .touchUpInside) - - navigationItem.leftBarButtonItem = UIBarButtonItem( - customView: UIStackView(arrangedSubviews: [back, title]) - ) + navigationItem.leftBarButtonItem = UIBarButtonItem(customView: title) + navigationItem.leftItemsSupplementBackButton = true } private func setupBindings() { diff --git a/Sources/RestoreFeature/Controllers/RestoreListController.swift b/Sources/RestoreFeature/Controllers/RestoreListController.swift index b396d1069df7008772290e5c9e2f275adb5ccf4b..33470e44212a67e1ee6833cfd70664aca840321f 100644 --- a/Sources/RestoreFeature/Controllers/RestoreListController.swift +++ b/Sources/RestoreFeature/Controllers/RestoreListController.swift @@ -30,27 +30,13 @@ public final class RestoreListController: UIViewController { public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + navigationItem.backButtonTitle = "" navigationController?.navigationBar.customize(translucent: true) } public override func viewDidLoad() { super.viewDidLoad() - setupNavigationBar() - setupBindings() - } - - private func setupNavigationBar() { - navigationItem.backButtonTitle = "" - - let back = UIButton.back() - back.addTarget(self, action: #selector(didTapBack), for: .touchUpInside) - - navigationItem.leftBarButtonItem = UIBarButtonItem( - customView: UIStackView(arrangedSubviews: [back]) - ) - } - private func setupBindings() { viewModel.hudPublisher .receive(on: DispatchQueue.main) .sink { [hud] in hud.update(with: $0) } diff --git a/Sources/SFTPFeature/SFTPController.swift b/Sources/SFTPFeature/SFTPController.swift index f80908b9cdf6106bacef3c2fa9f6c4eac46e8145..21bf8ca1f224ad3b5439d619fbd27025a2a96295 100644 --- a/Sources/SFTPFeature/SFTPController.swift +++ b/Sources/SFTPFeature/SFTPController.swift @@ -21,10 +21,15 @@ public final class SFTPController: UIViewController { required init?(coder: NSCoder) { nil } + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationItem.backButtonTitle = "" + navigationController?.navigationBar.customize(translucent: true) + } + public override func viewDidLoad() { super.viewDidLoad() setupScrollView() - setupNavigationBar() setupBindings() } @@ -38,17 +43,6 @@ public final class SFTPController: UIViewController { scrollViewController.contentView = screenView } - private func setupNavigationBar() { - navigationItem.backButtonTitle = "" - - let back = UIButton.back() - back.addTarget(self, action: #selector(didTapBack), for: .touchUpInside) - - navigationItem.leftBarButtonItem = UIBarButtonItem( - customView: UIStackView(arrangedSubviews: [back]) - ) - } - private func setupBindings() { viewModel.hudPublisher .receive(on: DispatchQueue.main) @@ -89,8 +83,4 @@ public final class SFTPController: UIViewController { .sink { [unowned self] in viewModel.didTapLogin() } .store(in: &cancellables) } - - @objc private func didTapBack() { - navigationController?.popViewController(animated: true) - } } diff --git a/Sources/SFTPFeature/SFTPView.swift b/Sources/SFTPFeature/SFTPView.swift index 3e62e4caeb0eca188fd1dad4db740545f0b3f045..5653d85bacd6c112d737217dfdb9c8312c631de4 100644 --- a/Sources/SFTPFeature/SFTPView.swift +++ b/Sources/SFTPFeature/SFTPView.swift @@ -23,7 +23,7 @@ final class SFTPView: UIView { paragraph.alignment = .left paragraph.lineHeightMultiple = 1.15 - let attString = NSAttributedString( + let attString = NSMutableAttributedString( string: Localized.AccountRestore.Sftp.subtitle, attributes: [ .foregroundColor: Asset.neutralBody.color, @@ -31,6 +31,13 @@ final class SFTPView: UIView { .paragraphStyle: paragraph ]) + attString.setAttributes( + attributes: [ + .foregroundColor: Asset.neutralDark.color, + .font: Fonts.Mulish.bold.font(size: 12.0) as Any, + .paragraphStyle: paragraph + ], betweenCharacters: "*") + subtitleLabel.numberOfLines = 0 subtitleLabel.attributedText = attString diff --git a/Sources/ScanFeature/Views/ScanView.swift b/Sources/ScanFeature/Views/ScanView.swift index 7aff4cf2c85928c1e9ef71bcebe2590b0eb1cf09..540f68f524bac7122bb0db21111c62e1aaddb454 100644 --- a/Sources/ScanFeature/Views/ScanView.swift +++ b/Sources/ScanFeature/Views/ScanView.swift @@ -80,12 +80,12 @@ final class ScanView: UIView { actionButton.isHidden = false case .alreadyFriends(let name): - text = Localized.Scan.Error.friends(name) + text = Localized.Scan.Error.alreadyFriends(name) actionButton.setTitle(Localized.Scan.contact, for: .normal) actionButton.isHidden = false case .cameraPermission: - text = Localized.Scan.Error.denied + text = Localized.Scan.Error.cameraPermissionNeeded actionButton.setTitle(Localized.Scan.settings, for: .normal) actionButton.isHidden = false diff --git a/Sources/SearchFeature/Controllers/SearchContainerController.swift b/Sources/SearchFeature/Controllers/SearchContainerController.swift index ad85b25640a1f1a5e418bf9974b2a0656021d202..3a1bf1e45af9077b7bc70315f5584be93b116242 100644 --- a/Sources/SearchFeature/Controllers/SearchContainerController.swift +++ b/Sources/SearchFeature/Controllers/SearchContainerController.swift @@ -14,11 +14,18 @@ public final class SearchContainerController: UIViewController { private var contentOffset: CGPoint? private var cancellables = Set<AnyCancellable>() + private let leftController: SearchLeftController private let viewModel = SearchContainerViewModel() - private let leftController = SearchLeftController() private let rightController = SearchRightController() private var drawerCancellables = Set<AnyCancellable>() + public init(_ invitation: String? = nil) { + self.leftController = .init(invitation) + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { nil } + public override func loadView() { view = screenView embedControllers() @@ -26,6 +33,7 @@ public final class SearchContainerController: UIViewController { public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + navigationItem.backButtonTitle = "" statusBarController.style.send(.darkContent) navigationController?.navigationBar.customize( backgroundColor: Asset.neutralWhite.color @@ -54,19 +62,13 @@ public final class SearchContainerController: UIViewController { } private func setupNavigationBar() { - navigationItem.backButtonTitle = " " - - let titleLabel = UILabel() - titleLabel.text = Localized.Ud.title - titleLabel.textColor = Asset.neutralActive.color - titleLabel.font = Fonts.Mulish.semiBold.font(size: 18.0) + let title = UILabel() + title.text = Localized.Ud.title + title.textColor = Asset.neutralActive.color + title.font = Fonts.Mulish.semiBold.font(size: 18.0) - let backButton = UIButton.back() - backButton.addTarget(self, action: #selector(didTapBack), for: .touchUpInside) - - navigationItem.leftBarButtonItem = UIBarButtonItem( - customView: UIStackView(arrangedSubviews: [backButton, titleLabel]) - ) + navigationItem.leftBarButtonItem = UIBarButtonItem(customView: title) + navigationItem.leftItemsSupplementBackButton = true } private func setupBindings() { @@ -91,10 +93,6 @@ public final class SearchContainerController: UIViewController { .store(in: &cancellables) } - @objc private func didTapBack() { - navigationController?.popViewController(animated: true) - } - private func embedControllers() { addChild(leftController) addChild(rightController) diff --git a/Sources/SearchFeature/Controllers/SearchLeftController.swift b/Sources/SearchFeature/Controllers/SearchLeftController.swift index bbfabd2836990bb00c86e01a09527bdbac8b1145..cc46ad60cf83f8baa03aa3ac7bb34562fbccaea8 100644 --- a/Sources/SearchFeature/Controllers/SearchLeftController.swift +++ b/Sources/SearchFeature/Controllers/SearchLeftController.swift @@ -19,14 +19,21 @@ final class SearchLeftController: UIViewController { lazy private var screenView = SearchLeftView() + let viewModel: SearchLeftViewModel private var dataSource: SearchDiffableDataSource! - private(set) var viewModel = SearchLeftViewModel() private var drawerCancellables = Set<AnyCancellable>() private let adrpURLString = "https://links.xx.network/adrp" private var cancellables = Set<AnyCancellable>() private var hudCancellables = Set<AnyCancellable>() + init(_ invitation: String? = nil) { + self.viewModel = .init(invitation) + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { nil } + override func loadView() { view = screenView } @@ -37,6 +44,11 @@ final class SearchLeftController: UIViewController { setupBindings() } + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + viewModel.viewDidAppear() + } + func endEditing() { screenView.inputField.endEditing(true) } @@ -131,6 +143,13 @@ final class SearchLeftController: UIViewController { .sink { [unowned self] in screenView.countryButton.setFlag($0.flag, prefix: $0.prefix) } .store(in: &cancellables) + viewModel.statePublisher + .map(\.input) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [unowned self] in screenView.inputField.update(content: $0) } + .store(in: &cancellables) + viewModel.statePublisher .compactMap(\.snapshot) .receive(on: DispatchQueue.main) @@ -403,7 +422,6 @@ final class SearchLeftController: UIViewController { coordinator.toDrawer(drawer, from: self) } - } extension SearchLeftController: UITableViewDelegate { diff --git a/Sources/SearchFeature/ViewModels/SearchLeftViewModel.swift b/Sources/SearchFeature/ViewModels/SearchLeftViewModel.swift index 30f77b191cbd9115f856d6133a2df3b2fa01fb0b..6f708401cc71d12838efcc64ca594818ecf2ae42 100644 --- a/Sources/SearchFeature/ViewModels/SearchLeftViewModel.swift +++ b/Sources/SearchFeature/ViewModels/SearchLeftViewModel.swift @@ -3,8 +3,11 @@ import UIKit import Shared import Combine import XXModels +import Defaults import Countries import Integration +import NetworkMonitor +import ReportingFeature import DependencyInjection typealias SearchSnapshot = NSDiffableDataSourceSnapshot<SearchSection, SearchItem> @@ -18,6 +21,8 @@ struct SearchLeftViewState { final class SearchLeftViewModel { @Dependency var session: SessionType + @Dependency var reportingStatus: ReportingStatus + @Dependency var networkMonitor: NetworkMonitoring var hudPublisher: AnyPublisher<HUDStatus, Never> { hudSubject.eraseToAnyPublisher() @@ -31,10 +36,38 @@ final class SearchLeftViewModel { stateSubject.eraseToAnyPublisher() } + private var invitation: String? private var searchCancellables = Set<AnyCancellable>() private let successSubject = PassthroughSubject<Contact, Never>() private let hudSubject = CurrentValueSubject<HUDStatus, Never>(.none) private let stateSubject = CurrentValueSubject<SearchLeftViewState, Never>(.init()) + private var networkCancellable = Set<AnyCancellable>() + + init(_ invitation: String? = nil) { + self.invitation = invitation + } + + func viewDidAppear() { + if let pendingInvitation = invitation { + invitation = nil + stateSubject.value.input = pendingInvitation + hudSubject.send(.onAction(Localized.Ud.Search.cancel)) + + networkCancellable.removeAll() + + networkMonitor.statusPublisher + .first { $0 == .available } + .eraseToAnyPublisher() + .flatMap { _ in self.session.waitForNodes(timeout: 5) } + .sink { + if case .failure(let error) = $0 { + self.hudSubject.send(.error(.init(with: error))) + } + } receiveValue: { + self.didStartSearching() + }.store(in: &networkCancellable) + } + } func didEnterInput(_ string: String) { stateSubject.value.input = string @@ -114,24 +147,42 @@ final class SearchLeftViewModel { var snapshot = SearchSnapshot() if var user = user { - if let contact = try? session.dbManager.fetchContacts(.init(id: [user.id])).first { + if let contact = try! session.dbManager.fetchContacts(.init(id: [user.id])).first { + user.isBanned = contact.isBanned + user.isBlocked = contact.isBlocked user.authStatus = contact.authStatus } - if user.authStatus != .friend { + if user.authStatus != .friend, !reportingStatus.isEnabled() { + snapshot.appendSections([.stranger]) + snapshot.appendItems([.stranger(user)], toSection: .stranger) + } else if user.authStatus != .friend, reportingStatus.isEnabled(), !user.isBanned, !user.isBlocked { snapshot.appendSections([.stranger]) snapshot.appendItems([.stranger(user)], toSection: .stranger) } } - let localsQuery = Contact.Query(text: stateSubject.value.input, authStatus: [.friend]) + let localsQuery = Contact.Query( + text: stateSubject.value.input, + authStatus: [.friend], + isBlocked: reportingStatus.isEnabled() ? false : nil, + isBanned: reportingStatus.isEnabled() ? false : nil + ) - if let locals = try? session.dbManager.fetchContacts(localsQuery), locals.count > 0 { - let localsWithoutMe = locals.filter { $0.id != session.myId } + if let locals = try? session.dbManager.fetchContacts(localsQuery), + let localsWithoutMe = removeMyself(from: locals), + localsWithoutMe.isEmpty == false { snapshot.appendSections([.connections]) - snapshot.appendItems(localsWithoutMe.map(SearchItem.connection), toSection: .connections) + snapshot.appendItems( + localsWithoutMe.map(SearchItem.connection), + toSection: .connections + ) } stateSubject.value.snapshot = snapshot } + + private func removeMyself(from collection: [Contact]) -> [Contact]? { + collection.filter { $0.id != session.myId } + } } diff --git a/Sources/SearchFeature/ViewModels/SearchRightViewModel.swift b/Sources/SearchFeature/ViewModels/SearchRightViewModel.swift index 5cfe38db68c498a15522f745174e41415281ba17..db977528e9a65491ada83947cdad167897532982 100644 --- a/Sources/SearchFeature/ViewModels/SearchRightViewModel.swift +++ b/Sources/SearchFeature/ViewModels/SearchRightViewModel.swift @@ -1,9 +1,11 @@ import Shared import Combine import XXModels +import Defaults import Foundation import Permissions import Integration +import ReportingFeature import DependencyInjection enum ScanningStatus: Equatable { @@ -23,6 +25,7 @@ enum ScanningError: Equatable { final class SearchRightViewModel { @Dependency var session: SessionType @Dependency var permissions: PermissionHandling + @Dependency var reportingStatus: ReportingStatus var foundPublisher: AnyPublisher<Contact, Never> { foundSubject.eraseToAnyPublisher() @@ -78,6 +81,16 @@ final class SearchRightViewModel { /// that we already have /// if let alreadyContact = try? session.dbManager.fetchContacts(.init(id: [userId])).first { + if alreadyContact.isBlocked, reportingStatus.isEnabled() { + statusSubject.send(.failed(.unknown("You previously blocked this user."))) + return + } + + if alreadyContact.isBanned, reportingStatus.isEnabled() { + statusSubject.send(.failed(.unknown("This user was banned."))) + return + } + /// Show error accordingly to the auth status /// if alreadyContact.authStatus == .friend { diff --git a/Sources/SearchFeature/Views/SearchRightView.swift b/Sources/SearchFeature/Views/SearchRightView.swift index 363808754852969a75c3c811836faa9b40455034..c2fd74760c79e8799c31bb91b0f73a85d4d5cd98 100644 --- a/Sources/SearchFeature/Views/SearchRightView.swift +++ b/Sources/SearchFeature/Views/SearchRightView.swift @@ -39,51 +39,39 @@ final class SearchRightView: UIView { required init?(coder: NSCoder) { nil } func update(status: ScanningStatus) { - var text: String + setupTitle(for: status) + setupImageView(for: status) + setupActionButton(for: status) + setupCornerColors(for: status) + setupAnimationView(for: status) + } - switch status { - case .reading, .processing: - imageView.isHidden = true - actionButton.isHidden = true - text = Localized.Scan.Status.reading - overlayView.updateCornerColor(Asset.brandPrimary.color) + private func setupTitle(for status: ScanningStatus) { + let title: String + switch status { case .success: - animationView.isHidden = true - actionButton.isHidden = true - imageView.isHidden = false - imageView.image = Asset.sharedSuccess.image - text = Localized.Scan.Status.success - overlayView.updateCornerColor(Asset.accentSuccess.color) - - case .failed(let error): - animationView.isHidden = true - imageView.image = Asset.scanError.image - imageView.isHidden = false - overlayView.updateCornerColor(Asset.accentDanger.color) + title = Localized.Scan.Status.success + case .reading: + title = Localized.Scan.Status.reading + case .processing: + title = Localized.Scan.Status.processing + + case .failed(let scanningError): + switch scanningError { + case .unknown(let content): + title = content - switch error { case .requestOpened: - text = Localized.Scan.Error.requested - actionButton.setTitle(Localized.Scan.requests, for: .normal) - actionButton.isHidden = false - + title = Localized.Scan.Error.requested case .alreadyFriends(let name): - text = Localized.Scan.Error.friends(name) - actionButton.setTitle(Localized.Scan.contact, for: .normal) - actionButton.isHidden = false - + title = Localized.Scan.Error.alreadyFriends(name) case .cameraPermission: - text = Localized.Scan.Error.denied - actionButton.setTitle(Localized.Scan.settings, for: .normal) - actionButton.isHidden = false - - case .unknown(let content): - text = content + title = Localized.Scan.Error.cameraPermissionNeeded } } - let attString = NSMutableAttributedString(string: text) + let attString = NSMutableAttributedString(string: title) let paragraph = NSMutableParagraphStyle() paragraph.alignment = .center paragraph.lineHeightMultiple = 1.35 @@ -92,13 +80,71 @@ final class SearchRightView: UIView { attString.addAttribute(.foregroundColor, value: Asset.neutralWhite.color) attString.addAttribute(.font, value: Fonts.Mulish.regular.font(size: 14.0) as Any) - if text.contains("#") { + if title.contains("#") { attString.addAttribute(name: .foregroundColor, value: Asset.brandPrimary.color, betweenCharacters: "#") } statusLabel.attributedText = attString } + private func setupImageView(for status: ScanningStatus) { + let image: UIImage? + + switch status { + case .reading, .processing: + image = nil + case .success: + image = Asset.sharedSuccess.image + case .failed(_): + image = Asset.scanError.image + } + + imageView.image = image + imageView.isHidden = image == nil + } + + private func setupActionButton(for status: ScanningStatus) { + let buttonTitle: String? + + switch status { + case .failed(.requestOpened): + buttonTitle = Localized.Scan.requests + case .failed(.alreadyFriends(_)): + buttonTitle = Localized.Scan.contact + case .failed(.cameraPermission): + buttonTitle = Localized.Scan.settings + case .reading, .processing, .success, .failed(.unknown(_)): + buttonTitle = nil + } + + actionButton.setTitle(buttonTitle, for: .normal) + actionButton.isHidden = buttonTitle == nil + } + + private func setupCornerColors(for status: ScanningStatus) { + let color: UIColor + + switch status { + case .reading, .processing: + color = Asset.brandPrimary.color + case .success: + color = Asset.accentSuccess.color + case .failed(_): + color = Asset.accentDanger.color + } + + overlayView.updateCornerColor(color) + } + + private func setupAnimationView(for status: ScanningStatus) { + switch status { + case .reading, .processing: + animationView.isHidden = false + case .success, .failed(_): + animationView.isHidden = true + } + } + private func setupConstraints() { overlayView.snp.makeConstraints { $0.top.equalToSuperview() diff --git a/Sources/SettingsFeature/Controllers/AccountDeleteController.swift b/Sources/SettingsFeature/Controllers/AccountDeleteController.swift index 1dc36e2ef958cc0c7fba6177e621915d96cfc4cc..ee5be65a72df17338ab3daca3ddcf58531057338 100644 --- a/Sources/SettingsFeature/Controllers/AccountDeleteController.swift +++ b/Sources/SettingsFeature/Controllers/AccountDeleteController.swift @@ -28,7 +28,6 @@ public final class AccountDeleteController: UIViewController { public override func viewDidLoad() { super.viewDidLoad() - setupNavigationBar() setupScrollView() setupBindings() @@ -42,12 +41,6 @@ public final class AccountDeleteController: UIViewController { } } - private func setupNavigationBar() { - let back = UIButton.back() - back.addTarget(self, action: #selector(didTapBack), for: .touchUpInside) - navigationItem.leftBarButtonItem = UIBarButtonItem(customView: back) - } - private func setupScrollView() { addChild(scrollViewController) view.addSubview(scrollViewController.view) @@ -84,10 +77,6 @@ public final class AccountDeleteController: UIViewController { .store(in: &cancellables) } - @objc private func didTapBack() { - navigationController?.popViewController(animated: true) - } - private func presentInfo(title: String, subtitle: String) { let actionButton = CapsuleButton() actionButton.set( diff --git a/Sources/SettingsFeature/Controllers/SettingsAdvancedController.swift b/Sources/SettingsFeature/Controllers/SettingsAdvancedController.swift index aeb6791eefb9685595edd21c816026de9f2ebb0a..efe25a0030b832db3507172bf4a966d26e0ed54f 100644 --- a/Sources/SettingsFeature/Controllers/SettingsAdvancedController.swift +++ b/Sources/SettingsFeature/Controllers/SettingsAdvancedController.swift @@ -17,6 +17,7 @@ public final class SettingsAdvancedController: UIViewController { public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + navigationItem.backButtonTitle = "" navigationController?.navigationBar .customize(backgroundColor: Asset.neutralWhite.color) } @@ -30,19 +31,13 @@ public final class SettingsAdvancedController: UIViewController { } private func setupNavigationBar() { - navigationItem.backButtonTitle = "" - let title = UILabel() title.text = Localized.Settings.Advanced.title title.textColor = Asset.neutralActive.color title.font = Fonts.Mulish.semiBold.font(size: 18.0) - let back = UIButton.back() - back.addTarget(self, action: #selector(didTapBack), for: .touchUpInside) - - navigationItem.leftBarButtonItem = UIBarButtonItem( - customView: UIStackView(arrangedSubviews: [back, title]) - ) + navigationItem.leftBarButtonItem = UIBarButtonItem(customView: title) + navigationItem.leftItemsSupplementBackButton = true } private func setupBindings() { @@ -66,21 +61,30 @@ public final class SettingsAdvancedController: UIViewController { .sink { [weak viewModel] in viewModel?.didToggleCrashReporting() } .store(in: &cancellables) + screenView.reportingSwitcher.switcherView + .publisher(for: .valueChanged) + .compactMap { [weak screenView] _ in screenView?.reportingSwitcher.switcherView.isOn } + .sink { [weak viewModel] isOn in viewModel?.didSetReporting(enabled: isOn) } + .store(in: &cancellables) + viewModel.sharePublisher .receive(on: DispatchQueue.main) .sink { [unowned self] in coordinator.toActivityController(with: [$0], from: self) } .store(in: &cancellables) + viewModel.state + .removeDuplicates() + .map(\.isReportingOptional) + .sink { [unowned self] in screenView.reportingSwitcher.isHidden = !$0 } + .store(in: &cancellables) + viewModel.state .removeDuplicates() .sink { [unowned self] state in screenView.logRecordingSwitcher.switcherView.setOn(state.isRecordingLogs, animated: true) screenView.crashReportingSwitcher.switcherView.setOn(state.isCrashReporting, animated: true) screenView.showUsernamesSwitcher.switcherView.setOn(state.isShowingUsernames, animated: true) + screenView.reportingSwitcher.switcherView.setOn(state.isReportingEnabled, animated: true) }.store(in: &cancellables) } - - @objc private func didTapBack() { - navigationController?.popViewController(animated: true) - } } diff --git a/Sources/SettingsFeature/Controllers/SettingsController.swift b/Sources/SettingsFeature/Controllers/SettingsController.swift index 52e78e17a42c3933f671917aca89b186362f349a..e58a347096e53ddefc3014517c4f11c946a06429 100644 --- a/Sources/SettingsFeature/Controllers/SettingsController.swift +++ b/Sources/SettingsFeature/Controllers/SettingsController.swift @@ -136,7 +136,7 @@ public final class SettingsController: UIViewController { title: Localized.Settings.Drawer.title(Localized.Settings.privacyPolicy), subtitle: Localized.Settings.Drawer.subtitle(Localized.Settings.privacyPolicy), actionTitle: Localized.ChatList.Dashboard.open) { - guard let url = URL(string: "https://xx.network/privategrity-corporation-privacy-policy") else { return } + guard let url = URL(string: "https://elixxir.io/privategrity-corporation-privacy-policy/") else { return } UIApplication.shared.open(url, options: [:]) } }.store(in: &cancellables) @@ -149,7 +149,7 @@ public final class SettingsController: UIViewController { title: Localized.Settings.Drawer.title(Localized.Settings.disclosures), subtitle: Localized.Settings.Drawer.subtitle(Localized.Settings.disclosures), actionTitle: Localized.ChatList.Dashboard.open) { - guard let url = URL(string: "https://xx.network/privategrity-corporation-terms-of-use") else { return } + guard let url = URL(string: "https://elixxir.io/privategrity-corporation-terms-of-use/") else { return } UIApplication.shared.open(url, options: [:]) } }.store(in: &cancellables) diff --git a/Sources/SettingsFeature/ViewModels/SettingsAdvancedViewModel.swift b/Sources/SettingsFeature/ViewModels/SettingsAdvancedViewModel.swift index 8717024458fee4281461d03a5c05649cca3ce576..3ec9be365abd621e7c1d9d421aff530d0560acc7 100644 --- a/Sources/SettingsFeature/ViewModels/SettingsAdvancedViewModel.swift +++ b/Sources/SettingsFeature/ViewModels/SettingsAdvancedViewModel.swift @@ -3,22 +3,27 @@ import XXLogger import Defaults import Foundation import CrashReporting +import ReportingFeature import DependencyInjection struct AdvancedViewState: Equatable { var isRecordingLogs = false var isCrashReporting = false var isShowingUsernames = false + var isReportingEnabled = false + var isReportingOptional = false } final class SettingsAdvancedViewModel { @KeyObject(.recordingLogs, defaultValue: true) var isRecordingLogs: Bool @KeyObject(.crashReporting, defaultValue: true) var isCrashReporting: Bool + private var cancellables = Set<AnyCancellable>() private let isShowingUsernamesKey = "isShowingUsernames" @Dependency private var logger: XXLogger @Dependency private var crashReporter: CrashReporter + @Dependency private var reportingStatus: ReportingStatus var sharePublisher: AnyPublisher<URL, Never> { shareRelay.eraseToAnyPublisher() } private let shareRelay = PassthroughSubject<URL, Never>() @@ -29,6 +34,12 @@ final class SettingsAdvancedViewModel { func loadCachedSettings() { stateRelay.value.isRecordingLogs = isRecordingLogs stateRelay.value.isCrashReporting = isCrashReporting + stateRelay.value.isReportingOptional = reportingStatus.isOptional() + + reportingStatus + .isEnabledPublisher() + .sink { [weak stateRelay] in stateRelay?.value.isReportingEnabled = $0 } + .store(in: &cancellables) guard let defaults = UserDefaults(suiteName: "group.elixxir.messenger") else { print("^^^ Couldn't access user defaults in the app group container \(#file):\(#line)") @@ -75,4 +86,8 @@ final class SettingsAdvancedViewModel { stateRelay.value.isCrashReporting.toggle() crashReporter.setEnabled(isCrashReporting) } + + func didSetReporting(enabled: Bool) { + reportingStatus.enable(enabled) + } } diff --git a/Sources/SettingsFeature/Views/SettingsAdvancedView.swift b/Sources/SettingsFeature/Views/SettingsAdvancedView.swift index fb3df7c771e7b5a205b5ec610ca7780bce045f88..0e5e5d89159fbb554f78dd781f5a9d3d4cdfe9da 100644 --- a/Sources/SettingsFeature/Views/SettingsAdvancedView.swift +++ b/Sources/SettingsFeature/Views/SettingsAdvancedView.swift @@ -7,6 +7,7 @@ final class SettingsAdvancedView: UIView { let logRecordingSwitcher = SettingsSwitcher() let crashReportingSwitcher = SettingsSwitcher() let showUsernamesSwitcher = SettingsSwitcher() + let reportingSwitcher = SettingsSwitcher() init() { super.init(frame: .zero) @@ -33,21 +34,29 @@ final class SettingsAdvancedView: UIView { icon: Asset.settingsCrash.image ) + reportingSwitcher.set( + title: Localized.Settings.Advanced.Reporting.title, + text: Localized.Settings.Advanced.Reporting.description, + icon: Asset.settingsCrash.image + ) + stackView.axis = .vertical stackView.addArrangedSubview(logRecordingSwitcher) stackView.addArrangedSubview(crashReportingSwitcher) stackView.addArrangedSubview(showUsernamesSwitcher) + stackView.addArrangedSubview(reportingSwitcher) stackView.setCustomSpacing(20, after: logRecordingSwitcher) stackView.setCustomSpacing(10, after: crashReportingSwitcher) stackView.setCustomSpacing(10, after: showUsernamesSwitcher) + stackView.setCustomSpacing(10, after: reportingSwitcher) addSubview(stackView) - stackView.snp.makeConstraints { make in - make.top.equalToSuperview().offset(24) - make.left.equalToSuperview().offset(16) - make.right.equalToSuperview().offset(-16) + stackView.snp.makeConstraints { + $0.top.equalToSuperview().offset(24) + $0.left.equalToSuperview().offset(16) + $0.right.equalToSuperview().offset(-16) } } diff --git a/Sources/Shared/AutoGenerated/Assets.swift b/Sources/Shared/AutoGenerated/Assets.swift index 6755de526a369e8245365d38a47984ea96a02e42..c55ff98183f5c8b9c3776a40a24bc8ddba91429b 100644 --- a/Sources/Shared/AutoGenerated/Assets.swift +++ b/Sources/Shared/AutoGenerated/Assets.swift @@ -70,6 +70,7 @@ public enum Asset { public static let menuRequests = ImageAsset(name: "menu_requests") public static let menuScan = ImageAsset(name: "menu_scan") public static let menuSettings = ImageAsset(name: "menu_settings") + public static let menuShare = ImageAsset(name: "menu_share") public static let onboardingBackground = ImageAsset(name: "onboarding_background") public static let onboardingBottomLogoStart = ImageAsset(name: "onboarding_bottom_logo_start") public static let onboardingEmail = ImageAsset(name: "onboarding_email") diff --git a/Sources/Shared/AutoGenerated/Strings.swift b/Sources/Shared/AutoGenerated/Strings.swift index ed861800297b7c53e92a0e83f19aa322de123ec4..3177da720c32c5e4209fa4dc0c9c1211d6e65b5e 100644 --- a/Sources/Shared/AutoGenerated/Strings.swift +++ b/Sources/Shared/AutoGenerated/Strings.swift @@ -218,7 +218,9 @@ public enum Localized { public static let login = Localized.tr("Localizable", "accountRestore.sftp.login") /// Password public static let password = Localized.tr("Localizable", "accountRestore.sftp.password") - /// Login to your server. Your credentials will be automatically and securely saved locally on your device. + /// Login to your server. Your credentials will be automatically and securely saved locally on your device. Your backups are encrypted and protected by your backup password which is stored separately from these credentials. + /// + /// *Please Note: at this time, host key fingerprint checking is not implemented. If the server's fingerprint changes you will not be notified.* public static let subtitle = Localized.tr("Localizable", "accountRestore.sftp.subtitle") /// Login to your SFTP public static let title = Localized.tr("Localizable", "accountRestore.sftp.title") @@ -272,6 +274,22 @@ public enum Localized { /// Backup settings public static let title = Localized.tr("Localizable", "backup.config.title") } + public enum Passphrase { + /// Cancel + public static let cancel = Localized.tr("Localizable", "backup.passphrase.cancel") + /// Set password and continue + public static let `continue` = Localized.tr("Localizable", "backup.passphrase.continue") + /// Please select a password for your backup. If you lose this password, you will not be able to restore your account. Make sure to keep a record somewhere safe. Your password needs to be at least 8 characters with at least 1 uppercase, 1 lowercase and 1 number characters + public static let subtitle = Localized.tr("Localizable", "backup.passphrase.subtitle") + /// Secure your backup + public static let title = Localized.tr("Localizable", "backup.passphrase.title") + public enum Input { + /// * * * * * * + public static let placeholder = Localized.tr("Localizable", "backup.passphrase.input.placeholder") + /// Passphrase + public static let title = Localized.tr("Localizable", "backup.passphrase.input.title") + } + } public enum Setup { /// Setup your #backup service#. public static let title = Localized.tr("Localizable", "backup.setup.title") @@ -322,6 +340,8 @@ public enum Localized { public static let delete = Localized.tr("Localizable", "chat.bubbleMenu.delete") /// Reply public static let reply = Localized.tr("Localizable", "chat.bubbleMenu.reply") + /// Report + public static let report = Localized.tr("Localizable", "chat.bubbleMenu.report") /// Retry public static let retry = Localized.tr("Localizable", "chat.bubbleMenu.retry") /// Select @@ -349,6 +369,16 @@ public enum Localized { /// All public static let deleteAll = Localized.tr("Localizable", "chat.menu.deleteAll") } + public enum Report { + /// Confirm and Report + public static let action = Localized.tr("Localizable", "chat.report.action") + /// Cancel + public static let cancel = Localized.tr("Localizable", "chat.report.cancel") + /// Reporting this user will block them, delete them from your connections and you won’t see direct messages from them again. In case this user is marked as banned user by us you also won’t see any new group chat messages from this user + public static let subtitle = Localized.tr("Localizable", "chat.report.subtitle") + /// Report user + public static let title = Localized.tr("Localizable", "chat.report.title") + } public enum RetrySheet { /// Cancel public static let cancel = Localized.tr("Localizable", "chat.retrySheet.cancel") @@ -368,6 +398,8 @@ public enum Localized { public static let clear = Localized.tr("Localizable", "chat.sheetMenu.clear") /// View contact profile public static let details = Localized.tr("Localizable", "chat.sheetMenu.details") + /// Report user + public static let report = Localized.tr("Localizable", "chat.sheetMenu.report") } } @@ -481,11 +513,11 @@ public enum Localized { public static func description(_ p1: Any) -> String { return Localized.tr("Localizable", "contact.delete.drawer.description", String(describing: p1)) } - /// Delete Connection? + /// Delete and block connection? public static let title = Localized.tr("Localizable", "contact.delete.drawer.title") } public enum Info { - /// Delete Connection + /// Delete and block connection public static let title = Localized.tr("Localizable", "contact.delete.info.title") } } @@ -633,6 +665,8 @@ public enum Localized { public static let contacts = Localized.tr("Localizable", "menu.contacts") /// Dashboard public static let dashboard = Localized.tr("Localizable", "menu.dashboard") + /// Join xx network + public static let join = Localized.tr("Localizable", "menu.join") /// Profile public static let profile = Localized.tr("Localizable", "menu.profile") /// Requests @@ -641,6 +675,16 @@ public enum Localized { public static let scan = Localized.tr("Localizable", "menu.scan") /// Settings public static let settings = Localized.tr("Localizable", "menu.settings") + /// Share my profile + public static let share = Localized.tr("Localizable", "menu.share") + /// Hi, I'm using xx messenger, you can download it here: + /// https://invite.xx.network + /// + /// And you can add me using this link: + /// %@ + public static func shareContent(_ p1: Any) -> String { + return Localized.tr("Localizable", "menu.shareContent", String(describing: p1)) + } /// Hello public static let title = Localized.tr("Localizable", "menu.title") /// Version %@ @@ -1010,13 +1054,13 @@ public enum Localized { } } public enum Error { - /// Camera needs permission to be used - public static let denied = Localized.tr("Localizable", "scan.error.denied") /// You've already added /// #%@# - public static func friends(_ p1: Any) -> String { - return Localized.tr("Localizable", "scan.error.friends", String(describing: p1)) + public static func alreadyFriends(_ p1: Any) -> String { + return Localized.tr("Localizable", "scan.error.alreadyFriends", String(describing: p1)) } + /// Camera needs permission to be used + public static let cameraPermissionNeeded = Localized.tr("Localizable", "scan.error.cameraPermissionNeeded") /// Something’s gone wrong. Please try again. public static let general = Localized.tr("Localizable", "scan.error.general") /// Invalid QR code @@ -1039,6 +1083,8 @@ public enum Localized { public static let `right` = Localized.tr("Localizable", "scan.segmentedControl.right") } public enum Status { + /// Processing... + public static let processing = Localized.tr("Localizable", "scan.status.processing") /// Place QR code inside frame to scan public static let reading = Localized.tr("Localizable", "scan.status.reading") /// Success @@ -1080,6 +1126,12 @@ public enum Localized { /// Record logs public static let title = Localized.tr("Localizable", "settings.advanced.logs.title") } + public enum Reporting { + /// Allows you to report users sending innapropriate content + public static let description = Localized.tr("Localizable", "settings.advanced.reporting.description") + /// Enable user reporting feature + public static let title = Localized.tr("Localizable", "settings.advanced.reporting.title") + } public enum ShowUsername { /// Allow us to show a more detailed push notification public static let description = Localized.tr("Localizable", "settings.advanced.showUsername.description") @@ -1208,6 +1260,17 @@ public enum Localized { } } + public enum Terms { + /// Accept and proceed + public static let accept = Localized.tr("Localizable", "terms.accept") + /// By enabling the checkbox on the left, you agree with the terms and conditions. + public static let radio = Localized.tr("Localizable", "terms.radio") + /// Show terms and conditions + public static let show = Localized.tr("Localizable", "terms.show") + /// Terms #&# Conditions + public static let title = Localized.tr("Localizable", "terms.title") + } + public enum Ud { /// There are no users with that %@. public static func noneFound(_ p1: Any) -> String { @@ -1257,9 +1320,9 @@ public enum Localized { return Localized.tr("Localizable", "ud.search.input", String(describing: p1)) } public enum Placeholder { - /// Your searches are anonymous. Search information is never linked to your account or personally identifiable. + /// Your searches are private. Search information is never linked to your account or personally identifiable. public static let subtitle = Localized.tr("Localizable", "ud.search.placeholder.subtitle") - /// Search for #friends# anonymously, add them to your #connections# to start a completely private messaging channel. + /// Search for #friends# privately, add them to your #connections# to start a completely private messaging channel. public static let title = Localized.tr("Localizable", "ud.search.placeholder.title") } } diff --git a/Sources/Shared/Extensions/NavigationBar.swift b/Sources/Shared/Extensions/NavigationBar.swift index 2b4d8d254ed4554cbeb8d58dc94df1c0f0f48754..b7efcb698321a974752e5c07cab1542ca0d26d80 100644 --- a/Sources/Shared/Extensions/NavigationBar.swift +++ b/Sources/Shared/Extensions/NavigationBar.swift @@ -4,14 +4,18 @@ public extension UINavigationBar { func customize( translucent: Bool = false, backgroundColor: UIColor = .clear, - shadowColor: UIColor? = nil + shadowColor: UIColor? = nil, + tint: UIColor = Asset.neutralActive.color ) { isTranslucent = translucent let barAppearance = UINavigationBarAppearance() barAppearance.backgroundColor = backgroundColor barAppearance.backgroundEffect = .none barAppearance.shadowColor = shadowColor + + tintColor = tint + compactAppearance = barAppearance standardAppearance = barAppearance - scrollEdgeAppearance = standardAppearance + scrollEdgeAppearance = barAppearance } } diff --git a/Sources/Shared/Resources/Assets.xcassets/AssetsMenu/menu_share.imageset/Contents.json b/Sources/Shared/Resources/Assets.xcassets/AssetsMenu/menu_share.imageset/Contents.json new file mode 100644 index 0000000000000000000000000000000000000000..5c2f805eb3317d819659b5d73f14b6a1b249804f --- /dev/null +++ b/Sources/Shared/Resources/Assets.xcassets/AssetsMenu/menu_share.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Icon.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Sources/Shared/Resources/Assets.xcassets/AssetsMenu/menu_share.imageset/Icon.pdf b/Sources/Shared/Resources/Assets.xcassets/AssetsMenu/menu_share.imageset/Icon.pdf new file mode 100644 index 0000000000000000000000000000000000000000..b09b053bb7c5bd064ae240eb8d69b92a81b85519 --- /dev/null +++ b/Sources/Shared/Resources/Assets.xcassets/AssetsMenu/menu_share.imageset/Icon.pdf @@ -0,0 +1,101 @@ +%PDF-1.7 + +1 0 obj + << >> +endobj + +2 0 obj + << /Length 3 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 5.000000 4.000000 cm +0.693500 0.711750 0.730000 scn +12.599999 1.800547 m +12.599999 4.501754 l +12.599999 4.999001 13.002944 5.402100 13.500000 5.402100 c +13.997056 5.402100 14.400000 4.999001 14.400000 4.501754 c +14.400000 1.800547 l +14.400000 0.807714 13.593665 0.000029 12.603939 0.000029 c +1.796060 0.000029 l +0.802609 0.000029 0.000000 0.804142 0.000000 1.800547 c +0.000000 4.501754 l +0.000000 4.999001 0.402944 5.402100 0.900000 5.402100 c +1.397056 5.402100 1.800000 4.999001 1.800000 4.501754 c +1.796060 1.800718 l +12.599999 1.800547 l +h +f +n +Q +q +-1.000000 -0.000000 0.000000 -1.000000 15.648438 20.002563 cm +0.693500 0.711750 0.730000 scn +1.539240 4.081299 m +2.545713 3.074440 l +2.545713 11.492543 l +2.545713 11.989956 2.945192 12.393188 3.445713 12.393188 c +3.942770 12.393188 4.345713 11.990088 4.345713 11.492543 c +4.345713 3.074440 l +5.352188 4.081299 l +5.705159 4.434405 6.273771 4.438073 6.627693 4.084015 c +6.979165 3.732409 6.977091 3.160265 6.624980 2.808019 c +4.084824 0.266890 l +3.906826 0.088822 3.676712 0.001439 3.446377 0.001585 c +3.215733 -0.000003 2.985826 0.087598 2.809317 0.264174 c +2.807509 0.265984 0.266448 2.808019 0.266448 2.808019 c +-0.086523 3.161125 -0.090189 3.729957 0.263733 4.084015 c +0.615205 4.435622 1.187128 4.433546 1.539240 4.081299 c +h +f +n +Q + +endstream +endobj + +3 0 obj + 1353 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 24.000000 24.000000 ] + /Resources 1 0 R + /Contents 2 0 R + /Parent 5 0 R + >> +endobj + +5 0 obj + << /Kids [ 4 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +6 0 obj + << /Pages 5 0 R + /Type /Catalog + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000001443 00000 n +0000001466 00000 n +0000001639 00000 n +0000001713 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +1772 +%%EOF \ No newline at end of file diff --git a/Sources/Shared/Resources/en.lproj/Localizable.strings b/Sources/Shared/Resources/en.lproj/Localizable.strings index dd95d4002c125c9caa98007bf7074c172bf5aaf8..a82b151e1ff1fb0a65ddffb5060c922057679532 100644 --- a/Sources/Shared/Resources/en.lproj/Localizable.strings +++ b/Sources/Shared/Resources/en.lproj/Localizable.strings @@ -8,6 +8,10 @@ = "Connections"; "menu.requests" = "Requests"; +"menu.join" += "Join xx network"; +"menu.share" += "Share my profile"; "menu.viewProfile" = "View Profile"; "menu.profile" @@ -22,6 +26,8 @@ = "Build %@"; "menu.version" = "Version %@"; +"menu.shareContent" += "Hi, I'm using xx messenger, you can download it here:\nhttps://invite.xx.network\n\nAnd you can add me using this link:\n%@"; // ChatListFeature @@ -108,6 +114,8 @@ = "Select"; "chat.bubbleMenu.retry" = "Retry"; +"chat.bubbleMenu.report" += "Report"; "chat.e2e.placeholder" = "You and %@ now have a #quantum-secure#, completely private channel for messaging.\n#Say hello#!"; @@ -119,6 +127,8 @@ = "Clear chat"; "chat.sheetMenu.details" = "View contact profile"; +"chat.sheetMenu.report" += "Report user"; "chat.retrySheet.retry" = "Try again"; "chat.retrySheet.delete" @@ -168,10 +178,23 @@ "chat.clear.cancel" = "Cancel"; +// ChatFeature - Report + +"chat.report.title" += "Report user"; +"chat.report.subtitle" += "Reporting this user will block them, delete them from your connections and you won’t see direct messages from them again. In case this user is marked as banned user by us you also won’t see any new group chat messages from this user"; +"chat.report.action" += "Confirm and Report"; +"chat.report.cancel" += "Cancel"; + // ScanFeature "scan.status.reading" = "Place QR code inside frame to scan"; +"scan.status.processing" += "Processing..."; "scan.status.success" = "Success"; "scan.display.copied" @@ -197,7 +220,7 @@ = "Scan Code"; "scan.segmentedControl.right" = "My Code"; -"scan.error.denied" +"scan.error.cameraPermissionNeeded" = "Camera needs permission to be used"; "scan.error.invalid" = "Invalid QR code"; @@ -205,7 +228,7 @@ = "Something’s gone wrong. Please try again."; "scan.error.requested" = "You already have a request open with this contact."; -"scan.error.friends" +"scan.error.alreadyFriends" = "You've already added \n#%@#"; "scan.error.pending" = "This user is already pending in your contact list"; @@ -235,9 +258,9 @@ // ContactFeature - Delete "contact.delete.info.title" -= "Delete Connection"; += "Delete and block connection"; "contact.delete.drawer.title" -= "Delete Connection?"; += "Delete and block connection?"; "contact.delete.drawer.description" = "This is a silent deletion, %@ will not know you deleted them. This action will remove all information on your phone about this user, including your communications. You #cannot undo this step, and cannot re-add them unless they delete you as a connection as well.#"; @@ -607,6 +630,10 @@ = "Enable crash reporting"; "settings.advanced.crashes.description" = "Automatically sends anonymous reports containing crash data"; +"settings.advanced.reporting.title" += "Enable user reporting feature"; +"settings.advanced.reporting.description" += "Allows you to report users sending innapropriate content"; "settings.advanced.accountBackup.title" = "Account Backup"; @@ -629,6 +656,19 @@ "backup.config.infrastructure" = "Backup over"; +"backup.passphrase.title" += "Secure your backup"; +"backup.passphrase.subtitle" += "Please select a password for your backup. If you lose this password, you will not be able to restore your account. Make sure to keep a record somewhere safe. Your password needs to be at least 8 characters with at least 1 uppercase, 1 lowercase and 1 number characters"; +"backup.passphrase.input.title" += "Passphrase"; +"backup.passphrase.input.placeholder" += "* * * * * *"; +"backup.passphrase.continue" += "Set password and continue"; +"backup.passphrase.cancel" += "Cancel"; + "backup.iCloud" = "iCloud"; "backup.dropbox" @@ -638,6 +678,17 @@ "backup.SFTP" = "SFTP"; +// Terms & Conditions + +"terms.title" += "Terms #&# Conditions"; +"terms.radio" += "By enabling the checkbox on the left, you agree with the terms and conditions."; +"terms.accept" += "Accept and proceed"; +"terms.show" += "Show terms and conditions"; + // Settings - Delete Account "settings.delete.title" @@ -849,7 +900,7 @@ "accountRestore.sftp.title" = "Login to your SFTP"; "accountRestore.sftp.subtitle" -= "Login to your server. Your credentials will be automatically and securely saved locally on your device."; += "Login to your server. Your credentials will be automatically and securely saved locally on your device. Your backups are encrypted and protected by your backup password which is stored separately from these credentials.\n\n*Please Note: at this time, host key fingerprint checking is not implemented. If the server's fingerprint changes you will not be notified.*"; "accountRestore.sftp.host" = "Host"; "accountRestore.sftp.username" @@ -995,9 +1046,9 @@ = "Cancel search"; "ud.search.placeholder.title" -= "Search for #friends# anonymously, add them to your #connections# to start a completely private messaging channel."; += "Search for #friends# privately, add them to your #connections# to start a completely private messaging channel."; "ud.search.placeholder.subtitle" -= "Your searches are anonymous. Search information is never linked to your account or personally identifiable."; += "Your searches are private. Search information is never linked to your account or personally identifiable."; // LaunchFeature diff --git a/Sources/Shared/Views/CapsuleButton.swift b/Sources/Shared/Views/CapsuleButton.swift index 96ef2e0963ec5ee20cc2b470f9a58c194847f936..4d681dc36b0f5c8ca9d214ac2ce026c940eadf01 100644 --- a/Sources/Shared/Views/CapsuleButton.swift +++ b/Sources/Shared/Views/CapsuleButton.swift @@ -30,7 +30,7 @@ public extension CapsuleButtonStyle { borderWidth: 0, borderColor: nil, titleColor: Asset.brandPrimary.color, - disabledTitleColor: Asset.neutralWhite.color + disabledTitleColor: Asset.neutralWhite.color.withAlphaComponent(0.5) ) static let brandColored = CapsuleButtonStyle( diff --git a/Sources/Shared/Views/SearchComponent.swift b/Sources/Shared/Views/SearchComponent.swift index 9608aad71aaa56a5da852516405d069837a703c5..b3d530991d687604af21ea311eca69c458566f32 100644 --- a/Sources/Shared/Views/SearchComponent.swift +++ b/Sources/Shared/Views/SearchComponent.swift @@ -115,6 +115,10 @@ public final class SearchComponent: UIView { } } + public func update(content: String) { + inputField.text = content + } + public func update(placeholder: String) { inputField.attributedPlaceholder = NSAttributedString( string: placeholder, diff --git a/Sources/TermsFeature/RadioButton.swift b/Sources/TermsFeature/RadioButton.swift new file mode 100644 index 0000000000000000000000000000000000000000..201aa3b9c19b2325a06168c4e9abfba4cecf0cd5 --- /dev/null +++ b/Sources/TermsFeature/RadioButton.swift @@ -0,0 +1,53 @@ +import UIKit +import Shared + +final class RadioButton: UIControl { + private let filledView = UIView() + private let containerView = UIView() + + init() { + super.init(frame: .zero) + + containerView.layer.borderWidth = 1 + containerView.layer.cornerRadius = 15 + containerView.layer.masksToBounds = true + containerView.layer.borderColor = Asset.neutralWhite.color.cgColor + + filledView.isHidden = true + filledView.layer.cornerRadius = 10 + filledView.layer.masksToBounds = true + filledView.backgroundColor = Asset.neutralWhite.color + + containerView.isUserInteractionEnabled = false + filledView.isUserInteractionEnabled = false + + addSubview(containerView) + containerView.addSubview(filledView) + + setupConstraints() + } + + required init?(coder: NSCoder) { nil } + + func set(enabled: Bool) { + filledView.isHidden = !enabled + } + + private func setupConstraints() { + containerView.snp.makeConstraints { + $0.width.equalTo(30) + $0.height.equalTo(30) + $0.top.equalToSuperview().offset(5) + $0.left.equalToSuperview().offset(5) + $0.right.equalToSuperview().offset(-5) + $0.bottom.equalToSuperview().offset(-5) + } + + filledView.snp.makeConstraints { + $0.top.equalToSuperview().offset(5) + $0.left.equalToSuperview().offset(5) + $0.right.equalToSuperview().offset(-5) + $0.bottom.equalToSuperview().offset(-5) + } + } +} diff --git a/Sources/TermsFeature/RadioTextComponent.swift b/Sources/TermsFeature/RadioTextComponent.swift new file mode 100644 index 0000000000000000000000000000000000000000..8f6509f21562ebc8d3a0941cff1a5db53b997908 --- /dev/null +++ b/Sources/TermsFeature/RadioTextComponent.swift @@ -0,0 +1,40 @@ +import UIKit +import Shared + +final class RadioTextComponent: UIView { + let titleLabel = UILabel() + let radioButton = RadioButton() + + var isEnabled: Bool = false { + didSet { radioButton.set(enabled: isEnabled) } + } + + init() { + super.init(frame: .zero) + + titleLabel.numberOfLines = 0 + titleLabel.textColor = Asset.neutralWhite.color + titleLabel.font = Fonts.Mulish.regular.font(size: 13.0) + + addSubview(titleLabel) + addSubview(radioButton) + + setupConstraints() + } + + required init?(coder: NSCoder) { nil } + + private func setupConstraints() { + titleLabel.snp.makeConstraints { + $0.left.equalTo(radioButton.snp.right).offset(7) + $0.centerY.equalTo(radioButton) + $0.right.equalToSuperview() + } + + radioButton.snp.makeConstraints { + $0.left.equalToSuperview() + $0.top.greaterThanOrEqualToSuperview() + $0.bottom.equalToSuperview() + } + } +} diff --git a/Sources/TermsFeature/TermsConditionsController.swift b/Sources/TermsFeature/TermsConditionsController.swift new file mode 100644 index 0000000000000000000000000000000000000000..b11ef15215945e388c8043ed98e3bc6b5001e142 --- /dev/null +++ b/Sources/TermsFeature/TermsConditionsController.swift @@ -0,0 +1,93 @@ +import UIKit +import Theme +import WebKit +import Shared +import Combine +import Defaults +import DependencyInjection + +public final class TermsConditionsController: UIViewController { + @Dependency var coordinator: TermsCoordinator + @Dependency var statusBarController: StatusBarStyleControlling + + @KeyObject(.acceptedTerms, defaultValue: false) var didAcceptTerms: Bool + + lazy private var screenView = TermsConditionsView() + + private let ndf: String? + private var cancellables = Set<AnyCancellable>() + + public init(_ ndf: String?) { + self.ndf = ndf + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { nil } + + public override func loadView() { + view = screenView + } + + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationItem.backButtonTitle = "" + navigationController?.navigationBar.customize( + translucent: true, + tint: Asset.neutralWhite.color + ) + } + + public override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + let gradient = CAGradientLayer() + gradient.colors = [ + UIColor(red: 122/255, green: 235/255, blue: 239/255, alpha: 1).cgColor, + UIColor(red: 56/255, green: 204/255, blue: 232/255, alpha: 1).cgColor, + UIColor(red: 63/255, green: 186/255, blue: 253/255, alpha: 1).cgColor, + UIColor(red: 98/255, green: 163/255, blue: 255/255, alpha: 1).cgColor + ] + + gradient.startPoint = CGPoint(x: 0, y: 0) + gradient.endPoint = CGPoint(x: 1, y: 1) + + gradient.frame = screenView.bounds + screenView.layer.insertSublayer(gradient, at: 0) + } + + public override func viewDidLoad() { + super.viewDidLoad() + + screenView.radioComponent + .radioButton + .publisher(for: .touchUpInside) + .sink { [unowned self] in + screenView.radioComponent.isEnabled.toggle() + screenView.nextButton.isEnabled = screenView.radioComponent.isEnabled + UIImpactFeedbackGenerator(style: .heavy).impactOccurred() + }.store(in: &cancellables) + + screenView.nextButton + .publisher(for: .touchUpInside) + .sink { [unowned self] in + didAcceptTerms = true + + if let ndf = ndf { + coordinator.presentUsername(ndf, self) + } else { + coordinator.presentChatList(self) + } + }.store(in: &cancellables) + + screenView.showTermsButton + .publisher(for: .touchUpInside) + .sink { [unowned self] _ in + let webView = WKWebView() + let webController = UIViewController() + webController.view.addSubview(webView) + webView.snp.makeConstraints { $0.edges.equalToSuperview() } + webView.load(URLRequest(url: URL(string: "https://elixxir.io/eula")!)) + present(webController, animated: true) + }.store(in: &cancellables) + } +} diff --git a/Sources/TermsFeature/TermsConditionsView.swift b/Sources/TermsFeature/TermsConditionsView.swift new file mode 100644 index 0000000000000000000000000000000000000000..2f3ff8fa327956951be5299f32b6537d7ad4824d --- /dev/null +++ b/Sources/TermsFeature/TermsConditionsView.swift @@ -0,0 +1,56 @@ +import UIKit +import Shared + +final class TermsConditionsView: UIView { + let nextButton = CapsuleButton() + let logoImageView = UIImageView() + let showTermsButton = CapsuleButton() + let radioComponent = RadioTextComponent() + + init() { + super.init(frame: .zero) + backgroundColor = Asset.neutralWhite.color + + logoImageView.contentMode = .center + logoImageView.image = Asset.onboardingLogoStart.image + radioComponent.titleLabel.text = Localized.Terms.radio + + nextButton.isEnabled = false + nextButton.set(style: .white, title: Localized.Terms.accept) + showTermsButton.set(style: .seeThroughWhite, title: Localized.Terms.show) + + addSubview(logoImageView) + addSubview(nextButton) + addSubview(radioComponent) + addSubview(showTermsButton) + + setupConstraints() + } + + required init?(coder: NSCoder) { nil } + + private func setupConstraints() { + logoImageView.snp.makeConstraints { + $0.top.equalTo(safeAreaLayoutGuide).offset(30) + $0.centerX.equalToSuperview() + } + + radioComponent.snp.makeConstraints { + $0.left.equalToSuperview().offset(40) + $0.right.equalToSuperview().offset(-40) + $0.bottom.equalTo(nextButton.snp.top).offset(-20) + } + + nextButton.snp.makeConstraints { + $0.left.equalToSuperview().offset(40) + $0.right.equalToSuperview().offset(-40) + $0.bottom.equalTo(showTermsButton.snp.top).offset(-10) + } + + showTermsButton.snp.makeConstraints { + $0.left.equalToSuperview().offset(40) + $0.right.equalToSuperview().offset(-40) + $0.bottom.equalTo(safeAreaLayoutGuide).offset(-40) + } + } +} diff --git a/Sources/TermsFeature/TermsCoordinator.swift b/Sources/TermsFeature/TermsCoordinator.swift new file mode 100644 index 0000000000000000000000000000000000000000..daff90e4b1b81b18e75e3a6e34557a3478e26f57 --- /dev/null +++ b/Sources/TermsFeature/TermsCoordinator.swift @@ -0,0 +1,25 @@ +import UIKit +import Presentation + +public struct TermsCoordinator { + var presentChatList: (UIViewController) -> Void + var presentUsername: (String, UIViewController) -> Void +} + +public extension TermsCoordinator { + static func live( + usernameFactory: @escaping (String) -> UIViewController, + chatListFactory: @escaping () -> UIViewController + ) -> Self { + .init( + presentChatList: { parent in + let presenter = ReplacePresenter() + presenter.present(chatListFactory(), from: parent) + }, + presentUsername: { ndf, parent in + let presenter = PushPresenter() + presenter.present(usernameFactory(ndf), from: parent) + } + ) + } +} diff --git a/Tests/AppTests/General/InvitationTests.swift b/Tests/AppTests/General/InvitationTests.swift new file mode 100644 index 0000000000000000000000000000000000000000..220f92a8d570778df333349044f85d55b696ce3a --- /dev/null +++ b/Tests/AppTests/General/InvitationTests.swift @@ -0,0 +1,35 @@ +import XCTest + +@testable import App + +final class AppDelegateTests: XCTestCase { + func test_invitationUniversalLink() { + XCTAssertNil(getUsernameFromInvitationDeepLink( + URL(string: "https://elixxir.io/connecting?username=some")! + )) + + XCTAssertNil(getUsernameFromInvitationDeepLink( + URL(string: "http://elixxir.io/connect?username=some")! + )) + + XCTAssertNil(getUsernameFromInvitationDeepLink( + URL(string: "https://io.elixxir/connect?username=some")! + )) + + XCTAssertEqual(getUsernameFromInvitationDeepLink( + URL(string: "https://elixxir.io/connect?username=brad")! + ), "brad") + + XCTAssertNil(getUsernameFromInvitationDeepLink( + URL(string: "https://elixxir.io/connect?password=value")! + )) + + XCTAssertNil(getUsernameFromInvitationDeepLink( + URL(string: "https://elixxir.io/connect?usernamer=some")! + )) + + XCTAssertNotEqual(getUsernameFromInvitationDeepLink( + URL(string: "https://elixxir.io/connect?username=anderson")! + ), "silva") + } +} diff --git a/client-ios.xcworkspace/xcshareddata/swiftpm/Package.resolved b/client-ios.xcworkspace/xcshareddata/swiftpm/Package.resolved index 2932261c8cc3c80fb2730138af5c8ff904e9334f..2edfffa23ebd11df1784b06aa0e734df8c56269a 100644 --- a/client-ios.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/client-ios.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -59,8 +59,8 @@ "kind" : "remoteSourceControl", "location" : "https://git.xx.network/elixxir/client-ios-db.git", "state" : { - "revision" : "785e1f653ee5eaaaf58a82c8abbcda2174fbc27a", - "version" : "1.0.8" + "revision" : "f8e3e0088de8301d6c4816e12f0aca1d6f02a280", + "version" : "1.1.0" } }, { @@ -350,6 +350,15 @@ "version" : "1.18.0" } }, + { + "identity" : "swiftcsv", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftcsv/SwiftCSV.git", + "state" : { + "revision" : "048a1d3c2950b9c151ef9364b36f91baadc2c28c", + "version" : "0.8.0" + } + }, { "identity" : "swiftybeaver", "kind" : "remoteSourceControl",