Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions Shared/Extensions/IconImageView.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
92 changes: 92 additions & 0 deletions iOS/Account/AccountNotificationInspectorView.swift
Original file line number Diff line number Diff line change
@@ -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
}
})
}
}
10 changes: 10 additions & 0 deletions iOS/MainFeed/MainFeedCollectionViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]))
}
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions iOS/SceneCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import Articles
import RSCore
import RSTree
import SafariServices
import SwiftUI

enum SearchScope: Int {
case timeline = 0
Expand Down Expand Up @@ -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
Expand Down