Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion App/Composition/AppEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -487,7 +487,12 @@ final class AppEnvironment: ObservableObject {
// deltas stay consistent (stale-while-revalidate paint).
store: documentStore,
// Live image ceilings for `uploadImage` prep (G14 tail).
contentLimits: contentLimits
contentLimits: contentLimits,
// work-consolidation.md G24 — `POST /api/documents/folders/{id}/
// documents` is subscriber-gated upstream. Same live box the
// messages gate reads, so a mid-session subscribe or lapse re-gates
// creating documents in a folder without a relaunch.
entitlementsProvider: { liveEntitlements.current() }
)
// Server document templates (work-consolidation.md G12). Reuses the same
// kit-layer `APIClient` like the other services do — the
Expand Down
103 changes: 103 additions & 0 deletions App/Features/Documents/DocumentInviteDeepLink.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// DocumentInviteDeepLink
//
// Turns an opened document-invite URL into a routed presentation of
// `DocumentInviteView` (work-consolidation.md G24). The address is the `url`
// the server puts in the invite email:
// `https://interlinedlist.com/documents/invite/{token}`.
//
// Deliberately separate from `ShareURLParser` / `ShareLinkDeepLink` in the
// Sharing feature rather than an extra case on them. A share link and an email
// invite are different objects with different routes, different response
// shapes and — critically — different capabilities: a share link can be
// claimed from this app, an invite cannot (see `DocumentInviteViewModel`).
// Folding them into one parser would put a claim button one boolean away from
// a surface that must never show one.
//
// Mirrors the project's deep-link convention: a `Notification.Name` colocated
// with the feature, a static poster so the URL handler in `InterlinedListApp`
// stays a one-liner, and `MainWindowView` owning the sheet presentation.
//
// Decision 0003: App-layer only; this file needs nothing but Foundation.

import Foundation

/// An invite reference extracted from a URL — the opaque token to resolve.
struct ParsedDocumentInvite: Equatable {
let token: String
}

enum DocumentInviteURLParser {

/// The `interlinedlist://` custom scheme the app registers, shared with
/// the OAuth callback and the share-link handler.
static let scheme = "interlinedlist"

/// Parses a URL into a `ParsedDocumentInvite`, or `nil` when it is not a
/// document invite link. Accepts the `https` web address the invite email
/// carries and the custom scheme, and tolerates the resource word landing
/// in the host (`interlinedlist://documents/invite/{token}` parses
/// "documents" as the host, not a path segment).
static func parse(_ url: URL) -> ParsedDocumentInvite? {
var segments: [String] = []
if let host = url.host,
!host.isEmpty,
host != "interlinedlist.com",
host != "www.interlinedlist.com" {
segments.append(host)
}
segments.append(contentsOf: url.pathComponents.filter { $0 != "/" && !$0.isEmpty })
return match(segments)
}

/// Parses a raw string (e.g. pasted from an invite email). Trims first so
/// a pasted line with a trailing newline still parses.
static func parse(string: String) -> ParsedDocumentInvite? {
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let url = URL(string: trimmed) else { return nil }
return parse(url)
}

// MARK: - Matching

/// Matches `[…, "documents", "invite", <token>]` at the tail.
///
/// `documents` is required, not optional: `/lists/invite/{token}` is a
/// *list* invite with its own route, and quietly treating it as a document
/// invite would resolve the wrong resource.
private static func match(_ segments: [String]) -> ParsedDocumentInvite? {
let cleaned = segments.filter { $0.lowercased() != "share" }
guard cleaned.count >= 3 else { return nil }
let tail = Array(cleaned.suffix(3))
guard tail[0].lowercased() == "documents",
tail[1].lowercased() == "invite",
!tail[2].isEmpty else { return nil }
return ParsedDocumentInvite(token: tail[2])
}
}

extension Foundation.Notification.Name {
/// Posted when an opened URL resolves to a document invite. `object` is the
/// `ParsedDocumentInvite`. Observed by `MainWindowView`, which presents
/// `DocumentInviteView`.
static let openDocumentInvite = Foundation.Notification.Name("InterlinedList.openDocumentInvite")
}

enum DocumentInviteDeepLink {

/// Attempts to route `url` as a document invite. Returns `true` (and posts
/// `.openDocumentInvite`) on a hit; `false` otherwise so the caller falls
/// through to the share-link and OAuth handlers. `post` defaults to `nil`,
/// in which case the parsed invite goes to `NotificationCenter.default`;
/// tests pass a capturing closure to observe routing without the center.
@discardableResult
@MainActor
static func handle(_ url: URL, post: ((ParsedDocumentInvite) -> Void)? = nil) -> Bool {
guard let parsed = DocumentInviteURLParser.parse(url) else { return false }
if let post {
post(parsed)
} else {
NotificationCenter.default.post(name: .openDocumentInvite, object: parsed)
}
return true
}
}
214 changes: 214 additions & 0 deletions App/Features/Documents/DocumentInviteView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
// DocumentInviteView
//
// The document email-invite landing (work-consolidation.md G24). Presented as
// a sheet when a `…/documents/invite/{token}` link is opened — pasted, or
// delivered via the `interlinedlist://` deep-link scheme.
//
// It shows what the invite grants and ends in "Accept in your browser", and
// that is the whole surface by design: `POST /api/documents/invite/{token}`
// is session-cookie-only in the live spec, so this app cannot claim an invite.
// Rendering an accept button here would be an affordance that always fails.
// Instead the landing states the branch the person is in and hands off a link.
// Accepting is documented as always free, so there is no entitlement gate.
//
// Mirrors `ResolveShareView`'s shape (header / branch / footer, sheet-sized)
// so the two landings feel like one family — but it is a separate view for a
// separate route, not a case bolted onto that one.
//
// Pure SwiftUI; no AppKit. Decision 0003: consumes only `InterlinedDomain`.

