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>
4 files changed,
+33
−3
ios/LChat/Views/ComposerField.swift+9 −0ios/LChat/Views/NativeChatScreen.swift+7 −2ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift+4 −1ios/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 |