Files
Outpost/Outpost/Features/Collections/DocumentShareSheet.swift
T
Puranjay Savar Mattas 9ca1c77bb9 refactor(share): popover instead of modal sheet, redesigned layout
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.
2026-08-14 22:06:04 +01:00

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