import SwiftUI
import InterlinedDomain

struct DocumentInviteView: View {

let parsed: ParsedDocumentInvite
let environment: AppEnvironment

@Environment(\.dismiss) private var dismiss
@Environment(\.openURL) private var openURL
@State private var viewModel: DocumentInviteViewModel?

var body: some View {
Group {
if let viewModel {
content(viewModel: viewModel)
} else {
ProgressView()
.accessibilityLabel("Opening invite")
.padding()
}
}
.frame(minWidth: 420, minHeight: 280)
.task {
if viewModel == nil {
let model = DocumentInviteViewModel(
documents: environment.documentsService,
token: parsed.token,
webBaseURL: environment.shareBaseURL
)
viewModel = model
await model.resolve()
}
}
}

@ViewBuilder
private func content(viewModel: DocumentInviteViewModel) -> some View {
VStack(alignment: .leading, spacing: 16) {
header

if viewModel.isLoading, viewModel.invite == nil {
ProgressView("Opening invite…")
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 24)
} else if let error = viewModel.error, viewModel.invite == nil {
errorState(error: error, viewModel: viewModel)
} else if let invite = viewModel.invite {
resolvedState(invite: invite, viewModel: viewModel)
}

Spacer()
footer(viewModel: viewModel)
}
.padding(16)
}

// MARK: - States

private var header: some View {
VStack(alignment: .leading, spacing: 4) {
Text("Document Invitation")
.font(.ilTitle())
Text("Someone invited you to a document on InterlinedList.")
.font(.ilBody())
.foregroundStyle(.secondary)
}
}

@ViewBuilder
private func resolvedState(
invite: DocumentInvite,
viewModel: DocumentInviteViewModel
) -> some View {
VStack(alignment: .leading, spacing: 12) {
LabeledContent("Document") {
Text(viewModel.displayTitle)
.font(.ilBody())
}
LabeledContent("Access") {
Text(roleDescription(invite.role))
.font(.ilBody())
}

Divider()

// One line per branch. The copy names the *browser* explicitly in
// every case, because that is where the invite is completed.
switch viewModel.nextStep {
case .alreadyAccepted:
stepMessage(
icon: "checkmark.circle",
text: "You've already accepted this invitation. Open the document in your browser."
)
case .signInInBrowser:
stepMessage(
icon: "person.crop.circle.badge.questionmark",
text: "Sign in on the web to accept. Invitations are accepted in the browser, not in this app."
)
case .wrongAccount:
stepMessage(
icon: "person.crop.circle.badge.exclamationmark",
text: "The account signed in on the web doesn't match the invited address. Switch accounts in your browser, then accept."
)
case .acceptInBrowser, .none:
stepMessage(
icon: "safari",
text: "Accepting an invitation happens in your browser. Accepting is free — no subscription required."
)
}
}
}

private func stepMessage(icon: String, text: String) -> some View {
HStack(alignment: .top, spacing: 8) {
Image(systemName: icon)
.foregroundStyle(Color.accentColor)
.accessibilityHidden(true)
Text(text)
.font(.ilBody())
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}

@ViewBuilder
private func errorState(
error: Error,
viewModel: DocumentInviteViewModel
) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle")
.foregroundStyle(Color.accentColor)
.accessibilityHidden(true)
Text("This invitation can't be opened")
.font(.ilSubtitle())
}
// The server answers one 404 for unknown / expired / revoked /
// deleted so tokens can't be probed; say all four rather than
// guessing which one it was.
Text("The link may have expired, been revoked, or the document may no longer exist.")
.font(.ilBody())
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Text(error.localizedDescription)
.font(.ilMono(10))
.foregroundStyle(.secondary)
Button("Try again") {
Task { await viewModel.resolve() }
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}

private func footer(viewModel: DocumentInviteViewModel) -> some View {
HStack {
Button("Close") { dismiss() }
.keyboardShortcut(.cancelAction)
Spacer()
if let url = viewModel.acceptURL {
Button {
openURL(url)
} label: {
Label(acceptButtonTitle(viewModel.nextStep), systemImage: "arrow.up.forward.app")
}
.buttonStyle(.borderedProminent)
.keyboardShortcut(.defaultAction)
.help("Opens interlinedlist.com — invitations are accepted in the browser")
}
}
}

// MARK: - Copy

private func acceptButtonTitle(_ step: DocumentInviteViewModel.NextStep?) -> String {
switch step {
case .alreadyAccepted: return "Open in Browser"
case .signInInBrowser: return "Sign In in Browser"
case .wrongAccount: return "Open in Browser"
case .acceptInBrowser, .none: return "Accept in Browser"
}
}

/// Turns the wire role into the phrasing the web uses for the same grant.
private func roleDescription(_ role: String) -> String {
switch role.lowercased() {
case "watcher": return "View only"
case "collaborator": return "Can edit"
case "manager": return "Can manage"
default: return role
}
}
}
Loading