eleven / messenger Public

mac: locked-pane phase-0 rungs — Link from Your iPhone / Ask for a New Invite (#1277)

The Mac's locked-space pane (a rail entry whose keys aren't on this Mac)
offered exactly one rung — Sign In with Eleven — plus destructive removal.
accounts-and-recovery.md phase 0 promised the missing rungs; the consume
side (SpaceClaim adopting add-device links via the Add-from-link sheet)
already existed, so the rungs open that sheet with contextual guidance.
Also: when a sign-in lands but the account holds no key for this space,
the pane now says so instead of silently staying locked.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 15a182c3f129834f805ba95140a85f7aff837dc1 parent 42393a4
4 files changed, +42 −10
  • docs/accounts-and-recovery.md +9 −4
  • ios/LChatMac/AddFromLinkSheet.swift +7 −1
  • ios/LChatMac/LChatMacApp.swift +8 −1
  • ios/LChatMac/RootView.swift +18 −4
diff --git a/docs/accounts-and-recovery.md b/docs/accounts-and-recovery.md
index 00b87a9..c7b672d 100644
--- a/docs/accounts-and-recovery.md
+++ b/docs/accounts-and-recovery.md
@@ -6,8 +6,9 @@ home (passkey, email code, Apple, Google, Microsoft, GitHub — the #1112 card),
identity anchors + ceremony proofs (`stateless.go`), the Sign-in options
management step, account combine/merge (#1098), sealed anchor sessions, and
the native passkey/Apple/email doors. Merged but dark: the synced DEK
-(#1144, flag off pending the device matrix). Not built yet: the phase-0
-locked-card rungs and `/invite` link redesign, owner anchoring at creation,
+(#1144, flag off pending the device matrix). Not built yet: the `/invite`
+link redesign (the phase-0 locked-card rungs shipped on the Mac 2026-07-22;
+native add-device mint/QR still open), owner anchoring at creation,
approve-from-another-device, paper kits, and the native OIDC doors (server
legs done, client work open) — see *Phasing* below, per-item.
This document supersedes the *Invariants* section of
@@ -359,8 +360,12 @@ Status per item as of 2026-07-22:
missing rungs — "Link from your iPhone" (add-device QR) and "Ask for a
new invite" — and failure copy that never dead-ends at a ceremony sheet.
The **cosmetic invite-link redesign** (`/invite#Name/<T>`) also rides
- here: no crypto change, old links parse forever. — **not built** (the Mac
- locked pane still shows the two-rung ladder; no `/invite` route exists).
+ here: no crypto change, old links parse forever. — **partially built**
+ (2026-07-22): the Mac locked pane now carries both rungs (each opens the
+ Add-from-link sheet with contextual guidance — the consume side of
+ add-device links already worked) plus signed-in-but-still-locked
+ feedback. Still open: a native mint/QR for add-device links (minting is
+ web-only today), and no `/invite` route exists.
1. **SiwA + synced DEK** on iOS/Mac; web gains approve-from-phone.
Identity-first onboarding lands with it — the vault becomes the handoff
bus and web↔native jumps stop losing fidelity. — **partially shipped**:
diff --git a/ios/LChatMac/AddFromLinkSheet.swift b/ios/LChatMac/AddFromLinkSheet.swift
index be2597f..7cf4e6e 100644
--- a/ios/LChatMac/AddFromLinkSheet.swift
+++ b/ios/LChatMac/AddFromLinkSheet.swift
@@ -13,21 +13,27 @@ struct AddFromLinkSheet: View {
@State private var link = ""
@State private var busy = false
@State private var error: String?
+ @State private var hint: String?
/// The browser handoff's pre-fill (onOpenURL stashes the validated link;
/// see LChatMacApp): consumed once, so a later manual open starts blank.
+ /// The locked pane's rungs stash contextual guidance the same way.
private func consumePendingLink() {
if let pending = app.pendingAddLink {
link = pending
app.pendingAddLink = nil
}
+ if let h = app.addFromLinkHint {
+ hint = h
+ app.addFromLinkHint = nil
+ }
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Add a space from a link")
.font(.headline)
- Text("Paste an invite link — the space joins this Mac and syncs to your Eleven account.")
+ Text(hint ?? "Paste an invite link — the space joins this Mac and syncs to your Eleven account.")
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
diff --git a/ios/LChatMac/LChatMacApp.swift b/ios/LChatMac/LChatMacApp.swift
index 7952730..17ebcf8 100644
--- a/ios/LChatMac/LChatMacApp.swift
+++ b/ios/LChatMac/LChatMacApp.swift
@@ -268,9 +268,16 @@ final class MacAppState: ObservableObject {
// Add-from-link sheet — the sheet IS the confirming click; a URL arriving
// from outside the app must never join silently. Consumed on sheet appear.
@Published var pendingAddLink: String?
+ // Context for the sheet when a rung opens it (the locked pane's "Link
+ // from Your iPhone…" / "Ask for a New Invite…") — replaces the sheet's
+ // generic subtitle for that one presentation. Consumed on sheet appear.
+ @Published var addFromLinkHint: String?
func requestCreateSpace() { createSpaceTick += 1 }
- func requestAddFromLink() { addFromLinkTick += 1 }
+ func requestAddFromLink(hint: String? = nil) {
+ addFromLinkHint = hint
+ addFromLinkTick += 1
+ }
// The Spaces menu's rail mirror: favourites when any exist, else the first
// nine active spaces (⌘1…⌘9 follow this order); the rest fold under More.
diff --git a/ios/LChatMac/RootView.swift b/ios/LChatMac/RootView.swift
index dc7722b..725271c 100644
--- a/ios/LChatMac/RootView.swift
+++ b/ios/LChatMac/RootView.swift
@@ -146,14 +146,16 @@ struct RootView: View {
// A rail entry with no native identity: signing in again pulls the key
// from the member's Eleven account; there is deliberately no web fallback.
- // Sign-in is the pane's PRIMARY action — the copy tells you to sign in, so
- // the button must be right here, not hidden in Settings (removal stays as
- // the destructive escape hatch).
+ // Sign-in leads, but it is never the only rung — a one-rung ladder reads
+ // as "the app is broken", so the pane always offers the add-device link
+ // and re-invite paths too, and every failure leaves them in reach
+ // (accounts-and-recovery.md, phase 0). Removal stays as the destructive
+ // escape hatch.
private func lockedPane(_ chat: SavedChat) -> some View {
ContentUnavailableView {
Label(app.serverName(chat), systemImage: "lock")
} description: {
- Text("This space can't be unlocked on this Mac. Sign in with Eleven to bring its key back, or remove it from the rail.")
+ Text("This space can't be unlocked on this Mac yet. Sign in with Eleven to bring its key back, link it from another device, or ask a member for a fresh invite.")
} actions: {
if firstRunSignIn.phase.busy {
ProgressView().controlSize(.small)
@@ -163,10 +165,22 @@ struct RootView: View {
}
.buttonStyle(.borderedProminent)
}
+ Button("Link from Your iPhone…") {
+ app.requestAddFromLink(hint: "On your iPhone (or anywhere you're already in this space), open the space menu, choose Add a device, and bring the link here — it signs this Mac in as you, with your history.")
+ }
+ Button("Ask for a New Invite…") {
+ app.requestAddFromLink(hint: "Any member of this space can send you a fresh invite link — add it here. You'll rejoin as a new member.")
+ }
Button("Remove from This Mac", role: .destructive) { app.remove(chat) }
if case .failed(let msg) = firstRunSignIn.phase {
Text(msg).font(.footnote).foregroundStyle(.red)
.frame(maxWidth: 420)
+ } else if firstRunSignIn.phase.signedIn {
+ // Signed in, yet this pane still renders: the account didn't
+ // hold a key for this space — say so instead of dead-ending.
+ Text("You're signed in, but that account doesn't hold a key for this space. Link it from a device that's already in, or ask a member for a fresh invite.")
+ .font(.footnote).foregroundStyle(.secondary)
+ .frame(maxWidth: 420)
}
}
}