import NukeUI import PhotosUI import SwiftUI struct EditProfileView: View { @Environment(AuthManager.self) private var auth @Environment(\.dismiss) private var dismiss let client: XRPCClient var onSaved: (() -> Void)? @State private var displayName = "" @State private var bio = "" @State private var existingAvatarURL: String? @State private var existingAvatarBlob: [String: AnyCodable]? @State private var selectedPhoto: PhotosPickerItem? @State private var newAvatarData: Data? @State private var newAvatarImage: UIImage? @State private var removeAvatar = false @State private var isLoading = true @State private var isSaving = false @State private var errorMessage: String? @State private var mentionState = MentionAutocompleteState() private let maxDisplayName = 64 private let maxBio = 256 var body: some View { Form { // Avatar section Section { HStack { Spacer() VStack(spacing: 12) { ZStack(alignment: .bottomTrailing) { if let newAvatarImage { Image(uiImage: newAvatarImage) .resizable() .scaledToFill() .frame(width: 100, height: 100) .clipShape(Circle()) } else if !removeAvatar, let existingAvatarURL { AvatarView(url: existingAvatarURL, size: 100) } else { ZStack { Circle().fill(.quaternary) Image(systemName: "person.fill") .font(.system(size: 40)) .foregroundStyle(.tertiary) } .frame(width: 100, height: 100) } PhotosPicker(selection: $selectedPhoto, matching: .images) { Image(systemName: "camera.fill") .font(.system(size: 14)) .foregroundStyle(.white) .frame(width: 32, height: 32) .background(Color.accentColor, in: Circle()) } .accessibilityLabel("Change profile photo") } if newAvatarImage != nil || (!removeAvatar && existingAvatarURL != nil) { Button("Remove photo", role: .destructive) { newAvatarData = nil newAvatarImage = nil selectedPhoto = nil removeAvatar = true } .font(.caption) } } Spacer() } .listRowBackground(Color.clear) } // Fields Section { VStack(alignment: .leading, spacing: 4) { Text("Display name") .font(.caption) .foregroundStyle(.secondary) TextField("Display name", text: $displayName) .textInputAutocapitalization(.words) Text("\(displayName.count)/\(maxDisplayName)") .font(.caption2) .foregroundStyle(displayName.count > maxDisplayName ? .red : .secondary) .frame(maxWidth: .infinity, alignment: .trailing) } VStack(alignment: .leading, spacing: 4) { Text("Bio") .font(.caption) .foregroundStyle(.secondary) TextEditor(text: $bio) .frame(minHeight: 100) .onChange(of: bio) { mentionState.update(text: bio) } Text("\(bio.count)/\(maxBio)") .font(.caption2) .foregroundStyle(bio.count > maxBio ? .red : .secondary) .frame(maxWidth: .infinity, alignment: .trailing) } } if let errorMessage { Section { Text(errorMessage) .foregroundStyle(.red) .font(.caption) } } } .safeAreaInset(edge: .bottom) { MentionSuggestionOverlay(state: mentionState) { suggestion in mentionState.complete(handle: suggestion.handle, in: &bio) } } .navigationTitle("Edit profile") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button { dismiss() } label: { Image(systemName: "xmark") } .tint(.primary) } ToolbarItem(placement: .topBarTrailing) { Button { Task { await save() } } label: { if isSaving { ProgressView() } else { Text("Save") .fontWeight(.semibold) } } .buttonStyle(.glassProminent) .disabled(isSaving || displayName.count > maxDisplayName || bio.count > maxBio) } } .onChange(of: selectedPhoto) { Task { await loadSelectedPhoto() } } .task { guard !isPreview else { return } await loadProfile() } .overlay { if isLoading { ProgressView() } } } private func loadProfile() async { guard let did = auth.userDID, let authContext = await auth.authContext() else { return } do { let profile = try await client.getActorProfile(actor: did, auth: authContext) displayName = profile.displayName ?? "" bio = profile.description ?? "" existingAvatarURL = profile.avatar // Fetch raw record to get avatar blob ref for preservation on save let record = try await client.getRecord(uri: "at://\(did)/social.grain.actor.profile/self", auth: authContext) if let avatar = record.value?.dictValue?["avatar"] { existingAvatarBlob = avatar.dictValue } } catch { errorMessage = "Failed to load profile" } isLoading = false } private func loadSelectedPhoto() async { guard let selectedPhoto else { return } do { if let data = try await selectedPhoto.loadTransferable(type: Data.self), let image = UIImage(data: data) { let resized = resizeImage(image, maxSize: 1000, maxBytes: 900_000) newAvatarImage = UIImage(data: resized) newAvatarData = resized removeAvatar = false } } catch { errorMessage = "Failed to load selected photo" } } private func save() async { guard let authContext = await auth.authContext() else { return } isSaving = true errorMessage = nil do { var avatarValue: AnyCodable? if let newAvatarData { // Upload new avatar let response = try await client.uploadBlob(data: newAvatarData, mimeType: "image/jpeg", auth: authContext) avatarValue = blobRefToAnyCodable(response.blob) } else if removeAvatar { avatarValue = nil } else if let existingAvatarBlob { // Preserve existing avatar avatarValue = AnyCodable(existingAvatarBlob) } var record: [String: AnyCodable] = [ "createdAt": AnyCodable(DateFormatting.nowISO()), ] let trimmedName = displayName.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmedName.isEmpty { record["displayName"] = AnyCodable(trimmedName) } let trimmedBio = bio.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmedBio.isEmpty { record["description"] = AnyCodable(trimmedBio) } if let avatarValue { record["avatar"] = avatarValue } _ = try await client.putRecord( collection: "social.grain.actor.profile", rkey: "self", record: AnyCodable(record), auth: authContext ) await auth.refreshAvatar() onSaved?() dismiss() } catch { errorMessage = "Failed to save: \(error.localizedDescription)" } isSaving = false } func blobRefToAnyCodable(_ blob: BlobRef) -> AnyCodable { var dict: [String: AnyCodable] = [ "$type": AnyCodable("blob"), ] if let mimeType = blob.mimeType { dict["mimeType"] = AnyCodable(mimeType) } if let size = blob.size { dict["size"] = AnyCodable(size) } if let ref = blob.ref { dict["ref"] = AnyCodable(["$link": AnyCodable(ref.link)]) } return AnyCodable(dict) } /// Shrink `image` to fit `maxSize` on its long edge and compress it under /// `maxBytes`. /// /// Both measurements are in pixels. `UIImage.size` is in points, and a /// renderer left on its default scale draws at the screen's — so on a 3x /// phone this used to hand back a 3000px JPEG for a `maxSize` of 1000, then /// spend the whole byte budget compressing those extra pixels. The avatar /// came out both larger and blurrier than asked for. /// `ImageProcessing.resizeImage` works in pixels for the same reason. func resizeImage(_ image: UIImage, maxSize: CGFloat, maxBytes: Int) -> Data { let pixelWidth = image.size.width * image.scale let pixelHeight = image.size.height * image.scale let scale = min(maxSize / pixelWidth, maxSize / pixelHeight, 1.0) let newSize = CGSize(width: round(pixelWidth * scale), height: round(pixelHeight * scale)) let format = UIGraphicsImageRendererFormat() format.scale = 1 let renderer = UIGraphicsImageRenderer(size: newSize, format: format) let rendered = renderer.image { _ in image.draw(in: CGRect(origin: .zero, size: newSize)) } // Binary search for quality that fits under maxBytes var low: CGFloat = 0.1 var high: CGFloat = 0.95 var result = rendered.jpegData(compressionQuality: high) ?? Data() if result.count <= maxBytes { return result } for _ in 0 ..< 8 { let mid = (low + high) / 2 let data = rendered.jpegData(compressionQuality: mid) ?? Data() if data.count <= maxBytes { result = data low = mid } else { high = mid } } return result } } #Preview { EditProfileView(client: .preview) .previewEnvironments() }