krz/hutch

an ios client for sourcehut

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

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