From e8792304cf3d6b0123b594349920b4e4dbc93f4a Mon Sep 17 00:00:00 2001 From: Juan Manuel Pereira Date: Wed, 27 Nov 2024 17:37:32 -0300 Subject: [PATCH] Implement tab bar remote message --- DuckDuckGo.xcodeproj/project.pbxproj | 20 +++ .../dax-response.imageset/Contents.json | 12 ++ .../Response-DDG-Question-96x96.svg | 20 +++ .../Common/Extensions/URLExtension.swift | 4 + DuckDuckGo/HomePage/View/HomePageView.swift | 5 +- DuckDuckGo/Localizable.xcstrings | 2 +- .../MainWindow/MainViewController.swift | 2 +- .../ActiveRemoteMessageModel+NewTabPage.swift | 5 +- .../ActiveRemoteMessageModel.swift | 7 + .../RemoteMessagingClient.swift | 2 +- .../TabBarRemoteMessageView.swift | 163 ++++++++++++++++++ .../TabBarRemoteMessageViewModel.swift | 98 +++++++++++ .../TabBar/View/TabBarViewController.swift | 76 +++++++- 13 files changed, 408 insertions(+), 8 deletions(-) create mode 100644 DuckDuckGo/Assets.xcassets/Images/dax-response.imageset/Contents.json create mode 100644 DuckDuckGo/Assets.xcassets/Images/dax-response.imageset/Response-DDG-Question-96x96.svg create mode 100644 DuckDuckGo/TabBar/TabBarRemoteMessaging/TabBarRemoteMessageView.swift create mode 100644 DuckDuckGo/TabBar/TabBarRemoteMessaging/TabBarRemoteMessageViewModel.swift diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 78b0fc71b0..1daa63df7b 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -2853,6 +2853,8 @@ B6FA893F269C424500588ECD /* PrivacyDashboardViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6FA893E269C424500588ECD /* PrivacyDashboardViewController.swift */; }; B6FA8941269C425400588ECD /* PrivacyDashboardPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6FA8940269C425400588ECD /* PrivacyDashboardPopover.swift */; }; BB0346F52CEB80B400D23E05 /* DownloadsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB0346F42CEB80B400D23E05 /* DownloadsTests.swift */; }; + BB3229052D08644400DA92E9 /* TabBarRemoteMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB3229042D08643700DA92E9 /* TabBarRemoteMessageView.swift */; }; + BB3229062D08644400DA92E9 /* TabBarRemoteMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB3229042D08643700DA92E9 /* TabBarRemoteMessageView.swift */; }; BB4339DB2C7F9606005D7ED7 /* PinnedTabsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB4339DA2C7F9606005D7ED7 /* PinnedTabsTests.swift */; }; BB470EBB2C5A66D6002EE91D /* BookmarkManagementDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB470EBA2C5A66D6002EE91D /* BookmarkManagementDetailViewModel.swift */; }; BB470EBC2C5A66D6002EE91D /* BookmarkManagementDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB470EBA2C5A66D6002EE91D /* BookmarkManagementDetailViewModel.swift */; }; @@ -2861,6 +2863,8 @@ BB731F312CDBA6360023D2E4 /* FireWindowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB731F302CDBA6320023D2E4 /* FireWindowTests.swift */; }; BB7B5F982C4ED73800BA4AF8 /* BookmarksSearchAndSortMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB7B5F972C4ED73800BA4AF8 /* BookmarksSearchAndSortMetrics.swift */; }; BB7B5F992C4ED73800BA4AF8 /* BookmarksSearchAndSortMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB7B5F972C4ED73800BA4AF8 /* BookmarksSearchAndSortMetrics.swift */; }; + BB9BDD492D09BAA80069E9EF /* TabBarRemoteMessageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB9BDD482D09BA9D0069E9EF /* TabBarRemoteMessageViewModel.swift */; }; + BB9BDD4A2D09BAA80069E9EF /* TabBarRemoteMessageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB9BDD482D09BA9D0069E9EF /* TabBarRemoteMessageViewModel.swift */; }; BBB881882C4029BA001247C6 /* BookmarkListTreeControllerSearchDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB881872C4029BA001247C6 /* BookmarkListTreeControllerSearchDataSource.swift */; }; BBB881892C4029BA001247C6 /* BookmarkListTreeControllerSearchDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB881872C4029BA001247C6 /* BookmarkListTreeControllerSearchDataSource.swift */; }; BBBB65402C77BB9400E69AC6 /* BookmarkSearchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB653F2C77BB9400E69AC6 /* BookmarkSearchTests.swift */; }; @@ -4820,12 +4824,14 @@ B6FA893E269C424500588ECD /* PrivacyDashboardViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyDashboardViewController.swift; sourceTree = ""; }; B6FA8940269C425400588ECD /* PrivacyDashboardPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyDashboardPopover.swift; sourceTree = ""; }; BB0346F42CEB80B400D23E05 /* DownloadsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadsTests.swift; sourceTree = ""; }; + BB3229042D08643700DA92E9 /* TabBarRemoteMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarRemoteMessageView.swift; sourceTree = ""; }; BB4339DA2C7F9606005D7ED7 /* PinnedTabsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedTabsTests.swift; sourceTree = ""; }; BB470EBA2C5A66D6002EE91D /* BookmarkManagementDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkManagementDetailViewModel.swift; sourceTree = ""; }; BB5789712B2CA70F0009DFE2 /* DataBrokerProtectionSubscriptionEventHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataBrokerProtectionSubscriptionEventHandler.swift; sourceTree = ""; }; BB5F46A22C8751F6005F72DF /* BookmarkSortTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkSortTests.swift; sourceTree = ""; }; BB731F302CDBA6320023D2E4 /* FireWindowTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FireWindowTests.swift; sourceTree = ""; }; BB7B5F972C4ED73800BA4AF8 /* BookmarksSearchAndSortMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksSearchAndSortMetrics.swift; sourceTree = ""; }; + BB9BDD482D09BA9D0069E9EF /* TabBarRemoteMessageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarRemoteMessageViewModel.swift; sourceTree = ""; }; BBB881872C4029BA001247C6 /* BookmarkListTreeControllerSearchDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkListTreeControllerSearchDataSource.swift; sourceTree = ""; }; BBBB653F2C77BB9400E69AC6 /* BookmarkSearchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkSearchTests.swift; sourceTree = ""; }; BBBEE1BE2C4FF63600035ABA /* SortBookmarksViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SortBookmarksViewModelTests.swift; sourceTree = ""; }; @@ -8270,6 +8276,7 @@ AA86491124D8318F001BABEE /* TabBar */ = { isa = PBXGroup; children = ( + BB68B6542D0B1E9200CEC812 /* TabBarRemoteMessaging */, AA86491224D831A1001BABEE /* View */, AA8EDF1F2491FCC10071C2E8 /* ViewModel */, AA9FF95724A1ECE20039E328 /* Model */, @@ -9470,6 +9477,15 @@ path = View; sourceTree = ""; }; + BB68B6542D0B1E9200CEC812 /* TabBarRemoteMessaging */ = { + isa = PBXGroup; + children = ( + BB3229042D08643700DA92E9 /* TabBarRemoteMessageView.swift */, + BB9BDD482D09BA9D0069E9EF /* TabBarRemoteMessageViewModel.swift */, + ); + path = TabBarRemoteMessaging; + sourceTree = ""; + }; BD7090D42C540C0D009EED82 /* MetadataCollectors */ = { isa = PBXGroup; children = ( @@ -11637,6 +11653,7 @@ C1C405882C7F80E50089DE8A /* PromotionView+FreemiumDBP.swift in Sources */, B6E1491029A5C30500AAFBE8 /* ContentBlockingTabExtension.swift in Sources */, 3706FB82293F65D500E42796 /* PasswordManagementNoteItemView.swift in Sources */, + BB9BDD492D09BAA80069E9EF /* TabBarRemoteMessageViewModel.swift in Sources */, 4BF97AD82B43C5B300EB4240 /* NetworkProtectionAppEvents.swift in Sources */, 3706FEC5293F6F0600E42796 /* BWInstallationService.swift in Sources */, BDBA85972C5D256C00BC54F5 /* VPNFeedbackFormView.swift in Sources */, @@ -11925,6 +11942,7 @@ F1B33DF32BAD929D001128B3 /* SubscriptionAppStoreRestorer.swift in Sources */, 3706FC1F293F65D500E42796 /* BookmarksBarViewController.swift in Sources */, 1DDC85042B83903E00670238 /* PreferencesWebTrackingProtectionView.swift in Sources */, + BB3229062D08644400DA92E9 /* TabBarRemoteMessageView.swift in Sources */, 3706FC20293F65D500E42796 /* PreferencesAutofillView.swift in Sources */, CD2AB5C22C8222F50019EB49 /* MaliciousSiteProtectionPreferences.swift in Sources */, 3706FC21293F65D500E42796 /* UserText+PasswordManager.swift in Sources */, @@ -13104,6 +13122,7 @@ 37D0469F2C7D0EDD00AEAA50 /* CustomBackground.swift in Sources */, B684592225C93BE000DC17B6 /* Publisher.asVoid.swift in Sources */, 4B9DB01D2A983B24000927DB /* Waitlist.swift in Sources */, + BB9BDD4A2D09BAA80069E9EF /* TabBarRemoteMessageViewModel.swift in Sources */, AAA0CC33252F181A0079BC96 /* NavigationButtonMenuDelegate.swift in Sources */, 1DA84D2F2C11989D0011C80F /* Update.swift in Sources */, AAC30A2A268E239100D2D9CD /* CrashReport.swift in Sources */, @@ -13714,6 +13733,7 @@ 4B59024826B3673600489384 /* ThirdPartyBrowser.swift in Sources */, B60C6F7729B0E286007BFAA8 /* SearchNonexistentDomainNavigationResponder.swift in Sources */, B65E6B9E26D9EC0800095F96 /* CircularProgressView.swift in Sources */, + BB3229052D08644400DA92E9 /* TabBarRemoteMessageView.swift in Sources */, 56A053FC2C19E8F7007D8FAB /* OnboardingActionsManager.swift in Sources */, EEE50C292C38249C003DD7FF /* OptionalExtension.swift in Sources */, AABEE69C24A902BB0043105B /* SuggestionContainer.swift in Sources */, diff --git a/DuckDuckGo/Assets.xcassets/Images/dax-response.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/Images/dax-response.imageset/Contents.json new file mode 100644 index 0000000000..50d23b7933 --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/dax-response.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Response-DDG-Question-96x96.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DuckDuckGo/Assets.xcassets/Images/dax-response.imageset/Response-DDG-Question-96x96.svg b/DuckDuckGo/Assets.xcassets/Images/dax-response.imageset/Response-DDG-Question-96x96.svg new file mode 100644 index 0000000000..e3d009683d --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/dax-response.imageset/Response-DDG-Question-96x96.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/DuckDuckGo/Common/Extensions/URLExtension.swift b/DuckDuckGo/Common/Extensions/URLExtension.swift index a49d39f36d..d6f8cf11d6 100644 --- a/DuckDuckGo/Common/Extensions/URLExtension.swift +++ b/DuckDuckGo/Common/Extensions/URLExtension.swift @@ -371,6 +371,10 @@ extension URL { return URL(string: "https://duckduckgo.com/updates")! } + static var survey: URL { + return URL(string: "https://selfserve.decipherinc.com/survey/selfserve/32ab/241004?list=2")! + } + static var webTrackingProtection: URL { return URL(string: "https://help.duckduckgo.com/duckduckgo-help-pages/privacy/web-tracking-protections/")! } diff --git a/DuckDuckGo/HomePage/View/HomePageView.swift b/DuckDuckGo/HomePage/View/HomePageView.swift index 44c71a6288..8fe1f5ff25 100644 --- a/DuckDuckGo/HomePage/View/HomePageView.swift +++ b/DuckDuckGo/HomePage/View/HomePageView.swift @@ -183,7 +183,10 @@ extension HomePage.Views { @ViewBuilder func remoteMessage() -> some View { - if let remoteMessage = activeRemoteMessageModel.remoteMessage, let modelType = remoteMessage.content, modelType.isSupported { + if let remoteMessage = activeRemoteMessageModel.remoteMessage, + !remoteMessage.isForTabBar, + let modelType = remoteMessage.content, + modelType.isSupported { ZStack { RemoteMessageView(viewModel: .init( messageId: remoteMessage.id, diff --git a/DuckDuckGo/Localizable.xcstrings b/DuckDuckGo/Localizable.xcstrings index f90ccc2115..38bbffa1d4 100644 --- a/DuckDuckGo/Localizable.xcstrings +++ b/DuckDuckGo/Localizable.xcstrings @@ -64606,4 +64606,4 @@ } }, "version" : "1.0" -} +} \ No newline at end of file diff --git a/DuckDuckGo/MainWindow/MainViewController.swift b/DuckDuckGo/MainWindow/MainViewController.swift index d0674088c2..ffc06c00aa 100644 --- a/DuckDuckGo/MainWindow/MainViewController.swift +++ b/DuckDuckGo/MainWindow/MainViewController.swift @@ -75,7 +75,7 @@ final class MainViewController: NSViewController { self.isBurner = tabCollectionViewModel.isBurner self.featureFlagger = featureFlagger - tabBarViewController = TabBarViewController.create(tabCollectionViewModel: tabCollectionViewModel) + tabBarViewController = TabBarViewController.create(tabCollectionViewModel: tabCollectionViewModel, activeRemoteMessageModel: NSApp.delegateTyped.activeRemoteMessageModel) bookmarksBarVisibilityManager = BookmarksBarVisibilityManager(selectedTabPublisher: tabCollectionViewModel.$selectedTabViewModel.eraseToAnyPublisher()) let networkProtectionPopoverManager: NetPPopoverManager = { diff --git a/DuckDuckGo/NewTabPage/ActiveRemoteMessageModel+NewTabPage.swift b/DuckDuckGo/NewTabPage/ActiveRemoteMessageModel+NewTabPage.swift index 26b617934d..c6d7c7b8fb 100644 --- a/DuckDuckGo/NewTabPage/ActiveRemoteMessageModel+NewTabPage.swift +++ b/DuckDuckGo/NewTabPage/ActiveRemoteMessageModel+NewTabPage.swift @@ -22,7 +22,10 @@ import RemoteMessaging extension ActiveRemoteMessageModel: NewTabPageActiveRemoteMessageProviding { var remoteMessagePublisher: AnyPublisher { - $remoteMessage.dropFirst().eraseToAnyPublisher() + $remoteMessage + .dropFirst() + .filter { $0?.isForTabBar == true } + .eraseToAnyPublisher() } func isMessageSupported(_ message: RemoteMessageModel) -> Bool { diff --git a/DuckDuckGo/RemoteMessaging/ActiveRemoteMessageModel.swift b/DuckDuckGo/RemoteMessaging/ActiveRemoteMessageModel.swift index 0af0f67f97..bf933c1774 100644 --- a/DuckDuckGo/RemoteMessaging/ActiveRemoteMessageModel.swift +++ b/DuckDuckGo/RemoteMessaging/ActiveRemoteMessageModel.swift @@ -185,3 +185,10 @@ extension RemoteMessageModelType { } } } + +extension RemoteMessageModel { + + var isForTabBar: Bool { + return id == TabBarRemoteMessage.tabBarPermanentSurveyRemoteMessageId + } +} diff --git a/DuckDuckGo/RemoteMessaging/RemoteMessagingClient.swift b/DuckDuckGo/RemoteMessaging/RemoteMessagingClient.swift index 3289912570..265658a8a0 100644 --- a/DuckDuckGo/RemoteMessaging/RemoteMessagingClient.swift +++ b/DuckDuckGo/RemoteMessaging/RemoteMessagingClient.swift @@ -47,7 +47,7 @@ final class RemoteMessagingClient: RemoteMessagingProcessing { static let minimumConfigurationRefreshInterval: TimeInterval = 60 * 30 static let endpoint: URL = { #if DEBUG - URL(string: "https://raw.githubusercontent.com/duckduckgo/remote-messaging-config/main/samples/ios/sample1.json")! + URL(string: "https://www.jsonblob.com/api/1316017217598578688")! #else URL(string: "https://staticcdn.duckduckgo.com/remotemessaging/config/v1/macos-config.json")! #endif diff --git a/DuckDuckGo/TabBar/TabBarRemoteMessaging/TabBarRemoteMessageView.swift b/DuckDuckGo/TabBar/TabBarRemoteMessaging/TabBarRemoteMessageView.swift new file mode 100644 index 0000000000..3f7d240f57 --- /dev/null +++ b/DuckDuckGo/TabBar/TabBarRemoteMessaging/TabBarRemoteMessageView.swift @@ -0,0 +1,163 @@ +// +// TabBarRemoteMessageView.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +struct TabBarRemoteMessageView: View { + @State private var presentPopup: Bool = false + + let model: TabBarRemoteMessage + let onClose: () -> Void + let onTap: (URL) -> Void + let onHover: () -> Void + + var body: some View { + HStack { + Button(model.buttonTitle) { + onTap(model.surveyURL) + } + .buttonStyle(DefaultActionButtonStyle( + enabled: true, + onClose: { onClose() }, + onHoverStart: { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + presentPopup = true + + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + presentPopup = false + } + } + + onHover() + }, + onHoverEnd: { presentPopup = false }) + ) + .frame(width: 147, height: 24) + .popover(isPresented: $presentPopup, arrowEdge: .bottom) { + HStack(alignment: .center) { + Image(.daxResponse) + .resizable() + .scaledToFit() + .frame(width: 72, height: 72) + .padding(.leading, 12) + + VStack(alignment: .leading) { + Text(model.popupTitle) + .font(.body) + .fontWeight(.bold) + .frame(alignment: .leading) + .padding(.bottom, 12) + + Text(model.popupSubtitle) + .font(.body) + .fontWeight(.medium) + .frame(alignment: .leading) + + } + .frame(maxWidth: 360, minHeight: 90) + .padding(.trailing, 24) + .padding(.leading, 4) + } + } + } + } +} + +private struct DefaultActionButtonStyle: ButtonStyle { + + public let enabled: Bool + public let onClose: () -> Void + public let onHoverStart: () -> Void + public let onHoverEnd: () -> Void + + public init( + enabled: Bool, + onClose: @escaping () -> Void, + onHoverStart: @escaping () -> Void = {}, + onHoverEnd: @escaping () -> Void = {} + ) { + self.enabled = enabled + self.onClose = onClose + self.onHoverStart = onHoverStart + self.onHoverEnd = onHoverEnd + } + + public func makeBody(configuration: Self.Configuration) -> some View { + ButtonContent( + configuration: configuration, + enabled: enabled, + onClose: onClose, + onHoverStart: onHoverStart, + onHoverEnd: onHoverEnd + ) + } + + struct ButtonContent: View { + let configuration: Configuration + let enabled: Bool + let onClose: () -> Void + let onHoverStart: () -> Void + let onHoverEnd: () -> Void + + @State private var isHovered: Bool = false + + var body: some View { + let enabledBackgroundColor = configuration.isPressed + ? Color("PrimaryButtonPressed") + : (isHovered + ? Color("PrimaryButtonHover") + : Color("PrimaryButtonRest")) + + let disabledBackgroundColor = Color.gray.opacity(0.1) + let enabledLabelColor = configuration.isPressed ? Color.white.opacity(0.8) : Color.white + let disabledLabelColor = Color.primary.opacity(0.3) + + HStack(spacing: 5) { + configuration.label + .font(.system(size: 13)) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + Button(action: { + onClose() + }) { + Image(.close) + .resizable() + .frame(width: 12, height: 12) + .foregroundColor(enabled ? Color.white : Color.primary.opacity(0.3)) + } + .buttonStyle(PlainButtonStyle()) // Avoids additional styling + } + .frame(minWidth: 44) + .padding(.top, 2.5) + .padding(.bottom, 3) + .padding(.horizontal, 7.5) + .background(enabled ? enabledBackgroundColor : disabledBackgroundColor) + .foregroundColor(enabled ? enabledLabelColor : disabledLabelColor) + .cornerRadius(5) + .onHover { hovering in + isHovered = hovering + if hovering { + onHoverStart() + } else { + onHoverEnd() + } + } + } + } +} diff --git a/DuckDuckGo/TabBar/TabBarRemoteMessaging/TabBarRemoteMessageViewModel.swift b/DuckDuckGo/TabBar/TabBarRemoteMessaging/TabBarRemoteMessageViewModel.swift new file mode 100644 index 0000000000..9547215b1c --- /dev/null +++ b/DuckDuckGo/TabBar/TabBarRemoteMessaging/TabBarRemoteMessageViewModel.swift @@ -0,0 +1,98 @@ +// +// TabBarRemoteMessageViewModel.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import RemoteMessaging + +struct TabBarRemoteMessage { + static let tabBarPermanentSurveyRemoteMessageId = "macos_permanent_survey_tab_bar" + + let buttonTitle: String + let popupTitle: String + let popupSubtitle: String + let surveyURL: URL +} + +final class TabBarRemoteMessageViewModel: ObservableObject { + + private let activeRemoteMessageModel: ActiveRemoteMessageModel + private var cancellable: AnyCancellable? + + @Published var remoteMessage: TabBarRemoteMessage? + + init(activeRemoteMessageModel: ActiveRemoteMessageModel) { + self.activeRemoteMessageModel = activeRemoteMessageModel + + cancellable = activeRemoteMessageModel.$remoteMessage + .sink(receiveValue: { model in + guard let model = model else { + self.remoteMessage = nil + return + } + + if model.shouldShowTabBarRemoteMessage, let tabBarRemoteMessage = model.mapToTabBarRemoteMessage() { + self.remoteMessage = tabBarRemoteMessage + } + }) + } + + func onDismiss() { + Task { await activeRemoteMessageModel.dismissRemoteMessage(with: .close) } + } + + /// When the user hovers the Tab Bar Remote Message and we show the popup, there is where when we mark + /// that the user really saw the message. + func onUserHovered() { + Task { await activeRemoteMessageModel.markRemoteMessageAsShown() } + } + + func onOpenSurvey() { + Task { await activeRemoteMessageModel.dismissRemoteMessage(with: .primaryAction) } + } +} + +private extension RemoteMessageModel { + + var shouldShowTabBarRemoteMessage: Bool { + guard let modelType = content else { return false } + + return modelType.isSupported && isForTabBar + } + + func mapToTabBarRemoteMessage() -> TabBarRemoteMessage? { + guard let modelType = content else { return nil } + + switch modelType { + case .bigSingleAction(let titleText, + let descriptionText, + _, + let primaryActionText, + let primaryAction): + + if case .survey(let value) = primaryAction, let surveyURL = URL(string: value) { + return .init(buttonTitle: titleText, + popupTitle: primaryActionText, + popupSubtitle: descriptionText, + surveyURL: surveyURL) + } else { + return nil + } + default: return nil + } + } +} diff --git a/DuckDuckGo/TabBar/View/TabBarViewController.swift b/DuckDuckGo/TabBar/View/TabBarViewController.swift index f9038f0b94..d10e67df2d 100644 --- a/DuckDuckGo/TabBar/View/TabBarViewController.swift +++ b/DuckDuckGo/TabBar/View/TabBarViewController.swift @@ -23,6 +23,7 @@ import Lottie import SwiftUI import WebKit import os.log +import RemoteMessaging final class TabBarViewController: NSViewController { @@ -70,6 +71,9 @@ final class TabBarViewController: NSViewController { private let pinnedTabsViewModel: PinnedTabsViewModel? private let pinnedTabsView: PinnedTabsView? private let pinnedTabsHostingView: PinnedTabsHostingView? + private let tabBarRemoteMessageViewModel: TabBarRemoteMessageViewModel + private let feedbackPopoverViewController: PopoverMessageViewController + private var feedbackBarButtonHostingController: NSHostingController? private var selectionIndexCancellable: AnyCancellable? private var mouseDownCancellable: AnyCancellable? @@ -86,9 +90,9 @@ final class TabBarViewController: NSViewController { } } - static func create(tabCollectionViewModel: TabCollectionViewModel) -> TabBarViewController { + static func create(tabCollectionViewModel: TabCollectionViewModel, activeRemoteMessageModel: ActiveRemoteMessageModel) -> TabBarViewController { NSStoryboard(name: "TabBar", bundle: nil).instantiateInitialController { coder in - self.init(coder: coder, tabCollectionViewModel: tabCollectionViewModel) + self.init(coder: coder, tabCollectionViewModel: tabCollectionViewModel, activeRemoteMessageModel: activeRemoteMessageModel) }! } @@ -96,8 +100,9 @@ final class TabBarViewController: NSViewController { fatalError("TabBarViewController: Bad initializer") } - init?(coder: NSCoder, tabCollectionViewModel: TabCollectionViewModel) { + init?(coder: NSCoder, tabCollectionViewModel: TabCollectionViewModel, activeRemoteMessageModel: ActiveRemoteMessageModel) { self.tabCollectionViewModel = tabCollectionViewModel + self.tabBarRemoteMessageViewModel = TabBarRemoteMessageViewModel(activeRemoteMessageModel: activeRemoteMessageModel) if !tabCollectionViewModel.isBurner, let pinnedTabCollection = tabCollectionViewModel.pinnedTabsManager?.tabCollection { let pinnedTabsViewModel = PinnedTabsViewModel(collection: pinnedTabCollection) let pinnedTabsView = PinnedTabsView(model: pinnedTabsViewModel) @@ -110,6 +115,15 @@ final class TabBarViewController: NSViewController { self.pinnedTabsHostingView = nil } + feedbackPopoverViewController = PopoverMessageViewController( + title: "Tell Us What You Think", + message: "Take our short survey and help us build the best browser.", + image: .daxResponse, + shouldShowCloseButton: false, + presentMultiline: true, + autoDismissDuration: nil + ) + super.init(coder: coder) } @@ -124,6 +138,7 @@ final class TabBarViewController: NSViewController { subscribeToTabModeChanges() setupAddTabButton() setupAsBurnerWindowIfNeeded() + addTabBarRemoteMessageListener() } override func viewWillAppear() { @@ -200,6 +215,61 @@ final class TabBarViewController: NSViewController { } } + private func addTabBarRemoteMessageListener() { + tabBarRemoteMessageViewModel.$remoteMessage.sink(receiveValue: { tabBarRemoteMessage in + if let tabBarRemoteMessage = tabBarRemoteMessage { + if self.feedbackBarButtonHostingController == nil { + self.showTabBarRemoteMessage(tabBarRemoteMessage) + } + } else { + if self.feedbackBarButtonHostingController != nil { + self.removeFeedbackButton() + } + } + }) + .store(in: &cancellables) + } + + private func showTabBarRemoteMessage(_ tabBarRemotMessage: TabBarRemoteMessage) { + let feedbackButtonView = TabBarRemoteMessageView( + model: tabBarRemotMessage, + onClose: { + self.tabBarRemoteMessageViewModel.onDismiss() + self.removeFeedbackButton() + }, + onTap: { surveyURL in + WindowControllersManager.shared.showTab(with: .contentFromURL(surveyURL, source: .appOpenUrl)) + self.tabBarRemoteMessageViewModel.onOpenSurvey() + self.removeFeedbackButton() + }, + onHover: { + self.tabBarRemoteMessageViewModel.onUserHovered() + } + ) + feedbackBarButtonHostingController = NSHostingController(rootView: feedbackButtonView) + guard let feedbackBarButtonHostingController else { return } + + feedbackBarButtonHostingController.view.translatesAutoresizingMaskIntoConstraints = false + + // Insert the hosting controller's view into the stack view just before the fire button + let index = max(0, rightSideStackView.arrangedSubviews.count - 1) + rightSideStackView.insertArrangedSubview(feedbackBarButtonHostingController.view, at: index) + + NSLayoutConstraint.activate([ + feedbackBarButtonHostingController.view.heightAnchor.constraint(equalToConstant: 24), + feedbackBarButtonHostingController.view.centerYAnchor.constraint(equalTo: rightSideStackView.centerYAnchor) + ]) + } + + private func removeFeedbackButton() { + guard let hostingController = feedbackBarButtonHostingController else { return } + + rightSideStackView.removeArrangedSubview(hostingController.view) + hostingController.view.removeFromSuperview() + hostingController.removeFromParent() + feedbackBarButtonHostingController = nil + } + private func setupPinnedTabsView() { layoutPinnedTabsView() subscribeToPinnedTabsViewModelOutputs()