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>
4 files changed,
+81
−26
ios/LChatCore/Package.resolved+0 −9ios/LChatCore/Sources/LChatCore/LChatAPI.swift+11 −0ios/LChatCore/Sources/LChatCore/SpaceCreate.swift+29 −17ios/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) |
| + } |
| +} |