gitbay/Views/Account/NotificationsView.swift

36835f99e6d463f3e18c5c3fc3562bb92513f52b
gitbay-ios/gitbay/Views/Account/NotificationsView.swift history · blame · raw

208 lines · 8288 bytes

4 symbols in this file
  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}