krz/hutch

an ios client for sourcehut

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

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