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:
@@ -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
|
||||||
@@ -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 {
|
||||||
labeledRow("Workspace", teamName)
|
Divider().frame(maxWidth: 420)
|
||||||
}
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user