Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions apple/InlineMac/App/KeyMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ public class KeyMonitor: Sendable {
private var vimNavHandlers: OrderedDictionary<String, (NSEvent) -> Void> = [:]
// Cmd+1...9: return true only when the handler actually acted.
private var commandNumberHandlers: OrderedDictionary<String, (NSEvent) -> Bool> = [:]
// Cmd+R/E compose shortcuts: return true only when the handler actually acted.
private var composeShortcutHandlers: OrderedDictionary<String, (NSEvent) -> Bool> = [:]

static let chatDetailAccessibilityIdentifier = "inline.chat-detail"

private var localEventMonitor: Any?
private weak var window: NSWindow?
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions apple/InlineMac/Views/ChatView/ChatViewAppKit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ class ChatViewAppKit: NSViewController {
view = ChatDropView()
view.translatesAutoresizingMaskIntoConstraints = false
view.wantsLayer = true
view.setAccessibilityIdentifier(KeyMonitor.chatDetailAccessibilityIdentifier)

transitionFromInitialState()
}
Expand Down
113 changes: 101 additions & 12 deletions apple/InlineMac/Views/Compose/ComposeAppKit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1546,6 +1546,7 @@ class ComposeAppKit: NSView {

private var keyMonitorUnsubscribe: (() -> Void)?
private var keyMonitorPasteUnsubscribe: (() -> Void)?
private var keyMonitorComposeShortcutUnsubscribe: (() -> Void)?
private var pendingImageSaveTasks: [String: Task<Void, Never>] = [:]
private var pendingVideoSaveTasks: [String: Task<Void, Never>] = [:]

Expand Down Expand Up @@ -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() {
Expand All @@ -1612,6 +1642,8 @@ class ComposeAppKit: NSView {
keyMonitorUnsubscribe = nil
keyMonitorPasteUnsubscribe?()
keyMonitorPasteUnsubscribe = nil
keyMonitorComposeShortcutUnsubscribe?()
keyMonitorComposeShortcutUnsubscribe = nil

// Clean up mention resources
mentionKeyMonitorEscUnsubscribe?()
Expand Down Expand Up @@ -1694,6 +1726,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()
}

func textViewDidPressArrowUp(_ textView: NSTextView) -> Bool {
if autocompleteMenu?.isVisible == true {
return handleAutocompleteArrow(.previous)
Expand All @@ -1710,25 +1752,72 @@ extension ComposeAppKit: NSTextViewDelegate, ComposeTextViewDelegate {
return true
}

// only if empty
guard textView.string.count == 0 else { return false }
return beginEditingLastSentMessage()
}

private func shouldDeferComposeShortcutToMenus() -> Bool {
autocompleteMenu?.isVisible == true
|| commandCompletionMenu?.isVisible == true
|| mentionCompletionMenu?.isVisible == true
}

@discardableResult
private func beginReplyingToLastMessage() -> Bool {
guard state.forwardContext == nil else { return false }
guard let lastMsgId = lastReplyableMessageId() else { return false }

// 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
state.setReplyingToMsgId(lastMsgId)
return true
}

@discardableResult
private func beginEditingLastSentMessage() -> Bool {
guard state.forwardContext == nil else { return false }
guard isEmptyTrimmed 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 {
Expand Down
19 changes: 16 additions & 3 deletions apple/InlineMac/Views/Compose/ComposeTextView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down