From e075a5056fc98243949485efc375746dc926a634 Mon Sep 17 00:00:00 2001 From: $H!NDGEKYUME Date: Wed, 3 Jun 2026 20:15:13 +0800 Subject: [PATCH 1/2] Add cmd+r and cmd+e shortcuts for reply and edit - cmd+r: reply to last message - cmd+e: edit last sent message - refactor existing arrow-up edit logic into reusable methods - add helper to check if compose text is empty - add helper to check if shortcuts should defer to menus --- .../Views/Compose/ComposeAppKit.swift | 85 ++++++++++++++++--- .../Views/Compose/ComposeTextView.swift | 19 ++++- 2 files changed, 89 insertions(+), 15 deletions(-) diff --git a/apple/InlineMac/Views/Compose/ComposeAppKit.swift b/apple/InlineMac/Views/Compose/ComposeAppKit.swift index 823657d8..25fd11e6 100644 --- a/apple/InlineMac/Views/Compose/ComposeAppKit.swift +++ b/apple/InlineMac/Views/Compose/ComposeAppKit.swift @@ -1694,6 +1694,16 @@ extension ComposeAppKit: NSTextViewDelegate, ComposeTextViewDelegate { return true // handled } + func textViewDidPressCommandR(_ textView: NSTextView) -> Bool { + guard !shouldDeferComposeShortcutToMenus() else { return false } + return beginReplyingToLastMessage() + } + + func textViewDidPressCommandE(_ textView: NSTextView) -> Bool { + guard !shouldDeferComposeShortcutToMenus() else { return false } + return beginEditingLastSentMessage(in: textView) + } + func textViewDidPressArrowUp(_ textView: NSTextView) -> Bool { if autocompleteMenu?.isVisible == true { return handleAutocompleteArrow(.previous) @@ -1710,25 +1720,76 @@ extension ComposeAppKit: NSTextViewDelegate, ComposeTextViewDelegate { return true } - // only if empty - guard textView.string.count == 0 else { return false } + return beginEditingLastSentMessage(in: textView) + } + + private func shouldDeferComposeShortcutToMenus() -> Bool { + autocompleteMenu?.isVisible == true + || commandCompletionMenu?.isVisible == true + || mentionCompletionMenu?.isVisible == true + } + + private func composeTextIsEmpty(_ textView: NSTextView) -> Bool { + textView.string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } - // fetch last message of ours in this chat that isn't sending or failed - let lastMsgId = try? dependencies.database.reader.read { db in - let lastMsg = try InlineKit.Message + @discardableResult + private func beginReplyingToLastMessage() -> Bool { + guard state.forwardContext == nil else { return false } + guard let lastMsgId = lastReplyableMessageId() else { return false } + + state.setReplyingToMsgId(lastMsgId) + return true + } + + @discardableResult + private func beginEditingLastSentMessage(in textView: NSTextView) -> Bool { + guard state.forwardContext == nil else { return false } + guard composeTextIsEmpty(textView) else { return false } + guard let lastMsgId = lastEditableOutgoingMessageId() else { return false } + + state.setEditingMsgId(lastMsgId) + return true + } + + private func lastReplyableMessageId() -> Int64? { + let anchorId = viewModel?.threadAnchor?.message.messageId + for message in viewModel?.messages.reversed() ?? [] { + guard message.message.messageId != anchorId else { continue } + guard message.canReply else { continue } + return message.message.messageId + } + + guard let chatId else { return nil } + return try? dependencies.database.reader.read { db in + try InlineKit.Message + .filter { $0.chatId == chatId } + .filter { $0.status != MessageSendingStatus.sending && $0.status != MessageSendingStatus.failed } + .order { $0.date.desc } + .limit(1) + .fetchOne(db)? + .messageId + } + } + + private func lastEditableOutgoingMessageId() -> Int64? { + for message in viewModel?.messages.reversed() ?? [] { + guard message.message.out == true else { continue } + guard message.message.status == .sent else { continue } + return message.message.messageId + } + + guard let chatId else { return nil } + return try? dependencies.database.reader.read { db in + try InlineKit.Message .filter { $0.chatId == chatId } .filter { $0.out == true } .filter { $0.status == MessageSendingStatus.sent } .order { $0.date.desc } .limit(1) - .fetchOne(db) - return lastMsg?.messageId + .fetchOne(db)? + .messageId } - guard let lastMsgId else { return false } - - // Trigger edit mode for last message - state.setEditingMsgId(lastMsgId) - return true // handled } func textViewDidPressReturn(_ textView: NSTextView) -> Bool { diff --git a/apple/InlineMac/Views/Compose/ComposeTextView.swift b/apple/InlineMac/Views/Compose/ComposeTextView.swift index be436796..1623b0bc 100644 --- a/apple/InlineMac/Views/Compose/ComposeTextView.swift +++ b/apple/InlineMac/Views/Compose/ComposeTextView.swift @@ -6,6 +6,8 @@ import TextProcessing protocol ComposeTextViewDelegate: NSTextViewDelegate { func textViewDidPressReturn(_ textView: NSTextView) -> Bool func textViewDidPressCommandReturn(_ textView: NSTextView) -> Bool + func textViewDidPressCommandR(_ textView: NSTextView) -> Bool + func textViewDidPressCommandE(_ textView: NSTextView) -> Bool func textViewDidPressArrowUp(_ textView: NSTextView) -> Bool func textViewDidPressArrowDown(_ textView: NSTextView) -> Bool func textViewDidPressArrowLeft(_ textView: NSTextView) -> Bool @@ -33,9 +35,20 @@ class ComposeNSTextView: NSTextView { override func keyDown(with event: NSEvent) { let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - if modifiers == [.command], event.charactersIgnoringModifiers?.lowercased() == "b" { - toggleBold(self) - return + if modifiers == [.command], let char = event.charactersIgnoringModifiers?.lowercased() { + if char == "b" { + toggleBold(self) + return + } + + if let delegate = delegate as? ComposeTextViewDelegate { + if char == "r", delegate.textViewDidPressCommandR(self) { + return + } + if char == "e", delegate.textViewDidPressCommandE(self) { + return + } + } } // Handle return key From 48763e7a014e4307b7dcc912dbce9e979d4315fb Mon Sep 17 00:00:00 2001 From: $H!NDGEKYUME Date: Wed, 3 Jun 2026 20:26:34 +0800 Subject: [PATCH 2/2] Accept reply/edit shortcuts when not focusing input field --- apple/InlineMac/App/KeyMonitor.swift | 59 +++++++++++++++++++ .../Views/ChatView/ChatViewAppKit.swift | 1 + .../Views/Compose/ComposeAppKit.swift | 44 +++++++++++--- 3 files changed, 96 insertions(+), 8 deletions(-) diff --git a/apple/InlineMac/App/KeyMonitor.swift b/apple/InlineMac/App/KeyMonitor.swift index 599febac..c29f1339 100644 --- a/apple/InlineMac/App/KeyMonitor.swift +++ b/apple/InlineMac/App/KeyMonitor.swift @@ -23,6 +23,10 @@ public class KeyMonitor: Sendable { private var vimNavHandlers: OrderedDictionary Void> = [:] // Cmd+1...9: return true only when the handler actually acted. private var commandNumberHandlers: OrderedDictionary Bool> = [:] + // Cmd+R/E compose shortcuts: return true only when the handler actually acted. + private var composeShortcutHandlers: OrderedDictionary Bool> = [:] + + static let chatDetailAccessibilityIdentifier = "inline.chat-detail" private var localEventMonitor: Any? private weak var window: NSWindow? @@ -137,6 +141,19 @@ public class KeyMonitor: Sendable { } } + /// Cmd+R/E reply/edit shortcuts when compose is not focused. Return true only if handled. + func addComposeShortcutHandler( + key: String, + handler: @escaping (NSEvent) -> Bool + ) -> (() -> Void) { + log.trace("Adding compose shortcut handler with key \(key)") + composeShortcutHandlers[key] = handler + return { [weak self] in + self?.log.trace("Removing compose shortcut handler with key \(key)") + self?.composeShortcutHandlers.removeValue(forKey: key) + } + } + // MARK: - Monitor private func setupKeyboardMonitoringIfNeeded() { @@ -167,6 +184,15 @@ public class KeyMonitor: Sendable { if handled { return nil } } + if modifiers == [.command], + !isTextInputCurrentlyFocused(), + let char = event.charactersIgnoringModifiers?.lowercased(), + char == "r" || char == "e" + { + let handled = callComposeShortcutHandler(event: event) + if handled { return nil } + } + if event.keyCode == ESCAPE_KEY_CODE { log.trace("Escape keydown; handlers=\(handlerKeys(escapeHandlers))") let handled = callHandler(for: .escape, event: event) @@ -330,6 +356,39 @@ public class KeyMonitor: Sendable { return last(event) } + private func callComposeShortcutHandler(event: NSEvent) -> Bool { + guard isFirstResponderInChatDetail() else { return false } + guard let last = composeShortcutHandlers.values.last else { return false } + return last(event) + } + + private func isFirstResponderInChatDetail() -> Bool { + guard let responder = window?.firstResponder else { return true } + return viewHierarchyContainsAccessibilityIdentifier( + Self.chatDetailAccessibilityIdentifier, + startingAt: responder + ) + } + + private func viewHierarchyContainsAccessibilityIdentifier( + _ identifier: String, + startingAt responder: NSResponder + ) -> Bool { + var view: NSView? = responder as? NSView + if view == nil, let text = responder as? NSText, let fieldEditor = text.delegate as? NSView { + view = fieldEditor + } + + var current = view + while let currentView = current { + if currentView.accessibilityIdentifier() == identifier { + return true + } + current = currentView.superview + } + return false + } + private func callPasteHandler(event: NSEvent) -> Bool { // only call the last one as otherwise multiple handlers will fight if let last = pasteHandlers.values.last { diff --git a/apple/InlineMac/Views/ChatView/ChatViewAppKit.swift b/apple/InlineMac/Views/ChatView/ChatViewAppKit.swift index ad175c94..bfbbc156 100644 --- a/apple/InlineMac/Views/ChatView/ChatViewAppKit.swift +++ b/apple/InlineMac/Views/ChatView/ChatViewAppKit.swift @@ -128,6 +128,7 @@ class ChatViewAppKit: NSViewController { view = ChatDropView() view.translatesAutoresizingMaskIntoConstraints = false view.wantsLayer = true + view.setAccessibilityIdentifier(KeyMonitor.chatDetailAccessibilityIdentifier) transitionFromInitialState() } diff --git a/apple/InlineMac/Views/Compose/ComposeAppKit.swift b/apple/InlineMac/Views/Compose/ComposeAppKit.swift index 25fd11e6..d9011540 100644 --- a/apple/InlineMac/Views/Compose/ComposeAppKit.swift +++ b/apple/InlineMac/Views/Compose/ComposeAppKit.swift @@ -1546,6 +1546,7 @@ class ComposeAppKit: NSView { private var keyMonitorUnsubscribe: (() -> Void)? private var keyMonitorPasteUnsubscribe: (() -> Void)? + private var keyMonitorComposeShortcutUnsubscribe: (() -> Void)? private var pendingImageSaveTasks: [String: Task] = [:] private var pendingVideoSaveTasks: [String: Task] = [:] @@ -1588,6 +1589,35 @@ class ComposeAppKit: NSView { self?.handleGlobalPaste() } ) + + keyMonitorComposeShortcutUnsubscribe = dependencies.keyMonitor?.addComposeShortcutHandler( + key: "compose_shortcuts_\(peerId)", + handler: { [weak self] event in + self?.handleGlobalComposeShortcut(event) ?? false + } + ) + } + + private func handleGlobalComposeShortcut(_ event: NSEvent) -> Bool { + guard dependencies.nav3?.cmdKVisible != true else { return false } + guard isActiveChatRoute() else { return false } + + switch event.charactersIgnoringModifiers?.lowercased() { + case "r": + guard !shouldDeferComposeShortcutToMenus() else { return false } + return beginReplyingToLastMessage() + case "e": + guard !shouldDeferComposeShortcutToMenus() else { return false } + return beginEditingLastSentMessage() + default: + return false + } + } + + private func isActiveChatRoute() -> Bool { + guard let route = dependencies.nav3?.currentRoute else { return false } + guard case let .chat(peer) = route else { return false } + return peer == peerId } private func handleGlobalPaste() { @@ -1612,6 +1642,8 @@ class ComposeAppKit: NSView { keyMonitorUnsubscribe = nil keyMonitorPasteUnsubscribe?() keyMonitorPasteUnsubscribe = nil + keyMonitorComposeShortcutUnsubscribe?() + keyMonitorComposeShortcutUnsubscribe = nil // Clean up mention resources mentionKeyMonitorEscUnsubscribe?() @@ -1701,7 +1733,7 @@ extension ComposeAppKit: NSTextViewDelegate, ComposeTextViewDelegate { func textViewDidPressCommandE(_ textView: NSTextView) -> Bool { guard !shouldDeferComposeShortcutToMenus() else { return false } - return beginEditingLastSentMessage(in: textView) + return beginEditingLastSentMessage() } func textViewDidPressArrowUp(_ textView: NSTextView) -> Bool { @@ -1720,7 +1752,7 @@ extension ComposeAppKit: NSTextViewDelegate, ComposeTextViewDelegate { return true } - return beginEditingLastSentMessage(in: textView) + return beginEditingLastSentMessage() } private func shouldDeferComposeShortcutToMenus() -> Bool { @@ -1729,10 +1761,6 @@ extension ComposeAppKit: NSTextViewDelegate, ComposeTextViewDelegate { || mentionCompletionMenu?.isVisible == true } - private func composeTextIsEmpty(_ textView: NSTextView) -> Bool { - textView.string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - } - @discardableResult private func beginReplyingToLastMessage() -> Bool { guard state.forwardContext == nil else { return false } @@ -1743,9 +1771,9 @@ extension ComposeAppKit: NSTextViewDelegate, ComposeTextViewDelegate { } @discardableResult - private func beginEditingLastSentMessage(in textView: NSTextView) -> Bool { + private func beginEditingLastSentMessage() -> Bool { guard state.forwardContext == nil else { return false } - guard composeTextIsEmpty(textView) else { return false } + guard isEmptyTrimmed else { return false } guard let lastMsgId = lastEditableOutgoingMessageId() else { return false } state.setEditingMsgId(lastMsgId)