gitbay/Views/Account/NotificationsView.swift
208 lines · 8288 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 Section {
21 Picker("Filter", selection: Bindable(model).showAll) {
22 Text("Unread").tag(false)
23 Text("All").tag(true)
24 }
25 .pickerStyle(.segmented)
26 .listRowBackground(Color.clear)
27 .listRowInsets(EdgeInsets())
28 }
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 // An empty inbox is a row, not an overlay: the push section
46 // below still has content.
47 if case .empty(let message) = model.state {
48 Text(message)
49 .font(.gbSans(.subheadline))
50 .foregroundStyle(.secondary)
51 }
52 PageFooter(list: model.list)
53
54 pushSection
55 }
56 .overlay {
57 if case .empty = model.state {} else { LoadStateOverlay(state: model.state) }
58 }
59 .navigationTitle("Notifications")
60 .navigationBarTitleDisplayMode(.inline)
61 .toolbar {
62 ToolbarItem(placement: .topBarTrailing) {
63 Button("Mark all read") {
64 Task { await model.markAllRead() }
65 }
66 .disabled(model.working)
67 }
68 }
69 .task { await model.load() }
70 .task { await model.loadPushSettings() }
71 .task { authorization = await registrar.authorizationStatus() }
72 // Settings is exactly where the denied row sends the user, and
73 // returning from it is a scene activation, not a refresh — this
74 // is what actually clears a stale "denied" once they flip the
75 // switch there.
76 .onChange(of: scenePhase) { _, newPhase in
77 guard newPhase == .active else { return }
78 Task {
79 let status = await registrar.authorizationStatus()
80 // Granting in Settings is the one route to .authorized
81 // that never passes through the toggle, so it is also
82 // the one route that never asks iOS for a device
83 // token. Without this the toggle turns on, the server
84 // preference is set, and no `device add` is ever sent.
85 let justGranted = status == .authorized && authorization != .authorized
86 authorization = status
87 if justGranted { await registrar.registerIfAlreadyAuthorized() }
88 }
89 }
90 .refreshable {
91 await model.load()
92 await model.loadPushSettings()
93 authorization = await registrar.authorizationStatus()
94 }
95 }
96
97 /// Push has two independent switches — iOS authorization and the
98 /// server preference — and the toggle must read false when either
99 /// one is off. Permission is asked here, on the user turning the
100 /// toggle on, not on the screen appearing: iOS asks once per
101 /// install, and a prompt sprung by navigation earns a reflexive
102 /// "Don't Allow" that nothing afterwards can explain.
103 private var pushSection: some View {
104 Section {
105 Toggle("Push notifications", isOn: Binding(
106 get: { authorization == .authorized && model.pushEnabled },
107 set: { on in Task { await setPush(on) } }
108 ))
109 .disabled(authorization == .denied)
110
111 if authorization == .denied {
112 GBNotice("Notifications are turned off for gitbay in Settings.", .gbWarn)
113 Button("Open Settings") {
114 if let url = URL(string: UIApplication.openSettingsURLString) {
115 UIApplication.shared.open(url)
116 }
117 }
118 .font(.gbSans(.subheadline))
119 }
120
121 ForEach(model.devices) { device in
122 Label {
123 VStack(alignment: .leading, spacing: 2) {
124 Text(device.label)
125 .font(.gbSans(.subheadline))
126 Text("added \(AdminQueues.relative(device.added)) · \(device.token)")
127 .font(.gbSans(.caption))
128 .foregroundStyle(.secondary)
129 .lineLimit(1)
130 }
131 } icon: {
132 Image(systemName: "iphone").foregroundStyle(.secondary)
133 }
134 .accessibilityElement(children: .ignore)
135 .accessibilityLabel("Registered device, \(device.label)")
136 .swipeActions {
137 Button("Remove", role: .destructive) {
138 Task { await model.removeDevice(device.id) }
139 }
140 }
141 }
142 } header: {
143 Text("Push notifications")
144 } footer: {
145 if !model.devices.isEmpty {
146 Text("Devices registered for push. Swipe one to remove it.")
147 }
148 }
149 }
150
151 private func setPush(_ on: Bool) async {
152 if on, authorization == .notDetermined {
153 let granted = await registrar.requestPushPermission()
154 authorization = await registrar.authorizationStatus()
155 guard granted else { return }
156 }
157 await model.setPush(on)
158 }
159}
160
161/// Routes to `destination` when the path resolves to a screen; a
162/// notification the app cannot route renders plainly and is not
163/// tappable.
164private struct NotificationLink: View {
165 let notification: InboxNotification
166
167 @ViewBuilder
168 var body: some View {
169 switch notification.destination {
170 case .issue(let repo, let number):
171 NavigationLink(value: IssueRoute.issue(repo: repo, number: number)) {
172 NotificationRow(notification: notification)
173 }
174 case .mr(let repo, let number):
175 NavigationLink(value: MRRoute.mr(repo: repo, number: number)) {
176 NotificationRow(notification: notification)
177 }
178 case .build(let repo, let number):
179 NavigationLink(value: BuildRoute.detail(repo: repo, number: number)) {
180 NotificationRow(notification: notification)
181 }
182 case nil:
183 NotificationRow(notification: notification)
184 }
185 }
186}
187
188private struct NotificationRow: View {
189 let notification: InboxNotification
190
191 var body: some View {
192 VStack(alignment: .leading, spacing: 4) {
193 HStack(alignment: .firstTextBaseline) {
194 Text(notification.actor)
195 .font(.gbSans(.subheadline).weight(notification.isUnread ? .semibold : .regular))
196 Spacer()
197 Text(notification.createdAt, format: .relative(presentation: .named))
198 .font(.gbSans(.caption))
199 .foregroundStyle(.secondary)
200 }
201 Text(notification.summary)
202 .font(.gbSans(.subheadline).weight(notification.isUnread ? .semibold : .regular))
203 .foregroundStyle(notification.isUnread ? .primary : .secondary)
204 .lineLimit(2)
205 }
206 .padding(.vertical, 2)
207 }
208}