eleven / ocho Public

ios: a setup link claimed in the app lands in Eleven's setup conversation (#1444)

A space created on the web and handed over with "Open in the app" claimed
its admin invite and then showed nothing at all: no Eleven, no name asked,
no Admin — an empty thread list in a space the person had just made.

SpaceClaim created Eleven's opening thread only for MEMBER invites; the
admin side was left to SpaceCreationFlow, so it existed only for spaces
born natively. Move it into the claim, where every admin-granting link
passes: create the "setup" app-thread and arm SetupLanding, gated on the
web's own condition (/api/me says admin + setup not yet done) so a
co-admin joining a long-settled space never gets dragged into a finished
conversation. SpaceCreationFlow drops its now-duplicate block.


Claude-Session: https://claude.ai/code/session_01GUA1EJFYwgr2nZNVBRFf8k
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by codex agent, operated by paul@keymail.dev ccb775d18142e26a97b4651b75aed27317b48fea parent 481721d
4 files changed, +81 −26
  • ios/LChatCore/Package.resolved +0 −9
  • ios/LChatCore/Sources/LChatCore/LChatAPI.swift +11 −0
  • ios/LChatCore/Sources/LChatCore/SpaceCreate.swift +29 −17
  • ios/LChatCore/Tests/LChatCoreTests/WelcomePendingTests.swift +41 −0
