Share button now opens a popover anchored to the toolbar icon instead of a full modal window. Redesigned the content as a narrow vertical card (icon section headers, avatar-initial rows for members, link card with collapsible title override) sized to fit the People section without scrolling in the common case.
413 lines
15 KiB
Swift
413 lines
15 KiB
Swift
#if os(macOS)
|
|
import AppKit
|
|
import SwiftUI
|
|
import OutlineKit
|
|
|
|
/// Content of the Share popover anchored to the reader toolbar's Share
|
|
/// button (see `DocumentReaderView`'s `.popover(isPresented:)`). Was a
|
|
/// modal `.sheet` originally — moved to a popover so it reads as "options
|
|
/// for this button" instead of interrupting the whole window.
|
|
@MainActor
|
|
struct DocumentShareSheet: View {
|
|
let apiClient: OutlineAPIClient
|
|
let documentId: String
|
|
|
|
@State private var share: OutlineShare?
|
|
@State private var titleOverride = ""
|
|
@State private var isLoadingShare = false
|
|
@State private var isUpdatingShare = false
|
|
@State private var isRevoking = false
|
|
@State private var isShowingRevokeConfirmation = false
|
|
@State private var isShowingTitleField = false
|
|
@State private var shareErrorMessage: String?
|
|
@State private var didCopy = false
|
|
|
|
@State private var members: [OutlineDocumentMember] = []
|
|
@State private var isLoadingMembers = false
|
|
@State private var isShowingAddPerson = false
|
|
@State private var userSearchQuery = ""
|
|
@State private var userSearchResults: [OutlineUser] = []
|
|
@State private var isSearchingUsers = false
|
|
@State private var selectedPermission = "read"
|
|
@State private var isAddingUser = false
|
|
@State private var actionErrorMessage: String?
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
Text("Share")
|
|
.font(.headline)
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 14)
|
|
.padding(.bottom, 10)
|
|
|
|
Divider()
|
|
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 18) {
|
|
shareLinkSection
|
|
peopleSection
|
|
}
|
|
.padding(16)
|
|
}
|
|
.frame(minHeight: 150, maxHeight: 900)
|
|
}
|
|
.frame(width: 280)
|
|
.task { await loadShare() }
|
|
.task { await loadMembers() }
|
|
.task(id: userSearchQuery) {
|
|
try? await Task.sleep(for: .milliseconds(250))
|
|
guard !Task.isCancelled else { return }
|
|
await searchUsers(userSearchQuery)
|
|
}
|
|
.confirmationDialog(
|
|
"Revoke this share link?",
|
|
isPresented: $isShowingRevokeConfirmation,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button("Revoke", role: .destructive) {
|
|
Task { await revoke() }
|
|
}
|
|
Button("Cancel", role: .cancel) {}
|
|
} message: {
|
|
Text("Anyone using this link will no longer be able to access the document.")
|
|
}
|
|
.alert("Couldn't Complete Action", isPresented: .constant(actionErrorMessage != nil)) {
|
|
Button("OK") { actionErrorMessage = nil }
|
|
} message: {
|
|
Text(actionErrorMessage ?? "")
|
|
}
|
|
}
|
|
|
|
// MARK: - Link section
|
|
|
|
@ViewBuilder
|
|
private var shareLinkSection: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
sectionHeader(icon: "link", title: "Public Link")
|
|
|
|
if isLoadingShare {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
.frame(maxWidth: .infinity, alignment: .center)
|
|
} else if let shareErrorMessage {
|
|
Text(shareErrorMessage)
|
|
.font(.callout)
|
|
.foregroundStyle(.red)
|
|
} else if let share {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
if let url = share.url {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "globe")
|
|
.foregroundStyle(.secondary)
|
|
.font(.callout)
|
|
Text(url)
|
|
.font(.callout)
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
Spacer(minLength: 0)
|
|
Button {
|
|
copyLink(url)
|
|
} label: {
|
|
Image(systemName: didCopy ? "checkmark" : "doc.on.doc")
|
|
.font(.callout)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.foregroundStyle(didCopy ? .green : .secondary)
|
|
.help("Copy link")
|
|
}
|
|
}
|
|
|
|
if isShowingTitleField {
|
|
TextField("Public page title", text: $titleOverride)
|
|
.textFieldStyle(.roundedBorder)
|
|
.font(.callout)
|
|
.disabled(isUpdatingShare)
|
|
.onSubmit {
|
|
Task { await setTitle(titleOverride) }
|
|
}
|
|
}
|
|
}
|
|
.padding(10)
|
|
.background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 8))
|
|
|
|
HStack(spacing: 12) {
|
|
Button(isShowingTitleField ? "Hide title field" : "Set public title") {
|
|
isShowingTitleField.toggle()
|
|
}
|
|
.buttonStyle(.plain)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
|
|
Spacer()
|
|
|
|
Button("Revoke", role: .destructive) {
|
|
isShowingRevokeConfirmation = true
|
|
}
|
|
.buttonStyle(.plain)
|
|
.font(.caption)
|
|
.foregroundStyle(.red)
|
|
.disabled(isRevoking)
|
|
}
|
|
} else {
|
|
Button {
|
|
Task { await create() }
|
|
} label: {
|
|
Label("Create Share Link", systemImage: "link.badge.plus")
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.regular)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - People section
|
|
|
|
@ViewBuilder
|
|
private var peopleSection: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack {
|
|
sectionHeader(icon: "person.2", title: "People with Access")
|
|
Spacer()
|
|
Button {
|
|
isShowingAddPerson.toggle()
|
|
} label: {
|
|
Image(systemName: isShowingAddPerson ? "xmark.circle.fill" : "person.badge.plus")
|
|
.font(.callout)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.foregroundStyle(.secondary)
|
|
.help("Add a person")
|
|
}
|
|
|
|
if isShowingAddPerson {
|
|
addPersonSection
|
|
}
|
|
|
|
if isLoadingMembers {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
.frame(maxWidth: .infinity, alignment: .center)
|
|
} else if members.isEmpty {
|
|
Text("No one else has explicit access yet.")
|
|
.font(.callout)
|
|
.foregroundStyle(.secondary)
|
|
} else {
|
|
VStack(spacing: 2) {
|
|
ForEach(members) { member in
|
|
memberRow(member)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func sectionHeader(icon: String, title: String) -> some View {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: icon)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
Text(title)
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(.secondary)
|
|
.textCase(.uppercase)
|
|
}
|
|
}
|
|
|
|
private var addPersonSection: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
TextField("Search people by name or email", text: $userSearchQuery)
|
|
.textFieldStyle(.roundedBorder)
|
|
|
|
Picker("Permission", selection: $selectedPermission) {
|
|
Text("Can view").tag("read")
|
|
Text("Can edit").tag("read_write")
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.labelsHidden()
|
|
|
|
if isSearchingUsers {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
.frame(maxWidth: .infinity, alignment: .center)
|
|
} else if !userSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
if userSearchResults.isEmpty {
|
|
Text("No matches.")
|
|
.font(.callout)
|
|
.foregroundStyle(.secondary)
|
|
} else {
|
|
VStack(spacing: 2) {
|
|
ForEach(userSearchResults) { user in
|
|
Button {
|
|
Task { await addUser(user) }
|
|
} label: {
|
|
HStack(spacing: 8) {
|
|
avatar(for: user.name)
|
|
Text(user.name)
|
|
.font(.callout)
|
|
Spacer(minLength: 0)
|
|
Image(systemName: "plus.circle")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.contentShape(Rectangle())
|
|
.padding(.vertical, 4)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled(isAddingUser)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(10)
|
|
.background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 8))
|
|
}
|
|
|
|
private func memberRow(_ member: OutlineDocumentMember) -> some View {
|
|
HStack(spacing: 8) {
|
|
avatar(for: member.name)
|
|
Text(member.name)
|
|
.font(.callout)
|
|
Spacer(minLength: 0)
|
|
if let permission = member.permission {
|
|
Text(permission == "read_write" ? "Can edit" : "Can view")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 2)
|
|
.background(.fill.tertiary, in: Capsule())
|
|
}
|
|
Button {
|
|
Task { await removeUser(member) }
|
|
} label: {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
|
|
private func avatar(for name: String) -> some View {
|
|
Circle()
|
|
.fill(.fill.secondary)
|
|
.frame(width: 22, height: 22)
|
|
.overlay {
|
|
Text(initials(for: name))
|
|
.font(.system(size: 10, weight: .semibold))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
private func initials(for name: String) -> String {
|
|
let parts = name.split(separator: " ").prefix(2)
|
|
let letters = parts.compactMap { $0.first }
|
|
return letters.isEmpty ? "?" : String(letters).uppercased()
|
|
}
|
|
|
|
private func copyLink(_ url: String) {
|
|
let pasteboard = NSPasteboard.general
|
|
pasteboard.clearContents()
|
|
pasteboard.setString(url, forType: .string)
|
|
didCopy = true
|
|
Task {
|
|
try? await Task.sleep(for: .seconds(1.5))
|
|
didCopy = false
|
|
}
|
|
}
|
|
|
|
private func loadShare() async {
|
|
isLoadingShare = true
|
|
defer { isLoadingShare = false }
|
|
do {
|
|
share = try await apiClient.shareInfo(documentId: documentId)
|
|
titleOverride = share?.title ?? ""
|
|
} catch {
|
|
shareErrorMessage = outlineErrorMessage(error, fallback: "Couldn't load sharing info.")
|
|
}
|
|
}
|
|
|
|
private func create() async {
|
|
isLoadingShare = true
|
|
defer { isLoadingShare = false }
|
|
do {
|
|
share = try await apiClient.createShare(CreateShareRequest(documentId: documentId))
|
|
titleOverride = share?.title ?? ""
|
|
} catch {
|
|
shareErrorMessage = outlineErrorMessage(error, fallback: "Couldn't create a share link.")
|
|
}
|
|
}
|
|
|
|
private func setTitle(_ title: String) async {
|
|
guard let share else { return }
|
|
let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard trimmed != (share.title ?? "") else { return }
|
|
isUpdatingShare = true
|
|
defer { isUpdatingShare = false }
|
|
do {
|
|
self.share = try await apiClient.updateShare(
|
|
UpdateShareRequest(id: share.id, published: share.published, title: trimmed)
|
|
)
|
|
} catch {
|
|
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update this share link.")
|
|
}
|
|
}
|
|
|
|
private func revoke() async {
|
|
guard let share else { return }
|
|
isRevoking = true
|
|
defer { isRevoking = false }
|
|
do {
|
|
try await apiClient.revokeShare(id: share.id)
|
|
self.share = nil
|
|
titleOverride = ""
|
|
isShowingTitleField = false
|
|
} catch {
|
|
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't revoke this share link.")
|
|
}
|
|
}
|
|
|
|
private func loadMembers() async {
|
|
isLoadingMembers = true
|
|
defer { isLoadingMembers = false }
|
|
members = (try? await apiClient.documentUsers(ListDocumentUsersRequest(id: documentId))) ?? []
|
|
}
|
|
|
|
private func searchUsers(_ query: String) async {
|
|
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else {
|
|
userSearchResults = []
|
|
return
|
|
}
|
|
isSearchingUsers = true
|
|
defer { isSearchingUsers = false }
|
|
userSearchResults = (try? await apiClient.listUsers(ListUsersRequest(query: trimmed))) ?? []
|
|
}
|
|
|
|
private func addUser(_ user: OutlineUser) async {
|
|
isAddingUser = true
|
|
defer { isAddingUser = false }
|
|
do {
|
|
_ = try await apiClient.addDocumentUser(
|
|
AddDocumentUserRequest(id: documentId, userId: user.id, permission: selectedPermission)
|
|
)
|
|
userSearchQuery = ""
|
|
userSearchResults = []
|
|
isShowingAddPerson = false
|
|
await loadMembers()
|
|
} catch {
|
|
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't add this person.")
|
|
}
|
|
}
|
|
|
|
private func removeUser(_ member: OutlineDocumentMember) async {
|
|
do {
|
|
try await apiClient.removeDocumentUser(RemoveDocumentUserRequest(id: documentId, userId: member.id))
|
|
await loadMembers()
|
|
} catch {
|
|
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't remove this person.")
|
|
}
|
|
}
|
|
}
|
|
#endif
|