eleven / ocho Public

Mac Catalyst: persist saved chats, fix composer Return/focus, fix bubble truncation (#12)

* ios: swipe-right to reply on mobile message rows

Mobile (touch) had no swipe affordance for replying — only the long-press context menu — while pointer devices get the hover button. Add a WhatsApp/Messages-style swipe-right-to-reply on the message row, mirroring the web (attachSwipeReply in chat.js): 56pt trigger, 80pt cap, a reply arrow that fades in on the leading edge, and a medium haptic on fire.

Gated out of the Mac Catalyst build with #if !targetEnvironment(macCatalyst). Uses .simultaneousGesture plus a horizontal-dominance lock so it coexists with the feed's vertical scroll and the left-edge back-swipe rather than fighting them.

* ios: fix Mac Catalyst keychain persistence + composer Return/focus

Saved chats never survived a relaunch on the Mac. The SecItem queries
omitted kSecUseDataProtectionKeychain, so Catalyst used the legacy
file-based macOS keychain instead of the iOS-style data-protection one;
in the sandbox those writes fail silently (the SecItemAdd status was
ignored), so the saved-chats list came back empty every launch. Set the
flag on all three queries (load/save/clear) — a no-op on iOS, the fix on
Catalyst.

Also fix the Mac composer: a bare Return now sends on the first press.
The send button's .keyboardShortcut(.return) was swallowed by the focused
multiline TextField (hence the "hit Enter twice" behaviour); intercept
the key on the field itself with .onKeyPress instead, with Shift+Return
still inserting a newline and touch keyboards keeping Return-as-newline.
The composer keeps focus after each send (re-asserted on the next runloop
tick so a send-button tap doesn't drop it) and auto-focuses on entry on
the Mac.

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

* docs: document headless iOS/macOS builds with predictable output paths

Add a native iOS & macOS section to the root README and flesh out the
ios/ build notes: build both variants from the command line without
launching Xcode, with -derivedDataPath build so products land in-repo at
a known path instead of a hashed DerivedData folder, plus a gitignored
LChat-*.app symlink to launch the deep Mac path. Also correct the stale
"no Enter-to-send on Mac" note now that Return sends.

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

---------
Co-authored-by: Claude <noreply@anthropic.com>
James Adam 55198209e87a8a15a0ac387c42a71cbf3f0a8a04 parent 75b66e2
5 files changed, +127 −11
  • README.md +47 −0
  • ios/.gitignore +2 −0
  • ios/LChat/Keychain.swift +9 −0
  • ios/LChat/Views/ChatView.swift +24 −3
  • ios/README.md +45 −8
diff --git a/README.md b/README.md
index 2d027d4..e3d7761 100644
--- a/README.md
+++ b/README.md
@@ -59,6 +59,53 @@ Copy `lchat` to your server and run it behind a TLS-terminating reverse proxy
(Caddy/nginx). **HTTPS is required in production** — the invite token travels to
the server, and the fragment-key model assumes the transport is encrypted.
+## Native iOS & macOS app
+
+There's a native SwiftUI client in [`ios/`](ios/README.md) that runs on
+iPhone/iPad and on macOS via **Mac Catalyst** (same target, same code, same
+end-to-end encryption contract). The `.xcodeproj` is generated by
+[`xcodegen`](https://github.com/yonsm/XcodeGen), not committed.
+
+You can build both variants from the command line **without launching Xcode**
+(handy for CI or a quick compile check) — `CODE_SIGNING_ALLOWED=NO` skips the
+need for a signing team:
+
+```sh
+cd ios
+xcodegen generate # writes LChat.xcodeproj from project.yml
+
+# iOS (compiles against the Simulator SDK — no device or signing needed):
+xcodebuild -project LChat.xcodeproj -scheme LChat \
+ -destination 'generic/platform=iOS Simulator' \
+ -derivedDataPath build CODE_SIGNING_ALLOWED=NO build
+
+# macOS (Mac Catalyst — a native build that doesn't touch the iOS Simulator):
+xcodebuild -project LChat.xcodeproj -scheme LChat \
+ -destination 'platform=macOS,variant=Mac Catalyst' \
+ -derivedDataPath build CODE_SIGNING_ALLOWED=NO build
+```
+
+`-derivedDataPath build` keeps the products inside the repo (under the
+gitignored `ios/build/`) instead of a hashed folder buried in
+`~/Library/Developer/Xcode/DerivedData/`. The built apps land at:
+
+- `ios/build/Build/Products/Debug-iphonesimulator/LChat.app`
+- `ios/build/Build/Products/Debug-maccatalyst/LChat.app` ← the runnable Mac app
+
+That Mac path is still a mouthful, so symlink it somewhere handy and launch it
+(the `LChat-*.app` symlink names are gitignored):
+
+```sh
+ln -sfn build/Build/Products/Debug-maccatalyst/LChat.app LChat-mac.app
+open LChat-mac.app # runs the Mac (Catalyst) build
+```
+
+A successful build ends in `** BUILD SUCCEEDED **`. If a Simulator destination
+can't be resolved (e.g. an out-of-date CoreSimulator), target the SDK directly
+instead: `-sdk iphonesimulator` in place of the `-destination` flag. See
+[`ios/README.md`](ios/README.md) for signing, running on a device, and how the
+client maps to the server.
+
## Deploy to Miren
[Miren](https://miren.dev) builds the container, runs it, and handles routing +
diff --git a/ios/.gitignore b/ios/.gitignore
index bf19373..ec8c35f 100644
--- a/ios/.gitignore
+++ b/ios/.gitignore
@@ -2,5 +2,7 @@
LChat.xcodeproj/
build/
DerivedData/
+# Convenience symlinks into build/ from the README's headless-build recipe.
+LChat-*.app
*.xcuserstate
.DS_Store
diff --git a/ios/LChat/Keychain.swift b/ios/LChat/Keychain.swift
index 81407af..c310b2e 100644
--- a/ios/LChat/Keychain.swift
+++ b/ios/LChat/Keychain.swift
@@ -4,6 +4,12 @@ import Security
// Keychain-backed JSON storage. The saved chats live here (not UserDefaults)
// because their invite URLs carry the group key — the "key is load-bearing" rule
// from CLAUDE.md.
+//
+// Every query sets kSecUseDataProtectionKeychain so Mac Catalyst uses the same
+// iOS-style data-protection keychain instead of the legacy file-based macOS
+// keychain. Without it the sandboxed Mac app's writes fail silently and nothing
+// persists — the saved-chats list comes back empty on every launch. (On iOS the
+// flag is a no-op; the data-protection keychain is already the only one.)
enum Keychain {
private static let service = "com.lchat.client"
@@ -12,6 +18,7 @@ enum Keychain {
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
+ kSecUseDataProtectionKeychain as String: true,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
@@ -29,6 +36,7 @@ enum Keychain {
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
+ kSecUseDataProtectionKeychain as String: true,
]
SecItemDelete(base as CFDictionary)
var add = base
@@ -42,6 +50,7 @@ enum Keychain {
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
+ kSecUseDataProtectionKeychain as String: true,
] as CFDictionary)
}
}
diff --git a/ios/LChat/Views/ChatView.swift b/ios/LChat/Views/ChatView.swift
index 4571b5b..e4952ee 100644
--- a/ios/LChat/Views/ChatView.swift
+++ b/ios/LChat/Views/ChatView.swift
@@ -21,6 +21,7 @@ struct ChatView: View {
@State private var showLeave = false
@State private var flashID: Int64?
@State private var dropTargeted = false
+ @FocusState private var composerFocused: Bool
let chat: SavedChat
@@ -48,6 +49,11 @@ struct ChatView: View {
feed
composer
}
+ // On the Mac there's no soft keyboard to worry about, so focus the
+ // composer on entry — the Return-to-send shortcut then works without
+ // first clicking into the field. On iOS we leave it unfocused so the
+ // keyboard doesn't immediately cover the feed.
+ .onAppear { if isMac { composerFocused = true } }
// On the Mac the name shows in the native titlebar, so don't repeat it
// in the nav bar (which carries just the back chevron + menu there).
.navigationTitle(isMac ? "" : store.groupName)
@@ -170,10 +176,23 @@ struct ChatView: View {
TextField("Message", text: $text, axis: .vertical)
.textFieldStyle(.plain)
.lineLimit(1...6)
+ .focused($composerFocused)
.padding(.horizontal, 12)
.padding(.vertical, 7)
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 18))
.overlay(RoundedRectangle(cornerRadius: 18).strokeBorder(Color(.separator).opacity(0.5), lineWidth: 0.5))
+ // Desktop convention (matches the web composer): a bare Return
+ // sends, Shift+Return inserts a newline. We intercept the key on
+ // the field itself rather than via a Button keyboardShortcut —
+ // a focused multiline field swallows the Button's shortcut, which
+ // is what made Return need a second press. Touch keyboards keep
+ // Return as a newline (the send button sends there), so only the
+ // Mac opts in.
+ .onKeyPress(.return, phases: .down) { press in
+ guard isMac, press.modifiers.isEmpty else { return .ignored }
+ if canSend { send() }
+ return .handled
+ }
Button(action: send) {
Image(systemName: "arrow.up.circle.fill")
@@ -182,9 +201,6 @@ struct ChatView: View {
}
.buttonStyle(.plain)
.disabled(!canSend)
- // Hardware keyboard (Mac/iPad): Return sends. Shift+Return isn't
- // matched, so the multiline field inserts a newline instead.
- .keyboardShortcut(.return, modifiers: [])
}
}
.padding(.horizontal, 10)
@@ -295,6 +311,11 @@ struct ChatView: View {
pendingPreview = nil
pendingName = nil
replyingTo = nil
+ // Keep the composer focused so you can fire off several messages in a row
+ // without clicking back into the field after each send. Tapping the send
+ // button resigns the field's first responder, so re-assert focus on the
+ // next runloop tick — setting it inline here gets clobbered by the tap.
+ DispatchQueue.main.async { composerFocused = true }
}
// Shared staging: show a preview, encrypt + upload, then arm the attachment.
diff --git a/ios/README.md b/ios/README.md
index c1be0bc..05cd918 100644
--- a/ios/README.md
+++ b/ios/README.md
@@ -37,6 +37,46 @@ Pick the destination in Xcode's run-destination menu:
- **iPhone/iPad** — a Simulator or a connected device.
- **Mac** — choose "My Mac (Mac Catalyst)".
+### Build without launching Xcode
+
+Both variants build straight from the command line — useful for CI or a quick
+compile check. `CODE_SIGNING_ALLOWED=NO` skips the need for a signing team; a
+successful build ends in `** BUILD SUCCEEDED **`.
+
+```sh
+cd ios
+xcodegen generate # if you haven't already
+
+# iOS — compiles against the Simulator SDK (no device or signing needed):
+xcodebuild -project LChat.xcodeproj -scheme LChat \
+ -destination 'generic/platform=iOS Simulator' \
+ -derivedDataPath build CODE_SIGNING_ALLOWED=NO build
+
+# macOS (Mac Catalyst) — a native build that doesn't touch the iOS Simulator:
+xcodebuild -project LChat.xcodeproj -scheme LChat \
+ -destination 'platform=macOS,variant=Mac Catalyst' \
+ -derivedDataPath build CODE_SIGNING_ALLOWED=NO build
+```
+
+`-derivedDataPath build` puts the products under the gitignored `ios/build/`
+instead of a hashed folder in `~/Library/Developer/Xcode/DerivedData/`:
+
+- `ios/build/Build/Products/Debug-iphonesimulator/LChat.app`
+- `ios/build/Build/Products/Debug-maccatalyst/LChat.app` (runnable Mac app)
+
+Symlink the deep Mac path somewhere handy to launch it (the `LChat-*.app`
+symlink names are gitignored):
+
+```sh
+ln -sfn build/Build/Products/Debug-maccatalyst/LChat.app LChat-mac.app
+open LChat-mac.app
+```
+
+If a Simulator destination can't be resolved (e.g. an out-of-date
+CoreSimulator), target the SDK directly instead — swap the `-destination` flag
+for `-sdk iphonesimulator`. The Mac Catalyst build is the most reliable headless
+gate since it never touches the Simulator service.
+
To run on a physical iOS device or on the Mac, set your signing team in Xcode
(Signing & Capabilities) — the bundle id is `com.lchat.client`.
@@ -48,15 +88,12 @@ Catalyst apps are sandboxed, `LChat/LChat.entitlements` grants
the server at all** (REST or WebSocket). The Keychain, photo picker, and share
sheet all work under the sandbox as-is.
-Headless build check for the Mac variant (doesn't need the iOS Simulator):
-
-```sh
-xcodebuild -project LChat.xcodeproj -scheme LChat \
- -destination 'platform=macOS,variant=Mac Catalyst' build
-```
+The Mac variant is also the most reliable headless build gate — see
+[Build without launching Xcode](#build-without-launching-xcode) above.
-One UX difference to be aware of: the composer sends via the send button (there's
-no Enter-to-send shortcut yet on Mac); Return inserts a newline.
+Composer keys on the Mac: **Return sends** (matching the web app on desktop) and
+**Shift+Return inserts a newline**. The field keeps focus after each send, so you
+can fire off several messages without clicking back in.
### Testing against a local server