gitbay/Views/Repos/RepoMirrorDomainViews.swift

36835f99e6d463f3e18c5c3fc3562bb92513f52b
gitbay-ios/gitbay/Views/Repos/RepoMirrorDomainViews.swift history · blame · raw

383 lines · 15747 bytes

7 symbols in this file
  1import SwiftUI
  2
  3/// A repository's mirrors: where each pushes to or pulls from, how its
  4/// last sync went, a way to sync now, add and remove.
  5struct MirrorsView: View {
  6
  7    @State private var model: MirrorsViewModel
  8    @State private var adding = false
  9    @State private var removing: Mirror?
 10    @State private var scheduled = false
 11
 12    init(client: GitbayClient, repo: String) {
 13        _model = State(initialValue: MirrorsViewModel(client: client, repoPath: repo))
 14    }
 15
 16    var body: some View {
 17        List {
 18            if let error = model.actionError, !adding {
 19                Section { GBNotice(error, .gbWarn) }
 20            } else if scheduled {
 21                Section { GBNotice("Sync scheduled. Pull to refresh for each mirror's result.", .gbOK) }
 22            }
 23            Section {
 24                ForEach(model.state.value ?? []) { mirror in
 25                    row(mirror)
 26                        .swipeActions {
 27                            Button("Remove", role: .destructive) {
 28                                scheduled = false
 29                                removing = mirror
 30                            }
 31                            .disabled(model.working)
 32                        }
 33                }
 34            } footer: {
 35                if model.state.value != nil {
 36                    Text("A push mirror sends every push here to the remote. A pull mirror fetches from it, and pushes here are refused.")
 37                }
 38            }
 39            if model.state.value != nil {
 40                Section {
 41                    Button {
 42                        Task { scheduled = await model.sync() }
 43                    } label: {
 44                        Label("Sync now", systemImage: "arrow.triangle.2.circlepath")
 45                            .font(.gbSans(.subheadline))
 46                    }
 47                    .disabled(model.working)
 48                    .accessibilityIdentifier("mirror-sync-button")
 49                }
 50            }
 51        }
 52        .overlay { LoadStateOverlay(state: model.state) }
 53        .navigationTitle("Mirrors")
 54        .navigationBarTitleDisplayMode(.inline)
 55        .toolbar {
 56            ToolbarItem(placement: .topBarTrailing) {
 57                Button {
 58                    scheduled = false
 59                    adding = true
 60                } label: { Image(systemName: "plus") }
 61                    .accessibilityLabel("Add Mirror")
 62                    .disabled(model.working)
 63                    .accessibilityIdentifier("mirror-add-button")
 64            }
 65        }
 66        .sheet(isPresented: $adding) {
 67            MirrorAddSheet(model: model) { adding = false }
 68        }
 69        .confirmationDialog(
 70            "Remove the mirror \(removing?.isPull == true ? "from" : "to") \(removing?.url ?? "")?",
 71            isPresented: Binding(get: { removing != nil }, set: { if !$0 { removing = nil } }),
 72            titleVisibility: .visible
 73        ) {
 74            Button("Remove", role: .destructive) {
 75                if let mirror = removing { Task { await model.remove(id: mirror.id) } }
 76                removing = nil
 77            }
 78            Button("Cancel", role: .cancel) {}
 79        }
 80        .task { await model.load() }
 81        .refreshable {
 82            scheduled = false
 83            await model.load()
 84        }
 85    }
 86
 87    private func row(_ mirror: Mirror) -> some View {
 88        VStack(alignment: .leading, spacing: 3) {
 89            Text(mirror.url)
 90                .font(.gbMono(.caption))
 91                .lineLimit(2)
 92            HStack(spacing: 6) {
 93                GBChip(mirror.isPull ? "pull" : "push", .secondary)
 94                if let username = mirror.username {
 95                    Text("as \(username)")
 96                }
 97                if mirror.pending {
 98                    Text("sync pending")
 99                } else if let synced = mirror.lastSync {
100                    Text("synced \(synced.formatted(.relative(presentation: .named)))")
101                }
102            }
103            .font(.gbSans(.caption2))
104            .foregroundStyle(.secondary)
105            if let error = mirror.lastError, !error.isEmpty {
106                Text(error)
107                    .font(.gbSans(.caption2))
108                    .foregroundStyle(Color.gbBad)
109            }
110        }
111        .padding(.vertical, 2)
112        .accessibilityElement(children: .combine)
113    }
114}
115
116private struct MirrorAddSheet: View {
117    let model: MirrorsViewModel
118    let onDone: () -> Void
119
120    @State private var url = ""
121    @State private var pull = false
122    @State private var username = ""
123    @State private var token = ""
124    @State private var attempted = false
125
126    var body: some View {
127        NavigationStack {
128            Form {
129                Section {
130                    TextField("https://…", text: $url)
131                        .autocorrectionDisabled()
132                        .textInputAutocapitalization(.never)
133                        .keyboardType(.URL)
134                        .accessibilityIdentifier("mirror-url")
135                    Picker("Direction", selection: $pull) {
136                        Text("Push").tag(false)
137                        Text("Pull").tag(true)
138                    }
139                    .pickerStyle(.segmented)
140                    .accessibilityIdentifier("mirror-direction")
141                } footer: {
142                    Text(pull
143                        ? "Pulls from the remote. Pushes to this repository are refused while the mirror exists."
144                        : "Pushes here are sent on to the remote.")
145                }
146                Section {
147                    TextField("Username (optional)", text: $username)
148                        .autocorrectionDisabled()
149                        .textInputAutocapitalization(.never)
150                    SecureField("Token (optional)", text: $token)
151                        .autocorrectionDisabled()
152                        .textInputAutocapitalization(.never)
153                } footer: {
154                    Text("For a remote that needs credentials. The token is stored for syncs and not shown again.")
155                }
156                if attempted, let error = model.actionError {
157                    Section { GBNotice(error, .gbWarn) }
158                }
159            }
160            .navigationTitle("Add Mirror")
161            .navigationBarTitleDisplayMode(.inline)
162            .toolbar {
163                ToolbarItem(placement: .cancellationAction) {
164                    Button("Cancel") { onDone() }.disabled(model.working)
165                }
166                ToolbarItem(placement: .confirmationAction) {
167                    if model.working {
168                        ProgressView()
169                    } else {
170                        Button("Add") {
171                            Task {
172                                attempted = true
173                                guard await model.add(url: url, pull: pull, username: username, token: token) else { return }
174                                // A secure field that still holds text as the sheet
175                                // goes makes iOS offer to save it as a password.
176                                token = ""
177                                await Task.yield()
178                                onDone()
179                            }
180                        }
181                        .disabled(url.trimmingCharacters(in: .whitespaces).isEmpty)
182                        .accessibilityIdentifier("mirror-submit")
183                    }
184                }
185            }
186            .interactiveDismissDisabled(model.working)
187        }
188    }
189}
190
191/// A repository's custom pages domains: each claim, verified or waiting
192/// on its DNS record, with verify and remove.
193struct PageDomainsView: View {
194
195    @State private var model: PageDomainsViewModel
196    @State private var adding = false
197    @State private var removing: PageDomain?
198    @State private var verified: String?
199
200    init(client: GitbayClient, repo: String) {
201        _model = State(initialValue: PageDomainsViewModel(client: client, repoPath: repo))
202    }
203
204    var body: some View {
205        List {
206            if let error = model.actionError, !adding {
207                Section { GBNotice(error, .gbWarn) }
208            } else if let verified {
209                Section {
210                    GBNotice("\(verified) is verified. Point its A and AAAA records at this server.", .gbOK)
211                }
212            }
213            Section {
214                ForEach(model.state.value ?? []) { claim in
215                    VStack(alignment: .leading, spacing: 3) {
216                        Text(claim.domain).font(.gbMono(.subheadline))
217                        HStack(spacing: 6) {
218                            GBChip(claim.state, claim.isVerified ? Color.gbOK : claim.isPending ? Color.gbWarn : Color.secondary)
219                            if let at = claim.verifiedAt {
220                                Text("since \(at.formatted(date: .abbreviated, time: .omitted))")
221                                    .font(.gbSans(.caption2))
222                                    .foregroundStyle(.secondary)
223                            }
224                        }
225                    }
226                    .padding(.vertical, 2)
227                    .accessibilityElement(children: .combine)
228                    .swipeActions {
229                        Button("Remove", role: .destructive) {
230                            verified = nil
231                            removing = claim
232                        }
233                        .disabled(model.working)
234                        if claim.isPending {
235                            Button("Verify") { verify(claim.domain) }
236                                .tint(.gbAccent)
237                                .disabled(model.working)
238                        }
239                    }
240                }
241            } footer: {
242                if model.state.value != nil {
243                    Text("A domain serves this repository's pages branch once a DNS TXT record proves the claim. Swipe a pending claim to verify it; an expired one has to be claimed again.")
244                }
245            }
246        }
247        .overlay { LoadStateOverlay(state: model.state) }
248        .navigationTitle("Custom Domains")
249        .navigationBarTitleDisplayMode(.inline)
250        .toolbar {
251            ToolbarItem(placement: .topBarTrailing) {
252                Button {
253                    verified = nil
254                    adding = true
255                } label: { Image(systemName: "plus") }
256                    .accessibilityLabel("Claim Domain")
257                    .disabled(model.working)
258                    .accessibilityIdentifier("domain-add-button")
259            }
260        }
261        .sheet(isPresented: $adding) {
262            DomainClaimSheet(model: model) { domain in
263                adding = false
264                if let domain { verified = domain }
265            }
266        }
267        .confirmationDialog(
268            "Remove \(removing?.domain ?? "")? It stops serving this repository's pages.",
269            isPresented: Binding(get: { removing != nil }, set: { if !$0 { removing = nil } }),
270            titleVisibility: .visible
271        ) {
272            Button("Remove", role: .destructive) {
273                if let claim = removing { Task { await model.remove(claim.domain) } }
274                removing = nil
275            }
276            Button("Cancel", role: .cancel) {}
277        }
278        .task { await model.load() }
279        .refreshable {
280            verified = nil
281            await model.load()
282        }
283    }
284
285    private func verify(_ domain: String) {
286        verified = nil
287        Task { if await model.verify(domain) { verified = domain } }
288    }
289}
290
291/// Claims a domain, then shows the TXT record to create and a way to
292/// verify it once it is in place. The record is shown only here.
293private struct DomainClaimSheet: View {
294    let model: PageDomainsViewModel
295    /// The domain when it was verified from here, else nil.
296    let onDone: (String?) -> Void
297
298    @State private var domain = ""
299    @State private var challenge: DomainChallenge?
300    @State private var attempted = false
301
302    var body: some View {
303        NavigationStack {
304            Form {
305                if let challenge {
306                    Section {
307                        record("Name", challenge.name, id: "domain-copy-name")
308                        LabeledContent("Type", value: "TXT")
309                        record("Value", challenge.value, id: "domain-copy-value")
310                    } header: {
311                        Text("Create this DNS record")
312                    } footer: {
313                        Text("This record is shown only now. Create it, then verify, here or later from the list. An unverified claim expires after a few days.")
314                    }
315                } else {
316                    Section {
317                        TextField("docs.example.com", text: $domain)
318                            .autocorrectionDisabled()
319                            .textInputAutocapitalization(.never)
320                            .keyboardType(.URL)
321                            .accessibilityIdentifier("domain-name")
322                    } footer: {
323                        Text("A domain you control. Claiming it gives you a DNS record that proves it.")
324                    }
325                }
326                if attempted, let error = model.actionError {
327                    Section { GBNotice(error, .gbWarn) }
328                }
329            }
330            .navigationTitle(challenge == nil ? "Claim Domain" : challenge!.domain)
331            .navigationBarTitleDisplayMode(.inline)
332            .toolbar {
333                ToolbarItem(placement: .cancellationAction) {
334                    Button(challenge == nil ? "Cancel" : "Done") { onDone(nil) }
335                        .disabled(model.working)
336                        .accessibilityIdentifier("domain-done")
337                }
338                ToolbarItem(placement: .confirmationAction) {
339                    if model.working {
340                        ProgressView()
341                    } else if let challenge {
342                        Button("Verify") {
343                            Task {
344                                attempted = true
345                                if await model.verify(challenge.domain) { onDone(challenge.domain) }
346                            }
347                        }
348                        .accessibilityIdentifier("domain-verify")
349                    } else {
350                        Button("Claim") {
351                            Task {
352                                attempted = true
353                                challenge = await model.add(domain)
354                            }
355                        }
356                        .disabled(domain.trimmingCharacters(in: .whitespaces).isEmpty)
357                        .accessibilityIdentifier("domain-submit")
358                    }
359                }
360            }
361            // The record cannot be fetched again, so it closes only by Done.
362            .interactiveDismissDisabled(model.working || challenge != nil)
363        }
364    }
365
366    private func record(_ label: String, _ text: String, id: String) -> some View {
367        HStack(alignment: .firstTextBaseline) {
368            VStack(alignment: .leading, spacing: 2) {
369                Text(label).font(.gbSans(.caption)).foregroundStyle(.secondary)
370                Text(text).font(.gbMono(.caption)).textSelection(.enabled)
371            }
372            Spacer()
373            Button {
374                UIPasteboard.general.string = text
375            } label: {
376                Image(systemName: "doc.on.doc")
377            }
378            .buttonStyle(.borderless)
379            .accessibilityLabel("Copy \(label)")
380            .accessibilityIdentifier(id)
381        }
382    }
383}