eleven / ocho Public

mac,ios: search hits land ON the message — and the Minion waves on iOS (#292)

Picking a Messages hit (Mac typeahead Return/click, iOS results tap)
now opens the chat AND lands on that message: the feed pages history
until the target is loaded, scrolls it into view, and the bubble does
the wave. Mac routes the request over MacAppState.nativeJump into the
thread's feed; iOS carries it via a pending-jump handoff consumed by
the settle pass (overriding the bottom pin for that open).

The wave itself is now on iOS too, same keyframes as the Mac's (feet
planted, tiptoes, eager wag that settles) — applied after the row's
.clipped() so the wag never beheads at the row edge. Reply-quote taps
share it via jumpAndWave.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 2d13d7cf8399e7bf7bd4427163a8419269485ff9 parent 6a22cb0
5 files changed, +110 −4
  • ios/LChat/Views/NativeChatScreen.swift +67 −3
  • ios/LChat/Views/ThreadListView.swift +1 −0
  • ios/LChatMac/LChatMacApp.swift +7 −0
  • ios/LChatMac/NativeChatView.swift +6 −0
  • ios/LChatMac/NativeFeedView.swift +29 −1
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift
index b6116b3..7296eff 100644
--- a/ios/LChat/Views/NativeChatScreen.swift
+++ b/ios/LChat/Views/NativeChatScreen.swift
@@ -47,6 +47,14 @@ struct NativeChatScreen: View {
// The pre-tap last-seen time; non-nil = this open should anchor unreads.
@State private var unreadCutoff: Int64?
+ // The jumped-to message does the little wave (the Mac feed's Minion) —
+ // danceTick retriggers it even for the same target.
+ @State private var danceID: Int64?
+ @State private var danceTick = 0
+ // A search hit's "open the chat ON this message" request, set by the
+ // chat list before pushing this screen.
+ static var pendingJump: (thread: String, message: Int64)?
+
@State private var draft = ""
@State private var caret = 0
@State private var composerFocused = false // ComposerField's own focus binding (UIKit-backed)
@@ -212,6 +220,24 @@ struct NativeChatScreen: View {
settled = true // empty chat — nothing to pin
return
}
+ // A search hit lands ON its message: page history until the target is
+ // loaded, then jump-and-wave (overrides the bottom pin for this open).
+ if let j = Self.pendingJump, j.thread == thread.id {
+ Self.pendingJump = nil
+ settled = true
+ Task {
+ var tries = 0
+ while !model.messages.contains(where: { $0.id == j.message }),
+ !model.exhausted, tries < 20 {
+ tries += 1
+ await model.loadOlder()
+ try? await Task.sleep(nanoseconds: 550_000_000)
+ }
+ try? await Task.sleep(nanoseconds: 150_000_000)
+ jumpAndWave(j.message, proxy: proxy)
+ }
+ return
+ }
// Notification-tap open with unreads: land the first unread at the
// TOP (just under the title pill) so reading continues from where it
// left off — Messages-style — instead of jumping past everything.
@@ -398,9 +424,7 @@ struct NativeChatScreen: View {
if m.mine { Spacer(minLength: 40) }
MessageBubble(message: m, showSender: isGroup && startsRun,
theme: .named(themeName),
- onJump: { target in
- withAnimation { proxy.scrollTo(target, anchor: .center) }
- },
+ onJump: { target in jumpAndWave(target, proxy: proxy) },
blobSource: { model.blobSource($0) },
onImageTap: { att, image, _ in
viewerImage = IdentifiedImage(id: att.id, image: image)
@@ -457,10 +481,50 @@ struct NativeChatScreen: View {
}
.simultaneousGesture(rowSwipe(m))
.clipped()
+ // The jump wave, applied AFTER .clipped() so the wag never
+ // beheads at the row edge. Feet planted, tiptoes, eager
+ // side-to-side — the Mac feed's Minion, same keyframes.
+ .keyframeAnimator(initialValue: DancePose(), trigger: danceTick) { view, pose in
+ let on = m.id == danceID
+ view
+ .rotationEffect(.degrees(on ? pose.angle : 0), anchor: .bottom)
+ .scaleEffect(x: 1, y: on ? pose.scale : 1, anchor: .bottom)
+ } keyframes: { _ in
+ KeyframeTrack(\.angle) {
+ CubicKeyframe(0, duration: 0.08)
+ CubicKeyframe(5, duration: 0.11)
+ CubicKeyframe(-5, duration: 0.12)
+ CubicKeyframe(5, duration: 0.12)
+ CubicKeyframe(-5, duration: 0.12)
+ CubicKeyframe(4, duration: 0.12)
+ CubicKeyframe(-3, duration: 0.12)
+ CubicKeyframe(0, duration: 0.18)
+ }
+ KeyframeTrack(\.scale) {
+ CubicKeyframe(1.04, duration: 0.14)
+ CubicKeyframe(1.04, duration: 0.63)
+ CubicKeyframe(1.0, duration: 0.2)
+ }
+ }
}
}
}
+ // The wave's animatable channels (wag + tiptoe stretch).
+ private struct DancePose {
+ var angle = 0.0
+ var scale = 1.0
+ }
+
+ // Scroll to a message and wave it — quote-taps and search hits share this.
+ private func jumpAndWave(_ id: Int64, proxy: ScrollViewProxy) {
+ withAnimation { proxy.scrollTo(id, anchor: .center) }
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
+ danceID = id
+ danceTick += 1
+ }
+ }
+
// One horizontal drag, two meanings: rightward on a bubble = quick-reply
// (with a haptic at the threshold), leftward = peek the times. Vertical
// drags stay the scroll's.
diff --git a/ios/LChat/Views/ThreadListView.swift b/ios/LChat/Views/ThreadListView.swift
index 287d649..1325d4b 100644
--- a/ios/LChat/Views/ThreadListView.swift
+++ b/ios/LChat/Views/ThreadListView.swift
@@ -136,6 +136,7 @@ struct ThreadListView: View {
Section("Messages") {
ForEach(Array(messageMatches.enumerated()), id: \.offset) { _, hit in
Button {
+ NativeChatScreen.pendingJump = (hit.thread.id, hit.message.id)
path.append(hit.thread.id)
} label: {
HStack(spacing: 10) {
diff --git a/ios/LChatMac/LChatMacApp.swift b/ios/LChatMac/LChatMacApp.swift
index 2312073..4bdfd1f 100644
--- a/ios/LChatMac/LChatMacApp.swift
+++ b/ios/LChatMac/LChatMacApp.swift
@@ -164,6 +164,13 @@ final class MacAppState: ObservableObject {
// the palette requests a thread by writing nativeOpenThread, which the
// active NativeChatView consumes.
@Published var nativeChats: [NativePaletteChat] = []
+ // A "open this thread AND land on this message" request (search hits):
+ // the matching feed consumes it once its history contains the target.
+ @Published var nativeJump: NativeJumpTarget?
+ struct NativeJumpTarget: Equatable {
+ let thread: String
+ let message: Int64
+ }
var nativeChatsOwner: String? // instance id, so a deactivating view only clears its own rows
@Published var nativeOpenThread: String?
diff --git a/ios/LChatMac/NativeChatView.swift b/ios/LChatMac/NativeChatView.swift
index d96e518..74522db 100644
--- a/ios/LChatMac/NativeChatView.swift
+++ b/ios/LChatMac/NativeChatView.swift
@@ -593,6 +593,12 @@ struct NativeChatView: View {
selectedID = thread.id
store.markRead(thread)
store.reopenIfHidden(thread)
+ // A message hit doesn't just open the chat — it lands ON the message
+ // (the feed scrolls there and the bubble waves).
+ if sel >= convos.count {
+ app.nativeJump = .init(thread: thread.id,
+ message: msgs[sel - convos.count].message.id)
+ }
search = ""
}
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift
index f64d30c..61f2f41 100644
--- a/ios/LChatMac/NativeFeedView.swift
+++ b/ios/LChatMac/NativeFeedView.swift
@@ -57,8 +57,11 @@ struct NativeFeedView: View {
let members = Set(thread.members)
self.hasBot = roster.contains { members.contains($0.user_id) && $0.isAgent }
self.appIntro = thread.appId.flatMap { AppChatProjection.intros[$0] }
+ self.threadID = thread.id
}
+ private let threadID: String
+
// The companion app's start-of-history intro (chatIntro on the web),
// shown above the earliest message once all history is loaded.
private let appIntro: String?
@@ -266,8 +269,15 @@ struct NativeFeedView: View {
.onChange(of: model.ready) { _, ready in
guard ready else { return }
pinUntilSettled(proxy)
+ consumeJump(proxy)
+ }
+ // Search hits land on their message: page history until the
+ // target is loaded, then jump-and-wave.
+ .onChange(of: app.nativeJump) { _, _ in consumeJump(proxy) }
+ .onAppear {
+ pinUntilSettled(proxy)
+ consumeJump(proxy)
}
- .onAppear { pinUntilSettled(proxy) }
// Anchor to the bottom BEFORE first layout — a parked/prewarmed
// model arrives with messages already folded, and the onAppear pin
// races layout and loses (the land-at-top bug, same as iOS).
@@ -999,6 +1009,24 @@ struct NativeFeedView: View {
}
}
+ // A pending open-on-message request (search): once this feed is the
+ // target and ready, page back until the message is loaded, then wave.
+ private func consumeJump(_ proxy: ScrollViewProxy) {
+ guard let j = app.nativeJump, j.thread == threadID, model.ready else { return }
+ app.nativeJump = nil
+ Task {
+ var tries = 0
+ while !model.messages.contains(where: { $0.id == j.message }),
+ !model.exhausted, tries < 20 {
+ tries += 1
+ await model.loadOlder()
+ try? await Task.sleep(nanoseconds: 550_000_000) // loadOlder's cooldown
+ }
+ try? await Task.sleep(nanoseconds: 150_000_000) // let rows land
+ jump(to: j.message, proxy: proxy)
+ }
+ }
+
private func jump(to id: Int64, proxy: ScrollViewProxy) {
guard model.messages.contains(where: { $0.id == id }) else { return }
// Already fully in view (with a little headroom for the wave)? Don't