eleven / messenger Public

Admin joins the new-chat list (web + native) (#710)

* web: Admin joins the new-chat list + palette for admins

The Admin app is system-scope (never in personalApps), so /admin was
effectively its only way in. Admins now get an Admin row in the New chat
sheet's Apps section (including the empty-roster state) and an "Open
admin" action in the palette — same resolveAppThread("admin") open as
every personal app row. Client-side role gate is a UI nicety only; the
app re-checks the role through its bridge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios: Admin row in the iOS new-chat sheet and Mac popover

Plumb the roster role natively (RosterEntry.role, optional so older
servers still decode; ThreadStore.isAdmin reads my own entry) and give
admins an Admin row: iOS in NewChatView's top section beside Discover
groups, Mac in the popover's APPS section reusing appRow with the
shared 🛠️ glyph. Both open store.openOrCreateAppThread("admin") and
land in the thread the way picking a person does. No changelog entry
yet — native notes wait for the release that ships them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 154e3b6217c8b57a8dccb50d5fa10d8d2a7f46cf parent b6b07c3
6 files changed, +84 −5
  • changelog/2026-07-11-admin-in-new-chat.md +3 −0
  • ios/LChat/Views/NewChatView.swift +35 −1
  • ios/LChatCore/Sources/LChatCore/Models.swift +4 −0
  • ios/LChatCore/Sources/LChatCore/ThreadStore.swift +8 −0
  • ios/LChatMac/NewChatPopover.swift +9 −0
  • web/static/shell.js +25 −4
diff --git a/changelog/2026-07-11-admin-in-new-chat.md b/changelog/2026-07-11-admin-in-new-chat.md
new file mode 100644
index 0000000..c5e08cd
--- /dev/null
+++ b/changelog/2026-07-11-admin-in-new-chat.md
@@ -0,0 +1,3 @@
+🤖 **Admins: the Admin panel now sits in your New chat list.** Open New chat
+and you'll find Admin under Apps (and in the ⌘K palette as "Open admin") —
+no need to remember the /admin command. Only admins see it.
diff --git a/ios/LChat/Views/NewChatView.swift b/ios/LChat/Views/NewChatView.swift
index c04a2b6..401bd07 100644
--- a/ios/LChat/Views/NewChatView.swift
+++ b/ios/LChat/Views/NewChatView.swift
@@ -11,7 +11,7 @@ struct NewChatView: View {
@Environment(\.dismiss) private var dismiss
let onCreated: (String) -> Void
- @State private var busyWith: String? // user id of the DM being created
+ @State private var busyWith: String? // user id of the DM being created ("admin" = the Admin row)
@State private var error: String?
private var others: [RosterEntry] { store.roster.filter { $0.user_id != store.myUserId } }
@@ -55,6 +55,28 @@ struct NewChatView: View {
.foregroundStyle(Color.accentColor)
}
}
+ // The Admin app, admins only (the roster carries our role; the
+ // server re-checks it) — same open-the-app-thread action as the
+ // web picker's Apps row, so /admin isn't the only way in.
+ if store.isAdmin {
+ Button { openAdmin() } label: {
+ HStack {
+ Label {
+ Text("Admin").font(.body.weight(.medium))
+ } icon: {
+ Image(systemName: "gearshape")
+ .foregroundStyle(Color.accentColor)
+ }
+ Spacer()
+ if busyWith == "admin" { ProgressView() }
+ }
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .disabled(busyWith != nil)
+ .accessibilityLabel("Admin")
+ .accessibilityHint("Opens this space's admin panel")
+ }
}
if others.isEmpty {
@@ -112,6 +134,18 @@ struct NewChatView: View {
}
}
+ // Open (creating on first use) the Admin app-thread, then dismiss into it —
+ // the same land-in-the-thread shape as picking a person.
+ private func openAdmin() {
+ busyWith = "admin" // an app id can't collide with a user id here
+ error = nil
+ Task {
+ let id = await store.openOrCreateAppThread(app: "admin", title: "Admin")
+ busyWith = nil
+ if let id { finish(id) } else { error = store.loadError ?? "Couldn't open Admin." }
+ }
+ }
+
private func finish(_ id: String) {
onCreated(id)
dismiss()
diff --git a/ios/LChatCore/Sources/LChatCore/Models.swift b/ios/LChatCore/Sources/LChatCore/Models.swift
index 81e8218..013cbc3 100644
--- a/ios/LChatCore/Sources/LChatCore/Models.swift
+++ b/ios/LChatCore/Sources/LChatCore/Models.swift
@@ -99,6 +99,10 @@ public struct RosterEntry: Codable, Equatable {
// "online" | "away" | "offline" — the effective state the server stamps on
// the roster (presence.go); kept live between refreshes by "presence" frames.
public var presence: String? = nil
+ // "admin" | "moderator" | "member" — who runs the space (db.go RosterEntry).
+ // Optional so payloads from older servers still decode; gates UI only (the
+ // server re-checks the role on every admin API call).
+ public var role: String? = nil
public var isAgent: Bool { is_agent ?? false }
public var isAI: Bool { kind == "ai" }
diff --git a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
index 7817046..fa2b674 100644
--- a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
+++ b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
@@ -47,6 +47,14 @@ public final class ThreadStore: ObservableObject {
/// member profile photos. nil until loaded / if we haven't been granted it.
public func instanceKey() -> String? { instanceKeyB64 }
+ /// Whether this member holds the admin role — my own roster entry carries it.
+ /// Gates admin-only UI (the Admin app's picker rows) as a nicety only: the
+ /// server re-checks the role on every admin API call. False until the roster
+ /// loads, so the rows simply appear with it.
+ public var isAdmin: Bool {
+ roster.first { $0.user_id == myUserId }?.role == "admin"
+ }
+
/// The photo source for a roster member — their profile photo (instance-key
/// encrypted), or nil if they have none / we lack the key or address. For the
/// new-chat picker. Mirrors avatarFor in web/static/chat.js.
diff --git a/ios/LChatMac/NewChatPopover.swift b/ios/LChatMac/NewChatPopover.swift
index 5567fd3..65b4007 100644
--- a/ios/LChatMac/NewChatPopover.swift
+++ b/ios/LChatMac/NewChatPopover.swift
@@ -105,6 +105,15 @@ struct NewChatPopover: View {
ForEach(ThreadStore.personalApps, id: \.id) { appEntry in
appRow(appEntry)
}
+ // The Admin app is system-scope (never in personalApps);
+ // admins get its row here all the same — the discoverable
+ // way in beyond /admin. Same open action, popover closes
+ // into the thread via onCreated like every app row.
+ if store.isAdmin {
+ appRow(("admin", "Admin", "🛠️"))
+ .accessibilityLabel("Admin")
+ .accessibilityHint("Opens this space's admin panel")
+ }
} else {
Text("SHARED APPS")
.font(.system(size: 10, weight: .semibold))
diff --git a/web/static/shell.js b/web/static/shell.js
index 2c69830..152b69f 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -1769,6 +1769,14 @@ function inviteAllowed() {
return !!(me && me.role === "admin");
}
+// Whether THIS member holds the admin role — gates the Admin app's entry
+// points in the pickers. A UI nicety, not security: the Admin app re-checks
+// the role through its host bridge (/api/me) before doing anything.
+function isAdmin() {
+ const me = roster.find((e) => e.user_id === myUserId);
+ return !!(me && me.role === "admin");
+}
+
// True when a composer holds unsent text — reload paths check this so picking up
// an update never eats someone's draft. Covers every mounted chat-app composer
// (Split view shows two) plus the legacy #text box.
@@ -1906,6 +1914,7 @@ window.lchatPaletteItems = () => {
actions.push({ id: "discover", label: "Discover groups" });
if (inviteAllowed()) actions.push({ id: "invite", label: "Invite someone" });
if (personalApps().length) actions.push({ id: "apps", label: "Open apps" });
+ if (isAdmin()) actions.push({ id: "admin", label: "Open admin" });
actions.push(
{ id: "add-device", label: "Add a device" },
{ id: "theme", label: "Theme" },
@@ -1922,6 +1931,10 @@ window.lchatPaletteRun = (kind, id) => {
discover: showDiscover,
invite: () => showNewChat("invite"),
apps: showApps,
+ admin: async () => {
+ const id = await resolveAppThread("admin");
+ if (id) openThread(id);
+ },
"add-device": addDevice,
theme: showThemePicker,
"edit-profile": editProfile,
@@ -7343,6 +7356,11 @@ function showNewChat(startMode) {
(a.title || a.name).toLowerCase().includes(q) &&
(!a.requiresService || (connectedServices && connectedServices.has(a.requiresService))),
);
+ // The Admin app is system-scope (deliberately absent from personalApps), but
+ // admins get its row in the Apps section all the same — the discoverable way
+ // in, beyond the /admin command. Null for everyone else.
+ const adminApp = isAdmin() ? { name: "admin", title: "Admin", glyph: "🛠️" } : null;
+ const adminMatches = (q) => !!adminApp && adminApp.title.toLowerCase().includes(q);
const addAppRow = (listEl, app) => {
const b = document.createElement("button");
b.className = "list-row";
@@ -7476,11 +7494,12 @@ const disc = document.createElement("button");
card.appendChild(p);
}
const apps = matchingApps("");
- if (apps.length) {
+ if (apps.length || adminApp) {
const list = document.createElement("div");
list.className = "pick-list";
list.appendChild(pickerSection("Apps"));
for (const app of apps) addAppRow(list, app);
+ if (adminApp) addAppRow(list, adminApp);
card.appendChild(list);
}
return;
@@ -7626,7 +7645,8 @@ const disc = document.createElement("button");
const people = others.filter((e) => !e.is_agent && rosterMatches(e, q));
const helpers = others.filter((e) => e.is_agent && rosterMatches(e, q));
const apps = mode === "people" ? matchingApps(q) : [];
- if (!people.length && !helpers.length && !apps.length) {
+ const showAdmin = mode === "people" && adminMatches(q);
+ if (!people.length && !helpers.length && !apps.length && !showAdmin) {
const p = document.createElement("p");
p.className = "sub";
p.style.padding = "12px 28px";
@@ -7634,12 +7654,13 @@ const disc = document.createElement("button");
list.appendChild(p);
return;
}
- if (people.length && (helpers.length || apps.length)) list.appendChild(pickerSection("People"));
+ if (people.length && (helpers.length || apps.length || showAdmin)) list.appendChild(pickerSection("People"));
for (const e of people) addRow(e);
if (helpers.length) list.appendChild(pickerSection("Helpers"));
for (const e of helpers) addRow(e);
- if (apps.length) list.appendChild(pickerSection("Apps"));
+ if (apps.length || showAdmin) list.appendChild(pickerSection("Apps"));
for (const app of apps) addAppRow(list, app);
+ if (showAdmin) addAppRow(list, adminApp);
};
filter.addEventListener("input", renderList);
renderList();