eleven / messenger Public

web+ios: Eleven's welcome mentions open groups — existence only, Browse chip (#714)

The finish step gains a conditional line + 'Browse open groups' chip when the
directory holds something this member could actually join (joined rows don't
count — default-join groups already sit in the sidebar; locked-level names
stay sealed, so the mention is existence-only by design).

Web: maybeStartWelcome asks /api/threads/open and hands startWelcome
openGroups + an openDiscover hook (showDiscover). Native: the model computes
the same flag from api.openThreads() for the welcome flow and the new
openDiscover verb presents the shared DiscoverGroupsView (iOS sheet off
ElevenChatScreen; Mac sheet off ElevenConversationPane, mirroring
NativeChatView's). Flow twin regenerated; flow test added (18/18).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 8f2fd1beb9b177d013c2bb9aa85644ee6bbd361d parent 27c98cb
9 files changed, +98 −0
  • changelog/2026-07-11-welcome-open-groups.md +1 −0
  • ios/LChat/Views/ElevenChatScreen.swift +12 −0
  • ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift +15 −0
  • ios/LChatCore/Sources/LChatCore/Flows/welcome.json +12 −0
  • ios/LChatMac/ElevenConversationPane.swift +21 −0
  • test/flow.test.mjs +15 −0
  • web/static/apps/welcome.flow.js +7 −0
  • web/static/apps/welcome.js +4 −0
  • web/static/shell.js +11 −0
diff --git a/changelog/2026-07-11-welcome-open-groups.md b/changelog/2026-07-11-welcome-open-groups.md
new file mode 100644
index 0000000..6f0fba7
--- /dev/null
+++ b/changelog/2026-07-11-welcome-open-groups.md
@@ -0,0 +1 @@
+🤖 Eleven now points new members at your open groups. If your space has open groups a newcomer could join, the welcome conversation ends with a mention and a "Browse open groups" button — straight into the directory.
diff --git a/ios/LChat/Views/ElevenChatScreen.swift b/ios/LChat/Views/ElevenChatScreen.swift
index 07dd55e..5ed3935 100644
--- a/ios/LChat/Views/ElevenChatScreen.swift
+++ b/ios/LChat/Views/ElevenChatScreen.swift
@@ -11,6 +11,7 @@ struct ElevenChatScreen: View {
@ObservedObject var store: ThreadStore
@Binding var path: [Route]
@State private var model: ElevenConversationModel?
+ @State private var showDiscover = false
@ScaledMetric(relativeTo: .body) private var chatFontSize: CGFloat = 17
@AppStorage("nativeTheme") private var themeName = "classic"
@@ -25,6 +26,16 @@ struct ElevenChatScreen: View {
}
.navigationTitle(thread.title)
.navigationBarTitleDisplayMode(.inline)
+ .sheet(isPresented: $showDiscover) {
+ // The community directory (shared view; web: showDiscover) — the
+ // welcome finish's "Browse open groups" chip lands here.
+ NavigationStack {
+ DiscoverGroupsView(store: store) { id in
+ showDiscover = false
+ path.append(.thread(id))
+ }
+ }
+ }
.onAppear {
guard model == nil else { return }
let m = ElevenConversationCache.shared.model(for: instance, thread: thread,
@@ -37,6 +48,7 @@ struct ElevenChatScreen: View {
}
}
}
+ m?.openDiscover = { showDiscover = true }
// Outgoing payloads stamp the SavedInstance's name — persist a
// flow rename, the way ServerSettingsSheet persists a manual one.
m?.onNameSet = { name in
diff --git a/ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift b/ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift
index 3e9617f..334b7b8 100644
--- a/ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift
+++ b/ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift
@@ -55,6 +55,9 @@ public final class ElevenConversationModel: ObservableObject {
/// The finish chip's Admin hop: open (creating if needed) the Admin app
/// thread — the host owns thread creation and navigation.
public var openAdmin: (() -> Void)?
+ /// The welcome finish's "Browse open groups" chip — the host presents the
+ /// Discover surface (the twin of the web's showDiscover).
+ public var openDiscover: (() -> Void)?
/// The welcome flow's name step just published a label — the host must
/// also persist the name into the saved instance (outgoing payloads stamp
/// SavedInstance.screenName; without this a flow rename fixes the roster
@@ -114,6 +117,15 @@ public final class ElevenConversationModel: ObservableObject {
}
env.flags["inviterName"] = .string(inviter)
env.flags["hasPendingThread"] = .bool(pendingThreadId != nil)
+ // Open groups (welcome only): existence, never names — a locked
+ // level's name stays sealed by design. Joined rows don't count:
+ // default-join groups already sit in the sidebar.
+ if appId == "welcome" {
+ let open = await api.openThreads()
+ env.flags["openGroups"] = .bool(open.contains { row in
+ !(row.joined ?? false) && ((row.open_tier ?? "").isEmpty || (row.eligible ?? false))
+ })
+ }
let name = await api.groupName()
env.space = ["name": .string(name), "nameSet": .bool(!name.isEmpty)]
env.live["bridge.myName"] = { [instance] in .string(instance.screenName) }
@@ -331,6 +343,9 @@ extension ElevenConversationModel: FlowIO {
case "openAdmin":
openAdmin?()
return .null
+ case "openDiscover":
+ openDiscover?()
+ return .null
default:
return .null // unknown/web-only verbs are quiet no-ops natively
}
diff --git a/ios/LChatCore/Sources/LChatCore/Flows/welcome.json b/ios/LChatCore/Sources/LChatCore/Flows/welcome.json
index 1381ec7..1cc8562 100644
--- a/ios/LChatCore/Sources/LChatCore/Flows/welcome.json
+++ b/ios/LChatCore/Sources/LChatCore/Flows/welcome.json
@@ -318,6 +318,10 @@
"text": "No problem — you can add one anytime from your profile.\n\n"
},
"That's it — you're all set. 🎉 Enjoy {space.name|this space}!",
+ {
+ "if": "openGroups",
+ "text": "\n\nThis space also has open groups anyone here can join — have a look around whenever you like."
+ },
{
"if": "!accessBackedUp",
"text": "\n\nOne more reminder before you go: you haven't signed in yet, so your access to this space lives only in this browser — if it's cleared or lost, there's no way back in. Sign in any time and you're safe."
@@ -364,6 +368,14 @@
"verb": "openPendingThread"
}
},
+ {
+ "if": "openGroups",
+ "label": "Browse open groups",
+ "advancing": false,
+ "effect": {
+ "verb": "openDiscover"
+ }
+ },
{
"if": "!native",
"label": "Take the tour",
diff --git a/ios/LChatMac/ElevenConversationPane.swift b/ios/LChatMac/ElevenConversationPane.swift
index b58e0ac..6b3b90e 100644
--- a/ios/LChatMac/ElevenConversationPane.swift
+++ b/ios/LChatMac/ElevenConversationPane.swift
@@ -11,6 +11,7 @@ struct ElevenConversationPane: View {
@Binding var selectedID: String?
@EnvironmentObject private var app: MacAppState
@State private var model: ElevenConversationModel?
+ @State private var showDiscover = false
@AppStorage(ChatTextSize.key) private var chatTextSize = ChatTextSize.defaultSize
@AppStorage("nativeTheme") private var themeName = "classic"
@@ -23,6 +24,25 @@ struct ElevenConversationPane: View {
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
+ .sheet(isPresented: $showDiscover) {
+ // The community directory (shared view; web: showDiscover) — the
+ // welcome finish's "Browse open groups" chip lands here. Mirrors
+ // NativeChatView's Discover sheet.
+ VStack(spacing: 0) {
+ DiscoverGroupsView(store: store) { threadID in
+ showDiscover = false
+ selectedID = threadID
+ }
+ Divider()
+ HStack {
+ Spacer()
+ Button("Done") { showDiscover = false }
+ .keyboardShortcut(.cancelAction)
+ }
+ .padding(12)
+ }
+ .frame(minWidth: 420, minHeight: 420)
+ }
.onAppear {
guard model == nil else { return }
let m = ElevenConversationCache.shared.model(for: instance, thread: thread,
@@ -35,6 +55,7 @@ struct ElevenConversationPane: View {
}
}
}
+ m?.openDiscover = { showDiscover = true }
// Outgoing payloads stamp the SavedInstance's name — persist a
// flow rename, the way MacSettingsView persists a manual one.
m?.onNameSet = { name in
diff --git a/test/flow.test.mjs b/test/flow.test.mjs
index 80dcc57..d1cec94 100644
--- a/test/flow.test.mjs
+++ b/test/flow.test.mjs
@@ -446,6 +446,21 @@ await test("welcome: skip everything — nudge acks, photo skip disarms, nag on
assert.match(fin.text, /^No problem — you can add one anytime from your profile\./);
assert.match(fin.text, /One more reminder before you go/);
assert.deepEqual(h.lastChips.map((c) => c.label), ["Sign in", "Take the tour"], "the parting sign-in chip rides the finish");
+ // No open groups in this env — the mention and its chip stay hidden.
+ assert.equal(/open groups/.test(fin.text), false);
+});
+
+await test("welcome: open groups get a finish mention + a Browse chip", async () => {
+ const h = harness({ enrolled: true, envExtra: welcomeEnv({ openGroups: true }) });
+ h.env.accessBackedUp = () => true;
+ runFlow(WELCOME_FLOW, h.env, h.io);
+ await tick();
+ await h.answer("Paul");
+ await h.tap("Skip for now"); // photo
+ const fin = h.transcript.find((t) => t.extra.key === "q-finish");
+ assert.match(fin.text, /This space also has open groups anyone here can join/);
+ await h.tap("Browse open groups");
+ assert.ok(h.verbLog.find((v) => v[0] === "openDiscover"), "the chip hands over to the directory");
});
await test("welcome: native embed — passkey step skipped, access secured by the sign-in, finish records completion", async () => {
diff --git a/web/static/apps/welcome.flow.js b/web/static/apps/welcome.flow.js
index 88ea08d..558539c 100644
--- a/web/static/apps/welcome.flow.js
+++ b/web/static/apps/welcome.flow.js
@@ -236,6 +236,12 @@ export default {
text: [
{ if: "vars.skippedPhoto", text: "No problem — you can add one anytime from your profile.\n\n" },
"That's it — you're all set. 🎉 Enjoy {space.name|this space}!",
+ // Open groups: existence only, never names — a locked level's name
+ // stays sealed by design, and the directory is one tap away.
+ {
+ if: "openGroups",
+ text: "\n\nThis space also has open groups anyone here can join — have a look around whenever you like.",
+ },
// The one honest parting warning for someone who chose "Not now":
// plain consequences, no mechanism talk, and the way out right there.
{
@@ -264,6 +270,7 @@ export default {
advancing: false,
effect: { verb: "openPendingThread" },
},
+ { if: "openGroups", label: "Browse open groups", advancing: false, effect: { verb: "openDiscover" } },
// The tour is the web's guided overlay — web-only for now.
{ if: "!native", label: "Take the tour", advancing: false, effect: { verb: "startTour" } },
],
diff --git a/web/static/apps/welcome.js b/web/static/apps/welcome.js
index 8dded43..aece623 100644
--- a/web/static/apps/welcome.js
+++ b/web/static/apps/welcome.js
@@ -72,6 +72,10 @@ function makeVerb(env) {
return bridge.markWelcomeDone && bridge.markWelcomeDone();
case "openPendingThread":
return env.openPendingThread && env.openPendingThread();
+ case "openDiscover":
+ // "Browse open groups" on the finish step — the host supplies the
+ // directory surface (web: showDiscover; native: the Discover sheet).
+ return env.openDiscover && env.openDiscover();
case "startTour":
// Reached by "Take the tour" at the end of the welcome — the user has
// just been talking to Eleven, so skip the tour's own "fancy a tour?"
diff --git a/web/static/shell.js b/web/static/shell.js
index e1794e1..ac43f17 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -4517,6 +4517,15 @@ async function maybeStartWelcome() {
};
}
const adminEntry = roster.find((e) => !e.is_agent && e.role === "admin");
+ // Open groups: Eleven's finish mentions the directory when there's
+ // something this member could actually join — existence only, never names
+ // (a locked level's name stays sealed by design). Joined rows don't count:
+ // default-join groups are already in the sidebar.
+ let openGroups = false;
+ try {
+ const r = await (await fetch(`/api/threads/open?token=${encodeURIComponent(session.token)}`)).json();
+ openGroups = (r.threads || []).some((t) => !t.joined && (!t.open_tier || t.eligible));
+ } catch {}
startWelcome({
threadId: id,
live,
@@ -4524,6 +4533,8 @@ async function maybeStartWelcome() {
inviterName,
adminName: (adminEntry && adminEntry.label) || "",
openPendingThread,
+ openGroups,
+ openDiscover: () => showDiscover(),
typing: (on) => {
setNoticeTyping(id, "Eleven", on);
if (on) pinFeed();