diff --git a/ios/LChatCore/Package.resolved b/ios/LChatCore/Package.resolved
index fe25d1c..d82f91f 100644
--- a/ios/LChatCore/Package.resolved
+++ b/ios/LChatCore/Package.resolved
@@ -1,14 +1,5 @@
{
"pins" : [
- {
- "identity" : "sparkle",
- "kind" : "remoteSourceControl",
- "location" : "https://github.com/sparkle-project/Sparkle",
- "state" : {
- "revision" : "b6496a74a087257ef5e6da1c5b29a447a60f5bd7",
- "version" : "2.9.4"
- }
- },
{
"identity" : "swift-cmark",
"kind" : "remoteSourceControl",
diff --git a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
index a76ee9e..8dd359b 100644
--- a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
+++ b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
@@ -735,6 +735,17 @@ public struct LChatAPI {
return code == 429 ? .tooMany : .failed
}
+ /// Whether this space still owes its admin the first-run setup
+ /// conversation (GET /api/me): we hold the admin role and setup_complete
+ /// is not yet set — shell.js maybeStartSetup's gate, natively. False on
+ /// any failure, so a flaky answer never conjures a setup conversation in
+ /// a space that has long finished one.
+ public func setupPending() async -> Bool {
+ struct Resp: Codable { let role: String?; let setup_complete: Bool? }
+ guard let me = try? await getJSON("/api/me", Resp.self) else { return false }
+ return me.role == "admin" && me.setup_complete != true
+ }
+
/// The caller's pinned Away flag (GET /api/me) — what the settings toggle
/// shows. nil when the fetch failed.
public func away() async -> Bool? {
diff --git a/ios/LChatCore/Sources/LChatCore/SpaceCreate.swift b/ios/LChatCore/Sources/LChatCore/SpaceCreate.swift
index 7d55c7f..144fffd 100644
--- a/ios/LChatCore/Sources/LChatCore/SpaceCreate.swift
+++ b/ios/LChatCore/Sources/LChatCore/SpaceCreate.swift
@@ -66,13 +66,11 @@ public enum SpaceCreationFlow {
let creation = try await HomeClient().createSpace(token: session.token)
let inst = try await SpaceClaim.claim(setupLink: creation.setup_link,
screenName: screenName)
+ // Eleven's setup conversation is already standing (SpaceClaim creates
+ // its thread and arms the landing for any admin-granting link), so
+ // this is just the minimal server-side setup: name-less, and
+ // setup_complete as the reaper's not-abandoned signal.
await SpaceSetup.finish(origin: inst.origin, token: inst.deviceToken, name: "")
- // Eleven's setup conversation: create its app-thread now and flag the
- // landing, so the creator's first screen is that chat (SetupLanding).
- // Best-effort — without it they land on the (empty) list, like today.
- if await SpaceSetup.createSetupThread(inst) {
- await SetupLanding.markPending(inst)
- }
return inst
}
}
@@ -196,17 +194,31 @@ public enum SpaceClaim {
}
}
- // A fresh member/DM-invite joiner — not add-device (an existing
- // member's new device) and not an admin-granting setup link (that
- // path lands in Eleven's SETUP conversation via SpaceCreationFlow):
- // create Eleven's welcome app-thread now and flag the landing, so
- // their first screen is the welcome conversation asking their name —
- // the native twin of join.js markWelcomePending + shell.js
- // maybeStartWelcome. Without it a native joiner never meets Eleven,
- // is never asked a name, and stays "Someone" to everyone (#1376).
- if !addDevice, info.grants_admin != true,
- await SpaceSetup.createWelcomeThread(inst) {
- await WelcomeLanding.markPending(inst)
+ // Eleven's opening conversation, whichever side of the invite this is.
+ // Add-device is neither: an existing member's new device has already
+ // met Eleven.
+ //
+ // ADMIN (a setup link): the space's first screen is the SETUP
+ // conversation. Not only the natively-created space — a space created
+ // on the web and handed over with "Open in the app" arrives here too,
+ // and used to land its admin in a space with no threads at all: no
+ // Eleven, no name asked, no way to the Admin app. The gate is the
+ // web's own (shell.js maybeStartSetup): admin + setup not yet done,
+ // so an admin-granting invite into an already-set-up space (a
+ // co-admin) never resurfaces a finished conversation.
+ //
+ // MEMBER: the welcome conversation, where a fresh joiner is greeted
+ // and asked their name — the native twin of join.js
+ // markWelcomePending + shell.js maybeStartWelcome. Without it a
+ // native joiner stays "Someone" to everyone (#1376).
+ if !addDevice {
+ if info.grants_admin == true {
+ if await api.setupPending(), await SpaceSetup.createSetupThread(inst) {
+ await SetupLanding.markPending(inst)
+ }
+ } else if await SpaceSetup.createWelcomeThread(inst) {
+ await WelcomeLanding.markPending(inst)
+ }
}
return inst
}
diff --git a/ios/LChatCore/Tests/LChatCoreTests/WelcomePendingTests.swift b/ios/LChatCore/Tests/LChatCoreTests/WelcomePendingTests.swift
index ffc0268..604bac2 100644
--- a/ios/LChatCore/Tests/LChatCoreTests/WelcomePendingTests.swift
+++ b/ios/LChatCore/Tests/LChatCoreTests/WelcomePendingTests.swift
@@ -77,3 +77,44 @@ final class WelcomePendingTests: XCTestCase {
PendingLabel.clear(instanceID: id)
}
}
+
+// 🤖 The admin-side twin: a space claimed through an admin-granting setup
+// link lands its admin in Eleven's SETUP conversation — whether the space was
+// created natively or on the web and handed over with "Open in the app".
+@MainActor
+final class SetupPendingTests: XCTestCase {
+ private func freshInstance() -> SavedInstance {
+ SavedInstance(origin: "https://setup-\(UUID().uuidString).invalid",
+ deviceToken: "tok", screenName: "", userPub: "pub", userPriv: "priv")
+ }
+
+ private func setupThread(id: String = "s1", app: String = "setup") -> ChatThread {
+ ChatThread(id: id, kind: "self", title: "Eleven", preview: "", lastAt: 0,
+ unread: false, pinned: false, hidden: false, muted: nil,
+ members: ["u1"], threadKeyB64: "k", avatar: nil,
+ isApp: true, appId: app, glyph: nil, badge: nil)
+ }
+
+ func testSetupLandsOnceWhenTheClaimArmedIt() async {
+ let inst = freshInstance()
+ // Unarmed: an admin whose setup was done long ago (before the flow
+ // recorded a finish marker) must never be dragged back into it.
+ let unarmed = await SetupLanding.threadToLand(instance: inst, threads: [setupThread()])
+ XCTAssertNil(unarmed)
+
+ SetupLanding.markPending(inst)
+ XCTAssertTrue(SetupLanding.isPending(inst))
+ let landed = await SetupLanding.threadToLand(instance: inst, threads: [setupThread()])
+ XCTAssertEqual(landed?.id, "s1")
+ let again = await SetupLanding.threadToLand(instance: inst, threads: [setupThread()])
+ XCTAssertNil(again, "the landing is once per device+instance")
+ }
+
+ func testSetupIgnoresOtherAppThreads() async {
+ let inst = freshInstance()
+ SetupLanding.markPending(inst)
+ let none = await SetupLanding.threadToLand(instance: inst,
+ threads: [setupThread(app: "welcome")])
+ XCTAssertNil(none)
+ }
+}