import SwiftUI import UIKit import UserNotifications /// The notification inbox: `notifications list [--all]`, paginated, with /// a switch between the default unread-only listing and everything. struct NotificationsView: View { @State private var model: NotificationsViewModel @Environment(PushRegistrar.self) private var registrar @Environment(\.scenePhase) private var scenePhase @State private var authorization: UNAuthorizationStatus = .notDetermined init(client: GitbayClient) { _model = State(initialValue: NotificationsViewModel(client: client)) } var body: some View { List { pushSection Picker("Filter", selection: Bindable(model).showAll) { Text("Unread").tag(false) Text("All").tag(true) } .pickerStyle(.segmented) .listRowBackground(Color.clear) .listRowInsets(EdgeInsets()) if let actionError = model.actionError { Section { GBNotice(actionError) } } ForEach(model.state.value ?? []) { notification in NotificationLink(notification: notification) .swipeActions { if notification.isUnread { Button("Mark read") { Task { await model.markRead(notification.id) } } .tint(.gbAccent) } } } PageFooter(list: model.list) } .overlay { LoadStateOverlay(state: model.state) } .navigationTitle("Notifications") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("Mark all read") { Task { await model.markAllRead() } } .disabled(model.working) } } .task { await model.load() } .task { await model.loadPushSettings() } .task { authorization = await registrar.authorizationStatus() } // Settings is exactly where the denied row sends the user, and // returning from it is a scene activation, not a refresh — this // is what actually clears a stale "denied" once they flip the // switch there. .onChange(of: scenePhase) { _, newPhase in guard newPhase == .active else { return } Task { let status = await registrar.authorizationStatus() // Granting in Settings is the one route to .authorized // that never passes through the toggle, so it is also // the one route that never asks iOS for a device // token. Without this the toggle turns on, the server // preference is set, and no `device add` is ever sent. let justGranted = status == .authorized && authorization != .authorized authorization = status if justGranted { await registrar.registerIfAlreadyAuthorized() } } } .refreshable { await model.load() await model.loadPushSettings() authorization = await registrar.authorizationStatus() } } /// Push has two independent switches — iOS authorization and the /// server preference — and the toggle must read false when either /// one is off. Permission is asked here, on the user turning the /// toggle on, not on the screen appearing: iOS asks once per /// install, and a prompt sprung by navigation earns a reflexive /// "Don't Allow" that nothing afterwards can explain. private var pushSection: some View { Section("Push notifications") { Toggle("Push notifications", isOn: Binding( get: { authorization == .authorized && model.pushEnabled }, set: { on in Task { await setPush(on) } } )) .disabled(authorization == .denied) if authorization == .denied { GBNotice("Notifications are turned off for gitbay in Settings.", .gbWarn) Button("Open Settings") { if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) } } .font(.gbSans(.subheadline)) } ForEach(model.devices) { device in VStack(alignment: .leading, spacing: 2) { Text(device.label) .font(.gbSans(.subheadline)) Text(device.token) .font(.gbMono(.caption)) .foregroundStyle(.secondary) } .swipeActions { Button("Remove", role: .destructive) { Task { await model.removeDevice(device.id) } } } } } } private func setPush(_ on: Bool) async { if on, authorization == .notDetermined { let granted = await registrar.requestPushPermission() authorization = await registrar.authorizationStatus() guard granted else { return } } await model.setPush(on) } } /// Routes to `destination` when the path resolves to a screen; a /// notification the app cannot route renders plainly and is not /// tappable. private struct NotificationLink: View { let notification: InboxNotification @ViewBuilder var body: some View { switch notification.destination { case .issue(let repo, let number): NavigationLink(value: IssueRoute.issue(repo: repo, number: number)) { NotificationRow(notification: notification) } case .mr(let repo, let number): NavigationLink(value: MRRoute.mr(repo: repo, number: number)) { NotificationRow(notification: notification) } case .build(let repo, let number): NavigationLink(value: BuildRoute.detail(repo: repo, number: number)) { NotificationRow(notification: notification) } case nil: NotificationRow(notification: notification) } } } private struct NotificationRow: View { let notification: InboxNotification var body: some View { VStack(alignment: .leading, spacing: 4) { HStack(alignment: .firstTextBaseline) { Text(notification.actor) .font(.gbSans(.subheadline).weight(notification.isUnread ? .semibold : .regular)) Spacer() Text(notification.createdAt, format: .relative(presentation: .named)) .font(.gbSans(.caption)) .foregroundStyle(.secondary) } Text(notification.summary) .font(.gbSans(.subheadline).weight(notification.isUnread ? .semibold : .regular)) .foregroundStyle(notification.isUnread ? .primary : .secondary) .lineLimit(2) } .padding(.vertical, 2) } }