diff --git a/Shared/Extensions/IconImageView.swift b/Shared/Extensions/IconImageView.swift new file mode 100644 index 0000000000..0ebaa6fbcb --- /dev/null +++ b/Shared/Extensions/IconImageView.swift @@ -0,0 +1,86 @@ +// +// IconImageView.swift +// NetNewsWire +// +// Created by Stuart Breckenridge on 07/02/2026. +// Copyright © 2026 Ranchero Software. All rights reserved. +// + +// SwiftUI wrapper for `IconImage` + +import SwiftUI +#if os(macOS) +import AppKit +typealias PlatformColor = NSColor +#else +import UIKit +typealias PlatformColor = UIColor +#endif + +struct IconImageView: View { + let icon: IconImage + var size: IconSize = .small + var cornerRadius: CGFloat = 4 + + @Environment(\.colorScheme) private var colorScheme + + private var isDarkMode: Bool { colorScheme == .dark } + + private var shouldShowBackground: Bool { + guard !icon.isBackgroundSuppressed else { return false } + if isDarkMode { + return icon.isDark + } else { + return icon.isBright + } + } + + private var tintColor: Color? { + guard let cg = icon.preferredColor else { return nil } + return Color(cgColor: cg) + } + + var body: some View { + ZStack { + if shouldShowBackground { + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .fill(backgroundColor) + } + + platformImage(for: icon) + .resizable() + .scaledToFit() + .symbolRenderingMode(icon.isSymbol ? SymbolRenderingMode.palette : SymbolRenderingMode.hierarchical) + .foregroundStyle(tintColor ?? defaultTint) + } + .frame(width: size.size.width, height: size.size.height) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + .accessibilityHidden(false) + } + + private var backgroundColor: Color { + #if os(macOS) + let nsColor = isDarkMode ? Assets.Colors.iconDarkBackground : Assets.Colors.iconLightBackground + return Color(nsColor: nsColor) + #else + return Color(Assets.Colors.iconBackground) + #endif + } + + // Fallback tint to something sensible if preferredColor is not set (for symbols) + private var defaultTint: Color { + #if os(macOS) + return Color(nsColor: Assets.Colors.primaryAccent) + #else + return Color(Assets.Colors.secondaryAccent) + #endif + } + + private func platformImage(for icon: IconImage) -> Image { + #if os(macOS) + return Image(nsImage: icon.image) + #else + return Image(uiImage: icon.image) + #endif + } +} diff --git a/iOS/Account/AccountNotificationInspectorView.swift b/iOS/Account/AccountNotificationInspectorView.swift new file mode 100644 index 0000000000..df5a814aae --- /dev/null +++ b/iOS/Account/AccountNotificationInspectorView.swift @@ -0,0 +1,92 @@ +// +// AccountNotificationInspectorView.swift +// NetNewsWire-iOS +// +// Created by Stuart Breckenridge on 07/02/2026. +// Copyright © 2026 Ranchero Software. All rights reserved. +// + +import SwiftUI +import UserNotifications +import Account + +struct AccountNotificationInspectorView: View { + + @Environment(\.dismiss) private var dismiss + @State private var uuid = UUID() + @State private var authorisationStatus: UNAuthorizationStatus = .notDetermined + + var account: Account! + + var body: some View { + NavigationStack { + if authorisationStatus == .notDetermined || authorisationStatus == .denied { + VStack { + ContentUnavailableView("Notifications Disabled", + systemImage: "bell.slash", + description: Text("Enable Notifications in Settings", comment: "Enable Notifications in Settings")) + + Button { + UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!) + } label: { + Text("Open Settings", comment: "Open Settings") + } + .buttonStyle(.borderedProminent) + .tint(.accentColor) + } + .navigationTitle(Text("New Article Notifications", comment: "New Article Notifications")) + .navigationSubtitle(Text(verbatim: account.nameForDisplay)) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button(role: .close) { + dismiss() + } + } + } + } else { + List(account.flattenedFeeds().sorted(by: { a, b in + a.nameForDisplay < b.nameForDisplay + }), id: \.feedID) { feed in + Toggle(isOn: Binding(get: { feed.isNotifyAboutNewArticles ?? false }, set: { feed.isNotifyAboutNewArticles = $0 })) { + HStack { + if let img = IconImageCache.shared.imageFor(feed.sidebarItemID!) { + IconImageView(icon: img) + .id(uuid) + } else if let img = feed.smallIcon { + IconImageView(icon: img) + .id(uuid) + } + Text(verbatim: feed.nameForDisplay) + Spacer() + } + } + .tint(.accentColor) + } + .navigationTitle(Text("New Article Notifications", comment: "New Article Notifications")) + .navigationBarTitleDisplayMode(.inline) + .navigationSubtitle(Text(verbatim: account.nameForDisplay)) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button(role: .close) { + dismiss() + } + } + } + .onReceive(NotificationCenter.default.publisher(for: Notification.Name.feedIconDidBecomeAvailable)) { _ in + uuid = UUID() + } + } + } + .task { + let settings = await UNUserNotificationCenter.current().notificationSettings() + self.authorisationStatus = settings.authorizationStatus + } + .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification), perform: { _ in + Task { + let settings = await UNUserNotificationCenter.current().notificationSettings() + self.authorisationStatus = settings.authorizationStatus + } + }) + } +} diff --git a/iOS/MainFeed/MainFeedCollectionViewController.swift b/iOS/MainFeed/MainFeedCollectionViewController.swift index ce9a182120..53f332f4c1 100644 --- a/iOS/MainFeed/MainFeedCollectionViewController.swift +++ b/iOS/MainFeed/MainFeedCollectionViewController.swift @@ -856,6 +856,8 @@ extension MainFeedCollectionViewController: UIContextMenuInteractionDelegate { var menuElements = [UIMenuElement]() menuElements.append(UIMenu(title: "", options: .displayInline, children: [self.getAccountInfoAction(account: account)])) + menuElements.append(UIMenu(title: "", options: .displayInline, children: [self.getAccountNotificationsAction(account: account)])) + if let markAllAction = self.markAllAsReadAction(account: account, contentView: interaction.view) { menuElements.append(UIMenu(title: "", options: .displayInline, children: [markAllAction])) } @@ -1102,6 +1104,14 @@ extension MainFeedCollectionViewController { return action } + func getAccountNotificationsAction(account: Account) -> UIAction { + let title = NSLocalizedString("Notifications", comment: "Notifications") + let action = UIAction(title: title, image: UIImage(systemName: "bell.badge")) { [weak self] _ in + self?.coordinator.showNotificationInspector(for: account) + } + return action + } + func deactivateAccountAction(account: Account) -> UIAction { let title = NSLocalizedString("Deactivate", comment: "Deactivate") let action = UIAction(title: title, image: Assets.Images.deactivate) { _ in diff --git a/iOS/SceneCoordinator.swift b/iOS/SceneCoordinator.swift index 5021a0c3e7..cf006333b7 100644 --- a/iOS/SceneCoordinator.swift +++ b/iOS/SceneCoordinator.swift @@ -14,6 +14,7 @@ import Articles import RSCore import RSTree import SafariServices +import SwiftUI enum SearchScope: Int { case timeline = 0 @@ -1348,6 +1349,12 @@ struct SidebarItemNode: Hashable, Sendable { rootSplitViewController.present(accountInspectorNavController, animated: true) } + func showNotificationInspector(for account: Account) { + let hostingController = UIHostingController(rootView: AccountNotificationInspectorView(account: account)) + hostingController.modalPresentationStyle = .formSheet + rootSplitViewController.present(hostingController, animated: true) + } + func showFeedInspector() { guard let feed = timelineFeed as? Feed ?? currentArticle?.feed else { return