ProfileView.swift (7218B)
1 import SwiftUI 2 3 public struct ProfileView: View { 4 @EnvironmentObject private var app: AppState 5 6 @State private var name: String = "" 7 @State private var displayName: String = "" 8 @State private var nip05: String = "" 9 @State private var about: String = "" 10 11 @State private var original: OriginalProfile = .empty 12 @State private var isLoading: Bool = false 13 @State private var isPosting: Bool = false 14 @State private var profileReadMessage: String? 15 @State private var postMessage: String? 16 @State private var showMessage: Bool = false 17 @FocusState private var focusedField: Field? 18 19 enum Field: Hashable { case name, displayName, nip05, about } 20 21 public init() {} 22 23 public var body: some View { 24 Form { 25 Section(header: Text("Profile")) { 26 VStack(alignment: .leading, spacing: 8) { 27 Text("name").font(.footnote).foregroundStyle(.secondary) 28 TextField("name", text: $name) 29 .textInputAutocapitalization(.never) 30 .autocorrectionDisabled() 31 .submitLabel(.next) 32 .focused($focusedField, equals: .name) 33 .onSubmit { focusedField = .displayName } 34 } 35 36 VStack(alignment: .leading, spacing: 8) { 37 Text("display_name").font(.footnote).foregroundStyle(.secondary) 38 TextField("display name", text: $displayName) 39 .textInputAutocapitalization(.words) 40 .autocorrectionDisabled() 41 .submitLabel(.next) 42 .focused($focusedField, equals: .displayName) 43 .onSubmit { focusedField = .nip05 } 44 } 45 46 VStack(alignment: .leading, spacing: 8) { 47 Text("nip05").font(.footnote).foregroundStyle(.secondary) 48 TextField("user@example.com", text: $nip05) 49 .keyboardType(.emailAddress) 50 .textInputAutocapitalization(.never) 51 .autocorrectionDisabled() 52 .submitLabel(.next) 53 .focused($focusedField, equals: .nip05) 54 .onSubmit { focusedField = .about } 55 } 56 57 VStack(alignment: .leading, spacing: 8) { 58 Text("about").font(.footnote).foregroundStyle(.secondary) 59 TextEditor(text: $about) 60 .frame(minHeight: 120) 61 .focused($focusedField, equals: .about) 62 } 63 } 64 65 Section { 66 SectionWideButton("Post Kind 0", enabled: isPostEnabled, isProminent: hasChanges) { 67 post() 68 } 69 .animation(.easeInOut(duration: 0.15), value: hasChanges) 70 } footer: { 71 VStack(alignment: .leading, spacing: 6) { 72 if let profileReadMessage { 73 Text(profileReadMessage) 74 } 75 if !isConnected { 76 Text("No relays connected. Connect to at least one relay to post.") 77 } 78 if let msg = postMessage { 79 Text(msg) 80 } 81 } 82 } 83 } 84 .listStyle(.insetGrouped) 85 .scrollDismissesKeyboard(.interactively) 86 .inlineNavigationTitle("Profile") 87 .toolbar { 88 ToolbarItem(placement: .topBarTrailing) { 89 if isLoading || isPosting { ProgressView() } 90 } 91 ToolbarItemGroup(placement: .keyboard) { 92 Spacer() 93 Button("Done") { focusedField = nil } 94 } 95 } 96 .onAppear { loadProfile() } 97 .refreshable { loadProfile() } 98 .alert("Post Result", isPresented: $showMessage) { 99 Button("OK", role: .cancel) { } 100 } message: { 101 Text(postMessage ?? "") 102 } 103 } 104 105 private var isConnected: Bool { app.relayConnectedCount > 0 } 106 private var isPostEnabled: Bool { isConnected && !isPosting } 107 private var hasChanges: Bool { 108 name != original.name || 109 displayName != original.displayName || 110 nip05 != original.nip05 || 111 about != original.about 112 } 113 114 private func loadProfile() { 115 guard let service = app.runtimeService else { return } 116 isLoading = true 117 Task { 118 do { 119 let meta = try await service.nostrProfileForSelf() 120 let loaded = OriginalProfile.from(meta) 121 await MainActor.run { 122 self.original = loaded 123 self.name = loaded.name 124 self.displayName = loaded.displayName 125 self.nip05 = loaded.nip05 126 self.about = loaded.about 127 self.profileReadMessage = meta == nil ? "No profile event found yet." : nil 128 self.isLoading = false 129 } 130 } catch { 131 await MainActor.run { 132 self.profileReadMessage = error.fieldRuntimeMessage 133 self.isLoading = false 134 } 135 } 136 } 137 } 138 139 private func post() { 140 guard let service = app.runtimeService, isPostEnabled else { return } 141 isPosting = true 142 postMessage = nil 143 let payload = PostPayload(name: name, displayName: displayName, nip05: nip05, about: about) 144 Task { 145 do { 146 let id = try await service.nostrPostProfile( 147 name: payload.name, 148 displayName: payload.displayName, 149 nip05: payload.nip05, 150 about: payload.about 151 ) 152 await MainActor.run { 153 self.original = OriginalProfile(name: name, displayName: displayName, nip05: nip05, about: about) 154 self.isPosting = false 155 self.postMessage = "Posted kind:0 event: \(id.rawValue)" 156 self.showMessage = true 157 self.app.refresh() 158 } 159 } catch { 160 await MainActor.run { 161 self.isPosting = false 162 self.postMessage = "Failed to post profile: \(error.fieldRuntimeMessage)" 163 self.showMessage = true 164 } 165 } 166 } 167 } 168 } 169 170 private struct OriginalProfile: Equatable { 171 var name: String 172 var displayName: String 173 var nip05: String 174 var about: String 175 176 static let empty = OriginalProfile(name: "", displayName: "", nip05: "", about: "") 177 178 static func from(_ p: NostrProfileEventMetadata?) -> OriginalProfile { 179 OriginalProfile( 180 name: p?.profile.name ?? "", 181 displayName: p?.profile.displayName ?? "", 182 nip05: p?.profile.nip05 ?? "", 183 about: p?.profile.about ?? "" 184 ) 185 } 186 } 187 188 private struct PostPayload { 189 var name: String 190 var displayName: String 191 var nip05: String 192 var about: String 193 }