feat(profile): avatar upload/remove with crop editor, name editing

Profile now has a real avatar row (AvatarBadge + Upload Photo…/Remove),
wired through last commit's presigned-upload backend: pick a file via
NSOpenPanel, crop/rotate/zoom in a new AvatarCropperView, upload,
point users.update at the result, then best-effort delete whatever
attachment the previous avatar pointed to so replacing/removing a
photo doesn't leak an orphaned blob in Outline's storage every time.

AvatarCropperView builds its on-screen preview and its final exported
image from the exact same SwiftUI view composition (just instantiated
twice — once for display, once through ImageRenderer) rather than a
separately hand-derived set of crop math for a higher resolution —
that's deliberate: there's no way to visually verify a second
independent set of transform math agrees with what the user actually
saw and confirmed without running the app, so making the export
WYSIWYG by construction was the safer choice here.

Name is now editable too (TextField + Save, users.update name-only).
Email is read-only with a note pointing to Outline's web app instead —
per instruction, changing it here isn't supported since email is
tied to sign-in.

SessionStore gained userId (never stored before — needed for every
users.update call) and applyUpdatedProfile(_:), so a successful
change reflects immediately in the account footer and everywhere
else without waiting for the next auth.info refresh.
This commit is contained in:
2026-08-15 16:27:01 +01:00
parent 052db93d69
commit 077042295b
4 changed files with 328 additions and 10 deletions
@@ -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
+185 -8
View File
@@ -1,4 +1,5 @@
#if os(macOS) #if os(macOS)
import AppKit
import SwiftUI import SwiftUI
import OutlineKit import OutlineKit
@@ -24,6 +25,12 @@ struct SettingsView: View {
@State private var didClearCache = false @State private var didClearCache = false
@State private var lastFullSyncSummary: FullSyncSummary? @State private var lastFullSyncSummary: FullSyncSummary?
@State private var lastFlushSummary: SyncFlushSummary? @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 /// Full Local Sync and cache-clearing both need a real connection to be
/// safe clearing while offline (or letting Full Local Sync think it /// safe clearing while offline (or letting Full Local Sync think it
@@ -109,23 +116,107 @@ struct SettingsView: View {
// MARK: - Profile // MARK: - Profile
private var profileDetail: some View { private var profileDetail: some View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 24) {
sectionHeader sectionHeader
VStack(alignment: .leading, spacing: 10) { avatarRow
labeledRow("Signed in as", session.userName ?? "") Divider().frame(maxWidth: 420)
if let email = session.userEmail { nameRow
labeledRow("Email", email) Divider().frame(maxWidth: 420)
} emailRow
if let teamName = session.teamName { if let teamName = session.teamName {
Divider().frame(maxWidth: 420)
labeledRow("Workspace", teamName) labeledRow("Workspace", teamName)
}
}
.frame(maxWidth: 420) .frame(maxWidth: 420)
}
Button("Log Out…", role: .destructive) { Button("Log Out…", role: .destructive) {
isShowingLogoutConfirmation = true 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 // MARK: - Offline & Sync
@@ -432,5 +523,91 @@ struct SettingsView: View {
didClearCache = false 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=<attachment-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 #endif
+13
View File
@@ -11,6 +11,7 @@ final class SessionStore {
private let defaults: UserDefaults private let defaults: UserDefaults
var isSignedIn: Bool var isSignedIn: Bool
private(set) var userId: String?
var userName: String? var userName: String?
var userEmail: String? var userEmail: String?
var userAvatarURL: URL? var userAvatarURL: URL?
@@ -68,6 +69,7 @@ final class SessionStore {
try? tokenStore.clear() try? tokenStore.clear()
defaults.removeObject(forKey: Self.serverURLDefaultsKey) defaults.removeObject(forKey: Self.serverURLDefaultsKey)
isSignedIn = false isSignedIn = false
userId = nil
userName = nil userName = nil
userEmail = nil userEmail = nil
userAvatarURL = nil userAvatarURL = nil
@@ -85,7 +87,18 @@ final class SessionStore {
apply(user: auth.user, team: auth.team, serverURL: serverURL) 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) { private func apply(user: OutlineUser, team: OutlineTeam, serverURL: URL) {
userId = user.id
userName = user.name userName = user.name
userEmail = user.email userEmail = user.email
// Outline can return either an absolute URL or a server-relative path // Outline can return either an absolute URL or a server-relative path
+14
View File
@@ -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