diff --git a/WireMessaging/Sources/WireMessagingUI/Conversation/ConversationViewerAccessBannerDismissalStore.swift b/WireMessaging/Sources/WireMessagingUI/Conversation/ConversationViewerAccessBannerDismissalStore.swift new file mode 100644 index 00000000000..9b8b7b59412 --- /dev/null +++ b/WireMessaging/Sources/WireMessagingUI/Conversation/ConversationViewerAccessBannerDismissalStore.swift @@ -0,0 +1,41 @@ +// +// Wire +// Copyright (C) 2026 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import Foundation + +/// Tracks, per conversation, whether the user has closed the `ConversationViewerAccessBanner`. +/// +/// This state is kept in memory only, for the lifetime of the app process, and is intentionally +/// never persisted (no `UserDefaults`, disk, or keychain), so the banner reappears again on next launch. +@MainActor +public final class ConversationViewerAccessBannerDismissalStore { + public static let shared = ConversationViewerAccessBannerDismissalStore() + typealias CellName = String + + private var dismissedCellNames: Set = [] + + private init() {} + + public func isDismissed(forCellName cellName: String) -> Bool { + dismissedCellNames.contains(cellName) + } + + public func markDismissed(forCellName cellName: String) { + dismissedCellNames.insert(cellName) + } +} diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift index 3c74ea9460d..d13ca4442fb 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift @@ -65,7 +65,7 @@ package struct FilesContentView: View { if viewModel.showReadOnlyBanner { ConversationViewerAccessBanner(backgroundColor: ColorTheme.Buttons.Secondary .disabledOutline) { - viewModel.showReadOnlyBanner = false + viewModel.dismissReadOnlyBanner() }.padding(.bottom, viewModel.isOffline ? 0 : 15) } diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift index b3dc21fcfb3..a349d40bbaf 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift @@ -273,7 +273,19 @@ package final class FilesViewModel: ObservableObject { selfUser = conversations.flatMap(\.participants).first(where: \.isSelfUser) if let selfUser { - showReadOnlyBanner = !isBrowsing && selfUser.role == .viewer && isDrivePermissionsFlagEnabled + let isDismissed = cellName + .map(ConversationViewerAccessBannerDismissalStore.shared.isDismissed(forCellName:)) ?? false + let isViewer = selfUser.role == .viewer + showReadOnlyBanner = isDrivePermissionsFlagEnabled && !isBrowsing && isViewer && !isDismissed && + !isRecycleBin + } + } + + func dismissReadOnlyBanner() { + showReadOnlyBanner = false + + if let cellName { + ConversationViewerAccessBannerDismissalStore.shared.markDismissed(forCellName: cellName) } } diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/InputBar/ConversationInputBarViewController/ConversationInputBarViewController.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/InputBar/ConversationInputBarViewController/ConversationInputBarViewController.swift index 34cbf2d4add..d2142d0ea01 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/InputBar/ConversationInputBarViewController/ConversationInputBarViewController.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/InputBar/ConversationInputBarViewController/ConversationInputBarViewController.swift @@ -178,13 +178,23 @@ final class ConversationInputBarViewController: UIViewController, .isGuest(in: conversation) && DeveloperFlag.enableDrivePermissions.isOn } + private var shouldShowDriveViewerBanner: Bool { + showDriveViewerBanner && + !ConversationViewerAccessBannerDismissalStore.shared + .isDismissed(forCellName: conversation.wireDriveCellName) + } + // MARK: subviews lazy var inputBar: InputBar = { + let driveConfiguration: InputBar.DriveConfiguration? = if conversation.isWireDriveEnabled { + .init(cellName: conversation.wireDriveCellName, showBanner: showDriveViewerBanner) + } else { + nil + } let inputBar = InputBar( buttons: inputBarButtons, - isWireDriveEnabled: conversation.isWireDriveEnabled, - showDriveViewerBanner: showDriveViewerBanner + driveConfiguration: driveConfiguration ) if !mediaShareRestrictionManager.canUseSpellChecking { inputBar.textView.spellCheckingType = .no @@ -504,6 +514,7 @@ final class ConversationInputBarViewController: UIViewController, override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + inputBar.hideDriveViewerBannerIfDismissed() updateButtonStates() inputBar.updateReturnKey() inputBar.updateEphemeralState() diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/InputBar/InputBar.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/InputBar/InputBar.swift index d3dfa99c669..7acdae16d3a 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/InputBar/InputBar.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/InputBar/InputBar.swift @@ -110,9 +110,13 @@ final class InputBar: UIView { typealias ConversationInputBar = L10n.Localizable.Conversation.InputBar + struct DriveConfiguration { + let cellName: String + let showBanner: Bool + } + private let inputBarVerticalInset: CGFloat = 34 - private let isWireDriveEnabled: Bool - private let showDriveViewerBanner: Bool + private let driveConfiguration: DriveConfiguration? static let rightIconSize: CGFloat = 32 private let textViewFont = FontSpec.normalRegularFont.font! @@ -239,14 +243,16 @@ final class InputBar: UIView { textView.isScrollEnabled = true } - required init(buttons: [UIButton], isWireDriveEnabled: Bool, showDriveViewerBanner: Bool) { + required init( + buttons: [UIButton], + driveConfiguration: DriveConfiguration? + ) { self.buttonsView = InputBarButtonsView(buttons: buttons) self.secondaryButtonsView = InputBarSecondaryButtonsView( editBarView: editingView, markdownBarView: markdownView ) - self.isWireDriveEnabled = isWireDriveEnabled - self.showDriveViewerBanner = showDriveViewerBanner + self.driveConfiguration = driveConfiguration super.init(frame: CGRect.zero) @@ -259,7 +265,7 @@ final class InputBar: UIView { addSubview(inputContainer) // Viewer access banner - if showDriveViewerBanner { + if let driveConfiguration, driveConfiguration.showBanner { inputContainer.addArrangedSubview(driveViewerAccessBanner) [driveViewerAccessBanner, self].forEach { $0.layer.cornerRadius = 12 @@ -272,7 +278,7 @@ final class InputBar: UIView { inputContainer.addArrangedSubview(upperContainer) [leftAccessoryView, textView, rightAccessoryStackView].forEach { upperContainer.addSubview($0) } - if isWireDriveEnabled { + if driveConfiguration != nil { inputContainer.addArrangedSubview(attachmentsContainer) } @@ -366,7 +372,7 @@ final class InputBar: UIView { textView.backgroundColor = .clear markdownView.delegate = textView - if !showDriveViewerBanner { + if let driveConfiguration, !driveConfiguration.showBanner { addBorder(for: .top) } updateReturnKey() @@ -390,7 +396,7 @@ final class InputBar: UIView { buttonInnerContainer ].forEach { $0.translatesAutoresizingMaskIntoConstraints = false } - if isWireDriveEnabled { + if driveConfiguration != nil { NSLayoutConstraint.activate([ attachmentsContainer.widthAnchor.constraint(equalTo: inputContainer.widthAnchor), attachmentsContainer.heightAnchor.constraint(equalToConstant: 82) @@ -457,7 +463,7 @@ final class InputBar: UIView { rowTopInsetConstraint ]) - if showDriveViewerBanner { + if let driveConfiguration, driveConfiguration.showBanner { driveViewerAccessBanner.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ driveViewerAccessBanner.leadingAnchor.constraint(equalTo: inputContainer.leadingAnchor), @@ -710,6 +716,8 @@ final class InputBar: UIView { } private func onDriveViewerAccessBannerClosed() { + guard let driveConfiguration else { return } + ConversationViewerAccessBannerDismissalStore.shared.markDismissed(forCellName: driveConfiguration.cellName) inputContainer.removeArrangedSubview(driveViewerAccessBanner) driveViewerAccessBanner.removeFromSuperview() addBorder(for: .top) @@ -717,6 +725,16 @@ final class InputBar: UIView { layer.maskedCorners = [] clipsToBounds = false } + + /// Hides the drive viewer access banner if it was dismissed elsewhere (e.g. from the Shared + /// Drive screen) while this `InputBar` instance was already alive and showing it. + func hideDriveViewerBannerIfDismissed() { + guard driveViewerAccessBanner.superview != nil, + let driveConfiguration, + ConversationViewerAccessBannerDismissalStore.shared.isDismissed(forCellName: driveConfiguration.cellName) + else { return } + onDriveViewerAccessBannerClosed() + } } extension InputBar {