krz/hutch

an ios client for sourcehut

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

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