krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v2.16.0: Hutch/Views/More/ProfileView.swift · raw

  1import PhotosUI
  2import SwiftUI
  3
  4private let profileBioMarkdownOptions = AttributedString.MarkdownParsingOptions(
  5    interpretedSyntax: .inlineOnlyPreservingWhitespace
  6)
  7
  8struct ProfileView: View {
  9    @Environment(AppState.self) private var appState
 10    @AppStorage(AppStorageKeys.contributionGraphsEnabled) private var contributionGraphsEnabled = true
 11    @State private var viewModel: SettingsViewModel?
 12    @State private var contributionViewModel: UserProfileViewModel?
 13    @State private var pendingDestructiveAction: ProfileDestructiveAction?
 14
 15    var body: some View {
 16        Group {
 17            if let viewModel {
 18                profileContent(viewModel)
 19            } else {
 20                SRHTLoadingStateView(message: "Loading profile…")
 21            }
 22        }
 23        .navigationTitle("Profile")
 24        .task {
 25            if viewModel == nil {
 26                let vm = SettingsViewModel(client: appState.client)
 27                viewModel = vm
 28                await vm.loadProfile()
 29            }
 30        }
 31        .task(id: contributionGraphsEnabled ? appState.currentUser?.canonicalName ?? "" : "contributions-disabled") {
 32            guard contributionGraphsEnabled, let currentUser = appState.currentUser else { return }
 33
 34            let owner = currentUser.canonicalName.hasPrefix("~")
 35                ? String(currentUser.canonicalName.dropFirst())
 36                : currentUser.canonicalName
 37            let actor = currentUser.canonicalName.hasPrefix("~") ? currentUser.canonicalName : "~\(currentUser.canonicalName)"
 38
 39            let vm: UserProfileViewModel
 40            if let existingViewModel = contributionViewModel,
 41               existingViewModel.actor == actor,
 42               existingViewModel.ownerUsername == owner {
 43                vm = existingViewModel
 44            } else {
 45                let newViewModel = UserProfileViewModel(
 46                    ownerUsername: owner,
 47                    actor: actor,
 48                    client: appState.client,
 49                    statsService: HutchStatsService(
 50                        configuration: appState.configuration,
 51                        currentActor: appState.currentUser?.canonicalName
 52                    )
 53                )
 54                contributionViewModel = newViewModel
 55                vm = newViewModel
 56            }
 57
 58            await vm.loadContributions()
 59        }
 60    }
 61
 62    @ViewBuilder
 63    private func profileContent(_ viewModel: SettingsViewModel) -> some View {
 64        @Bindable var vm = viewModel
 65
 66        Form {
 67            if let profile = viewModel.profile {
 68                profileSection(profile, viewModel: viewModel)
 69
 70                if contributionGraphsEnabled, let contributionViewModel {
 71                    Section {
 72                        ContributionProfileCard(
 73                            actor: contributionViewModel.actor,
 74                            weeks: contributionViewModel.contributionCalendar.map {
 75                                ContributionCalendarLayout.weekColumns(from: $0.days)
 76                            } ?? [],
 77                            stats: contributionViewModel.contributionStats,
 78                            isLoading: contributionViewModel.isLoadingContributions,
 79                            error: contributionViewModel.contributionsError ?? contributionViewModel.contributionStatusText,
 80                            isIndexedButEmpty: contributionViewModel.isContributionActivityIndexedButEmpty
 81                        )
 82                    }
 83                }
 84
 85                sshKeysSection(viewModel)
 86                pgpKeysSection(viewModel)
 87                patSection(viewModel)
 88            }
 89        }
 90        .overlay {
 91            if viewModel.isLoading, viewModel.profile == nil {
 92                SRHTLoadingStateView(message: "Loading profile…")
 93            } else if let error = viewModel.error, viewModel.profile == nil {
 94                SRHTErrorStateView(
 95                    title: "Couldn't Load Profile",
 96                    message: error,
 97                    retryAction: { await viewModel.loadProfile() }
 98                )
 99            }
100        }
101        .sheet(isPresented: $vm.isEditingProfile) {
102            if let profile = viewModel.profile {
103                EditProfileSheet(profile: profile, viewModel: viewModel)
104            }
105        }
106        .alert("Error", isPresented: Binding(
107            get: { viewModel.error != nil && viewModel.profile != nil },
108            set: { isPresented in
109                if !isPresented {
110                    viewModel.error = nil
111                }
112            }
113        )) {
114            Button("OK") { viewModel.error = nil }
115        } message: {
116            if let error = viewModel.error {
117                Text(error)
118            }
119        }
120        .alert(
121            pendingDestructiveAction?.title ?? "",
122            isPresented: Binding(
123                get: { pendingDestructiveAction != nil },
124                set: { isPresented in
125                    if !isPresented {
126                        pendingDestructiveAction = nil
127                    }
128                }
129            )
130        ) {
131            Button("Cancel", role: .cancel) {}
132            Button(pendingDestructiveAction?.confirmationLabel ?? "Confirm", role: .destructive) {
133                guard let action = pendingDestructiveAction else { return }
134                pendingDestructiveAction = nil
135                Task {
136                    switch action {
137                    case .deleteSSHKey(let key):
138                        await viewModel.deleteSSHKey(key)
139                    case .deletePGPKey(let key):
140                        await viewModel.deletePGPKey(key)
141                    }
142                }
143            }
144        } message: {
145            if let pendingDestructiveAction {
146                Text(pendingDestructiveAction.message)
147            }
148        }
149        .refreshable {
150            await viewModel.loadProfile()
151        }
152    }
153
154    @ViewBuilder
155    private func profileSection(_ profile: UserProfile, viewModel: SettingsViewModel) -> some View {
156        Section("Profile") {
157            HStack(spacing: 12) {
158                AsyncImage(url: profile.avatar.flatMap { URL(string: $0) }) { phase in
159                    switch phase {
160                    case .success(let image):
161                        image
162                            .resizable()
163                            .scaledToFill()
164                    default:
165                        Image(systemName: "person.crop.circle.fill")
166                            .resizable()
167                            .foregroundStyle(.secondary)
168                    }
169                }
170                .frame(width: 56, height: 56)
171                .clipShape(Circle())
172
173                VStack(alignment: .leading, spacing: 2) {
174                    Text(profile.canonicalName)
175                        .font(.headline)
176                    Text(profile.email)
177                        .font(.subheadline)
178                        .foregroundStyle(.secondary)
179                    if let userType = profile.userType {
180                        Text(userType.capitalized)
181                            .font(.caption)
182                            .foregroundStyle(.tertiary)
183                    }
184                }
185            }
186            .padding(.vertical, 4)
187
188            if let bio = profile.bio, !bio.isEmpty {
189                VStack(alignment: .leading, spacing: 2) {
190                    Text("Bio")
191                        .font(.caption)
192                        .foregroundStyle(.secondary)
193                    ProfileBioView(markdown: bio)
194                }
195            }
196
197            if let location = profile.location, !location.isEmpty {
198                LabeledContent("Location", value: location)
199            }
200
201            if let url = profile.url, !url.isEmpty {
202                LabeledContent("URL", value: url)
203            }
204
205            if let status = profile.paymentStatus {
206                LabeledContent("Payment", value: status.capitalized)
207            }
208
209            if let sub = profile.subscription {
210                if let status = sub.status {
211                    LabeledContent("Subscription", value: status.capitalized)
212                }
213                if let interval = sub.interval {
214                    LabeledContent("Interval", value: interval.capitalized)
215                }
216            }
217
218            Button("Edit Profile") {
219                viewModel.isEditingProfile = true
220            }
221
222            SRHTShareButton(url: SRHTWebURL.profile(canonicalName: profile.canonicalName), target: .profile) {
223                SwiftUI.Label("Share Profile", systemImage: "square.and.arrow.up")
224            }
225        }
226    }
227
228    @ViewBuilder
229    private func sshKeysSection(_ viewModel: SettingsViewModel) -> some View {
230        @Bindable var vm = viewModel
231
232        Section {
233            ForEach(viewModel.sshKeys) { key in
234                VStack(alignment: .leading, spacing: 2) {
235                    Text(key.fingerprint)
236                        .font(.caption.monospaced())
237                        .lineLimit(1)
238                        .truncationMode(.middle)
239
240                    HStack {
241                        if let comment = key.comment, !comment.isEmpty {
242                            Text(comment)
243                                .font(.caption2)
244                                .foregroundStyle(.secondary)
245                        }
246                        Spacer()
247                        Text(key.created.relativeDescription)
248                            .font(.caption2)
249                            .foregroundStyle(.tertiary)
250                    }
251
252                    if let lastUsed = key.lastUsed {
253                        Text("Last used \(lastUsed.relativeDescription)")
254                            .font(.caption2)
255                            .foregroundStyle(.tertiary)
256                    }
257                }
258                .swipeActions(edge: .trailing, allowsFullSwipe: false) {
259                    Button("Delete", role: .destructive) {
260                        pendingDestructiveAction = .deleteSSHKey(key)
261                    }
262                }
263            }
264
265            if viewModel.isAddingSSHKey {
266                TextField("Paste SSH public key", text: $vm.newSSHKey, axis: .vertical)
267                    .font(.caption.monospaced())
268                    .lineLimit(3...6)
269
270                HStack {
271                    Button("Cancel") {
272                        viewModel.isAddingSSHKey = false
273                        viewModel.newSSHKey = ""
274                    }
275                    Spacer()
276                    Button("Add") {
277                        Task { await viewModel.addSSHKey() }
278                    }
279                    .buttonStyle(.borderedProminent)
280                    .disabled(viewModel.newSSHKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
281                }
282            } else {
283                Button {
284                    viewModel.isAddingSSHKey = true
285                } label: {
286                    SwiftUI.Label("Add SSH Key", systemImage: "key")
287                }
288            }
289        } header: {
290            Text("SSH Keys")
291        } footer: {
292            Text("\(viewModel.sshKeys.count) key\(viewModel.sshKeys.count == 1 ? "" : "s")")
293        }
294    }
295
296    @ViewBuilder
297    private func pgpKeysSection(_ viewModel: SettingsViewModel) -> some View {
298        @Bindable var vm = viewModel
299
300        Section {
301            ForEach(viewModel.pgpKeys) { key in
302                VStack(alignment: .leading, spacing: 2) {
303                    Text(key.fingerprint)
304                        .font(.caption.monospaced())
305                        .lineLimit(1)
306                        .truncationMode(.middle)
307
308                    Text(key.created.relativeDescription)
309                        .font(.caption2)
310                        .foregroundStyle(.tertiary)
311                }
312                .swipeActions(edge: .trailing, allowsFullSwipe: false) {
313                    Button("Delete", role: .destructive) {
314                        pendingDestructiveAction = .deletePGPKey(key)
315                    }
316                }
317            }
318
319            if viewModel.isAddingPGPKey {
320                TextField("Paste PGP public key", text: $vm.newPGPKey, axis: .vertical)
321                    .font(.caption.monospaced())
322                    .lineLimit(3...6)
323
324                HStack {
325                    Button("Cancel") {
326                        viewModel.isAddingPGPKey = false
327                        viewModel.newPGPKey = ""
328                    }
329                    Spacer()
330                    Button("Add") {
331                        Task { await viewModel.addPGPKey() }
332                    }
333                    .buttonStyle(.borderedProminent)
334                    .disabled(viewModel.newPGPKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
335                }
336            } else {
337                Button {
338                    viewModel.isAddingPGPKey = true
339                } label: {
340                    SwiftUI.Label("Add PGP Key", systemImage: "key.fill")
341                }
342            }
343        } header: {
344            Text("PGP Keys")
345        } footer: {
346            Text("\(viewModel.pgpKeys.count) key\(viewModel.pgpKeys.count == 1 ? "" : "s")")
347        }
348    }
349
350    @ViewBuilder
351    private func patSection(_ viewModel: SettingsViewModel) -> some View {
352        Section {
353            if viewModel.isLoadingPATs {
354                HStack {
355                    Spacer()
356                    ProgressView()
357                    Spacer()
358                }
359            } else if viewModel.personalAccessTokens.isEmpty {
360                Button("Load Tokens") {
361                    Task { await viewModel.loadPersonalAccessTokens() }
362                }
363            } else {
364                ForEach(viewModel.personalAccessTokens) { token in
365                    VStack(alignment: .leading, spacing: 4) {
366                        HStack {
367                            Text(token.comment ?? "Token #\(token.id)")
368                                .font(.subheadline)
369                            Spacer()
370                        }
371
372                        HStack(spacing: 12) {
373                            Text("Issued \(token.issued.relativeDescription)")
374                                .font(.caption2)
375                                .foregroundStyle(.secondary)
376
377                            if let expires = token.expires {
378                                Text("Expires \(expires.relativeDescription)")
379                                    .font(.caption2)
380                                    .foregroundStyle(expires < Date.now ? .red : .secondary)
381                            }
382                        }
383
384                        if let grants = token.grants, !grants.isEmpty {
385                            Text(grants)
386                                .font(.caption2.monospaced())
387                                .foregroundStyle(.tertiary)
388                                .lineLimit(2)
389                        }
390                    }
391                }
392            }
393        } header: {
394            Text("Personal Access Tokens")
395        } footer: {
396            if !viewModel.personalAccessTokens.isEmpty {
397                Text("\(viewModel.personalAccessTokens.count) token\(viewModel.personalAccessTokens.count == 1 ? "" : "s")")
398            }
399        }
400    }
401}
402
403private struct ProfileBioView: View {
404    let markdown: String
405
406    var body: some View {
407        Text(profileBioAttributedString(markdown))
408            .frame(maxWidth: .infinity, alignment: .leading)
409            .tint(.accentColor)
410            .textSelection(.enabled)
411    }
412}
413
414func profileBioAttributedString(_ markdown: String) -> AttributedString {
415    guard let attributed = try? AttributedString(
416        markdown: markdown,
417        options: profileBioMarkdownOptions
418    ) else {
419        return AttributedString(markdown)
420    }
421    return attributed
422}
423
424private struct EditProfileSheet: View {
425    let profile: UserProfile
426    let viewModel: SettingsViewModel
427
428    @State private var email: String
429    @State private var url: String
430    @State private var location: String
431    @State private var bio: String
432    @State private var selectedPhoto: PhotosPickerItem?
433    @State private var avatarPreview: UIImage?
434    @State private var isShowingRemoveAvatarConfirmation = false
435
436    @Environment(\.dismiss) private var dismiss
437
438    init(profile: UserProfile, viewModel: SettingsViewModel) {
439        self.profile = profile
440        self.viewModel = viewModel
441        _email = State(initialValue: profile.email)
442        _url = State(initialValue: profile.url ?? "")
443        _location = State(initialValue: profile.location ?? "")
444        _bio = State(initialValue: profile.bio ?? "")
445    }
446
447    var body: some View {
448        NavigationStack {
449            Form {
450                Section {
451                    HStack {
452                        Spacer()
453                        VStack(spacing: 8) {
454                            PhotosPicker(selection: $selectedPhoto, matching: .images) {
455                                Group {
456                                    if let avatarPreview {
457                                        Image(uiImage: avatarPreview)
458                                            .resizable()
459                                            .scaledToFill()
460                                    } else {
461                                        AsyncImage(url: profile.avatar.flatMap { URL(string: $0) }) { phase in
462                                            switch phase {
463                                            case .success(let image):
464                                                image
465                                                    .resizable()
466                                                    .scaledToFill()
467                                            default:
468                                                Image(systemName: "person.crop.circle.fill")
469                                                    .resizable()
470                                                    .foregroundStyle(.secondary)
471                                            }
472                                        }
473                                    }
474                                }
475                                .frame(width: 80, height: 80)
476                                .clipShape(Circle())
477                                .overlay(
478                                    Circle()
479                                        .stroke(.secondary.opacity(0.3), lineWidth: 1)
480                                )
481                            }
482
483                            Text("Tap to change avatar")
484                                .font(.caption)
485                                .foregroundStyle(.secondary)
486
487                            if viewModel.isUploadingAvatar {
488                                ProgressView()
489                                    .controlSize(.small)
490                            }
491                        }
492                        Spacer()
493                    }
494                    .listRowBackground(Color.clear)
495
496                    if profile.avatar != nil || avatarPreview != nil {
497                        HStack {
498                            Spacer()
499                            Button(role: .destructive) {
500                                isShowingRemoveAvatarConfirmation = true
501                            } label: {
502                                Text("Remove Avatar")
503                            }
504                            .buttonStyle(.borderedProminent)
505                            .disabled(viewModel.isUploadingAvatar)
506                            Spacer()
507                        }
508                        .listRowBackground(Color.clear)
509                        .listRowSeparator(.hidden)
510                    }
511                }
512
513                Section("Edit Profile") {
514                    VStack(alignment: .leading, spacing: 4) {
515                        Text("Email")
516                            .font(.caption)
517                            .foregroundStyle(.secondary)
518                        TextField("Enter email", text: $email)
519                            .textContentType(.emailAddress)
520                            .keyboardType(.emailAddress)
521                            .autocorrectionDisabled()
522                            .textInputAutocapitalization(.never)
523                    }
524
525                    VStack(alignment: .leading, spacing: 4) {
526                        Text("URL")
527                            .font(.caption)
528                            .foregroundStyle(.secondary)
529                        TextField("Enter URL", text: $url)
530                            .textContentType(.URL)
531                            .keyboardType(.URL)
532                            .autocorrectionDisabled()
533                            .textInputAutocapitalization(.never)
534                    }
535
536                    VStack(alignment: .leading, spacing: 4) {
537                        Text("Location")
538                            .font(.caption)
539                            .foregroundStyle(.secondary)
540                        TextField("Enter location", text: $location)
541                    }
542
543                    VStack(alignment: .leading, spacing: 4) {
544                        Text("Bio")
545                            .font(.caption)
546                            .foregroundStyle(.secondary)
547                        TextField("Enter bio", text: $bio, axis: .vertical)
548                            .lineLimit(3...6)
549                    }
550                }
551            }
552            .navigationTitle("Edit Profile")
553            .navigationBarTitleDisplayMode(.inline)
554            .onChange(of: selectedPhoto) { _, newItem in
555                guard let newItem else { return }
556                Task {
557                    if let data = try? await newItem.loadTransferable(type: Data.self),
558                       let image = UIImage(data: data) {
559                        avatarPreview = image
560                        if let jpegData = image.jpegData(compressionQuality: 0.85) {
561                            await viewModel.uploadAvatar(jpegData: jpegData)
562                        }
563                    }
564                }
565            }
566            .alert("Remove Avatar?", isPresented: $isShowingRemoveAvatarConfirmation) {
567                Button("Cancel", role: .cancel) {}
568                Button("Remove Avatar", role: .destructive) {
569                    Task {
570                        await viewModel.removeAvatar()
571                        if viewModel.error == nil {
572                            avatarPreview = nil
573                            selectedPhoto = nil
574                        }
575                    }
576                }
577            } message: {
578                Text("Your profile avatar will be removed from SourceHut.")
579            }
580            .toolbar {
581                ToolbarItem(placement: .cancellationAction) {
582                    Button("Cancel") {
583                        dismiss()
584                    }
585                }
586                ToolbarItem(placement: .confirmationAction) {
587                    Button {
588                        Task {
589                            await viewModel.saveProfile(
590                                email: email,
591                                url: url,
592                                location: location,
593                                bio: bio
594                            )
595                            if viewModel.error == nil {
596                                dismiss()
597                            }
598                        }
599                    } label: {
600                        if viewModel.isSavingProfile {
601                            ProgressView()
602                                .controlSize(.small)
603                        } else {
604                            Text("Save")
605                        }
606                    }
607                    .disabled(viewModel.isSavingProfile)
608                }
609            }
610        }
611    }
612}
613
614private enum ProfileDestructiveAction {
615    case deleteSSHKey(SSHKey)
616    case deletePGPKey(PGPKey)
617
618    var title: String {
619        switch self {
620        case .deleteSSHKey:
621            "Remove SSH Key?"
622        case .deletePGPKey:
623            "Remove PGP Key?"
624        }
625    }
626
627    var confirmationLabel: String {
628        switch self {
629        case .deleteSSHKey:
630            "Remove SSH Key"
631        case .deletePGPKey:
632            "Remove PGP Key"
633        }
634    }
635
636    var message: String {
637        switch self {
638        case .deleteSSHKey(let key):
639            "Remove SSH key \(key.fingerprint) from your account?"
640        case .deletePGPKey(let key):
641            "Remove PGP key \(key.fingerprint) from your account?"
642        }
643    }
644}