diff --git a/Outpost/Features/Account/AvatarCropperView.swift b/Outpost/Features/Account/AvatarCropperView.swift new file mode 100644 index 0000000..a66e20f --- /dev/null +++ b/Outpost/Features/Account/AvatarCropperView.swift @@ -0,0 +1,114 @@ +#if os(macOS) +import AppKit +import SwiftUI + +/// Crop/rotate/zoom editor shown after picking a photo, before it's +/// uploaded — pan (drag), zoom (pinch or the slider), and 90°-increment +/// rotate, all inside a circular mask matching how the avatar actually +/// renders everywhere else in the app. +/// +/// The on-screen preview and the final exported image are built from the +/// exact same view composition (`avatarContent`), just instantiated once +/// for display and once inside an `ImageRenderer` — that's deliberate: +/// hand-deriving a separate set of crop-math for a higher-resolution +/// render would risk it silently disagreeing with what the user actually +/// saw and confirmed, and there's no way to visually verify that +/// agreement without running the app. Reusing the identical view tree +/// makes the export WYSIWYG by construction instead of by careful math. +struct AvatarCropperView: View { + let sourceImage: NSImage + let onConfirm: (Data) -> Void + let onCancel: () -> Void + + @State private var scale: CGFloat = 1 + @State private var offset: CGSize = .zero + @State private var rotationDegrees: Double = 0 + @GestureState private var dragTranslation: CGSize = .zero + + /// Used for both the live preview and the exported image — see the + /// type-level doc comment for why that's the same size, not two. + private let diameter: CGFloat = 320 + + var body: some View { + VStack(spacing: 20) { + Text("Edit Photo") + .font(.headline) + + ZStack { + avatarContent + .clipShape(Circle()) + Circle() + .strokeBorder(Color.primary.opacity(0.15), lineWidth: 1) + } + .frame(width: diameter, height: diameter) + .contentShape(Circle()) + .gesture( + DragGesture() + .updating($dragTranslation) { value, state, _ in state = value.translation } + .onEnded { value in + offset.width += value.translation.width + offset.height += value.translation.height + } + ) + + HStack(spacing: 16) { + Button { + withAnimation(.easeInOut(duration: 0.2)) { rotationDegrees -= 90 } + } label: { + Image(systemName: "rotate.left") + } + .help("Rotate left") + + Slider(value: $scale, in: 1...4) + .frame(width: 140) + + Button { + withAnimation(.easeInOut(duration: 0.2)) { rotationDegrees += 90 } + } label: { + Image(systemName: "rotate.right") + } + .help("Rotate right") + } + + HStack { + Button("Cancel", role: .cancel, action: onCancel) + Spacer() + Button("Use Photo") { + if let data = renderFinalImage() { + onConfirm(data) + } + } + .buttonStyle(.borderedProminent) + } + } + .padding(24) + .frame(width: 360) + } + + /// Aspect-fills `sourceImage` into a `diameter`×`diameter` square, then + /// applies the user's pan/zoom/rotation on top — identical between the + /// live preview and the final render (see the type-level doc comment). + private var avatarContent: some View { + Image(nsImage: sourceImage) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: diameter, height: diameter) + .scaleEffect(scale) + .rotationEffect(.degrees(rotationDegrees)) + .offset(x: offset.width + dragTranslation.width, y: offset.height + dragTranslation.height) + .frame(width: diameter, height: diameter) + .clipped() + } + + @MainActor + private func renderFinalImage() -> Data? { + let content = avatarContent + .clipShape(Circle()) + .frame(width: diameter, height: diameter) + let renderer = ImageRenderer(content: content) + renderer.scale = 2 // @2x so it isn't a blurry 320px avatar on Retina displays + guard let nsImage = renderer.nsImage else { return nil } + return nsImage.jpegData(compressionQuality: 0.9) + } +} +#endif diff --git a/Outpost/Features/Account/SettingsView.swift b/Outpost/Features/Account/SettingsView.swift index 3817cc1..095f996 100644 --- a/Outpost/Features/Account/SettingsView.swift +++ b/Outpost/Features/Account/SettingsView.swift @@ -1,4 +1,5 @@ #if os(macOS) +import AppKit import SwiftUI import OutlineKit @@ -24,6 +25,12 @@ struct SettingsView: View { @State private var didClearCache = false @State private var lastFullSyncSummary: FullSyncSummary? @State private var lastFlushSummary: SyncFlushSummary? + @State private var pickedPhoto: PickedPhoto? + @State private var isUploadingAvatar = false + @State private var avatarErrorMessage: String? + @State private var editableName = "" + @State private var isSavingName = false + @State private var nameErrorMessage: String? /// Full Local Sync and cache-clearing both need a real connection to be /// safe — clearing while offline (or letting Full Local Sync think it @@ -109,23 +116,107 @@ struct SettingsView: View { // MARK: - Profile private var profileDetail: some View { - VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 24) { sectionHeader - VStack(alignment: .leading, spacing: 10) { - labeledRow("Signed in as", session.userName ?? "—") - if let email = session.userEmail { - labeledRow("Email", email) - } - if let teamName = session.teamName { - labeledRow("Workspace", teamName) - } + avatarRow + Divider().frame(maxWidth: 420) + nameRow + Divider().frame(maxWidth: 420) + emailRow + if let teamName = session.teamName { + Divider().frame(maxWidth: 420) + labeledRow("Workspace", teamName) + .frame(maxWidth: 420) } - .frame(maxWidth: 420) Button("Log Out…", role: .destructive) { isShowingLogoutConfirmation = true } } + .sheet(item: $pickedPhoto) { picked in + AvatarCropperView( + sourceImage: picked.image, + onConfirm: { data in + pickedPhoto = nil + Task { await uploadAvatar(data: data) } + }, + onCancel: { pickedPhoto = nil } + ) + } + } + + private var avatarRow: some View { + HStack(spacing: 16) { + AvatarBadge(avatarURL: session.userAvatarURL, size: 64, placeholderSystemImage: "person.crop.circle.fill") + + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 10) { + Button(isUploadingAvatar ? "Uploading…" : "Upload Photo…") { pickPhoto() } + .disabled(isUploadingAvatar) + if session.userAvatarURL != nil { + Button("Remove", role: .destructive) { Task { await removeAvatar() } } + .buttonStyle(.plain) + .foregroundStyle(.red) + .disabled(isUploadingAvatar) + } + if isUploadingAvatar { + ProgressView().controlSize(.small) + } + } + if let avatarErrorMessage { + Text(avatarErrorMessage) + .font(.caption) + .foregroundStyle(.red) + } + } + } + } + + private var nameRow: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Name") + .font(.caption) + .foregroundStyle(.secondary) + HStack(spacing: 10) { + TextField("Name", text: $editableName) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: 280) + .onSubmit { Task { await saveName() } } + if editableName != (session.userName ?? "") && !editableName.trimmingCharacters(in: .whitespaces).isEmpty { + if isSavingName { + ProgressView().controlSize(.small) + } else { + Button("Save") { Task { await saveName() } } + .controlSize(.small) + } + } + } + if let nameErrorMessage { + Text(nameErrorMessage) + .font(.caption) + .foregroundStyle(.red) + } + } + .frame(maxWidth: 420, alignment: .leading) + .task { editableName = session.userName ?? "" } + } + + private var emailRow: some View { + VStack(alignment: .leading, spacing: 6) { + labeledRow("Email", session.userEmail ?? "—") + HStack(spacing: 4) { + Text("Email is tied to sign-in, so it can't be changed here — use") + if let serverURL = session.serverURL { + Link("Outline on the web", destination: serverURL) + } else { + Text("Outline on the web") + } + Text("instead.") + } + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: 420, alignment: .leading) } // MARK: - Offline & Sync @@ -432,5 +523,91 @@ struct SettingsView: View { didClearCache = false } } + + // MARK: - Profile actions + + private func pickPhoto() { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.png, .jpeg, .heic, .image] + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + panel.canChooseFiles = true + guard panel.runModal() == .OK, let url = panel.url, let image = NSImage(contentsOf: url) else { return } + pickedPhoto = PickedPhoto(image: image) + } + + private func uploadAvatar(data: Data) async { + guard let apiClient = session.apiClient, let userId = session.userId else { return } + isUploadingAvatar = true + defer { isUploadingAvatar = false } + let previousAttachmentId = attachmentId(from: session.userAvatarURL) + do { + let target = try await apiClient.createAttachment( + CreateAttachmentRequest(name: "avatar.jpg", contentType: "image/jpeg", size: data.count) + ) + try await apiClient.uploadAttachmentFile(target, fileData: data) + let updated = try await apiClient.updateUserAvatar( + UpdateUserAvatarRequest(id: userId, avatarUrl: target.attachment.url) + ) + session.applyUpdatedProfile(updated) + avatarErrorMessage = nil + // Best-effort — the new avatar is already live either way, this + // just stops the old upload from sitting around unreferenced. + if let previousAttachmentId { + try? await apiClient.deleteAttachment(id: previousAttachmentId) + } + } catch { + avatarErrorMessage = outlineErrorMessage(error, fallback: "Couldn't upload this photo.") + } + } + + private func removeAvatar() async { + guard let apiClient = session.apiClient, let userId = session.userId else { return } + isUploadingAvatar = true + defer { isUploadingAvatar = false } + let previousAttachmentId = attachmentId(from: session.userAvatarURL) + do { + let updated = try await apiClient.updateUserAvatar(UpdateUserAvatarRequest(id: userId, avatarUrl: nil)) + session.applyUpdatedProfile(updated) + avatarErrorMessage = nil + if let previousAttachmentId { + try? await apiClient.deleteAttachment(id: previousAttachmentId) + } + } catch { + avatarErrorMessage = outlineErrorMessage(error, fallback: "Couldn't remove this photo.") + } + } + + /// The avatar URL is `/api/attachments.redirect?id=` — + /// pulling the id back out of it is how the previous attachment gets + /// found for cleanup, since nothing else keeps a record of it. + private func attachmentId(from avatarURL: URL?) -> String? { + guard let avatarURL, let components = URLComponents(url: avatarURL, resolvingAgainstBaseURL: false) else { + return nil + } + return components.queryItems?.first(where: { $0.name == "id" })?.value + } + + private func saveName() async { + let trimmed = editableName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, trimmed != (session.userName ?? ""), let apiClient = session.apiClient, let userId = session.userId else { return } + isSavingName = true + defer { isSavingName = false } + do { + let updated = try await apiClient.updateUserName(UpdateUserNameRequest(id: userId, name: trimmed)) + session.applyUpdatedProfile(updated) + editableName = updated.name + nameErrorMessage = nil + } catch { + nameErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update your name.") + } + } +} + +/// Wraps a picked `NSImage` so it can drive `.sheet(item:)` — `NSImage` +/// itself isn't `Identifiable`. +private struct PickedPhoto: Identifiable { + let id = UUID() + let image: NSImage } #endif diff --git a/Outpost/Root/SessionStore.swift b/Outpost/Root/SessionStore.swift index d815378..7a6f0f1 100644 --- a/Outpost/Root/SessionStore.swift +++ b/Outpost/Root/SessionStore.swift @@ -11,6 +11,7 @@ final class SessionStore { private let defaults: UserDefaults var isSignedIn: Bool + private(set) var userId: String? var userName: String? var userEmail: String? var userAvatarURL: URL? @@ -68,6 +69,7 @@ final class SessionStore { try? tokenStore.clear() defaults.removeObject(forKey: Self.serverURLDefaultsKey) isSignedIn = false + userId = nil userName = nil userEmail = nil userAvatarURL = nil @@ -85,7 +87,18 @@ final class SessionStore { apply(user: auth.user, team: auth.team, serverURL: serverURL) } + /// Settings calls this after a successful name/avatar change so the + /// sidebar's account footer and everywhere else reading these reflect + /// it immediately, without waiting for the next `auth.info` refresh. + func applyUpdatedProfile(_ user: OutlineUser) { + guard let serverURL else { return } + userId = user.id + userName = user.name + userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL } + } + private func apply(user: OutlineUser, team: OutlineTeam, serverURL: URL) { + userId = user.id userName = user.name userEmail = user.email // Outline can return either an absolute URL or a server-relative path diff --git a/Outpost/Support/NSImage+JPEGData.swift b/Outpost/Support/NSImage+JPEGData.swift new file mode 100644 index 0000000..9f20459 --- /dev/null +++ b/Outpost/Support/NSImage+JPEGData.swift @@ -0,0 +1,14 @@ +#if os(macOS) +import AppKit + +extension NSImage { + /// `NSImage` has no built-in JPEG encoder (unlike `UIImage`) — routes + /// through a bitmap representation to get one. + func jpegData(compressionQuality: CGFloat) -> Data? { + guard let tiffData = tiffRepresentation, let bitmap = NSBitmapImageRep(data: tiffData) else { + return nil + } + return bitmap.representation(using: .jpeg, properties: [.compressionFactor: compressionQuality]) + } +} +#endif