gitbay/Views/Account/NotificationsView.swift
184 lines · 7267 bytes
1import SwiftUI
2import UIKit
3import UserNotifications
4
5/// The notification inbox: `notifications list [--all]`, paginated, with
6/// a switch between the default unread-only listing and everything.
7struct NotificationsView: View {
8
9 @State private var model: NotificationsViewModel
10 @Environment(PushRegistrar.self) private var registrar
11 @Environment(\.scenePhase) private var scenePhase
12 @State private var authorization: UNAuthorizationStatus = .notDetermined
13
14 init(client: GitbayClient) {
15 _model = State(initialValue: NotificationsViewModel(client: client))
16 }
17
18 var body: some View {
19 List {
20 pushSection
21
22 Picker("Filter", selection: Bindable(model).showAll) {
23 Text("Unread").tag(false)
24 Text("All").tag(true)
25 }
26 .pickerStyle(.segmented)
27 .listRowBackground(Color.clear)
28 .listRowInsets(EdgeInsets())
29
30 if let actionError = model.actionError {
31 Section { GBNotice(actionError) }
32 }
33
34 ForEach(model.state.value ?? []) { notification in
35 NotificationLink(notification: notification)
36 .swipeActions {
37 if notification.isUnread {
38 Button("Mark read") {
39 Task { await model.markRead(notification.id) }
40 }
41 .tint(.gbAccent)
42 }
43 }
44 }
45 PageFooter(list: model.list)
46 }
47 .overlay { LoadStateOverlay(state: model.state) }
48 .navigationTitle("Notifications")
49 .navigationBarTitleDisplayMode(.inline)
50 .toolbar {
51 ToolbarItem(placement: .topBarTrailing) {
52 Button("Mark all read") {
53 Task { await model.markAllRead() }
54 }
55 .disabled(model.working)
56 }
57 }
58 .task { await model.load() }
59 .task { await model.loadPushSettings() }
60 .task { authorization = await registrar.authorizationStatus() }
61 // Settings is exactly where the denied row sends the user, and
62 // returning from it is a scene activation, not a refresh — this
63 // is what actually clears a stale "denied" once they flip the
64 // switch there.
65 .onChange(of: scenePhase) { _, newPhase in
66 guard newPhase == .active else { return }
67 Task {
68 let status = await registrar.authorizationStatus()
69 // Granting in Settings is the one route to .authorized
70 // that never passes through the toggle, so it is also
71 // the one route that never asks iOS for a device
72 // token. Without this the toggle turns on, the server
73 // preference is set, and no `device add` is ever sent.
74 let justGranted = status == .authorized && authorization != .authorized
75 authorization = status
76 if justGranted { await registrar.registerIfAlreadyAuthorized() }
77 }
78 }
79 .refreshable {
80 await model.load()
81 await model.loadPushSettings()
82 authorization = await registrar.authorizationStatus()
83 }
84 }
85
86 /// Push has two independent switches — iOS authorization and the
87 /// server preference — and the toggle must read false when either
88 /// one is off. Permission is asked here, on the user turning the
89 /// toggle on, not on the screen appearing: iOS asks once per
90 /// install, and a prompt sprung by navigation earns a reflexive
91 /// "Don't Allow" that nothing afterwards can explain.
92 private var pushSection: some View {
93 Section("Push notifications") {
94 Toggle("Push notifications", isOn: Binding(
95 get: { authorization == .authorized && model.pushEnabled },
96 set: { on in Task { await setPush(on) } }
97 ))
98 .disabled(authorization == .denied)
99
100 if authorization == .denied {
101 GBNotice("Notifications are turned off for gitbay in Settings.", .gbWarn)
102 Button("Open Settings") {
103 if let url = URL(string: UIApplication.openSettingsURLString) {
104 UIApplication.shared.open(url)
105 }
106 }
107 .font(.gbSans(.subheadline))
108 }
109
110 ForEach(model.devices) { device in
111 VStack(alignment: .leading, spacing: 2) {
112 Text(device.label)
113 .font(.gbSans(.subheadline))
114 Text(device.token)
115 .font(.gbMono(.caption))
116 .foregroundStyle(.secondary)
117 }
118 .swipeActions {
119 Button("Remove", role: .destructive) {
120 Task { await model.removeDevice(device.id) }
121 }
122 }
123 }
124 }
125 }
126
127 private func setPush(_ on: Bool) async {
128 if on, authorization == .notDetermined {
129 let granted = await registrar.requestPushPermission()
130 authorization = await registrar.authorizationStatus()
131 guard granted else { return }
132 }
133 await model.setPush(on)
134 }
135}
136
137/// Routes to `destination` when the path resolves to a screen; a
138/// notification the app cannot route renders plainly and is not
139/// tappable.
140private struct NotificationLink: View {
141 let notification: InboxNotification
142
143 @ViewBuilder
144 var body: some View {
145 switch notification.destination {
146 case .issue(let repo, let number):
147 NavigationLink(value: IssueRoute.issue(repo: repo, number: number)) {
148 NotificationRow(notification: notification)
149 }
150 case .mr(let repo, let number):
151 NavigationLink(value: MRRoute.mr(repo: repo, number: number)) {
152 NotificationRow(notification: notification)
153 }
154 case .build(let repo, let number):
155 NavigationLink(value: BuildRoute.detail(repo: repo, number: number)) {
156 NotificationRow(notification: notification)
157 }
158 case nil:
159 NotificationRow(notification: notification)
160 }
161 }
162}
163
164private struct NotificationRow: View {
165 let notification: InboxNotification
166
167 var body: some View {
168 VStack(alignment: .leading, spacing: 4) {
169 HStack(alignment: .firstTextBaseline) {
170 Text(notification.actor)
171 .font(.gbSans(.subheadline).weight(notification.isUnread ? .semibold : .regular))
172 Spacer()
173 Text(notification.createdAt, format: .relative(presentation: .named))
174 .font(.gbSans(.caption))
175 .foregroundStyle(.secondary)
176 }
177 Text(notification.summary)
178 .font(.gbSans(.subheadline).weight(notification.isUnread ? .semibold : .regular))
179 .foregroundStyle(notification.isUnread ? .primary : .secondary)
180 .lineLimit(2)
181 }
182 .padding(.vertical, 2)
183 }
184}