web+ios: an email typed as your name gets a second look (#1473)
Answering "what should everyone here call you?" with an email address made it your name, silently — the name the whole space sees. input.confirm, a new rule in the flow language: a matching answer jumps to a step the author writes, and nothing is recorded and no effect runs until the person picks "Use it anyway" or "Type a different name". Both name steps (setup + welcome) use it, in both engines, with the native bundles regenerated. An unknown rule never fires. Also: nativeEnv never set bridge.canEnrol, so testQuickFormTrailingChip has been red on main since #1377 — the welcome access step skipped its chips as if self-hosted. Claude-Session: https://claude.ai/code/session_01GUA1EJFYwgr2nZNVBRFf8k
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
9 files changed,
+313
−5
changelog/2026-08-29-name-email-confirm.md+4 −0ios/LChatCore/Sources/LChatCore/FlowEngine.swift+34 −0ios/LChatCore/Sources/LChatCore/Flows/setup.json+39 −1ios/LChatCore/Sources/LChatCore/Flows/welcome.json+39 −1ios/LChatCore/Tests/LChatCoreTests/FlowEngineTests.swift+46 −0test/flow.test.mjs+70 −1web/static/flow.js+25 −0web/static/setup.flow.js+32 −1web/static/welcome.flow.js+24 −1
diff --git a/changelog/2026-08-29-name-email-confirm.md b/changelog/2026-08-29-name-email-confirm.md| new file mode 100644 |
| index 0000000..dcaa7e4 |
| --- /dev/null |
| +++ b/changelog/2026-08-29-name-email-confirm.md |
| @@ -0,0 +1,4 @@ |
| +🤖 Type an email address when Eleven asks what you'd like to be called, and it |
| +now checks before taking you at your word — "that's an email address, is that |
| +what you'd like to be called here?" — with a chip to keep it or type something |
| +else. It's the name everyone in the space sees, so it's worth a second look. |
diff --git a/ios/LChatCore/Sources/LChatCore/FlowEngine.swift b/ios/LChatCore/Sources/LChatCore/FlowEngine.swift| index abb0fc5..2c26bfd 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/FlowEngine.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/FlowEngine.swift |
| @@ -135,6 +135,33 @@ public final class FlowEnv { |
| } |
| } |
| +// MARK: - input.confirm: an answer that reads as something else |
| + |
| +// A name step accepts anything — which is right (people are called all sorts) |
| +// but means an email address typed into "what should everyone here call you?" |
| +// sails through and becomes the name the whole space sees. `input.confirm` |
| +// asks first: nothing is recorded and no effect runs until the person says |
| +// yes, and the flow author writes the confirming step like any other. |
| +// Whole-string only, so "Ada @ the shop" stays a name. |
| +// Twin: looksLikeEmail in web/static/flow.js. |
| +func looksLikeEmail(_ raw: String) -> Bool { |
| + let s = raw.trimmingCharacters(in: .whitespacesAndNewlines) |
| + guard let at = s.firstIndex(of: "@"), at != s.startIndex else { return false } |
| + let local = s[s.startIndex..<at] |
| + let domain = s[s.index(after: at)...] |
| + guard !domain.isEmpty, !local.contains(" "), !domain.contains(" "), !domain.contains("@") else { return false } |
| + guard let dot = domain.lastIndex(of: "."), dot != domain.startIndex else { return false } |
| + return domain[domain.index(after: dot)...].count >= 2 |
| +} |
| + |
| +// confirmNeeded: does this answer trip the step's confirm rule? An unknown |
| +// `when` never fires — a flow document from a newer build must degrade to |
| +// today's behaviour, not to a step nobody can leave. |
| +func confirmNeeded(_ confirm: FlowJSON, answer: String) -> Bool { |
| + guard let target = confirm["goto"].stringValue, !target.isEmpty else { return false } |
| + return confirm["when"].stringValue == "email" && looksLikeEmail(answer) |
| +} |
| + |
| // MARK: - the name-step answer cleaner (input.clean == "name") |
| // People answer a conversational "what should folks here call you?" |
| @@ -506,6 +533,13 @@ public final class FlowEngine { |
| await self.enter(stepId) |
| return |
| } |
| + // Confirm before accepting (input.confirm): the answer |
| + // stays in vars.answer for the confirming step's chips. |
| + if confirmNeeded(input["confirm"], answer: text), |
| + let target = input["confirm"]["goto"].stringValue { |
| + await self.enter(target) |
| + return |
| + } |
| if !step["effect"].isNull { |
| await self.runEffect(stepId: stepId, effect: step["effect"]) |
| } |
diff --git a/ios/LChatCore/Sources/LChatCore/Flows/setup.json b/ios/LChatCore/Sources/LChatCore/Flows/setup.json| index 190ae24..08b8710 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/Flows/setup.json |
| +++ b/ios/LChatCore/Sources/LChatCore/Flows/setup.json |
| @@ -48,6 +48,7 @@ |
| "skipIf": [ |
| "done" |
| ], |
| + "skipTo": "group", |
| "bubbles": [ |
| { |
| "text": "First — what should everyone here call you? Reply with your name.", |
| @@ -69,7 +70,11 @@ |
| "kind": "text", |
| "required": true, |
| "maxLen": 32, |
| - "clean": "name" |
| + "clean": "name", |
| + "confirm": { |
| + "when": "email", |
| + "goto": "name-email" |
| + } |
| }, |
| "effect": { |
| "verb": "setMyName", |
| @@ -88,6 +93,39 @@ |
| } |
| } |
| }, |
| + { |
| + "id": "name-email", |
| + "bubbles": [ |
| + { |
| + "text": "That's an email address — is that what you'd like to be called here? It's the name everyone in this space sees.", |
| + "key": "q-name-email" |
| + } |
| + ], |
| + "chips": [ |
| + { |
| + "label": "Use it anyway", |
| + "spent": "Kept", |
| + "effect": { |
| + "verb": "setMyName", |
| + "args": [ |
| + "$answer" |
| + ] |
| + }, |
| + "record": { |
| + "step": "name", |
| + "label": "noted: you're {vars.answer}", |
| + "value": "$answer" |
| + }, |
| + "ack": "Righto — {vars.answer} it is. 🤝", |
| + "goto": "group" |
| + }, |
| + { |
| + "label": "Type a different name", |
| + "spent": "Changed", |
| + "goto": "name" |
| + } |
| + ] |
| + }, |
| { |
| "id": "group", |
| "skipIf": [ |
diff --git a/ios/LChatCore/Sources/LChatCore/Flows/welcome.json b/ios/LChatCore/Sources/LChatCore/Flows/welcome.json| index 4b37670..32217d7 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/Flows/welcome.json |
| +++ b/ios/LChatCore/Sources/LChatCore/Flows/welcome.json |
| @@ -176,6 +176,7 @@ |
| "done", |
| "bridge.myName" |
| ], |
| + "skipTo": "photo", |
| "bubbles": [ |
| { |
| "text": "{ack?}First things first — what should folks here call you? Reply with your name.", |
| @@ -209,7 +210,11 @@ |
| "kind": "text", |
| "required": true, |
| "maxLen": 32, |
| - "clean": "name" |
| + "clean": "name", |
| + "confirm": { |
| + "when": "email", |
| + "goto": "name-email" |
| + } |
| }, |
| "effect": { |
| "verb": "setMyName", |
| @@ -228,6 +233,39 @@ |
| } |
| } |
| }, |
| + { |
| + "id": "name-email", |
| + "bubbles": [ |
| + { |
| + "text": "That's an email address — is that what you'd like to be called here? It's the name everyone in this space sees.", |
| + "key": "q-name-email" |
| + } |
| + ], |
| + "chips": [ |
| + { |
| + "label": "Use it anyway", |
| + "spent": "Kept", |
| + "effect": { |
| + "verb": "setMyName", |
| + "args": [ |
| + "$answer" |
| + ] |
| + }, |
| + "record": { |
| + "step": "name", |
| + "label": "noted: you're {vars.answer}", |
| + "value": "$answer" |
| + }, |
| + "ack": "Righto — {vars.answer} it is. 🤝", |
| + "goto": "photo" |
| + }, |
| + { |
| + "label": "Type a different name", |
| + "spent": "Changed", |
| + "goto": "name" |
| + } |
| + ] |
| + }, |
| { |
| "id": "photo", |
| "skipIf": [ |
diff --git a/ios/LChatCore/Tests/LChatCoreTests/FlowEngineTests.swift b/ios/LChatCore/Tests/LChatCoreTests/FlowEngineTests.swift| index 437d955..4c51cc7 100644 |
| --- a/ios/LChatCore/Tests/LChatCoreTests/FlowEngineTests.swift |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/FlowEngineTests.swift |
| @@ -85,6 +85,12 @@ final class FlowEngineTests: XCTestCase { |
| env.live["bridge.myName"] = { .string(name) } |
| env.live["bridge.enrolled"] = { .bool(false) } |
| env.live["bridge.fromHome"] = { .bool(false) } |
| + // Matches the JS harness default. Without it every flow read |
| + // "!bridge.canEnrol" as TRUE — a self-hosted space with no sign-in to |
| + // offer — so the welcome access step recorded "your access lives in |
| + // this browser" and skipped past its chips, quietly rotting |
| + // testQuickFormTrailingChip (red on main since the #1377 gate landed). |
| + env.live["bridge.canEnrol"] = { .bool(true) } |
| env.live["accessBackedUp"] = { .bool(true) } // native: secured by the sign-in |
| return env |
| } |
| @@ -158,6 +164,46 @@ final class FlowEngineTests: XCTestCase { |
| XCTAssertTrue(allChips.contains("Open admin")) |
| } |
| + // MARK: input.confirm — an email answered as a name |
| + |
| + // Twin of "setup: an email answered as a name is confirmed, not swallowed" |
| + // in test/flow.test.mjs. Both engines must hold the answer back until the |
| + // person confirms it: nothing recorded, no setMyName, no move on. |
| + func testEmailAnsweredAsNameIsConfirmed() async throws { |
| + let flow = try moduleFlow("setup") |
| + let io = Harness() |
| + io.verbResults["setMyName"] = .string("ok") |
| + io.verbResults["setGroupName"] = .string("ok") |
| + let env = nativeEnv(name: "", space: "") // no name yet: the question is asked |
| + let engine = FlowEngine(flow: flow, env: env, io: io) |
| + engine.start() |
| + await io.settle() |
| + defer { withExtendedLifetime(engine) {} } |
| + |
| + await io.answer("paul@rslw.com") |
| + XCTAssertTrue(io.transcript.last?.text.contains("That's an email address") ?? false, |
| + "the confirm step must come up") |
| + XCTAssertFalse(io.verbLog.contains { $0.0 == "setMyName" }, "nothing set yet") |
| + XCTAssertFalse(io.records.contains { $0.step == "name" }, "nothing recorded yet") |
| + |
| + await io.tap("Use it anyway") |
| + XCTAssertTrue(io.verbLog.contains { $0.0 == "setMyName" }) |
| + XCTAssertEqual(io.records.filter { $0.step == "name" }.count, 1, |
| + "the confirm records the NAME step, once") |
| + XCTAssertTrue(io.transcript.contains { $0.extra.key == "q-group" }, |
| + "carried on to the space name") |
| + } |
| + |
| + // Twin of the looksLikeEmail cases in test/flow.test.mjs. |
| + func testLooksLikeEmail() { |
| + for yes in ["paul@rslw.com", " Ada@example.co.uk ", "a.b+c@sub.domain.org"] { |
| + XCTAssertTrue(looksLikeEmail(yes), yes) |
| + } |
| + for no in ["Ada", "Ada @ the shop", "@ada", "ada@", "ada@localhost", "", "two@parts@here.com"] { |
| + XCTAssertFalse(looksLikeEmail(no), no) |
| + } |
| + } |
| + |
| // MARK: the native joiner welcome run |
| // Twin of the cleanNameAnswer cases in test/flow.test.mjs — both engines |
diff --git a/test/flow.test.mjs b/test/flow.test.mjs| index d65924d..7ec25af 100644 |
| --- a/test/flow.test.mjs |
| +++ b/test/flow.test.mjs |
| @@ -5,7 +5,7 @@ |
| // fixture discipline the Swift interpreter will inherit. |
| import assert from "node:assert/strict"; |
| import { test } from "node:test"; |
| -import { runFlow, renderText, evalCond, cleanNameAnswer } from "../web/static/flow.js"; |
| +import { runFlow, renderText, evalCond, cleanNameAnswer, looksLikeEmail, confirmNeeded } from "../web/static/flow.js"; |
| import SETUP_FLOW from "../web/static/setup.flow.js"; |
| import WELCOME_FLOW from "../web/static/welcome.flow.js"; |
| @@ -117,6 +117,61 @@ await test("cleanNameAnswer strips conversational replies down to the name", () |
| assert.equal(cleanNameAnswer("Im Dermot"), "Dermot"); // apostrophe skipped |
| }); |
| +await test("looksLikeEmail: whole-string only — a name with an @ in it is a name", () => { |
| + for (const yes of ["paul@rslw.com", " Ada@example.co.uk ", "a.b+c@sub.domain.org"]) { |
| + assert.equal(looksLikeEmail(yes), true, yes); |
| + } |
| + for (const no of ["Ada", "Ada @ the shop", "@ada", "ada@", "ada@localhost", "", null, "two@parts@here.com"]) { |
| + assert.equal(looksLikeEmail(no), false, String(no)); |
| + } |
| +}); |
| + |
| +await test("confirmNeeded: only a known rule fires, and only with a destination", () => { |
| + assert.equal(confirmNeeded({ when: "email", goto: "x" }, "a@b.com"), true); |
| + assert.equal(confirmNeeded({ when: "email", goto: "x" }, "Ada"), false); |
| + assert.equal(confirmNeeded({ when: "email" }, "a@b.com"), false, "no goto, no jump"); |
| + // Forward compatibility: a rule this build doesn't know must behave as if |
| + // absent rather than park the conversation on an unreachable step. |
| + assert.equal(confirmNeeded({ when: "phone", goto: "x" }, "a@b.com"), false); |
| + assert.equal(confirmNeeded(null, "a@b.com"), false); |
| +}); |
| + |
| +// An email typed where a name was asked used to become the name the whole |
| +// space sees, silently (Paul, 2026-08-29). Now it is confirmed first — and |
| +// until it is, nothing is recorded and setMyName never runs. |
| +await test("setup: an email answered as a name is confirmed, not swallowed", async () => { |
| + const h = harness({ verbs: { setMyName: "ok", setGroupName: "ok" } }); |
| + runFlow(SETUP_FLOW, h.env, h.io); |
| + await tick(); |
| + await h.answer("paul@rslw.com"); |
| + |
| + assert.match(h.lastText(), /That's an email address/); |
| + assert.equal(h.verbLog.some((v) => v[0] === "setMyName"), false, "nothing set yet"); |
| + assert.equal(h.records.some((r) => r.step === "name"), false, "nothing recorded yet"); |
| + |
| + // Changing your mind re-asks the name, still with nothing recorded. |
| + await h.tap("Type a different name"); |
| + assert.equal(h.records.some((r) => r.step === "name"), false); |
| + await h.answer("Paul"); |
| + assert.deepEqual(h.verbLog.find((v) => v[0] === "setMyName"), ["setMyName", "Paul"]); |
| + assert.equal(h.records.find((r) => r.step === "name").value, "Paul"); |
| + assert.ok(h.transcript.find((t) => t.extra.key === "q-group"), "carried on to the space name"); |
| +}); |
| + |
| +await test("setup: 'Use it anyway' keeps the email as the name, recorded once", async () => { |
| + const h = harness({ verbs: { setMyName: "ok", setGroupName: "ok" } }); |
| + runFlow(SETUP_FLOW, h.env, h.io); |
| + await tick(); |
| + await h.answer("paul@rslw.com"); |
| + await h.tap("Use it anyway"); |
| + |
| + assert.deepEqual(h.verbLog.find((v) => v[0] === "setMyName"), ["setMyName", "paul@rslw.com"]); |
| + const rec = h.records.filter((r) => r.step === "name"); |
| + assert.equal(rec.length, 1, "the confirm records the NAME step, once"); |
| + assert.equal(rec[0].value, "paul@rslw.com"); |
| + assert.ok(h.transcript.find((t) => t.extra.key === "q-group"), "carried on to the space name"); |
| +}); |
| + |
| await test("cleanNameAnswer leaves plain names (and lookalikes) alone", () => { |
| assert.equal(cleanNameAnswer("Dermot"), "Dermot"); |
| assert.equal(cleanNameAnswer(" Dermot "), "Dermot"); |
| @@ -615,6 +670,20 @@ const welcomeEnv = (over = {}) => ({ |
| ...over, |
| }); |
| +await test("welcome: the joiner's name step confirms an email the same way", async () => { |
| + // Past the access step (already signed in) so the name question is up. |
| + const h = harness({ verbs: { setMyName: "ok" }, enrolled: true, |
| + done: { access: true }, envExtra: welcomeEnv() }); |
| + h.env.accessBackedUp = () => true; |
| + runFlow(WELCOME_FLOW, h.env, h.io); |
| + await tick(); |
| + await h.answer("paul@rslw.com"); |
| + assert.match(h.lastText(), /That's an email address/); |
| + await h.tap("Use it anyway"); |
| + assert.deepEqual(h.verbLog.find((v) => v[0] === "setMyName"), ["setMyName", "paul@rslw.com"]); |
| + assert.equal(h.records.filter((r) => r.step === "name").length, 1); |
| +}); |
| + |
| // The quick-form fast path: both conversations' FIRST interactive step offers |
| // a right-aligned "Fill in a form instead" chip whose tap opens the host form |
| // (the openQuickForm verb) WITHOUT consuming the step — the conversation stays |
diff --git a/web/static/flow.js b/web/static/flow.js| index af2fe90..e7098e9 100644 |
| --- a/web/static/flow.js |
| +++ b/web/static/flow.js |
| @@ -17,6 +17,26 @@ |
| // delay(ms, fn) — a cancellable timer (probes); tests fire it |
| // } |
| +// ---- input.confirm: an answer that reads as something else --------------- |
| +// A name step accepts anything — which is right (people are called all sorts) |
| +// but means an email address typed into "what should everyone here call you?" |
| +// sails through and becomes the name the whole space sees. `input.confirm` |
| +// asks first: nothing is recorded and no effect runs until the person says |
| +// yes, and the flow author writes the confirming step like any other. |
| +// Whole-string only, so "Ada @ the shop" stays a name. |
| +// Twin: looksLikeEmail in FlowEngine.swift (native runs the same flows). |
| +export function looksLikeEmail(s) { |
| + return /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test((s || "").trim()); |
| +} |
| + |
| +// confirmNeeded: does this answer trip the step's confirm rule? An unknown |
| +// `when` never fires — a flow document from a newer build must degrade to |
| +// today's behaviour, not to a step nobody can leave. |
| +export function confirmNeeded(confirm, answer) { |
| + if (!confirm || !confirm.goto) return false; |
| + return confirm.when === "email" && looksLikeEmail(answer); |
| +} |
| + |
| // ---- the name-step answer cleaner (input.clean === "name") --------------- |
| // People answer a conversational "what should folks here call you?" |
| // conversationally — "I'm Dermot", "hi, my name is Ana." — and the raw reply |
| @@ -263,6 +283,11 @@ export function runFlow(flow, env, io) { |
| // A required answer left empty re-asks the whole step (say-dedupe by |
| // key means no duplicate bubbles — the input just re-arms). |
| if (step.input.required && !env.vars.answer) return enter(step.id); |
| + // Confirm before accepting (input.confirm): the answer stays in |
| + // vars.answer for the confirming step's chips to use or discard. |
| + if (confirmNeeded(step.input.confirm, env.vars.answer)) { |
| + return enter(step.input.confirm.goto); |
| + } |
| if (step.effect) await runEffect(stepCtx, step.effect); |
| }) |
| ); |
diff --git a/web/static/setup.flow.js b/web/static/setup.flow.js| index 2e73c20..a06c4b4 100644 |
| --- a/web/static/setup.flow.js |
| +++ b/web/static/setup.flow.js |
| @@ -46,6 +46,11 @@ export default { |
| }, |
| ], |
| skipIf: ["done"], |
| + // Explicit, because the confirm step (name-email) now sits between this |
| + // one and "group": a skip falls through in ARRAY order, and landing a |
| + // resumed client on a confirmation for an answer it never gave would |
| + // park the whole conversation there. |
| + skipTo: "group", |
| bubbles: [ |
| { text: "First — what should everyone here call you? Reply with your name.", label: "Your name:", hint: "e.g. Finn Wolfhard" }, |
| ], |
| @@ -60,7 +65,10 @@ export default { |
| effect: { verb: "openQuickForm" }, |
| }, |
| ], |
| - input: { kind: "text", required: true, maxLen: 32, clean: "name" }, |
| + // An email address typed here would become the name the whole space |
| + // sees, silently. Confirm it first (flow.js input.confirm) — everything |
| + // else is accepted as typed, because people are called all sorts. |
| + input: { kind: "text", required: true, maxLen: 32, clean: "name", confirm: { when: "email", goto: "name-email" } }, |
| effect: { verb: "setMyName", args: ["$answer"] }, |
| on: { |
| ok: { |
| @@ -70,6 +78,29 @@ export default { |
| }, |
| }, |
| }, |
| + { |
| + // The confirm step for an email answered as a name. Nothing has been |
| + // recorded yet, so both chips are live choices: keeping it runs the |
| + // effect + record the name step would have, and changing it re-asks. |
| + id: "name-email", |
| + bubbles: [ |
| + { |
| + text: "That's an email address — is that what you'd like to be called here? It's the name everyone in this space sees.", |
| + key: "q-name-email", |
| + }, |
| + ], |
| + chips: [ |
| + { |
| + label: "Use it anyway", |
| + spent: "Kept", |
| + effect: { verb: "setMyName", args: ["$answer"] }, |
| + record: { step: "name", label: "noted: you're {vars.answer}", value: "$answer" }, |
| + ack: "Righto — {vars.answer} it is. 🤝", |
| + goto: "group", |
| + }, |
| + { label: "Type a different name", spent: "Changed", goto: "name" }, |
| + ], |
| + }, |
| { |
| id: "group", |
| skipIf: ["done"], |
diff --git a/web/static/welcome.flow.js b/web/static/welcome.flow.js| index db53ec5..7037c17 100644 |
| --- a/web/static/welcome.flow.js |
| +++ b/web/static/welcome.flow.js |
| @@ -140,6 +140,7 @@ export default { |
| { |
| id: "name", |
| skipIf: ["done", "bridge.myName"], |
| + skipTo: "photo", // past name-email, the confirm step (see setup.flow.js) |
| bubbles: [ |
| { |
| text: "{ack?}First things first — what should folks here call you? Reply with your name.", |
| @@ -159,7 +160,9 @@ export default { |
| goto: "photo", |
| }, |
| ], |
| - input: { kind: "text", required: true, maxLen: 32, clean: "name" }, |
| + // Same guard as setup's name step: an email typed here would become the |
| + // name everyone in the space sees (flow.js input.confirm). |
| + input: { kind: "text", required: true, maxLen: 32, clean: "name", confirm: { when: "email", goto: "name-email" } }, |
| effect: { verb: "setMyName", args: ["$answer"] }, |
| on: { |
| ok: { |
| @@ -169,6 +172,26 @@ export default { |
| }, |
| }, |
| }, |
| + { |
| + id: "name-email", |
| + bubbles: [ |
| + { |
| + text: "That's an email address — is that what you'd like to be called here? It's the name everyone in this space sees.", |
| + key: "q-name-email", |
| + }, |
| + ], |
| + chips: [ |
| + { |
| + label: "Use it anyway", |
| + spent: "Kept", |
| + effect: { verb: "setMyName", args: ["$answer"] }, |
| + record: { step: "name", label: "noted: you're {vars.answer}", value: "$answer" }, |
| + ack: "Righto — {vars.answer} it is. 🤝", |
| + goto: "photo", |
| + }, |
| + { label: "Type a different name", spent: "Changed", goto: "name" }, |
| + ], |
| + }, |
| { |
| id: "photo", |
| // Native v1: no attachment flow in the conversation composer — the |