eleven / ocho Public

mac,ios: ↑ edits your last message; deletes implode (#304)

Up-arrow in an empty composer starts editing your most recent message
(Mac; iOS with a hardware keyboard) — the desktop convention. Deleted
messages now implode — a quick shrink-and-fade where the bubble stood:
rows carry a scale+opacity removal transition, and the delete fold is
the one mutation that publishes inside an animation.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell fa1ddab349f4f92e1b7e6faea611492bd6044bd8 parent 3f5992b
4 files changed, +33 −3
  • ios/LChat/Views/ComposerField.swift +9 −0
  • ios/LChat/Views/NativeChatScreen.swift +7 −2
  • ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift +4 −1
  • ios/LChatMac/NativeFeedView.swift +13 −0
diff --git a/ios/LChat/Views/ComposerField.swift b/ios/LChat/Views/ComposerField.swift
index 913231c..bc99d20 100644
--- a/ios/LChat/Views/ComposerField.swift
+++ b/ios/LChat/Views/ComposerField.swift
@@ -22,12 +22,15 @@ struct ComposerField: UIViewRepresentable {
var onSend: () -> Void
// A pasted image (the composer's "paste to upload") — data is JPEG.
var onPasteImage: ((Data) -> Void)? = nil
+ // Hardware-keyboard ↑ with an empty draft: edit your last message.
+ var onEditLast: (() -> Void)? = nil
func makeCoordinator() -> Coordinator { Coordinator(self) }
func makeUIView(context: Context) -> GrowingTextView {
let tv = GrowingTextView()
tv.onPasteImage = onPasteImage
+ tv.onEditLast = onEditLast
tv.delegate = context.coordinator
tv.font = .preferredFont(forTextStyle: .body)
tv.backgroundColor = .clear
@@ -107,6 +110,8 @@ final class GrowingTextView: UITextView {
// Pasting an image stages it as an upload instead of inserting nothing —
// text pastes are untouched, and a mixed clipboard does both.
var onPasteImage: ((Data) -> Void)?
+ // Hardware-keyboard ↑ with an empty draft (the desktop edit convention).
+ var onEditLast: (() -> Void)?
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
if action == #selector(paste(_:)), onPasteImage != nil,
@@ -144,6 +149,10 @@ final class GrowingTextView: UITextView {
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
if let mods = presses.first?.key?.modifierFlags { shiftHeld = mods.contains(.shift) }
+ if presses.first?.key?.keyCode == .keyboardUpArrow, text.isEmpty, let onEditLast {
+ onEditLast()
+ return
+ }
super.pressesBegan(presses, with: event)
}
override func pressesEnded(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift
index d189ea6..0ab3776 100644
--- a/ios/LChat/Views/NativeChatScreen.swift
+++ b/ios/LChat/Views/NativeChatScreen.swift
@@ -297,7 +297,7 @@ struct NativeChatScreen: View {
.id(m.id)
.transition(.asymmetric(
insertion: .move(edge: .bottom).combined(with: .opacity),
- removal: .opacity))
+ removal: .scale(scale: 0.05).combined(with: .opacity)))
.onAppear {
// `settled` gate: the opening pin-jump
// transiently materialises top rows, and each
@@ -649,7 +649,12 @@ struct NativeChatScreen: View {
// sends (the shouldSend rule).
ComposerField(text: $draft, focused: $composerFocused, caret: $caret,
placeholder: "Message", sendOnReturn: false, onSend: sendDraft,
- onPasteImage: { stageImageData($0) })
+ onPasteImage: { stageImageData($0) },
+ onEditLast: {
+ if let last = model.messages.last(where: { $0.mine && !$0.action }) {
+ startEdit(last)
+ }
+ })
.padding(.leading, 14)
.padding(.vertical, 8)
.onChange(of: draft) { _, _ in
diff --git a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
index db24a55..21c6e1c 100644
--- a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
+++ b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
@@ -634,7 +634,10 @@ public final class ChatFeedModel: ObservableObject {
var list = messages
list.removeAll { $0.id == id }
refreshReplyCounts(&list)
- messages = list
+ // The implosion: rows carry a scale+fade removal transition, and
+ // deletes are the one mutation that publishes inside an animation
+ // (arrivals/prepends stay unanimated by design).
+ withAnimation(.easeIn(duration: 0.22)) { messages = list }
let instanceID = instance.id
let tid = threadID
Task.detached(priority: .utility) {
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift
index 9c9029e..3df3371 100644
--- a/ios/LChatMac/NativeFeedView.swift
+++ b/ios/LChatMac/NativeFeedView.swift
@@ -150,6 +150,11 @@ struct NativeFeedView: View {
ForEach(Array(model.messages.enumerated()), id: \.element.id) { i, m in
row(m, previous: i > 0 ? model.messages[i - 1] : nil, proxy: proxy)
.id(m.id)
+ // Deleted messages implode — a quick shrink-and-
+ // fade where the bubble stood.
+ .transition(.asymmetric(
+ insertion: .identity,
+ removal: .scale(scale: 0.05).combined(with: .opacity)))
// Geometry probe for "is this row already in view?"
// (jump-without-scroll). Writes to the frame store,
// never to state.
@@ -705,6 +710,14 @@ struct NativeFeedView: View {
.buttonStyle(.plain)
.padding(.bottom, 4)
TextField("Message", text: $draft, axis: .vertical)
+ .onKeyPress(.upArrow) {
+ guard draft.isEmpty, editingID == nil,
+ let last = model.messages.last(where: { $0.mine && !$0.action }) else {
+ return .ignored
+ }
+ startEdit(last)
+ return .handled
+ }
// Paste an image (or a copied file) straight onto the draft -
// it stages as an upload, like the web composer's paste.
.onPasteCommand(of: [.png, .jpeg, .tiff, .image, .fileURL]) { providers in