krz/hutch

an ios client for sourcehut

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

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