mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
SubscriptionService held one provider per backend and each resolved its instance from settingsManager.subscriptionAccount, falling back to "the first enabled Invidious instance" when that was unset. With several logins possible on one instance that model breaks twice: two accounts would share a single in-memory subscription cache, and the fallback could send an account's request to a server it holds no credential for. Providers are now built per account and cached by account ID, and each resolves its instance and credential from the account it was built for - no fallback. The protocol's accountType becomes a DataContext, which is what actually identifies a provider now. Views that branched on "is the subscription account local" read the context instead, and the settings picker writes both stores. Keeping the legacy setting in step is deliberate for now: several views still read it, and letting the two diverge would show one account in the UI while serving another's data. The Phase 3 switcher removes the last readers. Signing out discards the account's provider so its cached subscriptions cannot be served after the account is gone.
350 lines
12 KiB
Swift
350 lines
12 KiB
Swift
//
|
|
// NotificationSettingsView.swift
|
|
// Yattee
|
|
//
|
|
// Settings view for background notifications configuration.
|
|
//
|
|
|
|
import SwiftUI
|
|
import NukeUI
|
|
|
|
struct NotificationSettingsView: View {
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
@State private var authorizationChecked = false
|
|
|
|
var body: some View {
|
|
SettingsFormContainer {
|
|
if let settings = appEnvironment?.settingsManager,
|
|
let notificationManager = appEnvironment?.notificationManager {
|
|
// Master toggle section
|
|
EnableSection(
|
|
settings: settings,
|
|
notificationManager: notificationManager,
|
|
appEnvironment: appEnvironment
|
|
)
|
|
|
|
// Permission status section
|
|
if authorizationChecked {
|
|
PermissionSection(notificationManager: notificationManager)
|
|
}
|
|
|
|
// Default for new subscriptions
|
|
if settings.backgroundNotificationsEnabled {
|
|
DefaultsSection(settings: settings)
|
|
|
|
// Manage channels section
|
|
ManageChannelsSection()
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle(String(localized: "settings.notifications.title"))
|
|
#if os(iOS)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
#endif
|
|
.task {
|
|
await appEnvironment?.notificationManager.refreshAuthorizationStatus()
|
|
authorizationChecked = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Enable Section
|
|
|
|
private struct EnableSection: View {
|
|
@Bindable var settings: SettingsManager
|
|
let notificationManager: NotificationManager
|
|
let appEnvironment: AppEnvironment?
|
|
|
|
var body: some View {
|
|
SettingsFormSection(footer: "settings.notifications.footer") {
|
|
Toggle(
|
|
String(localized: "settings.notifications.enable"),
|
|
isOn: Binding(
|
|
get: { settings.backgroundNotificationsEnabled },
|
|
set: { newValue in
|
|
if newValue {
|
|
enableNotifications()
|
|
} else {
|
|
disableNotifications()
|
|
}
|
|
}
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
private func enableNotifications() {
|
|
Task {
|
|
let granted = await notificationManager.requestAuthorization()
|
|
if granted {
|
|
settings.backgroundNotificationsEnabled = true
|
|
appEnvironment?.backgroundRefreshManager.handleNotificationsEnabledChanged(true)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func disableNotifications() {
|
|
settings.backgroundNotificationsEnabled = false
|
|
appEnvironment?.backgroundRefreshManager.handleNotificationsEnabledChanged(false)
|
|
}
|
|
}
|
|
|
|
// MARK: - Permission Section
|
|
|
|
private struct PermissionSection: View {
|
|
let notificationManager: NotificationManager
|
|
|
|
var body: some View {
|
|
SettingsFormSection {
|
|
HStack {
|
|
Text(String(localized: "settings.notifications.permission"))
|
|
Spacer()
|
|
if notificationManager.isAuthorized {
|
|
HStack(spacing: 4) {
|
|
Text(String(localized: "settings.notifications.permission.granted"))
|
|
Image(systemName: "checkmark.circle.fill")
|
|
}
|
|
.foregroundStyle(.green)
|
|
} else {
|
|
Button(String(localized: "settings.notifications.openSettings")) {
|
|
notificationManager.openNotificationSettings()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Defaults Section
|
|
|
|
private struct DefaultsSection: View {
|
|
@Bindable var settings: SettingsManager
|
|
|
|
var body: some View {
|
|
SettingsFormSection("settings.notifications.defaults.header", footer: "settings.notifications.defaultForNew.footer") {
|
|
Toggle(
|
|
String(localized: "settings.notifications.defaultForNew"),
|
|
isOn: $settings.defaultNotificationsForNewChannels
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Manage Channels Section
|
|
|
|
private struct ManageChannelsSection: View {
|
|
var body: some View {
|
|
SettingsFormSection {
|
|
#if os(tvOS)
|
|
NavigationLink {
|
|
TVSidebarDetailContainer(
|
|
systemImage: "bell.badge",
|
|
title: String(localized: "settings.notifications.manageChannels"),
|
|
showsDismissButton: true
|
|
) {
|
|
ManageChannelNotificationsView()
|
|
}
|
|
} label: {
|
|
Label(
|
|
String(localized: "settings.notifications.manageChannels"),
|
|
systemImage: "bell.badge"
|
|
)
|
|
}
|
|
#else
|
|
SettingsNavigationRow("settings.notifications.manageChannels", systemImage: "bell.badge") {
|
|
ManageChannelNotificationsView()
|
|
}
|
|
#endif
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Manage Channel Notifications View
|
|
|
|
struct ManageChannelNotificationsView: View {
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
@State private var subscriptions: [Subscription] = []
|
|
@State private var isLoading = false
|
|
@State private var errorMessage: String?
|
|
@State private var refreshID = UUID()
|
|
|
|
private var subscriptionService: SubscriptionService? { appEnvironment?.subscriptionService }
|
|
|
|
var body: some View {
|
|
Group {
|
|
if isLoading {
|
|
HStack {
|
|
Spacer()
|
|
ProgressView()
|
|
Spacer()
|
|
}
|
|
.frame(maxHeight: .infinity)
|
|
} else if let errorMessage {
|
|
ContentUnavailableView {
|
|
Label(
|
|
String(localized: "settings.notifications.loadError.title"),
|
|
systemImage: "exclamationmark.triangle"
|
|
)
|
|
} description: {
|
|
Text(errorMessage)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else if subscriptions.isEmpty {
|
|
ContentUnavailableView {
|
|
Label(
|
|
String(localized: "settings.notifications.noSubscriptions.title"),
|
|
systemImage: "person.2.slash"
|
|
)
|
|
} description: {
|
|
Text(String(localized: "settings.notifications.noSubscriptions.description"))
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else {
|
|
SettingsFormContainer {
|
|
SettingsFormSection {
|
|
ForEach(subscriptions, id: \.channelID) { subscription in
|
|
ChannelNotificationToggle(subscription: subscription)
|
|
}
|
|
.id(refreshID)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle(String(localized: "settings.notifications.manageChannels.title"))
|
|
#if os(iOS)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
#endif
|
|
.toolbar {
|
|
if !subscriptions.isEmpty {
|
|
ToolbarItem(placement: .primaryAction) {
|
|
Menu {
|
|
Button {
|
|
setAllNotifications(enabled: true)
|
|
} label: {
|
|
Label(String(localized: "settings.notifications.enableAll"), systemImage: "bell.fill")
|
|
}
|
|
Button {
|
|
setAllNotifications(enabled: false)
|
|
} label: {
|
|
Label(String(localized: "settings.notifications.disableAll"), systemImage: "bell.slash")
|
|
}
|
|
} label: {
|
|
Image(systemName: "ellipsis.circle")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.task {
|
|
await loadSubscriptionsAsync()
|
|
}
|
|
}
|
|
|
|
private func setAllNotifications(enabled: Bool) {
|
|
guard let dataManager = appEnvironment?.dataManager else { return }
|
|
for subscription in subscriptions {
|
|
dataManager.setNotificationsEnabled(enabled, for: subscription.channelID)
|
|
}
|
|
refreshID = UUID()
|
|
}
|
|
|
|
/// Loads subscriptions from the current subscription account provider.
|
|
/// For local accounts, loads from DataManager.
|
|
/// For Invidious/Piped accounts, fetches from the respective API.
|
|
private func loadSubscriptionsAsync() async {
|
|
guard let appEnvironment, let subscriptionService else { return }
|
|
|
|
isLoading = true
|
|
errorMessage = nil
|
|
defer { isLoading = false }
|
|
|
|
// For local account, load from DataManager
|
|
if appEnvironment.accountsManager.currentContext.isLocal {
|
|
subscriptions = appEnvironment.dataManager.subscriptions()
|
|
return
|
|
}
|
|
|
|
// For Invidious/Piped, fetch from service
|
|
do {
|
|
let channels = try await subscriptionService.fetchSubscriptions()
|
|
subscriptions = channels.map { Subscription.from(channel: $0) }
|
|
} catch {
|
|
LoggingService.shared.error(
|
|
"Failed to load subscriptions for notifications: \(error.localizedDescription)",
|
|
category: .general
|
|
)
|
|
errorMessage = error.localizedDescription
|
|
subscriptions = []
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Channel Notification Toggle
|
|
|
|
private struct ChannelNotificationToggle: View {
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
let subscription: Subscription
|
|
@State private var notificationsEnabled: Bool = false
|
|
|
|
private var yatteeServer: Instance? {
|
|
appEnvironment?.instancesManager.enabledYatteeServerInstances.first
|
|
}
|
|
|
|
private var effectiveAvatarURL: URL? {
|
|
AvatarURLBuilder.avatarURL(
|
|
channelID: subscription.channelID,
|
|
directURL: subscription.avatarURL,
|
|
serverURL: yatteeServer?.url,
|
|
size: 28
|
|
)
|
|
}
|
|
|
|
private var authHeader: String? {
|
|
guard let server = yatteeServer else { return nil }
|
|
return appEnvironment?.basicAuthCredentialsManager.basicAuthHeader(for: server)
|
|
}
|
|
|
|
private var toggleBinding: Binding<Bool> {
|
|
Binding(
|
|
get: { notificationsEnabled },
|
|
set: { newValue in
|
|
notificationsEnabled = newValue
|
|
appEnvironment?.dataManager.setNotificationsEnabled(newValue, for: subscription.channelID)
|
|
}
|
|
)
|
|
}
|
|
|
|
var body: some View {
|
|
Toggle(isOn: toggleBinding) {
|
|
HStack(spacing: 10) {
|
|
// Channel avatar
|
|
LazyImage(request: AvatarURLBuilder.imageRequest(url: effectiveAvatarURL, authHeader: authHeader)) { state in
|
|
if let image = state.image {
|
|
image
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fill)
|
|
} else {
|
|
Circle()
|
|
.fill(.quaternary)
|
|
}
|
|
}
|
|
.frame(width: 28, height: 28)
|
|
.clipShape(Circle())
|
|
|
|
// Channel name
|
|
Text(subscription.name)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
.onAppear {
|
|
// Load initial value from ChannelNotificationSettings
|
|
notificationsEnabled = appEnvironment?.dataManager.notificationsEnabled(for: subscription.channelID) ?? false
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
NavigationStack {
|
|
NotificationSettingsView()
|
|
}
|
|
}
|