eleven / ocho Public

ios+mac: Sign out is the full reset — clear this device, restore on next sign-in (#456)

* ios+mac: Sign out IS the reset — clear the device, restore on next sign-in

There's no half-signed-out state with a passkey: the spaces live in the
member's Eleven account, so Sign out now means 'clear this device'. A
final sync push runs first (seconds-old changes reach the account),
then the full wipe (DevReset renamed AppReset — a release feature now,
not a dev tool): iOS folds to first-run in place; the Mac relaunches
fresh. Both behind a destructive confirmation that says exactly what
happens. The Debug-only Reset app buttons are gone — Sign out replaced
them.

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

* mac: Sign Out… in the rail's … menu — same confirm + full-reset path as Settings

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

* ios: typing bubble follows the feed's text size — it was stuck at the default

The bubble sizes itself as one message line from \.chatFontSize, but on
iOS the environment was injected per-MessageBubble only, so the typing
bubble fell back to the default 16 and outgrew smaller text settings
(the Mac injects at the feed level and already matched). Injected at
the mount; an ImageRenderer test pins typing == one-line bubble across
the size ladder.

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

* core: typing dots at the web's 8pt/4pt metrics; height pinned to a REAL MessageBubble

The size probe now renders an actual MessageBubble (not a
reconstruction of its insets) — typing == one-line bubble is
pixel-identical across the size ladder (32/34/37/38/42pt).

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

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 0dae9d180a7696296ed4135191b502106148526e parent 14328cb
10 files changed, +125 −90
  • ios/LChat/LChatApp.swift +21 −21
  • ios/LChat/Views/InstanceSidebar.swift +14 −24
  • ios/LChat/Views/NativeChatScreen.swift +4 −0
  • ios/LChat/Views/SignInWithEleven.swift +3 −7
  • ios/LChatCore/Sources/LChatCore/{DevReset.swift => AppReset.swift} +8 −7
  • ios/LChatCore/Sources/LChatCore/TypingBubble.swift +5 −2
  • ios/LChatCore/Tests/LChatCoreTests/SizeProbeTests.swift +26 −0
  • ios/LChatMac/MacHomeSignIn.swift +25 −3
  • ios/LChatMac/MacSettingsView.swift +0 −26
  • ios/LChatMac/Sidebar.swift +19 −0
diff --git a/ios/LChat/LChatApp.swift b/ios/LChat/LChatApp.swift
index 6e12925..3d725f4 100644
--- a/ios/LChat/LChatApp.swift
+++ b/ios/LChat/LChatApp.swift
@@ -35,15 +35,13 @@ final class AppState: ObservableObject {
init() { instances = InstancesStore.load() }
- #if DEBUG
- /// Dev reset (the drawer's "Reset app"): DevReset has already wiped the
- /// stores — drop the in-memory mirror so the UI folds back to first-run
- /// without relaunching (nothing here re-persists).
- func devReset() {
+ /// Sign-out's second half: AppReset has already wiped the stores — drop
+ /// the in-memory mirror so the UI folds back to first-run without
+ /// relaunching (nothing here re-persists).
+ func eraseAll() {
instances = []
serverAvatars = [:]
}
- #endif
/// Fetch each server's photo for the switcher. /api/group is public and reports
/// whether a photo is set, so this works with no key and only fetches the bytes
@@ -388,21 +386,23 @@ struct RootView: View {
showServers = false
showAdd = true
},
- onReset: {
- // Dev reset (Debug builds only — the drawer's button is
- // #if DEBUG, and so is AppState.devReset; an ungated call
- // here broke the Release archive). Wipe the stores, then
- // fold the RUNNING app back to first-run in place — an
- // exit(0) here read as a crash. Order matters: wipe first,
- // then drop the in-memory mirrors so nothing re-persists.
- #if DEBUG
- DevReset.wipeAll()
- app.devReset()
- signIn.refreshSession()
- path = []
- nav.selectInstance(nil)
- showServers = false
- #endif
+ onSignOut: {
+ // Sign out = clear this device (the spaces live in the
+ // member's Eleven account; signing back in restores them).
+ // A last sync push first, so even seconds-old changes are
+ // in the account before the wipe; then wipe, drop the
+ // in-memory mirrors, and fold to first-run in place.
+ Task {
+ if let session = HomeSessionStore.load() {
+ _ = await HomeSync.push(instances: app.instances, session: session)
+ }
+ AppReset.wipeAll()
+ app.eraseAll()
+ signIn.refreshSession()
+ path = []
+ nav.selectInstance(nil)
+ showServers = false
+ }
}
)
.offset(x: (progress - 1) * drawerWidth)
diff --git a/ios/LChat/Views/InstanceSidebar.swift b/ios/LChat/Views/InstanceSidebar.swift
index 1fae9c8..2284561 100644
--- a/ios/LChat/Views/InstanceSidebar.swift
+++ b/ios/LChat/Views/InstanceSidebar.swift
@@ -13,13 +13,14 @@ struct ServerDrawer: View {
@ObservedObject var signIn: HomeSignInModel
var onSelect: (SavedInstance) -> Void
var onAdd: () -> Void
- // The dev reset, owned by RootView (it must also fold nav/session state).
- var onReset: () -> Void = {}
+ // Sign out = full local reset, owned by RootView (final sync push, wipe,
+ // fold nav/session back to first-run).
+ var onSignOut: () -> Void = {}
// "More" starts expanded if the active space is one of the collapsed ones,
// so you never lose sight of where you are.
@State private var showMore = false
@State private var showArchived = false
- @State private var confirmReset = false
+ @State private var confirmSignOut = false
private var active: [SavedInstance] { app.instances.filter { !$0.isArchived } }
private var starred: [SavedInstance] { active.filter(\.isStarred) }
@@ -97,8 +98,11 @@ struct ServerDrawer: View {
// affordances below that; the drawer is just the server list.
EmptyView()
} else if signIn.sessionExists {
+ // Sign out clears the DEVICE — there's no half-signed-out
+ // state with a passkey; the spaces live in the account and
+ // come back on the next sign-in. Hence the confirmation.
Button {
- signIn.signOut()
+ confirmSignOut = true
} label: {
Label("Sign out", systemImage: "person.badge.key")
.font(.body.weight(.medium))
@@ -108,6 +112,12 @@ struct ServerDrawer: View {
.contentShape(Rectangle())
}
.buttonStyle(.plain)
+ .confirmationDialog(
+ "Sign out? Your spaces, messages and keys are removed from this iPhone. Sign back in to get them back.",
+ isPresented: $confirmSignOut, titleVisibility: .visible
+ ) {
+ Button("Sign Out & Clear This iPhone", role: .destructive, action: onSignOut)
+ }
} else {
Button {
Task { await signIn.signIn(into: app) }
@@ -157,26 +167,6 @@ struct ServerDrawer: View {
.padding(.bottom, 12)
}
}
- #if DEBUG
- // Dev builds only: a true first-run needs the Keychain wiped —
- // identities survive an app uninstall, so reinstalling isn't a reset.
- Button {
- confirmReset = true
- } label: {
- Text("Reset app (dev)")
- .font(.footnote)
- .foregroundStyle(.red.opacity(0.8))
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding(.horizontal, 20)
- .padding(.bottom, 12)
- .contentShape(Rectangle())
- }
- .buttonStyle(.plain)
- .confirmationDialog("Erase every server, key, and message from this device?",
- isPresented: $confirmReset, titleVisibility: .visible) {
- Button("Erase Everything", role: .destructive, action: onReset)
- }
- #endif
}
.frame(width: 300)
.frame(maxHeight: .infinity)
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift
index 0ff9f00..5da2d14 100644
--- a/ios/LChat/Views/NativeChatScreen.swift
+++ b/ios/LChat/Views/NativeChatScreen.swift
@@ -372,6 +372,10 @@ struct NativeChatScreen: View {
HStack(spacing: 0) {
TypingBubble(theme: .named(themeName),
label: isGroup ? typingSummary(model.typing) : nil)
+ // The bubble sizes itself as one MESSAGE line —
+ // without the feed's size it fell back to the
+ // default and outgrew small text settings.
+ .environment(\.chatFontSize, chatFontSize)
Spacer(minLength: 40)
}
.padding(EdgeInsets(top: 8, leading: 12, bottom: 2, trailing: 12))
diff --git a/ios/LChat/Views/SignInWithEleven.swift b/ios/LChat/Views/SignInWithEleven.swift
index cac79d0..3258014 100644
--- a/ios/LChat/Views/SignInWithEleven.swift
+++ b/ios/LChat/Views/SignInWithEleven.swift
@@ -82,13 +82,9 @@ final class HomeSignInModel: ObservableObject {
phase = .idle
}
- /// Forget the home session — the device keeps its servers, it just stops
- /// syncing them until the next sign-in. (The passkey itself is untouched.)
- func signOut() {
- HomeSessionStore.clear()
- sessionExists = false
- phase = .idle
- }
+ // (No partial sign-out exists: with a passkey there's nothing to be half
+ // signed-out OF — Sign out is the full local reset, owned by RootView's
+ // onSignOut so nav state folds with it. The passkey itself is untouched.)
/// Mint a NEW Eleven passkey and seal this device's servers into a fresh
/// backup. Deliberately a separate act from Sign in (home has no account
diff --git a/ios/LChatCore/Sources/LChatCore/DevReset.swift b/ios/LChatCore/Sources/LChatCore/AppReset.swift
similarity index 84%
rename from ios/LChatCore/Sources/LChatCore/DevReset.swift
rename to ios/LChatCore/Sources/LChatCore/AppReset.swift
index 5c0c99f..c79789d 100644
--- a/ios/LChatCore/Sources/LChatCore/DevReset.swift
+++ b/ios/LChatCore/Sources/LChatCore/AppReset.swift
@@ -1,13 +1,14 @@
import Foundation
import Security
-// 🤖 Dev-only full reset: wipes everything the app persists — Keychain
-// identities, thread keys, the home session, UserDefaults, and the on-disk
-// stores — so the next launch is a true first run. This exists because
-// Keychain items SURVIVE an app uninstall on iOS: deleting the app and
-// reinstalling brings every identity straight back, which makes first-run
-// flows untestable. Only Debug UI ever calls this.
-public enum DevReset {
+// 🤖 The full local reset behind SIGN OUT: wipes everything the app persists —
+// Keychain identities, thread keys, the home session, UserDefaults, and the
+// on-disk stores — so the next launch is a true first run. Signing out of
+// Eleven MEANS clearing this device: the spaces live in the member's Eleven
+// account, and signing back in restores them (the caller pushes a final sync
+// first). Also the reason a plain uninstall isn't a reset: Keychain items
+// survive app deletion on iOS.
+public enum AppReset {
public static func wipeAll() {
// Whole-service deletes (no account = every item), with and without
// the app-family access group — matching how the stores write.
diff --git a/ios/LChatCore/Sources/LChatCore/TypingBubble.swift b/ios/LChatCore/Sources/LChatCore/TypingBubble.swift
index 7aa284a..d0e49f1 100644
--- a/ios/LChatCore/Sources/LChatCore/TypingBubble.swift
+++ b/ios/LChatCore/Sources/LChatCore/TypingBubble.swift
@@ -37,12 +37,15 @@ public struct TypingBubble: View {
// feed publish. A phase-offset sine can't drift or sync.
TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in
let t = context.date.timeIntervalSinceReferenceDate
- HStack(spacing: 5) {
+ // 8pt dots, tight 4pt gaps (the web's .typing-bubble
+ // metrics): smaller dots read as an oversized, empty
+ // bubble even at the correct height.
+ HStack(spacing: 4) {
ForEach(0..<3, id: \.self) { i in
let phase = sin((t / 0.55 - Double(i) * 0.33) * .pi)
Circle()
.fill(.secondary)
- .frame(width: 7, height: 7)
+ .frame(width: 8, height: 8)
.opacity(0.35 + 0.6 * (phase + 1) / 2)
}
}
diff --git a/ios/LChatCore/Tests/LChatCoreTests/SizeProbeTests.swift b/ios/LChatCore/Tests/LChatCoreTests/SizeProbeTests.swift
new file mode 100644
index 0000000..5a11650
--- /dev/null
+++ b/ios/LChatCore/Tests/LChatCoreTests/SizeProbeTests.swift
@@ -0,0 +1,26 @@
+import XCTest
+import SwiftUI
+@testable import LChatCore
+
+// 🤖 The typing bubble must be EXACTLY one message line tall at every text
+// size (Paul's spec). Measured against a REAL MessageBubble, not a
+// reconstruction, so bubble-padding changes can't silently diverge the two.
+final class SizeProbeTests: XCTestCase {
+ @MainActor
+ func testTypingBubbleMatchesOneLineBubbleAtEverySize() {
+ let msg = ChatFeedModel.Message(id: 1, name: "James", text: "hello",
+ mine: false, at: 1_700_000_000, edited: false)
+ for size: CGFloat in [11, 13, 15, 17, 20] {
+ let typing = ImageRenderer(content:
+ TypingBubble(theme: .named("classic")).environment(\.chatFontSize, size))
+ let bubble = ImageRenderer(content:
+ MessageBubble(message: msg, showSender: false, theme: .named("classic"))
+ .environment(\.chatFontSize, size))
+ let t = typing.nsImage?.size.height ?? 0
+ let b = bubble.nsImage?.size.height ?? 0
+ print("PROBE size \(size): typing \(t) bubble \(b)")
+ XCTAssertEqual(t, b, accuracy: 1.0,
+ "typing bubble \(t) vs real one-line bubble \(b) at size \(size)")
+ }
+ }
+}
diff --git a/ios/LChatMac/MacHomeSignIn.swift b/ios/LChatMac/MacHomeSignIn.swift
index 91500cd..ca678a9 100644
--- a/ios/LChatMac/MacHomeSignIn.swift
+++ b/ios/LChatMac/MacHomeSignIn.swift
@@ -62,10 +62,23 @@ final class MacHomeSignIn: ObservableObject {
}
}
- func signOut() {
- HomeSessionStore.clear()
+ /// Sign out = clear this Mac: there's no half-signed-out state with a
+ /// passkey — the spaces live in the member's Eleven account and the next
+ /// sign-in restores them. A last sync push first (so seconds-old changes
+ /// reach the account), then the full local wipe, then a fresh relaunch
+ /// (the running UI is saturated with the wiped state).
+ func signOut(from app: MacAppState) async {
+ if let session = HomeSessionStore.load() {
+ _ = await HomeSync.push(instances: app.instancesInRailOrder(), session: session)
+ }
+ AppReset.wipeAll()
sessionExists = false
phase = .idle
+ let cfg = NSWorkspace.OpenConfiguration()
+ cfg.createsNewApplicationInstance = true
+ NSWorkspace.shared.openApplication(at: Bundle.main.bundleURL, configuration: cfg) { _, _ in
+ DispatchQueue.main.async { NSApp.terminate(nil) }
+ }
}
/// Re-read the Keychain session: another surface's model (the first-run
@@ -203,12 +216,21 @@ final class MacHomeSignIn: ObservableObject {
struct HomeSignInSection: View {
@EnvironmentObject var app: MacAppState
@ObservedObject var model: MacHomeSignIn
+ @State private var confirmSignOut = false
var body: some View {
Section("Eleven") {
if model.sessionExists {
LabeledContent("Signed in") {
- Button("Sign Out") { model.signOut() }
+ Button("Sign Out…") { confirmSignOut = true }
+ }
+ .confirmationDialog(
+ "Sign out? Your spaces, messages and keys are removed from this Mac. Sign back in to get them back.",
+ isPresented: $confirmSignOut, titleVisibility: .visible
+ ) {
+ Button("Sign Out & Clear This Mac", role: .destructive) {
+ Task { await model.signOut(from: app) }
+ }
}
if case .restored(let n) = model.phase, n > 0 {
Text("\(n) space\(n == 1 ? "" : "s") restored.")
diff --git a/ios/LChatMac/MacSettingsView.swift b/ios/LChatMac/MacSettingsView.swift
index a9f876b..e80b58c 100644
--- a/ios/LChatMac/MacSettingsView.swift
+++ b/ios/LChatMac/MacSettingsView.swift
@@ -29,7 +29,6 @@ struct MacSettingsView: View {
}
@StateObject private var homeSignIn = MacHomeSignIn()
- @State private var confirmReset = false
var body: some View {
Form {
@@ -86,31 +85,6 @@ struct MacSettingsView: View {
Text(awayNote).font(.footnote).foregroundStyle(.secondary)
}
}
- #if DEBUG
- // Dev builds only: a true first-run needs the Keychain wiped —
- // identities survive reinstalls, so trashing the app isn't a reset.
- Section("Development") {
- Button("Reset app…", role: .destructive) { confirmReset = true }
- .confirmationDialog(
- "Erase every server, key, and message from this Mac?",
- isPresented: $confirmReset, titleVisibility: .visible
- ) {
- Button("Erase Everything", role: .destructive) {
- DevReset.wipeAll()
- // Relaunch fresh rather than bare-terminate — a
- // sudden quit reads as a crash. The new instance
- // boots against the wiped stores (first-run).
- let cfg = NSWorkspace.OpenConfiguration()
- cfg.createsNewApplicationInstance = true
- NSWorkspace.shared.openApplication(
- at: Bundle.main.bundleURL, configuration: cfg
- ) { _, _ in
- DispatchQueue.main.async { NSApp.terminate(nil) }
- }
- }
- }
- }
- #endif
}
.formStyle(.grouped)
// No frame here — the Settings window's single fixed size lives on the
diff --git a/ios/LChatMac/Sidebar.swift b/ios/LChatMac/Sidebar.swift
index 82be11f..dd44ad8 100644
--- a/ios/LChatMac/Sidebar.swift
+++ b/ios/LChatMac/Sidebar.swift
@@ -13,6 +13,10 @@ import UniformTypeIdentifiers
struct Sidebar: View {
@EnvironmentObject var app: MacAppState
@State private var dragging: SavedChat?
+ // Sign out from the rail's "…" menu — same full-reset semantics as the
+ // Settings button (MacHomeSignIn.signOut: final push, wipe, relaunch).
+ @StateObject private var signOut = MacHomeSignIn()
+ @State private var confirmSignOut = false
// The rail shows favourites ONLY when any exist; otherwise up to nine
// spaces. Everything else lives in the "…" menu — archived spaces at its
@@ -55,6 +59,17 @@ struct Sidebar: View {
// Keyed by the SET of chat ids (not the array) so reordering doesn't re-run
// the name fetch — only adding/removing an instance does.
.task(id: Set(app.chats.map(\.id))) { await loadNames() }
+ // Sign-ins happen on other surfaces (first-run, Settings, the browser
+ // handoff) — re-check whenever the rail shows so the item isn't stale.
+ .onAppear { signOut.refreshSession() }
+ .confirmationDialog(
+ "Sign out? Your spaces, messages and keys are removed from this Mac. Sign back in to get them back.",
+ isPresented: $confirmSignOut, titleVisibility: .visible
+ ) {
+ Button("Sign Out & Clear This Mac", role: .destructive) {
+ Task { await signOut.signOut(from: app) }
+ }
+ }
}
private func icon(_ chat: SavedChat) -> some View {
@@ -104,6 +119,10 @@ struct Sidebar: View {
Button("Manage Spaces on Eleven…") {
NSWorkspace.shared.open(URL(string: "https://home.elevenmessenger.com/")!)
}
+ if signOut.sessionExists {
+ Divider()
+ Button("Sign Out…") { confirmSignOut = true }
+ }
} label: {
Image(systemName: "ellipsis")
.font(.callout.weight(.semibold))