From 299e456723f855ef85e1a0a4022625f0499bcfc6 Mon Sep 17 00:00:00 2001 From: Bryan Montz Date: Wed, 13 Nov 2024 07:22:41 -0600 Subject: [PATCH 1/5] add sensitive content analysis --- Nos.xcodeproj/project.pbxproj | 38 +++-- .../xcshareddata/swiftpm/Package.resolved | 24 +-- Nos/Assets/Localization/Localizable.xcstrings | 33 +++++ .../SensitiveContentController.swift | 138 ++++++++++++++++++ Nos/Nos.entitlements | 4 + Nos/NosApp.swift | 2 + Nos/NosDev.entitlements | 4 + Nos/NosStaging.entitlements | 4 + Nos/Service/DependencyInjection.swift | 9 ++ Nos/Service/FeatureFlags.swift | 7 +- Nos/Views/Components/Media/GalleryView.swift | 15 ++ .../Media/SensitiveContentOverlayView.swift | 118 +++++++++++++++ Nos/Views/Note/CompactNoteView.swift | 2 +- .../NoteComposer/ImagePickerButton.swift | 47 +++++- .../ImagePickerUIViewController.swift | 41 ++++-- .../Settings/SensitiveImageSettingView.swift | 79 ++++++++++ Nos/Views/Settings/SettingsView.swift | 6 + 17 files changed, 527 insertions(+), 44 deletions(-) create mode 100644 Nos/Controller/SensitiveContentController.swift create mode 100644 Nos/Views/Components/Media/SensitiveContentOverlayView.swift create mode 100644 Nos/Views/Settings/SensitiveImageSettingView.swift diff --git a/Nos.xcodeproj/project.pbxproj b/Nos.xcodeproj/project.pbxproj index 1d727756b..e009af5e0 100644 --- a/Nos.xcodeproj/project.pbxproj +++ b/Nos.xcodeproj/project.pbxproj @@ -191,6 +191,8 @@ 504454722C90729100251A7E /* Event+Hydration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5044546F2C90728500251A7E /* Event+Hydration.swift */; }; 5045540D2C81E10C0044ECAE /* EditableAvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5045540C2C81E10C0044ECAE /* EditableAvatarView.swift */; }; 506102882CC3D29B003DC0E3 /* TextDebouncer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 506102872CC3D29B003DC0E3 /* TextDebouncer.swift */; }; + 50782E8F2CE38F7E005BB513 /* SensitiveContentOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50782E8E2CE38F7A005BB513 /* SensitiveContentOverlayView.swift */; }; + 50782E902CE38F7F005BB513 /* SensitiveContentOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50782E8E2CE38F7A005BB513 /* SensitiveContentOverlayView.swift */; }; 508133CB2C79F78500DFBF75 /* AttributedString+Quotation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 508133CA2C79F78500DFBF75 /* AttributedString+Quotation.swift */; }; 508133DB2C7A003600DFBF75 /* AttributedString+QuotationsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 508133DA2C7A003600DFBF75 /* AttributedString+QuotationsTests.swift */; }; 508133DC2C7A007700DFBF75 /* AttributedString+Quotation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 508133CA2C79F78500DFBF75 /* AttributedString+Quotation.swift */; }; @@ -200,9 +202,11 @@ 509533002C62535400E0BACA /* zap_request.json in Resources */ = {isa = PBXBuildFile; fileRef = 509532FF2C62535400E0BACA /* zap_request.json */; }; 5095330B2C625B5D00E0BACA /* zap_request_one_sat.json in Resources */ = {isa = PBXBuildFile; fileRef = 509533092C625B5D00E0BACA /* zap_request_one_sat.json */; }; 5095330C2C625B5D00E0BACA /* zap_request_no_amount.json in Resources */ = {isa = PBXBuildFile; fileRef = 5095330A2C625B5D00E0BACA /* zap_request_no_amount.json */; }; + 50C58CE02CE23FF8009A938A /* SensitiveImageSettingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50C58CDF2CE23FF1009A938A /* SensitiveImageSettingView.swift */; }; 50DE6B1B2C6B88FE0065665D /* View+StyledBorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50DE6B1A2C6B88FE0065665D /* View+StyledBorder.swift */; }; 50E2EB722C86175900D4B360 /* NSRegularExpression+Replacement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E2EB712C86175900D4B360 /* NSRegularExpression+Replacement.swift */; }; 50E2EB7B2C8617C800D4B360 /* NSRegularExpression+Replacement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E2EB712C86175900D4B360 /* NSRegularExpression+Replacement.swift */; }; + 50EDC3B42CCBC86E00166B24 /* SensitiveContentController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50EDC3B32CCBC86E00166B24 /* SensitiveContentController.swift */; }; 50F695072C6392C4000E4C74 /* zap_receipt.json in Resources */ = {isa = PBXBuildFile; fileRef = 50F695062C6392C4000E4C74 /* zap_receipt.json */; }; 5B098DBC2BDAF6CB00500A1B /* NoteParserTests+NIP08.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B098DBB2BDAF6CB00500A1B /* NoteParserTests+NIP08.swift */; }; 5B098DC62BDAF73500500A1B /* AttributedString+Links.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B098DC52BDAF73500500A1B /* AttributedString+Links.swift */; }; @@ -738,6 +742,7 @@ 5044546F2C90728500251A7E /* Event+Hydration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Event+Hydration.swift"; sourceTree = ""; }; 5045540C2C81E10C0044ECAE /* EditableAvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditableAvatarView.swift; sourceTree = ""; }; 506102872CC3D29B003DC0E3 /* TextDebouncer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextDebouncer.swift; sourceTree = ""; }; + 50782E8E2CE38F7A005BB513 /* SensitiveContentOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SensitiveContentOverlayView.swift; sourceTree = ""; }; 508133CA2C79F78500DFBF75 /* AttributedString+Quotation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttributedString+Quotation.swift"; sourceTree = ""; }; 508133DA2C7A003600DFBF75 /* AttributedString+QuotationsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttributedString+QuotationsTests.swift"; sourceTree = ""; }; 508B2B602C9EF65300C14034 /* NSPersistentContainer+Nos.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSPersistentContainer+Nos.swift"; sourceTree = ""; }; @@ -745,8 +750,10 @@ 509532FF2C62535400E0BACA /* zap_request.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = zap_request.json; sourceTree = ""; }; 509533092C625B5D00E0BACA /* zap_request_one_sat.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = zap_request_one_sat.json; sourceTree = ""; }; 5095330A2C625B5D00E0BACA /* zap_request_no_amount.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = zap_request_no_amount.json; sourceTree = ""; }; + 50C58CDF2CE23FF1009A938A /* SensitiveImageSettingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SensitiveImageSettingView.swift; sourceTree = ""; }; 50DE6B1A2C6B88FE0065665D /* View+StyledBorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+StyledBorder.swift"; sourceTree = ""; }; 50E2EB712C86175900D4B360 /* NSRegularExpression+Replacement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSRegularExpression+Replacement.swift"; sourceTree = ""; }; + 50EDC3B32CCBC86E00166B24 /* SensitiveContentController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SensitiveContentController.swift; sourceTree = ""; }; 50F695062C6392C4000E4C74 /* zap_receipt.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = zap_receipt.json; sourceTree = ""; }; 5B098DBB2BDAF6CB00500A1B /* NoteParserTests+NIP08.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NoteParserTests+NIP08.swift"; sourceTree = ""; }; 5B098DC52BDAF73500500A1B /* AttributedString+Links.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttributedString+Links.swift"; sourceTree = ""; }; @@ -1287,6 +1294,7 @@ C9F84C22298DC7B900C6714D /* SettingsView.swift */, 5BFF66B32A58853D00AA79DD /* PublishedEventsView.swift */, 04F16AA62CBDBD91003AD693 /* DeleteConfirmationView.swift */, + 50C58CDF2CE23FF1009A938A /* SensitiveImageSettingView.swift */, ); path = Settings; sourceTree = ""; @@ -1459,6 +1467,7 @@ 03C8B4952C6D065900A07CCD /* ImageViewer.swift */, 03E181462C754BA300886CC6 /* LinkView.swift */, C905B0762A619E99009B8A78 /* LPLinkViewRepresentable.swift */, + 50782E8E2CE38F7A005BB513 /* SensitiveContentOverlayView.swift */, C92DF80729C25FA900400561 /* SquareImage.swift */, 038863DD2C6FF51500B09797 /* ZoomableContainer.swift */, ); @@ -1976,6 +1985,7 @@ C97A1C8A29E45B4E009D9E8D /* RawEventController.swift */, 030036842C5D39DD002C71F5 /* RefreshController.swift */, C9C547502A4F1CC3006B0741 /* SearchController.swift */, + 50EDC3B32CCBC86E00166B24 /* SensitiveContentController.swift */, ); path = Controller; sourceTree = ""; @@ -2164,7 +2174,7 @@ C9B737702AB24D5F00398BE7 /* XCRemoteSwiftPackageReference "SwiftGenPlugin" */, C91565BF2B2368FA0068EECA /* XCRemoteSwiftPackageReference "ViewInspector" */, 3AD3185B2B294E6200026B07 /* XCRemoteSwiftPackageReference "xcstrings-tool-plugin" */, - C9FD34F42BCEC89C008F8D95 /* XCRemoteSwiftPackageReference "secp256k1.swift" */, + C9FD34F42BCEC89C008F8D95 /* XCRemoteSwiftPackageReference "secp256k1" */, C9FD35112BCED5A6008F8D95 /* XCRemoteSwiftPackageReference "nostr-sdk-ios" */, 03C49ABE2C938A9C00502321 /* XCRemoteSwiftPackageReference "SwiftSoup" */, 039389212CA4985C00698978 /* XCRemoteSwiftPackageReference "SDWebImageWebPCoder" */, @@ -2368,6 +2378,7 @@ 5B79F60B2B98ACA0002DA9BE /* PickYourUsernameSheet.swift in Sources */, 5BFF66B62A58A8A000AA79DD /* MutesView.swift in Sources */, C913DA0A2AEAF52B003BDD6D /* NoteWarningController.swift in Sources */, + 50782E8F2CE38F7E005BB513 /* SensitiveContentOverlayView.swift in Sources */, 3F30020929C23895003D4F8B /* OnboardingNotOldEnoughView.swift in Sources */, 039F09592CC051FF00FEEC81 /* CreateAccountView.swift in Sources */, 042406F32C907A15008F2A21 /* NosToggle.swift in Sources */, @@ -2520,6 +2531,7 @@ C90352BA2C1235CD000A5993 /* NosNavigationDestination.swift in Sources */, 03D1B4282C3C1A5D001778CD /* NostrIdentifier.swift in Sources */, 04C9D7992CC29EDD00EAAD4D /* FeaturedAuthor+Cohort5.swift in Sources */, + 50EDC3B42CCBC86E00166B24 /* SensitiveContentController.swift in Sources */, DC5F203F2A6AE24200F8D73F /* ImagePickerButton.swift in Sources */, 5B79F5EB2B97B5E9002DA9BE /* ConfirmUsernameDeletionSheet.swift in Sources */, C9032C2E2BAE31ED001F4EC6 /* ProfileFeedType.swift in Sources */, @@ -2562,6 +2574,7 @@ C9E37E152A1E8143003D4B0A /* ReportTarget.swift in Sources */, C9DEBFD4298941000078B43A /* PersistenceController.swift in Sources */, 5B834F692A83FC7F000C1432 /* ProfileSocialStatsView.swift in Sources */, + 50C58CE02CE23FF8009A938A /* SensitiveImageSettingView.swift in Sources */, 045EDD052CAC025700B67964 /* ScrollViewProxy+Animate.swift in Sources */, CD09A74629A50F750063464F /* SideMenuContent.swift in Sources */, 030E56F32CC2836D00A4A51E /* CopyKeyView.swift in Sources */, @@ -2704,6 +2717,7 @@ C99721CC2AEBED26004EBEAB /* String+Empty.swift in Sources */, C93CA0C429AE3A1E00921183 /* JSONEvent.swift in Sources */, C9DEC04629894BED0078B43A /* Event+CoreDataClass.swift in Sources */, + 50782E902CE38F7F005BB513 /* SensitiveContentOverlayView.swift in Sources */, C92E7F6B2C4EFF7200B80638 /* WebSocketConnection.swift in Sources */, 035729A02BE41653005FEE85 /* SocialGraphCacheTests.swift in Sources */, 508B2B622C9EF65300C14034 /* NSPersistentContainer+Nos.swift in Sources */, @@ -2799,11 +2813,11 @@ /* Begin PBXTargetDependency section */ 3AD3185D2B294E9000026B07 /* PBXTargetDependency */ = { isa = PBXTargetDependency; - productRef = 3AD3185C2B294E9000026B07 /* plugin:XCStringsToolPlugin */; + productRef = 3AD3185C2B294E9000026B07 /* XCStringsToolPlugin */; }; 3AEABEF32B2BF806001BC933 /* PBXTargetDependency */ = { isa = PBXTargetDependency; - productRef = 3AEABEF22B2BF806001BC933 /* plugin:XCStringsToolPlugin */; + productRef = 3AEABEF22B2BF806001BC933 /* XCStringsToolPlugin */; }; C90862C229E9804B00C35A71 /* PBXTargetDependency */ = { isa = PBXTargetDependency; @@ -2812,11 +2826,11 @@ }; C9A6C7442AD83F7A001F9500 /* PBXTargetDependency */ = { isa = PBXTargetDependency; - productRef = C9A6C7432AD83F7A001F9500 /* plugin:SwiftGenPlugin */; + productRef = C9A6C7432AD83F7A001F9500 /* SwiftGenPlugin */; }; C9D573402AB24A3700E06BB4 /* PBXTargetDependency */ = { isa = PBXTargetDependency; - productRef = C9D5733F2AB24A3700E06BB4 /* plugin:SwiftGenPlugin */; + productRef = C9D5733F2AB24A3700E06BB4 /* SwiftGenPlugin */; }; C9DEBFE6298941020078B43A /* PBXTargetDependency */ = { isa = PBXTargetDependency; @@ -3673,7 +3687,7 @@ minimumVersion = 4.0.0; }; }; - C9FD34F42BCEC89C008F8D95 /* XCRemoteSwiftPackageReference "secp256k1.swift" */ = { + C9FD34F42BCEC89C008F8D95 /* XCRemoteSwiftPackageReference "secp256k1" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/GigaBitcoin/secp256k1.swift"; requirement = { @@ -3712,12 +3726,12 @@ package = 03C49ABE2C938A9C00502321 /* XCRemoteSwiftPackageReference "SwiftSoup" */; productName = SwiftSoup; }; - 3AD3185C2B294E9000026B07 /* plugin:XCStringsToolPlugin */ = { + 3AD3185C2B294E9000026B07 /* XCStringsToolPlugin */ = { isa = XCSwiftPackageProductDependency; package = 3AD3185B2B294E6200026B07 /* XCRemoteSwiftPackageReference "xcstrings-tool-plugin" */; productName = "plugin:XCStringsToolPlugin"; }; - 3AEABEF22B2BF806001BC933 /* plugin:XCStringsToolPlugin */ = { + 3AEABEF22B2BF806001BC933 /* XCStringsToolPlugin */ = { isa = XCSwiftPackageProductDependency; package = 3AD3185B2B294E6200026B07 /* XCRemoteSwiftPackageReference "xcstrings-tool-plugin" */; productName = "plugin:XCStringsToolPlugin"; @@ -3791,7 +3805,7 @@ package = C99DBF7C2A9E81CF00F7068F /* XCRemoteSwiftPackageReference "SDWebImageSwiftUI" */; productName = SDWebImageSwiftUI; }; - C9A6C7432AD83F7A001F9500 /* plugin:SwiftGenPlugin */ = { + C9A6C7432AD83F7A001F9500 /* SwiftGenPlugin */ = { isa = XCSwiftPackageProductDependency; package = C9B737702AB24D5F00398BE7 /* XCRemoteSwiftPackageReference "SwiftGenPlugin" */; productName = "plugin:SwiftGenPlugin"; @@ -3811,7 +3825,7 @@ package = C9B71DBC2A8E9BAD0031ED9F /* XCRemoteSwiftPackageReference "sentry-cocoa" */; productName = Sentry; }; - C9D5733F2AB24A3700E06BB4 /* plugin:SwiftGenPlugin */ = { + C9D5733F2AB24A3700E06BB4 /* SwiftGenPlugin */ = { isa = XCSwiftPackageProductDependency; package = C9C8450C2AB249DB00654BC1 /* XCRemoteSwiftPackageReference "SwiftGenPlugin" */; productName = "plugin:SwiftGenPlugin"; @@ -3823,12 +3837,12 @@ }; C9FD34F52BCEC89C008F8D95 /* secp256k1 */ = { isa = XCSwiftPackageProductDependency; - package = C9FD34F42BCEC89C008F8D95 /* XCRemoteSwiftPackageReference "secp256k1.swift" */; + package = C9FD34F42BCEC89C008F8D95 /* XCRemoteSwiftPackageReference "secp256k1" */; productName = secp256k1; }; C9FD34F72BCEC8B5008F8D95 /* secp256k1 */ = { isa = XCSwiftPackageProductDependency; - package = C9FD34F42BCEC89C008F8D95 /* XCRemoteSwiftPackageReference "secp256k1.swift" */; + package = C9FD34F42BCEC89C008F8D95 /* XCRemoteSwiftPackageReference "secp256k1" */; productName = secp256k1; }; C9FD35122BCED5A6008F8D95 /* NostrSDK */ = { diff --git a/Nos.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Nos.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 7590212f9..0e6879e80 100644 --- a/Nos.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Nos.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/krzyzanowskim/CryptoSwift.git", "state" : { - "revision" : "c9c3df6ab812de32bae61fc0cd1bf6d45170ebf0", - "version" : "1.8.2" + "revision" : "678d442c6f7828def400a70ae15968aef67ef52d", + "version" : "1.8.3" } }, { @@ -68,8 +68,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/PostHog/posthog-ios.git", "state" : { - "revision" : "08b53e64abd503ca3e5f0eebd6b89a32fbe33850", - "version" : "3.6.1" + "revision" : "4524c42d2b70a9e7ebffb913e7a2ab60ce00ecd1", + "version" : "3.13.3" } }, { @@ -77,8 +77,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/SDWebImage/SDWebImage.git", "state" : { - "revision" : "be0bcd7823ce56629948491f2eaeaa19979514f7", - "version" : "5.19.4" + "revision" : "8a1be70a625683bc04d6903e2935bf23f3c6d609", + "version" : "5.19.7" } }, { @@ -113,8 +113,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/getsentry/sentry-cocoa.git", "state" : { - "revision" : "8fd4e804f2e72e0b9c1b189ce4e8349c4d10b6a2", - "version" : "8.30.0" + "revision" : "54cc2e3e4fcbf9d4c7708ce00d3b6eee29aecbb1", + "version" : "8.38.0" } }, { @@ -149,8 +149,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "ee97538f5b81ae89698fd95938896dec5217b148", - "version" : "1.1.1" + "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", + "version" : "1.1.4" } }, { @@ -158,8 +158,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-concurrency-extras", "state" : { - "revision" : "479750bd98fac2e813fffcf2af0728b5b0085795", - "version" : "0.1.1" + "revision" : "a46265bb4f75808b0e15d971eebc408f557870a3", + "version" : "0.1.2" } }, { diff --git a/Nos/Assets/Localization/Localizable.xcstrings b/Nos/Assets/Localization/Localizable.xcstrings index 0d478adff..929f6410f 100644 --- a/Nos/Assets/Localization/Localizable.xcstrings +++ b/Nos/Assets/Localization/Localizable.xcstrings @@ -3361,6 +3361,17 @@ } } }, + "contentWarning" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Content Warning" + } + } + } + }, "contentWarningExplanation" : { "extractionState" : "manual", "localizations" : { @@ -16919,6 +16930,17 @@ } } }, + "sensitiveContentUploadWarning" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The media you've selected may contain nudity. Nos prefers to foster a healthy and safe online community. Please confirm that you’re comfortable with this content being posted online." + } + } + } + }, "settings" : { "extractionState" : "manual", "localizations" : { @@ -18918,6 +18940,17 @@ } } }, + "upload" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Upload" + } + } + } + }, "url" : { "extractionState" : "manual", "localizations" : { diff --git a/Nos/Controller/SensitiveContentController.swift b/Nos/Controller/SensitiveContentController.swift new file mode 100644 index 000000000..96e66a864 --- /dev/null +++ b/Nos/Controller/SensitiveContentController.swift @@ -0,0 +1,138 @@ +import Combine +import Dependencies +import Foundation +import SensitiveContentAnalysis + +protocol FileDownloading {} +extension FileDownloading { + + func file(byDownloadingFrom url: URL) async throws -> URL { + let temporaryDirectory = FileManager.default.temporaryDirectory + let fileURL = temporaryDirectory.appendingPathComponent(url.lastPathComponent) + let (data, _) = try await URLSession.shared.data(from: url) + try data.write(to: fileURL) + return fileURL + } +} + +extension SCSensitivityAnalysisPolicy { + var description: String { + switch self { + case .disabled: + "Sensitive Content Analysis is currently disabled. To enable, go to Settings app -> Privacy & Security -> \"Sensitive Content Warning\"." // swiftlint:disable:this line_length + default: + "Sensitive Content Analysis is currently enabled. To disable, go to Settings app -> Privacy & Security -> \"Sensitive Content Warning\"." // swiftlint:disable:this line_length + } + } +} + +actor SensitiveContentController: FileDownloading { + + @Dependency(\.featureFlags) private var featureFlags + + enum AnalysisState: Equatable { + case analyzing + case analyzed(Bool) // true == sensitive + case allowed // user has okay'ed this content already + + var shouldObfuscate: Bool { + switch self { + case .analyzing: true + case .analyzed(let isSensitive): + isSensitive + case .allowed: false + } + } + } + + static let shared = SensitiveContentController() + + private var cache = [String: AnalysisState]() + + private var publishers = [String: CurrentValueSubject]() + + private let analyzer = SCSensitivityAnalyzer() + + nonisolated var isSensitivityAnalysisEnabled: Bool { + analyzer.analysisPolicy != .disabled + } + + @discardableResult + func shouldObfuscateContent(atURL url: URL) async -> Bool { + assert(!url.isFileURL) + + guard isSensitivityAnalysisEnabled && url.isImage else { + return false + } + + if let analysisState = cache[url.absoluteString] { + return analysisState.shouldObfuscate + } + + do { + let tempFileURL = try await file(byDownloadingFrom: url) + + #if DEBUG + let shouldOverrideAnalyzer = featureFlags.isEnabled(.sensitiveContentIncoming) + if shouldOverrideAnalyzer { + try await Task.sleep(nanoseconds: 1 * 1_000_000_000) // simulate time to analyze + updateState(for: url, to: .analyzed(true)) + return true + } + #endif + + let result = try await analyzer.analyzeImage(at: tempFileURL) + updateState(for: url, to: .analyzed(result.isSensitive)) + return result.isSensitive + } catch { + return false + } + } + + func allowContent(at url: URL) { + updateState(for: url, to: .allowed) + } + + func shouldWarnUserUploadingFile(at fileURL: URL) async -> Bool { + guard isSensitivityAnalysisEnabled else { + return false + } + + #if DEBUG + let shouldOverrideAnalyzer = featureFlags.isEnabled(.sensitiveContentOutgoing) + if shouldOverrideAnalyzer { + try? await Task.sleep(nanoseconds: 250_000_000) // simulate time to analyze + updateState(for: fileURL, to: .analyzed(true)) + return true + } + #endif + + do { + let result = try await analyzer.analyzeImage(at: fileURL) + return result.isSensitive + } catch { + return false + } + } + + func publisher(for url: URL) -> AnyPublisher { + if let publisher = publishers[url.absoluteString] { + return publisher.eraseToAnyPublisher() + } else { + let publisher = CurrentValueSubject(.analyzing) + publishers[url.absoluteString] = publisher + cache[url.absoluteString] = .analyzing + return publisher.eraseToAnyPublisher() + } + } + + private func updateState(for url: URL, to newState: AnalysisState) { + cache[url.absoluteString] = newState + if let publisher = publishers[url.absoluteString] { + publisher.send(newState) + } else { + let publisher = CurrentValueSubject(newState) + publishers[url.absoluteString] = publisher + } + } +} diff --git a/Nos/Nos.entitlements b/Nos/Nos.entitlements index 96a45cf2e..293cce116 100644 --- a/Nos/Nos.entitlements +++ b/Nos/Nos.entitlements @@ -4,6 +4,10 @@ aps-environment development + com.apple.developer.sensitivecontentanalysis.client + + analysis + com.apple.security.app-sandbox com.apple.security.files.user-selected.read-only diff --git a/Nos/NosApp.swift b/Nos/NosApp.swift index 48ef27900..4ed586a19 100644 --- a/Nos/NosApp.swift +++ b/Nos/NosApp.swift @@ -15,6 +15,7 @@ struct NosApp: App { private let appController = AppController() @Environment(\.scenePhase) private var scenePhase @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate +// @State private var sensitiveContentController = SensitiveContentController() init() { _ = crashReporting // force crash reporting init as early as possible @@ -35,6 +36,7 @@ struct NosApp: App { .environment(appController) .environment(currentUser) .environment(pushNotificationService) +// .environment(sensitiveContentController) .onOpenURL { DeepLinkService.handle($0, router: router) } .onChange(of: scenePhase) { _, newPhase in switch newPhase { diff --git a/Nos/NosDev.entitlements b/Nos/NosDev.entitlements index 2c8b0a3f4..26c148eec 100644 --- a/Nos/NosDev.entitlements +++ b/Nos/NosDev.entitlements @@ -4,6 +4,10 @@ aps-environment development + com.apple.developer.sensitivecontentanalysis.client + + analysis + com.apple.security.app-sandbox com.apple.security.files.user-selected.read-only diff --git a/Nos/NosStaging.entitlements b/Nos/NosStaging.entitlements index 10057c975..e4ded7e2b 100644 --- a/Nos/NosStaging.entitlements +++ b/Nos/NosStaging.entitlements @@ -4,6 +4,10 @@ aps-environment development + com.apple.developer.sensitivecontentanalysis.client + + analysis + com.apple.security.app-sandbox com.apple.security.files.user-selected.read-only diff --git a/Nos/Service/DependencyInjection.swift b/Nos/Service/DependencyInjection.swift index 0d6c37979..4f8139941 100644 --- a/Nos/Service/DependencyInjection.swift +++ b/Nos/Service/DependencyInjection.swift @@ -94,6 +94,11 @@ extension DependencyValues { get { self[PreviewEventRepositoryKey.self] } set { self[PreviewEventRepositoryKey.self] = newValue } } + + var sensitiveContentController: SensitiveContentController { + get { self[SensitiveContentControllerKey.self] } + set { self[SensitiveContentControllerKey.self] = newValue } + } } fileprivate enum AnalyticsKey: DependencyKey { @@ -198,3 +203,7 @@ fileprivate enum OpenGraphServiceKey: DependencyKey { fileprivate enum PreviewEventRepositoryKey: DependencyKey { static let liveValue: any PreviewEventRepository = DefaultPreviewEventRepository() } + +fileprivate enum SensitiveContentControllerKey: DependencyKey { + static let liveValue = SensitiveContentController() +} diff --git a/Nos/Service/FeatureFlags.swift b/Nos/Service/FeatureFlags.swift index 65ce692f8..e040e19f1 100644 --- a/Nos/Service/FeatureFlags.swift +++ b/Nos/Service/FeatureFlags.swift @@ -8,6 +8,9 @@ enum FeatureFlag { /// - Note: See [Figma](https://www.figma.com/design/6MeujQUXzC1AuviHEHCs0J/Nos---In-Progress?node-id=9221-8504) /// for the new flow. case newOnboardingFlow + + case sensitiveContentOutgoing + case sensitiveContentIncoming } /// The set of feature flags used by the app. @@ -31,7 +34,9 @@ protocol FeatureFlags { /// Feature flags and their values. private var featureFlags: [FeatureFlag: Bool] = [ - .newOnboardingFlow: true + .newOnboardingFlow: true, + .sensitiveContentOutgoing: false, + .sensitiveContentIncoming: false ] /// Returns true if the feature is enabled. diff --git a/Nos/Views/Components/Media/GalleryView.swift b/Nos/Views/Components/Media/GalleryView.swift index 83e50e4b0..f37eb330e 100644 --- a/Nos/Views/Components/Media/GalleryView.swift +++ b/Nos/Views/Components/Media/GalleryView.swift @@ -9,6 +9,9 @@ struct GalleryView: View { /// Inline metadata describing the data in ``urls``. let metadata: InlineMetadataCollection? + + /// The ``Author`` that posted the content. + let author: Author? /// The currently-selected tab in the tab view. @State private var selectedTab = 0 @@ -19,6 +22,12 @@ struct GalleryView: View { /// The media service that loads content from URLs and determines the orientation for this gallery. @Dependency(\.mediaService) private var mediaService + init(urls: [URL], metadata: InlineMetadataCollection?, author: Author? = nil) { + self.urls = urls + self.metadata = metadata + self.author = author + } + /// The orientation determined by the `metadata`, if any. private var metadataOrientation: MediaOrientation? { metadata?[urls.first?.absoluteString]?.orientation @@ -50,6 +59,9 @@ struct GalleryView: View { LinkView(url: urls[index]) .tag(index) } + .overlay { + SensitiveContentOverlayView(url: urls[index], author: author) + } } } .tabViewStyle(.page(indexDisplayMode: .never)) @@ -81,6 +93,9 @@ struct GalleryView: View { AspectRatioContainer(orientation: orientation) { LinkView(url: url) } + .overlay { + SensitiveContentOverlayView(url: url, author: author) + } } /// A loading view that determines the orientation for the gallery. When possible, the aspect ratio of the diff --git a/Nos/Views/Components/Media/SensitiveContentOverlayView.swift b/Nos/Views/Components/Media/SensitiveContentOverlayView.swift new file mode 100644 index 000000000..9708fa9f3 --- /dev/null +++ b/Nos/Views/Components/Media/SensitiveContentOverlayView.swift @@ -0,0 +1,118 @@ +import Combine +import SwiftUI +import SwiftUINavigation +import UIKit + +struct VisualEffectView: UIViewRepresentable { + let effect: UIBlurEffect.Style + + func makeUIView(context: Context) -> UIVisualEffectView { + UIVisualEffectView(effect: UIBlurEffect(style: effect)) + } + + func updateUIView(_ uiView: UIVisualEffectView, context: Context) { + uiView.effect = UIBlurEffect(style: effect) + } +} + +struct SensitiveContentOverlayView: View { + /// The URL of the content to check for sensitivity. + let url: URL + let author: Author? + + @Environment(\.managedObjectContext) private var viewContext + + @State private var analysisState: SensitiveContentController.AnalysisState + @State private var cancellable: AnyCancellable? + @State private var showingReportMenu = false + + @State private var alert: AlertState? + + init(url: URL, author: Author?) { + self.url = url + self.author = author + + let shouldAnalyzeContent = url.isImage && SensitiveContentController.shared.isSensitivityAnalysisEnabled + analysisState = shouldAnalyzeContent ? .analyzing : .allowed + } + + var body: some View { + ZStack { + VisualEffectView(effect: .light) + + if analysisState == .analyzing { + ProgressView() + .scaleEffect(2) + } else { + Text("This content may be sensitive.") // TODO: Localize + + HStack { + Spacer() + VStack(alignment: .trailing) { + Menu { + Button("muteUser") { + Task { @MainActor in + do { + try await author?.mute(viewContext: viewContext) + } catch { + alert = AlertState(title: { + TextState(String(localized: "error")) + }, message: { + TextState(error.localizedDescription) + }) + } + } + } + + Button("flagUser", action: { showingReportMenu = true }) + } label: { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.white) + .padding(10) + .background(Circle().fill(Color.gray.opacity(0.5))) + .frame(width: 40, height: 40) + } + .buttonStyle(PlainButtonStyle()) + + Spacer() + + Button { + Task { + await SensitiveContentController.shared.allowContent(at: url) + } + } label: { + HStack { + Image(systemName: "eye") + Text("Show") + } + .padding([.top, .bottom], 12) + .padding([.leading, .trailing], 16) + } + .background(Color.gray.opacity(0.5)) + .clipShape(Capsule()) + } + .padding([.top, .bottom], 12) + .padding([.leading, .trailing], 12) + } + } + } + .opacity(analysisState.shouldObfuscate ? 1 : 0) + .animation(.easeInOut(duration: 0.2), value: analysisState) + .contentShape(Rectangle()) // Define the touchable area to capture taps + .onTapGesture {} // Prevent taps from passing through the overlay + .reportMenu($showingReportMenu, reportedObject: .author(author!)) + .task { + if url.isImage && SensitiveContentController.shared.isSensitivityAnalysisEnabled { + Task { // This is intentional! SwiftUI will sometimes cancel the .task of the view. + await SensitiveContentController.shared.shouldObfuscateContent(atURL: url) + + cancellable = await SensitiveContentController.shared.publisher(for: url) + .receive(on: DispatchQueue.main) + .sink { state in + analysisState = state + } + } + } + } + } +} diff --git a/Nos/Views/Note/CompactNoteView.swift b/Nos/Views/Note/CompactNoteView.swift index cd9ee06f8..ef93d6640 100644 --- a/Nos/Views/Note/CompactNoteView.swift +++ b/Nos/Views/Note/CompactNoteView.swift @@ -156,7 +156,7 @@ struct CompactNoteView: View { .allowsHitTesting(!note.isPreview) } if note.kind == EventKind.text.rawValue, showLinkPreviews, !note.contentLinks.isEmpty { - GalleryView(urls: note.contentLinks, metadata: note.inlineMetadata) + GalleryView(urls: note.contentLinks, metadata: note.inlineMetadata, author: note.author) } } .frame(maxWidth: .infinity, alignment: .leading) diff --git a/Nos/Views/NoteComposer/ImagePickerButton.swift b/Nos/Views/NoteComposer/ImagePickerButton.swift index f0c816bea..b52de0620 100644 --- a/Nos/Views/NoteComposer/ImagePickerButton.swift +++ b/Nos/Views/NoteComposer/ImagePickerButton.swift @@ -8,8 +8,11 @@ struct ImagePickerButton